feat(fleet): read attributable trip receivables and receipts from finance

This commit is contained in:
2026-10-05 11:51:47 +08:00
parent 113d2b1fec
commit 7577650eee
11 changed files with 891 additions and 2 deletions
@@ -0,0 +1,101 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baiyue.common.core.api.ApiResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.*;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class TripOperationsFinanceServiceTest {
static class Reads extends JdbcTemplate {
final Queue<List<?>> results=new ArrayDeque<>();final List<String> sql=new ArrayList<>();
@Override @SuppressWarnings("unchecked") public <T> List<T> query(String statement,RowMapper<T> mapper,Object...args){sql.add(statement);return (List<T>)results.remove();}
}
static TripOperationsFinanceService.Ownership owner(String customer,String dispatch,String trip) {return new TripOperationsFinanceService.Ownership(new TripOperationsFinanceService.Binding(customer,dispatch),1,trip);}
static TripOperationsFinanceService.Delivery delivery(String customer,String dispatch,String no) {return new TripOperationsFinanceService.Delivery(new TripOperationsFinanceService.Binding(customer,dispatch),no);}
TripFinanceClient.Result value() {return new TripFinanceClient.Result("AVAILABLE",List.of(new TripFinanceClient.Money("CNY",new BigDecimal("20"),null)),List.of(new TripFinanceClient.Source("1","DL-A","FEE-A","Delivery",1,"CNY",new BigDecimal("20"),"AVAILABLE")),List.of(),"actual source");}
@Test void readsFrozenCustomerDispatchThenFinanceWithoutCrossDatabase() {
var jdbc=new Reads();jdbc.results.add(List.of(TripOperationsServiceTest.DEPARTURE));
jdbc.results.add(List.of(owner("1","D-A","TR-A"),owner("2","D-B","TR-A")));
jdbc.results.add(List.of(delivery("1","D-A","DL-A"),delivery("2","D-B","DL-B")));
var client=mock(TripFinanceClient.class);when(client.query(anyString(),any())).thenReturn(ApiResponse.success(value()));
var service=new TripOperationsFinanceService(jdbc,new ObjectMapper().registerModule(new JavaTimeModule()),client,"test-token");
var result=service.read("TR-A",false);assertThat(result.attributionStatus()).isEqualTo("ATTRIBUTED");
verify(client).query("test-token",new TripFinanceClient.Query(List.of(new TripFinanceClient.Business(1,"DL-A"),new TripFinanceClient.Business(2,"DL-B")),false));
assertThat(jdbc.sql).allSatisfy(sql->assertThat(sql).doesNotContain("fin_fee_source","fin_receipt"));
}
@Test void dispatchUsedByAnotherDepartureIsNotAssignedAgain() {
var jdbc=new Reads();jdbc.results.add(List.of(TripOperationsServiceTest.DEPARTURE));
jdbc.results.add(List.of(new TripOperationsFinanceService.Ownership(new TripOperationsFinanceService.Binding("1","D-A"),2,"TR-OTHER"),new TripOperationsFinanceService.Ownership(new TripOperationsFinanceService.Binding("2","D-B"),2,"TR-OTHER")));
jdbc.results.add(List.of(delivery("1","D-A","DL-A"),delivery("2","D-B","DL-B")));
var client=mock(TripFinanceClient.class);when(client.query(anyString(),any())).thenReturn(ApiResponse.success(new TripFinanceClient.Result("PENDING_ALLOCATION",List.of(),List.of(),List.of(),"pending")));
var result=new TripOperationsFinanceService(jdbc,new ObjectMapper().registerModule(new JavaTimeModule()),client,"test-token").read("TR-A",true);
assertThat(result.attributionStatus()).isEqualTo("PENDING_ALLOCATION");verify(client).query("test-token",new TripFinanceClient.Query(List.of(),true));
}
@Test void databaseQueryCountIsConstantForOneTwoAndTwoHundredBindings() throws Exception {
for(int count:List.of(1,2,200)) {
var json=new ObjectMapper().registerModule(new JavaTimeModule());
var snapshot=json.readTree(TripOperationsServiceTest.DEPARTURE);var cargo=json.createArrayNode();
var owners=new ArrayList<TripOperationsFinanceService.Ownership>();var deliveries=new ArrayList<TripOperationsFinanceService.Delivery>();
var expected=new ArrayList<TripFinanceClient.Business>();
for(int i=1;i<=count;i++) {
var item=json.createObjectNode().put("customerId",Integer.toString(i)).put("dispatchNo","D-"+i);
cargo.add(item);owners.add(owner(Integer.toString(i),"D-"+i,"TR-A"));
deliveries.add(delivery(Integer.toString(i),"D-"+i,"DL-"+i));expected.add(new TripFinanceClient.Business(i,"DL-"+i));
}
((com.fasterxml.jackson.databind.node.ObjectNode)snapshot.get("confirmedLoad")).set("cargo",cargo);
var jdbc=new Reads();jdbc.results.add(List.of(json.writeValueAsString(snapshot)));jdbc.results.add(owners);jdbc.results.add(deliveries);
var client=mock(TripFinanceClient.class);when(client.query(anyString(),any())).thenReturn(ApiResponse.success(new TripFinanceClient.Result("AVAILABLE",List.of(),List.of(),List.of(),"authority")));
var result=new TripOperationsFinanceService(jdbc,json,client,"test-token").read("TR-A",false);
assertThat(result.attributionStatus()).isEqualTo("ATTRIBUTED");assertThat(jdbc.sql).hasSize(3);assertThat(jdbc.results).isEmpty();
verify(client,times(1)).query("test-token",new TripFinanceClient.Query(expected,false));
}
}
@Test void batchDeliverySqlExecutesExactPairsWithoutCartesianCrossCustomerLeak() {
var data=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
class DatabaseReads extends JdbcTemplate {
int queries;
DatabaseReads(){super(data);}
@Override @SuppressWarnings("unchecked") public <T> List<T> query(String statement,RowMapper<T> mapper,Object...args) {
queries++;
if(statement.contains("JSON_TABLE")) {
assertThat(args).containsExactly("1","D-A","2","D-B");
return (List<T>)List.of(owner("1","D-A","TR-A"),owner("2","D-B","TR-A"));
}
return super.query(statement,mapper,args);
}
}
var jdbc=new DatabaseReads();
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT,trip_no VARCHAR(64),departed_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_fleet_trip_history(trip_id BIGINT,action VARCHAR(32),record_version BIGINT,snapshot_json VARCHAR(10000))");
jdbc.execute("CREATE TABLE ll_delivery_order(customer_id VARCHAR(32),dispatch_no VARCHAR(64),delivery_no VARCHAR(64))");
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-A',CURRENT_TIMESTAMP)");
jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(1,'DEPART',1,?)",TripOperationsServiceTest.DEPARTURE);
jdbc.update("INSERT INTO ll_delivery_order VALUES('1','D-A','DL-A'),('2','D-B','DL-B'),('1','D-B','WRONG-CUSTOMER-A'),('2','D-A','WRONG-CUSTOMER-B')");
var client=mock(TripFinanceClient.class);when(client.query(anyString(),any())).thenReturn(ApiResponse.success(value()));
new TripOperationsFinanceService(jdbc,new ObjectMapper().registerModule(new JavaTimeModule()),client,"test-token").read("TR-A",false);
assertThat(jdbc.queries).isEqualTo(3);
verify(client).query("test-token",new TripFinanceClient.Query(List.of(new TripFinanceClient.Business(1,"DL-A"),new TripFinanceClient.Business(2,"DL-B")),false));
}
@Test void moreThanTwoHundredBindingsStopsBeforeBatchReadsOrFinance() throws Exception {
var json=new ObjectMapper().registerModule(new JavaTimeModule());var snapshot=json.readTree(TripOperationsServiceTest.DEPARTURE);var cargo=json.createArrayNode();
for(int i=1;i<=201;i++)cargo.add(json.createObjectNode().put("customerId",Integer.toString(i)).put("dispatchNo","D-"+i));
((com.fasterxml.jackson.databind.node.ObjectNode)snapshot.get("confirmedLoad")).set("cargo",cargo);
var jdbc=new Reads();jdbc.results.add(List.of(json.writeValueAsString(snapshot)));var client=mock(TripFinanceClient.class);
assertThatThrownBy(()->new TripOperationsFinanceService(jdbc,json,client,"test-token").read("TR-A",false)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class);
assertThat(jdbc.sql).hasSize(1);verifyNoInteractions(client);
}
@Test void mismatchedCustomerAndUnauthorizedReceiptAreRejected() {
assertThatThrownBy(()->TripOperationsFinanceService.validate(value(),List.of(new TripFinanceClient.Business(2,"DL-A")),false)).isInstanceOf(IllegalStateException.class);
var withReceipt=new TripFinanceClient.Result("AVAILABLE",List.of(new TripFinanceClient.Money("CNY",BigDecimal.ONE,BigDecimal.ONE)),List.of(),List.of(),"actual");
assertThatThrownBy(()->TripOperationsFinanceService.validate(withReceipt,List.of(),false)).isInstanceOf(IllegalStateException.class);
}
@Test void financeEndpointRequiresOperationsAndExistingReceivablePermission() throws Exception {
var method=Arrays.stream(PlatformTripOperationsController.class.getMethods()).filter(m->m.getName().equals("finance")).findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class).value()).contains("platform:fleet:operations:list","platform:finance:customer-arap:list");
}
}