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,33 @@
package com.baiyue.finance.fleet;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.DeliveryAck;
import com.baiyue.common.event.EventEnvelope;
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;
import java.util.List;
@RestController
@RequestMapping("/internal/finance/v1/vehicle-costs")
public class InternalVehicleCostController {
private final VehicleCostService service;
private final String token;
public InternalVehicleCostController(VehicleCostService service, @Value("${baiyue.security.internal-service-token:}") String token) {
this.service = service; this.token = token;
}
@PostMapping("/events")
public ApiResponse<DeliveryAck> receive(@RequestHeader(value="X-Internal-Service-Token", required=false) String supplied,
@RequestBody EventEnvelope event) { authorize(supplied); return ApiResponse.success(service.receive(event)); }
@PostMapping("/sources/query")
public ApiResponse<List<VehicleCostModels.Cost>> sources(@RequestHeader(value="X-Internal-Service-Token", required=false) String supplied,
@Valid @RequestBody VehicleCostModels.SourceQuery request) { authorize(supplied); return ApiResponse.success(service.sources(request)); }
private void authorize(String supplied) {
if (token == null || token.isBlank() || supplied == null
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), supplied.getBytes(StandardCharsets.UTF_8)))
throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
}
}
@@ -0,0 +1,64 @@
package com.baiyue.finance.fleet;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/platform/finance/v1/vehicle-costs")
public class PlatformVehicleCostController {
private final VehicleCostService costs;
private final VehicleCostVouchers vouchers;
public PlatformVehicleCostController(VehicleCostService costs, VehicleCostVouchers vouchers) { this.costs = costs; this.vouchers = vouchers; }
@GetMapping
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')")
public ApiResponse<PageResult<VehicleCostModels.Cost>> list(@Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.list(query)); }
@GetMapping("/{costNo}")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')")
public ApiResponse<VehicleCostModels.Cost> detail(@PathVariable String costNo) { return ApiResponse.success(costs.detail(costNo)); }
@GetMapping("/{costNo}/history")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')")
public ApiResponse<PageResult<VehicleCostModels.History>> history(@PathVariable String costNo, @Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.history(costNo, query)); }
@GetMapping("/{costNo}/payments")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')")
public ApiResponse<PageResult<VehicleCostModels.Paid>> payments(@PathVariable String costNo, @Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.payments(costNo, query)); }
@PostMapping("/{costNo}/approve")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:approve')")
public ApiResponse<VehicleCostModels.Cost> approve(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Command request) {
return ApiResponse.success(costs.approve(costNo, user.userId(), name(user), key, request));
}
@PostMapping("/{costNo}/void")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:void')")
public ApiResponse<VehicleCostModels.Cost> voidCost(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Command request) {
return ApiResponse.success(costs.voidCost(costNo, user.userId(), name(user), key, request));
}
@PostMapping("/{costNo}/payments")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:pay')")
public ApiResponse<VehicleCostModels.Cost> pay(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Payment request) {
return ApiResponse.success(costs.pay(costNo, user.userId(), name(user), key, request));
}
@PostMapping(value="/{costNo}/vouchers", consumes="multipart/form-data")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:upload')")
public ApiResponse<VehicleCostFileClient.Metadata> upload(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file) {
return ApiResponse.success(vouchers.upload(costs.voucherOwner(costNo), user.userId(), file));
}
@GetMapping("/{costNo}/vouchers/{fileId}/content")
@PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')")
public ResponseEntity<byte[]> download(@PathVariable String costNo, @PathVariable long fileId) {
return vouchers.download(costs.voucherOwner(costNo), fileId);
}
private static String name(AuthenticatedUser user) {
if (user.displayName() != null && !user.displayName().isBlank()) return user.displayName();
return user.username() == null || user.username().isBlank() ? "—" : user.username();
}
}
@@ -0,0 +1,8 @@
package com.baiyue.finance.fleet;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods=false)
@EnableFeignClients(clients=VehicleCostFileClient.class)
public class VehicleCostClientConfiguration { }
@@ -0,0 +1,11 @@
package com.baiyue.finance.fleet;
import com.baiyue.finance.controller.FinanceExceptionHandler;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** Keep Finance business errors and diagnostic identifiers on the dedicated fleet endpoints. */
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages="com.baiyue.finance.fleet")
public class VehicleCostExceptionHandler extends FinanceExceptionHandler { }
@@ -0,0 +1,23 @@
package com.baiyue.finance.fleet;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.ResponseEntity;
@FeignClient(name="file-service", contextId="financeVehicleCostFiles", url="${baiyue.clients.file.url:}",
path="/internal/file/v1/business-files/0/FINANCE_VEHICLE_VOUCHER")
public interface VehicleCostFileClient {
@PostMapping(value="/{costId}", consumes="multipart/form-data")
ApiResponse<Metadata> upload(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId, @PathVariable long costId, @RequestPart("file") MultipartFile file);
@GetMapping("/{costId}/{fileId}")
ApiResponse<Metadata> metadata(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long costId, @PathVariable long fileId);
@GetMapping("/{costId}/{fileId}/content")
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long costId, @PathVariable long fileId);
record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId,
String fileName, String contentType, Long sizeBytes) { }
}
@@ -0,0 +1,45 @@
package com.baiyue.finance.fleet;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
/** Company-owned costs are deliberately separate from customer fee sources and settlements. */
public final class VehicleCostModels {
private VehicleCostModels() { }
public static final String EVENT_TYPE = "CompanyVehicleCostSubmitted";
public record Source(@NotBlank @Pattern(regexp="REPAIR|RESCUE|TRIP_DIRECT") String sourceType,
@NotBlank @Size(max=64) String sourceNo, @Positive long sourceVersion,
@NotBlank @Pattern(regexp="[1-9][0-9]*") String vehicleId,
@NotBlank @Size(max=64) String vehiclePlate, @Size(max=64) String tripNo,
@Positive long supplierId, @NotBlank @Size(max=200) String feeName,
@NotNull LocalDate businessDate, @NotBlank @Pattern(regexp="CNY|USD") String originalCurrency,
@NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=12,fraction=2) BigDecimal originalAmount,
@NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=9,fraction=6) BigDecimal exchangeRate,
@NotBlank @Pattern(regexp="[0-9]{4}-[0-9]{2}") String exchangeRateMonth,
@NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=15,fraction=2) BigDecimal amount,
@Size(max=1000) String remark) { }
public record Command(@NotNull @PositiveOrZero Long expectedVersion, @Size(max=1000) String reason) { }
public record Payment(@NotNull @PositiveOrZero Long expectedVersion,
@NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=15,fraction=2) BigDecimal amount,
@NotNull Instant paidAt, @NotBlank @Pattern(regexp="ONLINE|BANK_TRANSFER|CASH|OTHER") String method,
@Size(max=128) String referenceNo,
@NotEmpty @Size(max=20) List<@NotBlank @Pattern(regexp="[1-9][0-9]*") String> voucherFileIds,
@Size(max=1000) String remark) { }
public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) {
public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; }
public long offset() { return ((long) page - 1) * size; }
}
public record SourceKey(@NotBlank @Pattern(regexp="REPAIR|RESCUE|TRIP_DIRECT") String sourceType,
@NotBlank @Size(max=64) String sourceNo) { }
public record SourceQuery(@NotEmpty @Size(max=100) List<@Valid 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 History(long recordVersion, String action, String reason, String operatorName,
Instant occurredAt, Cost snapshot) { }
public record Paid(String paymentNo, String currency, BigDecimal amount, Instant paidAt, String method, String referenceNo,
List<String> voucherFileIds, String operatorName, Instant recordedAt, String remark) { }
}
@@ -0,0 +1,230 @@
package com.baiyue.finance.fleet;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.event.*;
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceSupplierValidator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validator;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.*;
import java.util.*;
/** Only this company-cost ledger is written. Customer costs, settlements and wallets are untouched. */
@Service
public class VehicleCostService {
private final JdbcTemplate jdbc;
private final ObjectMapper json;
private final Validator validator;
private final FinanceSupplierValidator suppliers;
private final FinanceDocumentNumberGenerator numbers;
private final VehicleCostVouchers vouchers;
private record Row(long id, long subjectId, VehicleCostModels.Cost cost) { }
private final RowMapper<Row> rowMapper = (r, ignored) -> {
var source = read(r.getString("source_json"), VehicleCostModels.Source.class);
var paid = r.getBigDecimal("paid_amount");
var status = r.getString("status");
var outstanding = Set.of("APPROVED", "PARTIALLY_PAID", "PAID").contains(status) ? source.amount().subtract(paid) : null;
return new Row(r.getLong("id"), r.getLong("subject_id"), new VehicleCostModels.Cost(r.getString("cost_no"),
"PLATFORM_DEFAULT", source, r.getString("supplier_name"), "CNY", status, paid, outstanding, r.getLong("record_version")));
};
public VehicleCostService(JdbcTemplate jdbc, ObjectMapper json, Validator validator,
FinanceSupplierValidator suppliers, FinanceDocumentNumberGenerator numbers, VehicleCostVouchers vouchers) {
this.jdbc = jdbc; this.json = json; this.validator = validator;
this.suppliers = suppliers; this.numbers = numbers; this.vouchers = vouchers;
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public DeliveryAck receive(EventEnvelope event) {
try { event.requireValid(); } catch (RuntimeException error) { throw bad("公司车辆费用事件信封无效"); }
if (!VehicleCostModels.EVENT_TYPE.equals(event.eventType()) || event.eventVersion() != 1
|| !"0".equals(event.tenantId()) || !"COMPANY_VEHICLE_COST".equals(event.aggregateType())
|| !EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash()))
throw bad("公司车辆费用事件类型、归属或摘要无效");
if (event.payload().has("customerId")) throw bad("公司车辆费用不得携带客户归属");
var source = read(event.payload().toString(), VehicleCostModels.Source.class);
validate(source);
if (!event.aggregateId().equals(source.sourceType() + ":" + source.sourceNo()) || event.aggregateVersion() != source.sourceVersion())
throw bad("费用来源与事件聚合版本不一致");
long subject = subject(true);
var priorEvent = jdbc.queryForList("SELECT payload_hash,status FROM fin_inbox_event WHERE source_system='LASTLEG' AND event_id=? FOR UPDATE", event.eventId());
if (!priorEvent.isEmpty()) {
if (!event.payloadHash().equals(priorEvent.get(0).get("payload_hash"))) throw conflict("同一事件对应不同公司费用内容");
if (!"PROCESSED".equals(priorEvent.get(0).get("status"))) throw conflict("费用事件尚未处理完成");
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
}
String sourceHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(source)));
var current = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND source_type=? AND source_no=? FOR UPDATE",
rowMapper, subject, source.sourceType(), source.sourceNo());
Row row;
if (!current.isEmpty()) {
row = current.get(0);
long version = row.cost().source().sourceVersion();
if (source.sourceVersion() < version) throw conflict("费用来源版本已过期");
if (source.sourceVersion() == version) {
var storedHash = jdbc.queryForObject("SELECT source_hash FROM fin_vehicle_cost WHERE id=?", String.class, row.id());
if (!sourceHash.equals(storedHash)) throw conflict("同一费用来源版本对应不同内容");
inbox(event, row.id());
return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE);
}
if (!Set.of("PENDING", "VOIDED").contains(row.cost().status()) || row.cost().paidAmount().signum() != 0)
throw conflict("已入账费用须先作废;已付款费用不能替换");
String supplier = suppliers.requireActive(source.supplierId(), null).name();
jdbc.update("UPDATE fin_vehicle_cost SET source_version=?,source_hash=?,source_json=?,supplier_id=?,supplier_name=?,amount=?,status='PENDING',record_version=record_version+1,updated_at=? WHERE id=?",
source.sourceVersion(), sourceHash, write(source), source.supplierId(), supplier, source.amount(), now(), row.id());
row = required(row.cost().costNo(), false);
} else {
String supplier = suppliers.requireActive(source.supplierId(), null).name();
long id = IdWorker.getId();
String no = numbers.next("VC");
jdbc.update("INSERT INTO fin_vehicle_cost(id,subject_id,cost_no,source_type,source_no,source_version,source_hash,source_json,supplier_id,supplier_name,amount,paid_amount,status,record_version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,0,'PENDING',0,?,?)",
id, subject, no, source.sourceType(), source.sourceNo(), source.sourceVersion(), sourceHash, write(source), source.supplierId(), supplier, source.amount(), now(), now());
row = required(no, false);
}
audit(row, "RECEIVE", event.eventId(), event.payloadHash(), 0, "系统", "接收公司车辆费用来源");
inbox(event, row.id());
return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED);
}
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
public PageResult<VehicleCostModels.Cost> list(VehicleCostModels.Query query) {
validate(query); long subject = subject(false);
long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost WHERE subject_id=?", Long.class, subject);
var rows = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? ORDER BY updated_at DESC,id DESC LIMIT ? OFFSET ?",
rowMapper, subject, query.size(), query.offset());
return new PageResult<>(rows.stream().map(Row::cost).toList(), query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1));
}
@Transactional(readOnly=true)
public VehicleCostModels.Cost detail(String no) { return required(no, false).cost(); }
@Transactional(readOnly=true)
public List<VehicleCostModels.Cost> sources(VehicleCostModels.SourceQuery query) {
validate(query); long subject = subject(false);
String predicates = String.join(" OR ", Collections.nCopies(query.sources().size(), "(source_type=? AND source_no=?)"));
var args = new ArrayList<Object>(); args.add(subject);
for (var key : query.sources()) { args.add(key.sourceType()); args.add(key.sourceNo()); }
return jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND (" + predicates + ")", rowMapper, args.toArray()).stream().map(Row::cost).toList();
}
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
public PageResult<VehicleCostModels.History> history(String no, VehicleCostModels.Query query) {
validate(query); long id = required(no, false).id();
long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost_revision WHERE cost_id=?", Long.class, id);
var rows = jdbc.query("SELECT * FROM fin_vehicle_cost_revision WHERE cost_id=? ORDER BY record_version DESC LIMIT ? OFFSET ?",
(r, ignored) -> new VehicleCostModels.History(r.getLong("record_version"), r.getString("action"), r.getString("reason"),
r.getString("operator_name"), r.getTimestamp("occurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC),
read(r.getString("snapshot_json"), VehicleCostModels.Cost.class)), id, query.size(), query.offset());
return new PageResult<>(rows, query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1));
}
@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ)
public PageResult<VehicleCostModels.Paid> payments(String no, VehicleCostModels.Query query) {
validate(query); long id = required(no, false).id();
long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost_payment WHERE cost_id=?", Long.class, id);
var rows = jdbc.query("SELECT payment_json FROM fin_vehicle_cost_payment WHERE cost_id=? ORDER BY id DESC LIMIT ? OFFSET ?",
(r, ignored) -> read(r.getString("payment_json"), VehicleCostModels.Paid.class), id, query.size(), query.offset());
return new PageResult<>(rows, query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1));
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public VehicleCostModels.Cost approve(String no, long operator, String operatorName, String key, VehicleCostModels.Command command) {
return transition(no, operator, operatorName, key, command, "APPROVE");
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public VehicleCostModels.Cost voidCost(String no, long operator, String operatorName, String key, VehicleCostModels.Command command) {
return transition(no, operator, operatorName, key, command, "VOID");
}
private VehicleCostModels.Cost transition(String no, long operator, String name, String key, VehicleCostModels.Command command, String action) {
validate(command); subject(true); var row = required(no, true);
String hash = commandHash(no, command); if (replay(row, operator, action, key, hash)) return row.cost();
requireVersion(row, command.expectedVersion());
if (action.equals("APPROVE")) {
if (!"PENDING".equals(row.cost().status())) throw conflict("仅待入账费用可以入账");
suppliers.requireActive(row.cost().source().supplierId(), null);
} else if (!Set.of("PENDING", "APPROVED").contains(row.cost().status()) || row.cost().paidAmount().signum() != 0) {
throw conflict("已付款或已作废费用不能直接作废");
}
jdbc.update("UPDATE fin_vehicle_cost SET status=?,record_version=record_version+1,updated_at=? WHERE id=?",
action.equals("APPROVE") ? "APPROVED" : "VOIDED", now(), row.id());
var changed = required(no, false);
audit(changed, action, key, hash, operator, name, text(command.reason(), action.equals("APPROVE") ? "确认公司车辆费用入账" : "作废公司车辆费用"));
return changed.cost();
}
@Transactional(isolation=Isolation.READ_COMMITTED)
public VehicleCostModels.Cost pay(String no, long operator, String name, String key, VehicleCostModels.Payment request) {
validate(request); subject(true); var row = required(no, true);
String hash = commandHash(no, request); if (replay(row, operator, "PAY", key, hash)) return row.cost();
requireVersion(row, request.expectedVersion());
if (!Set.of("APPROVED", "PARTIALLY_PAID").contains(row.cost().status())) throw conflict("仅已入账且未付清费用可以登记付款");
if (request.paidAt().isAfter(Instant.now())) throw bad("实际付款时间不能晚于当前时间");
if (request.amount().compareTo(row.cost().outstandingAmount()) > 0) throw conflict("付款金额不得超过未付余额");
suppliers.requireActive(row.cost().source().supplierId(), null);
vouchers.validate(row.id(), request.voucherFileIds());
var payment = new VehicleCostModels.Paid(numbers.next("VP"), "CNY", request.amount(), request.paidAt(), request.method(),
request.referenceNo(), List.copyOf(request.voucherFileIds()), text(name, "—"), Instant.now(), request.remark());
jdbc.update("INSERT INTO fin_vehicle_cost_payment(id,cost_id,payment_no,amount,payment_json,created_at) VALUES(?,?,?,?,?,?)",
IdWorker.getId(), row.id(), payment.paymentNo(), request.amount(), write(payment), now());
var paid = row.cost().paidAmount().add(request.amount());
jdbc.update("UPDATE fin_vehicle_cost SET paid_amount=?,status=?,record_version=record_version+1,updated_at=? WHERE id=?",
paid, paid.compareTo(row.cost().source().amount()) == 0 ? "PAID" : "PARTIALLY_PAID", now(), row.id());
var changed = required(no, false);
audit(changed, "PAY", key, hash, operator, name, text(request.remark(), "登记公司车辆费用实际付款"));
return changed.cost();
}
public long voucherOwner(String no) { return required(no, false).id(); }
private void validate(Object value) {
if (value == null || !validator.validate(value).isEmpty()) throw bad("公司车辆费用参数不完整或格式无效");
if (value instanceof VehicleCostModels.Source source) {
if ("TRIP_DIRECT".equals(source.sourceType()) && (source.tripNo() == null || source.tripNo().isBlank()))
throw bad("车次直接成本须保留真实车次号");
if (!source.exchangeRateMonth().equals(source.businessDate().toString().substring(0, 7)))
throw bad("费用业务日期或汇率月份无效");
if ("CNY".equals(source.originalCurrency()) && source.exchangeRate().compareTo(BigDecimal.ONE) != 0)
throw bad("CNY费用汇率必须为1");
if (source.originalAmount().multiply(source.exchangeRate()).setScale(2, RoundingMode.HALF_UP).compareTo(source.amount()) != 0)
throw bad("公司车辆费用原币及折算金额不守恒");
}
}
private long subject(boolean lock) {
var ids = jdbc.queryForList("SELECT id FROM fin_subject WHERE subject_code='PLATFORM_DEFAULT' AND status='ACTIVE'" + (lock ? " FOR UPDATE" : ""), Long.class);
if (ids.size() != 1) throw conflict("平台财务主体不存在或未启用");
return ids.get(0);
}
private Row required(String no, boolean lock) {
var rows = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND cost_no=?" + (lock ? " FOR UPDATE" : ""), rowMapper, subject(false), no);
if (rows.isEmpty()) throw new FinanceEventException("FIN_404", "公司车辆费用不存在");
return rows.get(0);
}
private boolean replay(Row row, long operator, String action, String key, String hash) {
if (operator <= 0 || key == null || key.isBlank() || key.length() > 128) throw bad("缺少有效操作人或幂等键");
var rows = jdbc.queryForList("SELECT cost_id,request_hash FROM fin_vehicle_cost_revision WHERE operator_id=? AND action=? AND request_key=?",
operator, action, key);
if (rows.isEmpty()) return false;
if (((Number) rows.get(0).get("cost_id")).longValue() != row.id() || !hash.equals(rows.get(0).get("request_hash")))
throw conflict("幂等键已用于其他财务操作内容");
return true;
}
private void audit(Row row, String action, String key, String hash, long operator, String name, String reason) {
jdbc.update("INSERT INTO fin_vehicle_cost_revision(id,cost_id,record_version,action,request_key,request_hash,operator_id,operator_name,reason,snapshot_json,occurred_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
IdWorker.getId(), row.id(), row.cost().recordVersion(), action, key, hash, operator, text(name, "—"), reason, write(row.cost()), now());
}
private void inbox(EventEnvelope event, long id) {
jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status,result_resource_id,processed_at) VALUES(?,'LASTLEG',?,?,?,'PROCESSED',?,?)",
event.eventId(), event.eventType(), write(event), event.payloadHash(), id, now());
}
private static void requireVersion(Row row, long version) { if (row.cost().recordVersion() != version) throw conflict("公司车辆费用版本已变化"); }
private String commandHash(String no, Object request) { return EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(Map.of("costNo", no, "request", request)))); }
private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException("公司费用快照无法保存", error); } }
private <T> T read(String value, Class<T> type) { try { return json.readerFor(type).with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(value); } catch (JsonProcessingException error) { throw bad("公司费用快照格式无效"); } }
private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); }
private static String text(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); }
private static FinanceEventException bad(String message) { return new FinanceEventException("FIN_400", message); }
private static FinanceEventException conflict(String message) { return new FinanceEventException("FIN_409", message); }
}
@@ -0,0 +1,61 @@
package com.baiyue.finance.fleet;
import com.baiyue.finance.service.FinanceEventException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Service
public class VehicleCostVouchers {
private final VehicleCostFileClient files;
private final String token;
public VehicleCostVouchers(VehicleCostFileClient files, @Value("${baiyue.security.internal-service-token:}") String token) {
this.files = files; this.token = token;
}
public void validate(long costId, List<String> ids) {
if (ids == null || ids.isEmpty() || ids.size() > 20 || new HashSet<>(ids).size() != ids.size()) throw bad("付款凭证不能为空或重复");
for (var id : ids) metadata(costId, fileId(id));
}
public VehicleCostFileClient.Metadata upload(long costId, long operator, MultipartFile file) {
requireToken();
var response = call(() -> files.upload(token, operator, costId, file));
if (response == null || !response.successful()) throw unavailable();
return requireOwner(costId, response.data(), null);
}
public VehicleCostFileClient.Metadata metadata(long costId, long fileId) {
requireToken();
var response = call(() -> files.metadata(token, costId, fileId));
if (response == null || !response.successful()) throw unavailable();
return requireOwner(costId, response.data(), fileId);
}
public ResponseEntity<byte[]> download(long costId, long fileId) {
var metadata = metadata(costId, fileId);
var response = call(() -> files.download(token, costId, fileId));
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null
|| response.getBody().length != metadata.sizeBytes() || response.getHeaders().getContentType() == null
|| !metadata.contentType().equalsIgnoreCase(response.getHeaders().getContentType().toString())) throw unavailable();
return response;
}
private static VehicleCostFileClient.Metadata requireOwner(long costId, VehicleCostFileClient.Metadata value, Long expected) {
if (value == null || value.fileId() == null || value.fileId() <= 0 || expected != null && !expected.equals(value.fileId())
|| !"PLATFORM".equals(value.ownerScope()) || !Long.valueOf(0).equals(value.tenantId())
|| !"FINANCE_VEHICLE_VOUCHER".equals(value.businessType()) || !Long.toString(costId).equals(value.businessId())
|| value.contentType() == null || !Set.of("image/jpeg", "image/png").contains(value.contentType()) || value.sizeBytes() == null || value.sizeBytes() <= 0)
throw bad("公司车辆付款凭证不存在或归属不匹配");
return value;
}
private static long fileId(String value) {
try { long id = Long.parseLong(value); if (id > 0) return id; } catch (RuntimeException ignored) { }
throw bad("付款凭证编号无效");
}
private void requireToken() { if (token == null || token.isBlank()) throw unavailable(); }
private static <T> T call(java.util.function.Supplier<T> request) {
try { return request.get(); } catch (feign.FeignException unavailable) { throw unavailable(); }
}
private static FinanceEventException bad(String message) { return new FinanceEventException("FIN_400", message); }
private static FinanceEventException unavailable() { return new FinanceEventException("FIN_503", "公司车辆付款凭证服务不可用"); }
}
@@ -17,7 +17,7 @@ import java.util.Set;
public class FinanceDocumentNumberGenerator {
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT);
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC");
private static final Set<String> SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP");
private static final long MAX_DAILY_SEQUENCE = 9_999L;
private final JdbcTemplate jdbc;