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,31 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.dto.TripFinanceModels;
import com.baiyue.finance.service.TripFinanceQueryService;
import com.baiyue.finance.service.FinanceEventException;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/finance/v1/trip-receivables")
public class InternalTripFinanceController {
private final TripFinanceQueryService service;
private final String expectedToken;
public InternalTripFinanceController(TripFinanceQueryService service,
@Value("${baiyue.security.internal-service-token}") String expectedToken) {
this.service=service;this.expectedToken=expectedToken;
}
@PostMapping("/query")
public ApiResponse<TripFinanceModels.Result> query(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@Valid @RequestBody TripFinanceModels.Query query) {
if(token==null || expectedToken==null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),expectedToken.getBytes(StandardCharsets.UTF_8)))
throw new FinanceEventException("FIN-EVT-401","内部服务令牌无效");
return FinanceResponses.success(service.query(query));
}
}
@@ -0,0 +1,17 @@
package com.baiyue.finance.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.util.List;
public final class TripFinanceModels {
private TripFinanceModels() { }
public record Business(@Positive long customerId, @NotBlank @Size(max=64) String deliveryNo) { }
public record Query(@NotNull @Size(max=200) List<@Valid Business> businesses, boolean receiptsVisible) { }
public record Money(String currency, BigDecimal receivableAmount, BigDecimal receivedAmount) { }
public record Source(long customerId, String businessNo, String sourceNo, String feeItemName, int sourceVersion,
String currency, BigDecimal amount, String status) { }
public record Document(String billNo, String currency, BigDecimal receivedAmount, String allocationStatus, String reason) { }
public record Result(String status, List<Money> totals, List<Source> sources, List<Document> documents, String reason) { }
}
@@ -0,0 +1,85 @@
package com.baiyue.finance.service;
import com.baiyue.finance.dto.TripFinanceModels;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.*;
/** Only whole documents whose complete source set belongs to this trip are attributable. No ratios. */
@Service
public class TripFinanceQueryService {
private final JdbcTemplate jdbc;
public TripFinanceQueryService(JdbcTemplate jdbc) {this.jdbc=jdbc;}
record Fee(long id,long customerId,String businessNo,String sourceNo,String name,int version,String currency,BigDecimal amount,String status) { }
record Bill(long id,long customerId,String no,String currency) { }
@Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
public TripFinanceModels.Result query(TripFinanceModels.Query request) {
if(request.businesses().isEmpty())return new TripFinanceModels.Result("PENDING_ALLOCATION",List.of(),List.of(),List.of(),"尚无可明确归属车次的派送费用来源");
var args=new HashMap<String,Object>();var predicates=new ArrayList<String>();
int index=0;
for(var b:request.businesses().stream().distinct().toList()) {
predicates.add("(f.customer_id=:customer"+index+" AND f.business_no=:business"+index+")");
args.put("customer"+index,b.customerId());args.put("business"+index,b.deliveryNo());index++;
}
var fees=new NamedParameterJdbcTemplate(jdbc).query("""
SELECT f.id,f.customer_id,f.business_no,f.source_no,f.fee_item_name,f.source_fee_version,f.currency_code,f.amount,f.status
FROM fin_fee_source f WHERE f.source_system='LASTLEG' AND f.business_type='DELIVERY_ORDER'
AND f.source_type='OVERSEAS_DELIVERY_FEE' AND f.direction='RECEIVABLE'
AND f.review_status='APPROVED' AND f.status IN ('AVAILABLE','SETTLED')
AND NOT EXISTS (SELECT 1 FROM fin_fee_source newer WHERE newer.source_system=f.source_system
AND newer.source_type=f.source_type AND newer.source_fee_id=f.source_fee_id
AND newer.source_fee_version>f.source_fee_version)
AND (
"""+String.join(" OR ",predicates)+") ORDER BY f.customer_id,f.business_no,f.source_fee_id LIMIT 2001",
args,(r,i)->new Fee(r.getLong("id"),r.getLong("customer_id"),r.getString("business_no"),r.getString("source_no"),r.getString("fee_item_name"),r.getInt("source_fee_version"),r.getString("currency_code"),r.getBigDecimal("amount"),r.getString("status")));
if(fees.size()>2000)throw new FinanceEventException("FIN_400","车次费用来源超过2000条,请核对来源范围");
return project(fees,request);
}
TripFinanceModels.Result project(List<Fee> fees,TripFinanceModels.Query request) {
var receivables=new TreeMap<String,BigDecimal>();var received=new TreeMap<String,BigDecimal>();
var pending=new HashSet<String>();var docs=new ArrayList<TripFinanceModels.Document>();
var eligible=new HashMap<Long,Fee>();fees.forEach(f->{eligible.put(f.id(),f);receivables.merge(f.currency(),f.amount(),BigDecimal::add);});
boolean missing=request.businesses().stream().anyMatch(b->fees.stream().noneMatch(f->f.customerId()==b.customerId()&&f.businessNo().equals(b.deliveryNo())));
if(request.receiptsVisible()&&!fees.isEmpty()) {
var named=new NamedParameterJdbcTemplate(jdbc);
var refs=named.query("""
SELECT b.id,b.tenant_id,b.bill_no,b.currency_code,l.source_id FROM fin_bill_line l
JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id
JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id AND f.source_fee_version=l.source_version
WHERE l.source_id IN (:ids) AND b.status='ISSUED'
""",Map.of("ids",eligible.keySet()),(r,i)->new Object[]{
new Bill(r.getLong("id"),r.getLong("tenant_id"),r.getString("bill_no"),r.getString("currency_code")),r.getLong("source_id")});
var bills=new LinkedHashMap<Long,Bill>();var byFee=new HashMap<Long,Set<Long>>();
for(var ref:refs){var bill=(Bill)ref[0];bills.put(bill.id(),bill);byFee.computeIfAbsent((Long)ref[1],k->new HashSet<>()).add(bill.id());}
for(var fee:fees)if(byFee.getOrDefault(fee.id(),Set.of()).size()!=1)pending.add(fee.currency());
if(!bills.isEmpty()) {
var lines=named.query("SELECT bill_id,tenant_id,source_id,source_version,currency_code FROM fin_bill_line WHERE bill_id IN (:ids)",
Map.of("ids",bills.keySet()),(r,i)->new Object[]{r.getLong("bill_id"),r.getLong("tenant_id"),r.getLong("source_id"),r.getInt("source_version"),r.getString("currency_code")});
var byBill=new HashMap<Long,List<Object[]>>();for(var line:lines)byBill.computeIfAbsent((Long)line[0],k->new ArrayList<>()).add(line);
var payments=named.query("""
SELECT rb.bill_id,r.currency_code,SUM(rb.applied_amount) amount FROM fin_receipt_bill rb
JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id
JOIN fin_bill b ON b.id=rb.bill_id AND b.tenant_id=rb.tenant_id AND b.currency_code=r.currency_code
WHERE rb.bill_id IN (:ids) AND r.status='CONFIRMED' GROUP BY rb.bill_id,r.currency_code
""",Map.of("ids",bills.keySet()),(r,i)->new Object[]{r.getLong("bill_id"),r.getString("currency_code"),r.getBigDecimal("amount")});
var paidByBill=new HashMap<Long,BigDecimal>();for(var payment:payments)paidByBill.put((Long)payment[0],(BigDecimal)payment[2]);
for(var bill:bills.values()) {
var billLines=byBill.getOrDefault(bill.id(),List.of());
boolean exact=!billLines.isEmpty()&&billLines.stream().allMatch(l->{var f=eligible.get((Long)l[2]);return f!=null&&f.customerId()==bill.customerId()&&(Long)l[1]==bill.customerId()&&f.version()==(Integer)l[3]&&f.currency().equals(bill.currency())&&f.currency().equals(l[4]);});
BigDecimal paid=exact?paidByBill.get(bill.id()):null;
String reason=!exact?"账单包含本车次以外或无法明确归属的来源,实收待分配":paid==null?"尚无已确认的本车次收款分配":"整张账单的全部来源明确归属本车次";
if(paid==null)pending.add(bill.currency());else received.merge(bill.currency(),paid,BigDecimal::add);
docs.add(new TripFinanceModels.Document(bill.no(),bill.currency(),paid,paid==null?"PENDING_ALLOCATION":"ATTRIBUTED",reason));
}
}
}
var totals=receivables.entrySet().stream().map(e->new TripFinanceModels.Money(e.getKey(),e.getValue(),
!request.receiptsVisible()||pending.contains(e.getKey())?null:received.get(e.getKey()))).toList();
String status=fees.isEmpty()||missing?"PENDING_ALLOCATION":"AVAILABLE";
String reason=missing?"部分派送尚无已审核有效的Finance来源,已确认金额不代表完整车次应收":"仅包含已审核有效的Finance来源;实收只计明确归属本车次的已确认分配";
return new TripFinanceModels.Result(status,totals,fees.stream().map(f->new TripFinanceModels.Source(f.customerId(),f.businessNo(),f.sourceNo(),f.name(),f.version(),f.currency(),f.amount(),f.status())).toList(),List.copyOf(docs),reason);
}
}