feat(fleet): bind quote versions and reconcile actual return quantities

This commit is contained in:
2026-10-03 13:32:55 +08:00
parent cb6c54ffe5
commit a6fd0ba3c5
60 changed files with 1363 additions and 75 deletions
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.fleet.quote;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.IllegalTransactionStateException;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.nio.file.*;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetQuoteCandidatePersistenceTest {
private FleetQuoteCandidateService candidates;private FleetQuoteService quotes;private TransactionTemplate transaction;
private final LocalDate day=LocalDate.of(2026,10,3);
@BeforeEach void setup()throws Exception{
var source=new DriverManagerDataSource("jdbc:h2:mem:quote-candidates-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(source);String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_fleet_quote","ll_fleet_quote_history")){
var match=Pattern.compile("(?s)CREATE TABLE "+table+" \\(.*?;").matcher(schema);assertThat(match.find()).isTrue();jdbc.execute(match.group().replace(" JSON"," LONGTEXT"));
}
var directory=mock(FleetQuoteDirectory.class);when(directory.name(anyString(),anyLong())).thenReturn("测试客户");
var json=new ObjectMapper().registerModule(new JavaTimeModule());var manager=new DataSourceTransactionManager(source);transaction=new TransactionTemplate(manager);
quotes=new FleetQuoteService(new FleetQuoteRepository(jdbc),directory,json);
var proxy=new ProxyFactory(new FleetQuoteCandidateService(new FleetQuoteCandidateRepository(jdbc),directory,json));proxy.setProxyTargetClass(true);
proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));candidates=(FleetQuoteCandidateService)proxy.getProxy();
}
private FleetQuoteModels.Draft draft(String kind,long customer,BigDecimal price){return new FleetQuoteModels.Draft(kind,customer,"LA","ONT8",BigDecimal.TEN,"USD",BigDecimal.ONE,BigDecimal.TEN,price,day,day,null);}
private FleetQuoteModels.Quote create(String key,String kind,long customer,BigDecimal price){return transaction.execute(s->quotes.create("1",new FleetQuoteModels.Create(key,draft(kind,customer,price))));}
private FleetQuoteCandidateModels.Context context(){return new FleetQuoteCandidateModels.Context(1L," LA ","ont8","USD",day,"PALLET");}
@Test void realQueryRestrictsCustomerPurposeAndDateAndDoesNotPromoteUnknownToZero(){
create("candidate-sale","SALE",1,BigDecimal.ZERO);create("candidate-other","SALE",2,BigDecimal.ONE);create("candidate-buy","PURCHASE",1,BigDecimal.ONE);
var result=candidates.candidates(new FleetQuoteCandidateModels.Query(1L," LA ","ont8","USD",day,"PALLET",1,20));
assertThat(result.total()).isEqualTo(1);assertThat(result.records().get(0).quotedUnitPrice()).isEqualByComparingTo("0");
assertThat(candidates.candidates(new FleetQuoteCandidateModels.Query(1L,"LA","ONT8","USD",day.plusDays(1),"PALLET",1,20)).total()).isZero();
}
@Test void sourceResolutionRequiresTheBookingTransactionAndKeepsOriginalVersionAfterLaterPriceChange(){
var quote=create("candidate-source","SALE",1,BigDecimal.ZERO);var ref=new FleetQuoteCandidateModels.QuoteRef(quote.quoteNo(),quote.recordVersion(),"LOOSE_DELIVERY");
assertThatThrownBy(()->candidates.lockAndResolve(context(),ref)).isInstanceOf(IllegalTransactionStateException.class);
var source=transaction.execute(s->candidates.lockAndResolve(context(),ref));
transaction.executeWithoutResult(s->quotes.update(quote.quoteNo(),"1",new FleetQuoteModels.Update(quote.recordVersion(),draft("SALE",1,new BigDecimal("15.00")))));
assertThat(source.quotedUnitPrice()).isEqualByComparingTo("0");assertThat(source.recordVersion()).isEqualTo(quote.recordVersion());
assertThatThrownBy(()->transaction.execute(s->candidates.lockAndResolve(context(),ref))).hasMessageContaining("重新选择");
}
}
@@ -0,0 +1,67 @@
package com.baiyue.lastleg.fleet.quote;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.annotation.*;
import org.springframework.security.access.prepost.PreAuthorize;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetQuoteCandidateServiceTest {
private final ObjectMapper json=new ObjectMapper().registerModule(new JavaTimeModule());
private final FleetQuoteCandidateRepository repository=mock(FleetQuoteCandidateRepository.class);
private final FleetQuoteDirectory directory=mock(FleetQuoteDirectory.class);
private final FleetQuoteCandidateService service=new FleetQuoteCandidateService(repository,directory,json);
private final LocalDate day=LocalDate.of(2026,10,3);
private FleetQuoteCandidateModels.Context context(){return new FleetQuoteCandidateModels.Context(1L," LA ","ont8","USD",day,"PALLET");}
private FleetQuoteModels.Draft draft(String kind,long party,String currency,BigDecimal loose){return new FleetQuoteModels.Draft(kind,party,"LA","ONT8",BigDecimal.TEN,currency,BigDecimal.TEN,BigDecimal.ONE,loose,day,day,null);}
private FleetQuoteCandidateRepository.QuoteRow row(String number,String status,long version,FleetQuoteModels.Draft d)throws Exception{return new FleetQuoteCandidateRepository.QuoteRow(number,"客户名称快照",status,version,json.writeValueAsString(d));}
private FleetQuoteCandidateModels.Query query(int size){var c=context();return new FleetQuoteCandidateModels.Query(c.customerId(),c.originCity(),c.destinationCode(),c.currency(),c.effectiveOn(),c.priceBasis(),1,size);}
@Test void candidatesKeepZeroAndBothDateBoundariesButExcludeUnknownPurchaseCustomerAndCurrency()throws Exception{
when(repository.candidates(any())).thenReturn(List.of(row("FQ-ZERO","ACTIVE",7,draft("SALE",1,"USD",BigDecimal.ZERO)),
row("FQ-UNKNOWN","ACTIVE",1,draft("SALE",1,"USD",null)),row("FQ-PURCHASE","ACTIVE",1,draft("PURCHASE",1,"USD",BigDecimal.ONE)),
row("FQ-OTHER","ACTIVE",1,draft("SALE",2,"USD",BigDecimal.ONE)),row("FQ-CNY","ACTIVE",1,draft("SALE",1,"CNY",BigDecimal.ONE)),
row("FQ-OFF","INACTIVE",1,draft("SALE",1,"USD",BigDecimal.ONE)),row("FQ-NEXT","ACTIVE",3,draft("SALE",1,"USD",BigDecimal.TEN))));
var result=service.candidates(query(1));assertThat(result.total()).isEqualTo(2);assertThat(result.records()).hasSize(1);
assertThat(result.records().get(0).quotedUnitPrice()).isEqualByComparingTo("0");assertThat(result.records().get(0).recordVersion()).isEqualTo(7);
verify(directory,times(1)).name("SALE",1L);verify(repository).candidates(new FleetQuoteCandidateModels.Context(1L,"la","ONT8","USD",day,"PALLET"));
}
@Test void selectionRejectsStaleVersionsWrongBasisAndUnavailablePrice()throws Exception{
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ZERO)));
assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",7L,"LOOSE_DELIVERY"))).hasMessageContaining("重新选择");
assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"FULL_DELIVERY"))).hasMessageContaining("计价项目");
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",null)));
assertThatThrownBy(()->service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY"))).hasMessageContaining("尚未报价");
}
@Test void resolvedSnapshotRetainsSourcePriceInsteadOfInventingNegotiatedAmount()throws Exception{
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ZERO)));
var result=service.lockAndResolve(context(),new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY"));
assertThat(result.customerId()).isEqualTo("1");assertThat(result.customerName()).isEqualTo("客户名称快照");assertThat(result.quotedUnitPrice()).isEqualByComparingTo("0");
assertThat(result.priceItem()).isEqualTo("LOOSE_DELIVERY");assertThat(result.effectiveOn()).isEqualTo(day);
}
@Test void selectionRejectsChangedRouteCustomerCurrencyDateAndStatus()throws Exception{
var ref=new FleetQuoteCandidateModels.QuoteRef("FQ",8L,"LOOSE_DELIVERY");
when(repository.lock("FQ")).thenReturn(row("FQ","ACTIVE",8,draft("SALE",1,"USD",BigDecimal.ONE)));
for(var c:List.of(new FleetQuoteCandidateModels.Context(2L,"LA","ONT8","USD",day,"PALLET"),new FleetQuoteCandidateModels.Context(1L,"SF","ONT8","USD",day,"PALLET"),
new FleetQuoteCandidateModels.Context(1L,"LA","LAX9","USD",day,"PALLET"),new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","CNY",day,"PALLET"),
new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","USD",day.plusDays(1),"PALLET")))assertThatThrownBy(()->service.lockAndResolve(c,ref)).hasMessageContaining("不匹配");
when(repository.lock("FQ")).thenReturn(row("FQ","INACTIVE",8,draft("SALE",1,"USD",BigDecimal.ONE)));
assertThatThrownBy(()->service.lockAndResolve(context(),ref)).hasMessageContaining("不匹配");
}
@Test void selectionRequiresOwnersTransactionAndReadEndpointRequiresQuotePermission()throws Exception{
var method=FleetQuoteCandidateService.class.getMethod("lockAndResolve",FleetQuoteCandidateModels.Context.class,FleetQuoteCandidateModels.QuoteRef.class);
assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY);
var query=PlatformFleetQuoteCandidateController.class.getMethod("candidates",FleetQuoteCandidateModels.Query.class);
assertThat(query.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:fleet:quote:list')");
}
@Test void missingExplicitCityOrBusinessDateCannotFallBackToWarehouseNameOrSystemDate(){
var ref=new FleetQuoteCandidateModels.QuoteRef("FQ",0L,"LOOSE_DELIVERY");
assertThatThrownBy(()->service.lockAndResolve(new FleetQuoteCandidateModels.Context(1L," ","ONT8","USD",day,"PALLET"),ref)).hasMessageContaining("明确客户、路线");
assertThatThrownBy(()->service.lockAndResolve(new FleetQuoteCandidateModels.Context(1L,"LA","ONT8","USD",null,"PALLET"),ref)).hasMessageContaining("服务日期");
verifyNoInteractions(repository,directory);
}
}