feat(fleet): integrate costs quotes operations and in-transit handovers

This commit is contained in:
2026-10-03 12:38:04 +08:00
parent 274ecac474
commit cb6c54ffe5
107 changed files with 7304 additions and 42 deletions
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records")
public class PlatformVehicleCareFinanceController {
private final VehicleCareFinanceService service;
public PlatformVehicleCareFinanceController(VehicleCareFinanceService service) { this.service = service; }
@PostMapping("/finance-status")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')")
public ApiResponse<List<VehicleCareFinanceModels.Status>> statuses(@PathVariable long vehicleId, @Valid @RequestBody VehicleCareFinanceModels.Query request) {
return ApiResponse.success(service.statuses(vehicleId, request.recordNos()));
}
@PostMapping("/{recordNo}/finance")
@PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-finance-submit')")
public ApiResponse<VehicleCareFinanceModels.Submitted> submit(@PathVariable long vehicleId, @PathVariable String recordNo,
@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody VehicleCareFinanceModels.Submit request) {
return ApiResponse.success(service.submit(vehicleId, recordNo, user.userId().toString(), request));
}
}
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@FeignClient(name="finance-service", contextId="vehicleCareFinance", url="${baiyue.clients.finance.url:}")
public interface VehicleCareFinanceClient {
@PostMapping("/internal/finance/v1/vehicle-costs/sources/query")
ApiResponse<List<VehicleCareFinanceModels.Cost>> query(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody VehicleCareFinanceModels.SourceQuery request);
}
@@ -0,0 +1,17 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.event.EventEnvelope;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/** The fee slice owns currency conversion and the shared reliable outbox. */
public interface VehicleCareFinanceGateway {
record Conversion(String originalCurrency, BigDecimal originalAmount, BigDecimal exchangeRate,
String exchangeRateMonth, BigDecimal settlementAmount) { }
Conversion convert(String currency, BigDecimal amount, LocalDate businessDate);
/** Must join the caller's transaction; failure must roll back the fleet source mapping too. */
void publish(EventEnvelope event);
Map<String, String> deliveryStates(List<String> eventKeys);
}
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.fleet.maintenance;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
public final class VehicleCareFinanceModels {
private VehicleCareFinanceModels() { }
public record Submit(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long supplierId,
@NotNull LocalDate businessDate, @NotBlank @Size(min=8,max=128) String idempotencyKey) { }
public record Query(@NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> recordNos) { }
public record Source(String sourceType, String sourceNo, long sourceVersion, String vehicleId, String vehiclePlate,
String tripNo, long supplierId, String feeName, LocalDate businessDate, String originalCurrency, BigDecimal originalAmount,
BigDecimal exchangeRate, String exchangeRateMonth, BigDecimal amount, String remark) { }
public record SourceKey(String sourceType, String sourceNo) { }
public record SourceQuery(List<SourceKey> sources) { }
public record Cost(String costNo, String subjectCode, Source source, String supplierName, String currency, String status,
BigDecimal paidAmount, BigDecimal outstandingAmount, long recordVersion) { }
public record Status(String recordNo, String status, Long sourceVersion, Long submittedRecordVersion, Cost finance) { }
public record Submitted(String recordNo, long sourceVersion, long submittedRecordVersion, boolean replayed, String status) { }
}
@@ -0,0 +1,149 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.event.*;
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.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
@Service
public class VehicleCareFinanceService {
private static final String EVENT_TYPE = EventTypes.COMPANY_VEHICLE_COST_SUBMITTED;
private final VehicleCareMapper records;
private final JdbcTemplate jdbc;
private final ObjectMapper json;
private final VehicleCareFinanceGateway accounting;
private final VehicleCareFinanceClient finance;
private final VehicleCareSupplierClient suppliers;
private final String token;
record Submission(long recordId, long sourceVersion, long recordVersion, String eventId, String requestHash, String payload) { }
public VehicleCareFinanceService(VehicleCareMapper records, JdbcTemplate jdbc, ObjectMapper json, VehicleCareFinanceGateway accounting,
VehicleCareFinanceClient finance, VehicleCareSupplierClient suppliers,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.records = records; this.jdbc = jdbc; this.json = json; this.accounting = accounting;
this.finance = finance; this.suppliers = suppliers; this.token = token;
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public VehicleCareFinanceModels.Submitted submit(long vehicleId, String number, String operator, VehicleCareFinanceModels.Submit command) {
var row = records.lock(vehicleId, number); requireCostRecord(row);
String hash = hash(command);
var prior = jdbc.query("SELECT * FROM ll_fleet_finance_source WHERE record_id=? AND operator_id=? AND request_key=?",
(r, i) -> submission(r), row.getId(), operator, command.idempotencyKey());
if (!prior.isEmpty()) {
if (!hash.equals(prior.get(0).requestHash())) throw conflict("财务提交标识已用于其他内容");
return new VehicleCareFinanceModels.Submitted(number, prior.get(0).sourceVersion(), prior.get(0).recordVersion(), true, "PENDING_FINANCE");
}
if (!row.getRecordVersion().equals(command.recordVersion())) throw conflict("运维记录已变化,请重新核对后提交");
var latest = latest(row.getId());
var current = queryFinance(List.of(row)).get(number); // Fail closed if Finance cannot establish current facts.
if (latest != null) {
if (current == null || current.source().sourceVersion() != latest.sourceVersion()) throw conflict("上次提交尚未完成财务接收,请稍后核对");
if (!"VOIDED".equals(current.status())) throw conflict("费用已提交,重新登记须先在财务作废;已付款费用不可改写");
} else if (current != null) throw conflict("财务来源已有记录但本地提交关系不一致,请核对");
var details = draft(row); var incident = details.incident();
if (incident == null || incident.reportedCost() == null || incident.reportedCost().signum() <= 0) throw bad("请先保存正数申报费用;未填写或明确零费用不生成财务应付");
requireSupplier(command.supplierId());
var converted = accounting.convert(incident.currency(), incident.reportedCost(), command.businessDate());
if (converted.settlementAmount().signum() <= 0 || converted.settlementAmount().precision() - converted.settlementAmount().scale() > 15)
throw bad("折算金额必须为正数且在财务支持范围内");
var source = new VehicleCareFinanceModels.Source(row.getRecordType(), row.getRecordNo(), latest == null ? 1 : latest.sourceVersion() + 1,
row.getVehicleId().toString(), row.getVehiclePlate(), null, command.supplierId(), incident.item(), command.businessDate(),
converted.originalCurrency(), converted.originalAmount(), converted.exchangeRate(), converted.exchangeRateMonth(), converted.settlementAmount(), details.remark());
if (source.feeName() == null || source.feeName().length() > 200) throw bad("提交财务的维修或救援项目须在200字以内");
var payload = json.valueToTree(source); String eventId = UUID.randomUUID().toString();
String eventKey = EVENT_TYPE + ':' + row.getRecordType() + ':' + number + ':' + source.sourceVersion();
var envelope = new EventEnvelope(eventId, EVENT_TYPE, 1, Instant.now(), "0", "COMPANY_VEHICLE_COST",
row.getRecordType() + ':' + number, source.sourceVersion(), null, UUID.randomUUID().toString(), eventKey, null,
payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid();
jdbc.update("INSERT INTO ll_fleet_finance_source(id,record_id,source_version,record_version,operator_id,request_key,request_hash,event_id,payload_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)",
IdWorker.getId(), row.getId(), source.sourceVersion(), row.getRecordVersion(), operator, command.idempotencyKey(), hash, eventId, write(source), LocalDateTime.now(ZoneOffset.UTC));
accounting.publish(envelope);
return new VehicleCareFinanceModels.Submitted(number, source.sourceVersion(), row.getRecordVersion(), false, "PENDING_FINANCE");
}
public List<VehicleCareFinanceModels.Status> statuses(long vehicleId, List<String> numbers) {
if (numbers == null || numbers.isEmpty() || numbers.size() > 100 || numbers.stream().anyMatch(n -> n == null || n.isBlank())) throw bad("财务状态查询须提供1至100个有效记录号");
var recordsByNo = records.findFinanceRecords(vehicleId, List.copyOf(new LinkedHashSet<>(numbers)));
if (recordsByNo.size() != new HashSet<>(numbers).size()) throw missing();
var latestById = latest(recordsByNo.stream().map(VehicleCareEntity::getId).toList());
var remote = latestById.isEmpty() ? Map.<String, VehicleCareFinanceModels.Cost>of() : queryFinance(recordsByNo.stream().filter(r -> latestById.containsKey(r.getId())).toList());
var deliveryKeys = recordsByNo.stream().filter(r -> latestById.containsKey(r.getId()))
.map(r -> EVENT_TYPE + ':' + r.getRecordType() + ':' + r.getRecordNo() + ':' + latestById.get(r.getId()).sourceVersion()).toList();
var deliveries = accounting.deliveryStates(deliveryKeys);
return recordsByNo.stream().map(row -> {
var local = latestById.get(row.getId()); if (local == null) return new VehicleCareFinanceModels.Status(row.getRecordNo(), "UNLINKED", null, null, null);
var cost = remote.get(row.getRecordNo());
if (cost == null || cost.source().sourceVersion() < local.sourceVersion()) {
var delivery = deliveries.get(EVENT_TYPE + ':' + row.getRecordType() + ':' + row.getRecordNo() + ':' + local.sourceVersion());
if (delivery == null) throw unavailable("财务投递关系缺失,状态尚未确认");
return new VehicleCareFinanceModels.Status(row.getRecordNo(), "DEAD".equals(delivery) ? "FINANCE_ATTENTION" : "PENDING_FINANCE", local.sourceVersion(), local.recordVersion(), null);
}
if (cost.source().sourceVersion() != local.sourceVersion()) throw conflict("财务来源版本与本地提交不一致");
if (!hash(cost.source()).equals(hash(read(local.payload(), VehicleCareFinanceModels.Source.class)))) throw conflict("财务来源内容与本地提交快照不一致");
return new VehicleCareFinanceModels.Status(row.getRecordNo(), cost.status(), local.sourceVersion(), local.recordVersion(), cost);
}).toList();
}
/** Called under the existing record lock; ordinary processing updates do not depend on Finance. */
public void guardFinancialEdit(VehicleCareEntity row, VehicleCareModels.Draft next) {
if (!Set.of("REPAIR", "RESCUE").contains(row.getRecordType())) return;
var before = draft(row);
if (Objects.equals(before.incident().item(), next.incident().item())
&& Objects.equals(before.incident().currency(), next.incident().currency())
&& equalAmount(before.incident().reportedCost(), next.incident().reportedCost())) return;
var latest = latest(row.getId()); if (latest == null) return;
var remote = queryFinance(List.of(row)).get(row.getRecordNo());
if (remote == null || remote.source().sourceVersion() != latest.sourceVersion()) throw conflict("财务接收状态未确认,暂不能更改费用字段");
if (!"VOIDED".equals(remote.status())) throw conflict("已提交费用须先在财务作废,已付款费用禁止改写");
}
private Map<String, VehicleCareFinanceModels.Cost> queryFinance(List<VehicleCareEntity> rows) {
requireToken();
try {
var keys = rows.stream().map(r -> new VehicleCareFinanceModels.SourceKey(r.getRecordType(), r.getRecordNo())).toList();
var response = finance.query(token, new VehicleCareFinanceModels.SourceQuery(keys));
if (response == null || !response.successful() || response.data() == null) throw unavailable("财务状态读取失败,未确认入账或付款");
var result = new HashMap<String, VehicleCareFinanceModels.Cost>();
for (var cost : response.data()) {
if (cost == null || cost.source() == null || !"PLATFORM_DEFAULT".equals(cost.subjectCode()) || !"CNY".equals(cost.currency())
|| !Set.of("PENDING","APPROVED","PARTIALLY_PAID","PAID","VOIDED").contains(Objects.toString(cost.status(), ""))
|| !keys.contains(new VehicleCareFinanceModels.SourceKey(cost.source().sourceType(), cost.source().sourceNo()))
|| rows.stream().noneMatch(r -> r.getRecordNo().equals(cost.source().sourceNo()) && r.getVehicleId().toString().equals(cost.source().vehicleId()))
|| cost.paidAmount() == null || cost.paidAmount().signum() < 0
|| result.put(cost.source().sourceNo(), cost) != null) throw unavailable("财务返回的来源归属或状态无效");
}
return result;
} catch (feign.FeignException error) { throw unavailable("Finance暂不可用,请稍后核对"); }
}
private void requireSupplier(long id) {
requireToken();
try { var r = suppliers.get(token, id, true);
if (r == null || !r.successful() || r.data() == null || r.data().supplierId() != id || !"ACTIVE".equals(r.data().status())) throw bad("供应商不存在或未启用");
} catch (feign.FeignException error) { throw unavailable("供应商主数据暂不可用"); }
}
private Submission latest(long recordId) { return latest(List.of(recordId)).get(recordId); }
private Map<Long, Submission> latest(List<Long> ids) {
if (ids.isEmpty()) return Map.of();
var rows = jdbc.query("SELECT s.* FROM ll_fleet_finance_source s JOIN (SELECT record_id,MAX(source_version) source_version FROM ll_fleet_finance_source WHERE record_id IN (" + placeholders(ids.size()) + ") GROUP BY record_id) v ON v.record_id=s.record_id AND v.source_version=s.source_version",
(r, i) -> submission(r), ids.toArray());
var map = new HashMap<Long, Submission>(); rows.forEach(r -> map.put(r.recordId(), r)); return map;
}
private static Submission submission(java.sql.ResultSet r) throws java.sql.SQLException { return new Submission(r.getLong("record_id"), r.getLong("source_version"), r.getLong("record_version"), r.getString("event_id"), r.getString("request_hash"), r.getString("payload_json")); }
private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); }
private VehicleCareModels.Draft draft(VehicleCareEntity row) { return read(row.getDetailsJson(), VehicleCareModels.Draft.class); }
private static boolean equalAmount(BigDecimal a, BigDecimal b) { return a == null ? b == null : b != null && a.compareTo(b) == 0; }
private String hash(Object value) { return EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(value))); }
private String write(Object value) { try { return json.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法保存", e); } }
private <T> T read(String value, Class<T> type) { try { return json.readValue(value, type); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法读取", e); } }
private static void requireCostRecord(VehicleCareEntity row) { if (row == null || !Set.of("REPAIR","RESCUE").contains(row.getRecordType())) throw missing(); }
private void requireToken() { if (token == null || token.isBlank()) throw unavailable("内部服务身份未配置"); }
private static ResponseStatusException bad(String text) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, text); }
private static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT, text); }
private static ResponseStatusException unavailable(String text) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, text); }
private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前车辆维修救援记录不存在"); }
}
@@ -5,6 +5,10 @@ import org.apache.ibatis.annotations.*;
@Mapper
public interface VehicleCareMapper extends BaseMapper<VehicleCareEntity> {
@Select({"<script>SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no IN",
"<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>",
"AND record_type IN ('REPAIR','RESCUE')</script>"})
java.util.List<VehicleCareEntity> findFinanceRecords(@Param("vehicleId") long vehicleId, @Param("numbers") java.util.List<String> numbers);
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo}")
VehicleCareEntity find(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo);
@Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo} FOR UPDATE")
@@ -27,9 +27,10 @@ public class VehicleCareService {
private final VehicleRepository vehicles;
private final VehicleCareFileGateway files;
private final ObjectMapper json;
private final VehicleCareFinanceService finance;
public VehicleCareService(VehicleCareMapper records, VehicleCareHistoryMapper histories,
VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json) {
this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json;
VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json, VehicleCareFinanceService finance) {
this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json; this.finance = finance;
}
public PageResult<VehicleCareModels.Response> page(long vehicleId, VehicleCareModels.Query query) {
vehicles.findById(vehicleId).orElseThrow(VehicleCareService::missing);
@@ -65,6 +66,7 @@ public class VehicleCareService {
public VehicleCareModels.Response update(long vehicleId, String number, String operator, VehicleCareModels.Update request) {
var row = required(vehicleId, number, true); version(row, request.recordVersion()); validate(request.details());
if (!row.getRecordType().equals(request.details().recordType())) throw conflict("已保存记录不能改变运维类型");
finance.guardFinancialEdit(row, request.details());
if (request.reason() == null || request.reason().isBlank()) throw bad("请填写更正原因");
row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details()));
persist(row, request.recordVersion(), "UPDATE", request.reason(), operator);
@@ -121,7 +123,7 @@ public class VehicleCareService {
return new VehicleCareModels.Response(row.getRecordNo(), row.getVehicleId().toString(), row.getVehiclePlate(),
read(row.getDetailsJson(), VehicleCareModels.Draft.class), attached(row).stream().map(VehicleCareModels.Attached::file).toList(),
row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt().toInstant(ZoneOffset.UTC),
Set.of("REPAIR", "RESCUE").contains(row.getRecordType()) ? "UNLINKED" : null);
null); // Current financial facts are queried in a separate bounded batch, never frozen into operational history.
}
private VehicleCareEntity required(long vehicleId, String number, boolean lock) {
var row = lock ? records.lock(vehicleId, number) : records.find(vehicleId, number);
@@ -0,0 +1,13 @@
package com.baiyue.lastleg.fleet.maintenance;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
@FeignClient(name="warehouse-service", contextId="vehicleCareSupplier", url="${baiyue.clients.warehouse.url:}")
public interface VehicleCareSupplierClient {
@GetMapping("/internal/warehouse/v1/suppliers/{supplierId}")
ApiResponse<Supplier> get(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long supplierId,
@RequestParam boolean requireActive);
record Supplier(long supplierId, String name, String category, String status) { }
}