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
@@ -13,7 +13,8 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/platform/v1/last-leg/fleet/operations")
public class PlatformTripOperationsController {
private final TripOperationsService service;
public PlatformTripOperationsController(TripOperationsService service) { this.service=service; }
private final TripOperationsFinanceService finance;
public PlatformTripOperationsController(TripOperationsService service,TripOperationsFinanceService finance) { this.service=service;this.finance=finance; }
@GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')")
public ApiResponse<TripOperationsModels.Result> report(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { return ApiResponse.success(service.report(query,canReadCosts(user))); }
@GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:operations:export')")
@@ -21,5 +22,10 @@ public class PlatformTripOperationsController {
return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8"))
.header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=trip-operations.csv").body(service.exportCsv(query,canReadCosts(user)));
}
@GetMapping("/{tripNo}/finance")
@PreAuthorize("hasAuthority('platform:fleet:operations:list') and hasAuthority('platform:finance:customer-arap:list')")
public ApiResponse<TripOperationsFinanceService.Result> finance(@PathVariable String tripNo,@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(finance.read(tripNo,user!=null&&user.permissions().contains("platform:finance:receipt:view")));
}
private static boolean canReadCosts(AuthenticatedUser user){return user!=null&&user.permissions().contains("platform:fleet:trip:list");}
}
@@ -0,0 +1,19 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
@FeignClient(name="finance-service",contextId="tripFinanceClient",url="${baiyue.clients.finance.url:}")
public interface TripFinanceClient {
@PostMapping("/internal/finance/v1/trip-receivables/query")
ApiResponse<Result> query(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Query query);
record Business(long customerId,String deliveryNo) { }
record Query(List<Business> businesses,boolean receiptsVisible) { }
record Money(String currency,BigDecimal receivableAmount,BigDecimal receivedAmount) { }
record Source(String customerId,String businessNo,String sourceNo,String feeItemName,int sourceVersion,String currency,BigDecimal amount,String status) { }
record Document(String billNo,String currency,BigDecimal receivedAmount,String allocationStatus,String reason) { }
record Result(String status,List<Money> totals,List<Source> sources,List<Document> documents,String reason) { }
}
@@ -0,0 +1,85 @@
package com.baiyue.lastleg.fleet.reporting;
import com.baiyue.lastleg.fleet.trip.FleetTripModels;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
@Service
public class TripOperationsFinanceService {
private final JdbcTemplate jdbc;private final ObjectMapper json;private final TripFinanceClient finance;private final String token;
public TripOperationsFinanceService(JdbcTemplate jdbc,ObjectMapper json,TripFinanceClient finance,
@Value("${baiyue.security.internal-service-token}") String token) {this.jdbc=jdbc;this.json=json;this.finance=finance;this.token=token;}
public record Result(String tripNo,List<String> dispatchNos,boolean receiptsVisible,TripFinanceClient.Result finance,String attributionStatus,String attributionReason) { }
record Binding(String customerId,String dispatchNo) { }
record Ownership(Binding binding,long tripCount,String tripNo) { }
record Delivery(Binding binding,String deliveryNo) { }
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public Result read(String tripNo,boolean receiptsVisible) {
var snapshots=jdbc.query("""
SELECT h.snapshot_json FROM ll_fleet_trip t JOIN ll_fleet_trip_history h ON h.trip_id=t.id AND h.action='DEPART'
WHERE t.trip_no=? AND t.departed_at IS NOT NULL ORDER BY h.record_version LIMIT 1
""",(r,i)->r.getString(1),tripNo);
if(snapshots.size()!=1)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"实际发车记录不存在");
FleetTripModels.AuditSnapshot snapshot;
try {snapshot=json.readValue(snapshots.get(0),FleetTripModels.AuditSnapshot.class);}
catch(Exception e){throw new IllegalStateException("实际发车财务归属快照无法读取",e);}
if(snapshot.trip()==null||!tripNo.equals(snapshot.trip().tripNo())||snapshot.confirmedLoad()==null||snapshot.confirmedLoad().cargo()==null)
throw new IllegalStateException("实际发车快照缺少车次或货物归属");
var bindings=snapshot.confirmedLoad().cargo().stream().map(c->new Binding(c.customerId(),c.dispatchNo())).distinct().toList();
if(bindings.size()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次来源超过200组,请核对来源");
var businesses=new ArrayList<TripFinanceClient.Business>();boolean pending=bindings.isEmpty();
if(!bindings.isEmpty()) {
var args=new ArrayList<Object>();var ownerPairs=new ArrayList<String>();var deliveryPairs=new ArrayList<String>();
for(var binding:bindings) {
ownerPairs.add("(c.customer_id=? AND c.dispatch_no=?)");
deliveryPairs.add("(customer_id=? AND dispatch_no=?)");
args.add(binding.customerId());args.add(binding.dispatchNo());
}
// One scan of the global immutable DEPART set, including outside report filters.
var owners=jdbc.query("""
SELECT c.customer_id,c.dispatch_no,COUNT(DISTINCT t.trip_no) trip_count,MAX(t.trip_no) trip_no FROM ll_fleet_trip t
JOIN ll_fleet_trip_history h ON h.trip_id=t.id AND h.action='DEPART'
JOIN JSON_TABLE(h.snapshot_json,'$.confirmedLoad.cargo[*]' COLUMNS(
customer_id VARCHAR(32) PATH '$.customerId',dispatch_no VARCHAR(64) PATH '$.dispatchNo')) c
WHERE t.departed_at IS NOT NULL AND (
"""+String.join(" OR ",ownerPairs)+") GROUP BY c.customer_id,c.dispatch_no",
(r,i)->new Ownership(new Binding(r.getString("customer_id"),r.getString("dispatch_no")),r.getLong("trip_count"),r.getString("trip_no")),args.toArray());
var deliveries=jdbc.query("SELECT customer_id,dispatch_no,delivery_no FROM ll_delivery_order WHERE ("
+String.join(" OR ",deliveryPairs)+") ORDER BY customer_id,dispatch_no,delivery_no LIMIT 201",
(r,i)->new Delivery(new Binding(r.getString("customer_id"),r.getString("dispatch_no")),r.getString("delivery_no")),args.toArray());
if(deliveries.size()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次派送来源超过200条,请核对来源");
var ownersByBinding=new HashMap<Binding,Ownership>();
for(var owner:owners)if(ownersByBinding.put(owner.binding(),owner)!=null)throw new IllegalStateException("车次来源归属汇总重复");
var deliveriesByBinding=new HashMap<Binding,List<String>>();
for(var delivery:deliveries)deliveriesByBinding.computeIfAbsent(delivery.binding(),k->new ArrayList<>()).add(delivery.deliveryNo());
for(var binding:bindings) {
var owner=ownersByBinding.get(binding);
var numbers=deliveriesByBinding.getOrDefault(binding,List.of());
if(owner==null||owner.tripCount()!=1||!tripNo.equals(owner.tripNo())||numbers.isEmpty()){pending=true;continue;}
for(var no:numbers)businesses.add(new TripFinanceClient.Business(Long.parseLong(binding.customerId()),no));
}
}
if(businesses.size()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次派送来源超过200条,请核对来源");
TripFinanceClient.Result result;
try {
var response=finance.query(token,new TripFinanceClient.Query(businesses.stream().distinct().toList(),receiptsVisible));
if(response==null||!response.successful()||response.data()==null)throw new IllegalStateException("财务响应缺失");
result=response.data();validate(result,businesses,receiptsVisible);
}catch(Exception e){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Finance车次应收/实收暂不可查询",e);}
return new Result(tripNo,bindings.stream().map(Binding::dispatchNo).distinct().toList(),receiptsVisible,result,
pending?"PENDING_ALLOCATION":"ATTRIBUTED",pending?"部分来源无法唯一归属本车次,完整金额待分配":"按不可变发车客户及排车来源核对派送费用");
}
static void validate(TripFinanceClient.Result value,List<TripFinanceClient.Business> businesses,boolean receiptsVisible) {
if(value.totals()==null||value.sources()==null||value.documents()==null||!Set.of("AVAILABLE","PENDING_ALLOCATION").contains(value.status()))throw new IllegalStateException("财务归属响应无效");
var currencies=new HashSet<String>();
for(var money:value.totals())if(money.currency()==null||!money.currency().matches("[A-Z]{3}")||!currencies.add(money.currency())||money.receivableAmount()==null||money.receivableAmount().signum()<0||money.receivedAmount()!=null&&money.receivedAmount().signum()<0||!receiptsVisible&&money.receivedAmount()!=null)throw new IllegalStateException("财务金额响应无效");
for(var source:value.sources())if(businesses.stream().noneMatch(b->Long.toString(b.customerId()).equals(source.customerId())&&b.deliveryNo().equals(source.businessNo()))||source.sourceVersion()<1||source.amount()==null||source.amount().signum()<0)throw new IllegalStateException("财务来源超出车次");
if(!receiptsVisible&&!value.documents().isEmpty())throw new IllegalStateException("无实收权限却返回收款来源");
}
}
@@ -3,7 +3,7 @@
"info": {
"title": "真实出车与自有外接运营统计",
"version": "1.0.0",
"description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;财务未查询明确NOT_QUERIED;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
"description": "仅实际departed_at非空车次;使用DEPART不可变快照,一车次计一次,跨客户或多目的地不重复。物理托盘去重;自有与外接箱数以BOUND外接实收归属核算,支持同排车/同托混货。全筛选汇总与分页同快照;sourceType筛选含该类货的整车,并非裁切另一类货物。from含、to不含,UTC,最多366天/5000车次。发车车牌/司机使用历史,货物结案状态使用当前事实。约定金额按币种分别累计,不是财务应收/实收;列表财务按需读取,未读取明确NOT_QUERIED;单车次财务仅按DEPART客户/排车核对唯一派送来源,Finance返回已审核有效应收与明确归属整车次账单的确认收款分配。混合来源、无收款分配或多车次归属不估算,显示待分配。应收额仍按Finance实际币种展示,不把外接约定金额当应收;成本只通过FleetTripCostService.summaries(Collection<String>)按最多100车次一批读取,保持现有trip:list成本读权限。无权限为FORBIDDEN,既有车次无登记为NOT_RECORDED;可读汇总仅包含已登记直接成本,返回未登记及未能分摊车次数,未知分摊值为null。查询不跨服务逐行HTTP,不写执行状态。 图表与分页共用全筛选快照:按UTC日展示实运托盘及外接约定金额,目的地按稳定键统计涉及车次。按托均价仅包含PALLET计价,用该币种约定总金额除确认计价标准托位,ORDER报价排除。里程mile、油耗L是授权补充事实,与车辆档案累计里程和直接成本独立。"
},
"security": [
{
@@ -589,6 +589,51 @@
}
}
}
},
"/api/platform/v1/last-leg/fleet/operations/{tripNo}/finance": {
"get": {
"description": "operations:list与platform:finance:customer-arap:list同时要求;实收及收款单据另要求platform:finance:receipt:view,否则receiptsVisible=false、实收null、documents空。按需一次跨服务读取,无列表逐行HTTP;不写状态/费用/分配。",
"parameters": [
{
"name": "tripNo",
"in": "path",
"required": true,
"schema": {
"type": "string",
"maxLength": 64
}
}
],
"responses": {
"200": {
"description": "同车次财务归属结果;已确认部分与完整金额待分配分别标明",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/TripFinance"
}
}
}
}
}
},
"403": {
"description": "缺运营或应收权限"
},
"404": {
"description": "无实际发车记录"
},
"503": {
"description": "Finance不可查询,不能替换成零"
}
}
}
}
},
"components": {
@@ -1102,6 +1147,181 @@
"maximum": 10485760
}
}
},
"TripFinanceMoney": {
"type": "object",
"required": [
"currency",
"receivableAmount",
"receivedAmount"
],
"properties": {
"currency": {
"type": "string"
},
"receivableAmount": {
"type": "number",
"minimum": 0
},
"receivedAmount": {
"type": "number",
"minimum": 0,
"nullable": true
}
}
},
"TripFinanceSource": {
"type": "object",
"required": [
"customerId",
"businessNo",
"sourceNo",
"feeItemName",
"sourceVersion",
"currency",
"amount",
"status"
],
"properties": {
"customerId": {
"type": "string"
},
"businessNo": {
"type": "string"
},
"sourceNo": {
"type": "string",
"nullable": true
},
"feeItemName": {
"type": "string"
},
"sourceVersion": {
"type": "integer",
"minimum": 1
},
"currency": {
"type": "string"
},
"amount": {
"type": "number",
"minimum": 0
},
"status": {
"type": "string"
}
}
},
"TripFinanceDocument": {
"type": "object",
"required": [
"billNo",
"currency",
"receivedAmount",
"allocationStatus",
"reason"
],
"properties": {
"billNo": {
"type": "string"
},
"currency": {
"type": "string"
},
"receivedAmount": {
"type": "number",
"minimum": 0,
"nullable": true
},
"allocationStatus": {
"type": "string",
"enum": [
"ATTRIBUTED",
"PENDING_ALLOCATION"
]
},
"reason": {
"type": "string"
}
}
},
"TripFinanceAuthority": {
"type": "object",
"required": [
"status",
"totals",
"sources",
"documents",
"reason"
],
"properties": {
"status": {
"type": "string",
"enum": [
"AVAILABLE",
"PENDING_ALLOCATION"
]
},
"totals": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TripFinanceMoney"
}
},
"sources": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TripFinanceSource"
}
},
"documents": {
"type": "array",
"items": {
"$ref": "#/components/schemas/TripFinanceDocument"
}
},
"reason": {
"type": "string"
}
}
},
"TripFinance": {
"type": "object",
"required": [
"tripNo",
"dispatchNos",
"receiptsVisible",
"finance",
"attributionStatus",
"attributionReason"
],
"properties": {
"tripNo": {
"type": "string"
},
"dispatchNos": {
"type": "array",
"items": {
"type": "string"
}
},
"receiptsVisible": {
"type": "boolean"
},
"finance": {
"$ref": "#/components/schemas/TripFinanceAuthority"
},
"attributionStatus": {
"type": "string",
"enum": [
"ATTRIBUTED",
"PENDING_ALLOCATION"
]
},
"attributionReason": {
"type": "string"
}
}
}
}
},
@@ -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");
}
}