feat(fleet): read attributable trip receivables and receipts from finance
This commit is contained in:
+7
-1
@@ -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");}
|
||||
}
|
||||
|
||||
+19
@@ -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) { }
|
||||
}
|
||||
+85
@@ -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("无实收权限却返回收款来源");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user