From 7577650eee6e721b865212a2b8c5d70034812f6d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 11:51:47 +0800 Subject: [PATCH] feat(fleet): read attributable trip receivables and receipts from finance --- .../InternalTripFinanceController.java | 31 +++ .../baiyue/finance/dto/TripFinanceModels.java | 17 ++ .../service/TripFinanceQueryService.java | 85 +++++++ .../openapi/finance-trip-receivables-v1.yaml | 234 ++++++++++++++++++ .../InternalTripFinanceControllerTest.java | 23 ++ .../service/TripFinanceQueryServiceTest.java | 68 +++++ .../PlatformTripOperationsController.java | 8 +- .../fleet/reporting/TripFinanceClient.java | 19 ++ .../TripOperationsFinanceService.java | 85 +++++++ .../openapi/fleet-operations-v1.yaml | 222 ++++++++++++++++- .../TripOperationsFinanceServiceTest.java | 101 ++++++++ 11 files changed, 891 insertions(+), 2 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java create mode 100644 finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java create mode 100644 finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java create mode 100644 finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml create mode 100644 finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceServiceTest.java diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java new file mode 100644 index 000000000..8666ed8d7 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java @@ -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 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)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java b/finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java new file mode 100644 index 000000000..79e1becb2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java @@ -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 totals, List sources, List documents, String reason) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java new file mode 100644 index 000000000..e889a9458 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java @@ -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();var predicates=new ArrayList(); + 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 fees,TripFinanceModels.Query request) { + var receivables=new TreeMap();var received=new TreeMap(); + var pending=new HashSet();var docs=new ArrayList(); + var eligible=new HashMap();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();var byFee=new HashMap>(); + 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>();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();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); + } +} diff --git a/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml new file mode 100644 index 000000000..5b15dabc6 --- /dev/null +++ b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml @@ -0,0 +1,234 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "车次Finance权威应收与实收只读来源", + "version": "1.0.0", + "description": "Lastleg以不可变DEPART验证后的客户/派送业务号读取。仅本Finance库;最新已审核有效LASTLEG OVERSEAS_DELIVERY_FEE应收,按币种。实收只取fin_receipt_bill已确认分配;整张ISSUED账单全部来源均明确归属请求车次时才计一次。混合账单、无分配、无有效来源为待分配,不按账单总额或比例估算。最多200业务、2000费用,批量查询。现行应收结算单收款沿关联账单,fin_settlement_payment仅用于应付,不重复叠加。" + }, + "paths": { + "/internal/finance/v1/trip-receivables/query": { + "post": { + "security": [ + { + "internalToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "businesses", + "receiptsVisible" + ], + "properties": { + "businesses": { + "type": "array", + "maxItems": 200, + "items": { + "type": "object", + "required": [ + "customerId", + "deliveryNo" + ], + "properties": { + "customerId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "deliveryNo": { + "type": "string", + "maxLength": 64 + } + } + } + }, + "receiptsVisible": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Finance来源及归属,不生成费用/账单/分配", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TripFinanceAuthority" + } + } + } + } + } + }, + "400": { + "description": "输入或来源数量超限" + }, + "401": { + "description": "内部令牌无效" + } + } + } + } + }, + "components": { + "securitySchemes": { + "internalToken": { + "type": "apiKey", + "in": "header", + "name": "X-Internal-Service-Token" + } + }, + "schemas": { + "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" + } + } + } + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java new file mode 100644 index 000000000..58d137d07 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.controller; +import com.baiyue.finance.dto.TripFinanceModels; +import com.baiyue.finance.service.TripFinanceQueryService; +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class InternalTripFinanceControllerTest { + @Test void rejectsAbsentOrWrongInternalTokenBeforeReadingFinance() { + var service=mock(TripFinanceQueryService.class);var controller=new InternalTripFinanceController(service,"synthetic-token"); + var query=new TripFinanceModels.Query(List.of(),false); + assertThatThrownBy(()->controller.query(null,query)).isInstanceOf(FinanceEventException.class); + assertThatThrownBy(()->controller.query("wrong",query)).isInstanceOf(FinanceEventException.class); + verifyNoInteractions(service); + } + @Test void validInternalTokenReturnsOnlyTheProviderResult() { + var service=mock(TripFinanceQueryService.class);var query=new TripFinanceModels.Query(List.of(),false); + var result=new TripFinanceModels.Result("PENDING_ALLOCATION",List.of(),List.of(),List.of(),"pending"); + when(service.query(query)).thenReturn(result); + assertThat(new InternalTripFinanceController(service,"synthetic-token").query("synthetic-token",query).data()).isSameAs(result); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java new file mode 100644 index 000000000..b7601d48d --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java @@ -0,0 +1,68 @@ +package com.baiyue.finance.service; +import com.baiyue.finance.dto.TripFinanceModels; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.*; +import java.math.BigDecimal; +import static org.assertj.core.api.Assertions.*; +class TripFinanceQueryServiceTest { + JdbcTemplate jdbc;TripFinanceQueryService service; + @BeforeEach void setup() { + jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","")); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT,customer_id BIGINT,business_no VARCHAR(64),business_type VARCHAR(32),source_no VARCHAR(64),fee_item_name VARCHAR(64),source_fee_id BIGINT,source_fee_version INT,source_system VARCHAR(32),source_type VARCHAR(32),direction VARCHAR(32),review_status VARCHAR(32),status VARCHAR(32),currency_code VARCHAR(3),amount DECIMAL(20,6))"); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT,tenant_id BIGINT,bill_no VARCHAR(64),currency_code VARCHAR(3),status VARCHAR(16))"); + jdbc.execute("CREATE TABLE fin_bill_line(tenant_id BIGINT,bill_id BIGINT,source_id BIGINT,source_version INT,currency_code VARCHAR(3))"); + jdbc.execute("CREATE TABLE fin_receipt(id BIGINT,tenant_id BIGINT,status VARCHAR(16),currency_code VARCHAR(3))"); + jdbc.execute("CREATE TABLE fin_receipt_bill(tenant_id BIGINT,receipt_id BIGINT,bill_id BIGINT,applied_amount DECIMAL(20,6))"); + service=new TripFinanceQueryService(jdbc); + } + void fee(long id,long customer,String business,int version,String currency,String amount,String status) { + jdbc.update("INSERT INTO fin_fee_source VALUES(?,?,?,'DELIVERY_ORDER',?,'Delivery',99,?,'LASTLEG','OVERSEAS_DELIVERY_FEE','RECEIVABLE','APPROVED',?,?,?)",id,customer,business,business,version,status,currency,new BigDecimal(amount)); + } + TripFinanceModels.Query request(boolean receipts) {return new TripFinanceModels.Query(List.of(new TripFinanceModels.Business(1,"DL-A")),receipts);} + void bill(long source,int version) { + jdbc.update("INSERT INTO fin_bill VALUES(1,1,'BILL-A','CNY','ISSUED')"); + jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,?,?,'CNY')",source,version); + } + void receipt(String amount) { + jdbc.update("INSERT INTO fin_receipt VALUES(1,1,'CONFIRMED','CNY')"); + jdbc.update("INSERT INTO fin_receipt_bill VALUES(1,1,1,?)",new BigDecimal(amount)); + } + @Test void exactWholeBillUsesRecordedAllocationAmount() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("3.5"); + var result=service.query(request(true));assertThat(result.totals().get(0).receivedAmount()).isEqualByComparingTo("3.5"); + assertThat(result.totals().get(0).receivableAmount()).isEqualByComparingTo("20"); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("ATTRIBUTED"); + } + @Test void mixedBillNeverProratesReceipt() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("30"); + jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,999,1,'CNY')"); + var result=service.query(request(true));assertThat(result.totals().get(0).receivedAmount()).isNull(); + assertThat(result.documents().get(0).billNo()).isEqualTo("BILL-A"); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("PENDING_ALLOCATION"); + } + @Test void unbilledOrNoConfirmedReceiptIsUnknownNotZero() { + fee(1,1,"DL-A",1,"CNY","0","AVAILABLE"); + assertThat(service.query(request(true)).totals().get(0).receivedAmount()).isNull(); + bill(1,1);assertThat(service.query(request(true)).totals().get(0).receivedAmount()).isNull(); + assertThat(service.query(request(true)).totals().get(0).receivableAmount()).isEqualByComparingTo("0"); + } + @Test void latestVoidedDoesNotFallBackAndOtherCustomerExcluded() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");fee(2,1,"DL-A",2,"CNY","20","VOIDED"); + fee(3,2,"DL-A",1,"CNY","999","AVAILABLE"); + var result=service.query(request(true));assertThat(result.sources()).isEmpty();assertThat(result.status()).isEqualTo("PENDING_ALLOCATION"); + } + @Test void currenciesSeparateAndNoReceiptPermissionMasksDocuments() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE"); + jdbc.update("UPDATE fin_fee_source SET source_fee_id=1 WHERE id=1"); + fee(2,1,"DL-A",1,"USD","5","AVAILABLE"); + var result=service.query(request(false));assertThat(result.totals()).extracting(TripFinanceModels.Money::currency).containsExactly("CNY","USD"); + assertThat(result.totals()).allSatisfy(m->assertThat(m.receivedAmount()).isNull());assertThat(result.documents()).isEmpty(); + } + @Test void wholeTripBillIsCountedOnceAcrossTwoFeeLines() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");jdbc.update("UPDATE fin_fee_source SET source_fee_id=1 WHERE id=1"); + fee(2,1,"DL-A",1,"CNY","10","AVAILABLE");bill(1,1);jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,2,1,'CNY')");receipt("7"); + var result=service.query(request(true));assertThat(result.documents()).hasSize(1);assertThat(result.totals().get(0).receivedAmount()).isEqualByComparingTo("7"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java index db01842bf..b0e86d9ae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java @@ -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 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 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");} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java new file mode 100644 index 000000000..97e7cebba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java @@ -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 query(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Query query); + record Business(long customerId,String deliveryNo) { } + record Query(List 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 totals,List sources,List documents,String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java new file mode 100644 index 000000000..bab41f757 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java @@ -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 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();boolean pending=bindings.isEmpty(); + if(!bindings.isEmpty()) { + var args=new ArrayList();var ownerPairs=new ArrayList();var deliveryPairs=new ArrayList(); + 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(); + for(var owner:owners)if(ownersByBinding.put(owner.binding(),owner)!=null)throw new IllegalStateException("车次来源归属汇总重复"); + var deliveriesByBinding=new HashMap>(); + 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 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(); + 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("无实收权限却返回收款来源"); + } +} diff --git a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml index aa4fb9ffa..9c464547d 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-operations-v1.yaml @@ -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)按最多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)按最多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" + } + } } } }, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceServiceTest.java new file mode 100644 index 000000000..3bea7c9c8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceServiceTest.java @@ -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> results=new ArrayDeque<>();final List sql=new ArrayList<>(); + @Override @SuppressWarnings("unchecked") public List query(String statement,RowMapper mapper,Object...args){sql.add(statement);return (List)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();var deliveries=new ArrayList(); + var expected=new ArrayList(); + 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 List query(String statement,RowMapper mapper,Object...args) { + queries++; + if(statement.contains("JSON_TABLE")) { + assertThat(args).containsExactly("1","D-A","2","D-B"); + return (List)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"); + } +}