feat(fleet): integrate costs quotes operations and in-transit handovers
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
| 目标 | 首选入口 |
|
||||
|---|---|
|
||||
| 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` |
|
||||
| 公司车辆成本 | `fleet/`及`src/main/resources/openapi/finance-vehicle-costs-v1.yaml`;独立平台主体费用、入账和付款,不进入客户账务。编号VC/VP,凭证FINANCE_VEHICLE_VOUCHER;维修/救援由Lastleg完整快照事件接入 |
|
||||
| 平台财务凭证上传 | `PlatformFinanceController.java` 的 `POST /vouchers`、`client/FinanceFileClient.java`;文件类型固定 `FINANCE_VOUCHER`,业务 ID/客户范围使用当前客户 ID |
|
||||
| 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` |
|
||||
| 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` |
|
||||
|
||||
@@ -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", "公司车辆付款凭证服务不可用"); }
|
||||
}
|
||||
+1
-1
@@ -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;
|
||||
|
||||
@@ -94,6 +94,65 @@ VALUES
|
||||
ON DUPLICATE KEY UPDATE
|
||||
subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0;
|
||||
|
||||
CREATE TABLE fin_vehicle_cost (
|
||||
id BIGINT NOT NULL COMMENT '公司费用主键',
|
||||
subject_id BIGINT UNSIGNED NOT NULL COMMENT '平台财务主体',
|
||||
cost_no VARCHAR(64) NOT NULL COMMENT '公司车辆费用编号',
|
||||
source_type VARCHAR(32) NOT NULL COMMENT '费用来源类型',
|
||||
source_no VARCHAR(64) NOT NULL COMMENT '来源业务编号',
|
||||
source_version BIGINT NOT NULL COMMENT '来源快照版本',
|
||||
source_hash VARCHAR(71) NOT NULL COMMENT '来源内容摘要',
|
||||
source_json JSON NOT NULL COMMENT '原币及折算不可变来源快照',
|
||||
supplier_id BIGINT NOT NULL COMMENT '收款供应商标识',
|
||||
supplier_name VARCHAR(200) NOT NULL COMMENT '供应商名称快照',
|
||||
amount DECIMAL(17,2) NOT NULL COMMENT 'CNY公司费用金额',
|
||||
paid_amount DECIMAL(17,2) NOT NULL DEFAULT 0 COMMENT 'CNY累计实际付款',
|
||||
status VARCHAR(32) NOT NULL COMMENT '入账与付款状态',
|
||||
record_version BIGINT NOT NULL COMMENT '并发版本',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC',
|
||||
updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fin_vehicle_cost_no (cost_no),
|
||||
UNIQUE KEY uk_fin_vehicle_cost_source (subject_id,source_type,source_no),
|
||||
KEY idx_fin_vehicle_cost_page (subject_id,updated_at,id),
|
||||
CONSTRAINT fk_fin_vehicle_cost_subject FOREIGN KEY (subject_id) REFERENCES fin_subject(id),
|
||||
CONSTRAINT chk_fin_vehicle_cost_amount CHECK (amount>0 AND paid_amount>=0 AND paid_amount<=amount),
|
||||
CONSTRAINT chk_fin_vehicle_cost_owner CHECK (subject_id>0 AND supplier_id>0),
|
||||
CONSTRAINT chk_fin_vehicle_cost_status CHECK (status IN ('PENDING','APPROVED','PARTIALLY_PAID','PAID','VOIDED'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用,不进入客户账务';
|
||||
|
||||
CREATE TABLE fin_vehicle_cost_revision (
|
||||
id BIGINT NOT NULL COMMENT '公司车辆费用历史主键',
|
||||
cost_id BIGINT NOT NULL COMMENT '所属公司费用',
|
||||
record_version BIGINT NOT NULL COMMENT '费用版本',
|
||||
action VARCHAR(32) NOT NULL COMMENT '接收入账作废付款动作',
|
||||
request_key VARCHAR(128) NOT NULL COMMENT '动作幂等键',
|
||||
request_hash VARCHAR(71) NOT NULL COMMENT '请求摘要',
|
||||
operator_id BIGINT NOT NULL COMMENT '操作人或系统标识',
|
||||
operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称快照',
|
||||
reason VARCHAR(1000) NULL COMMENT '更正或操作说明',
|
||||
snapshot_json JSON NOT NULL COMMENT '不可变费用快照',
|
||||
occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fin_vehicle_cost_revision (cost_id,record_version),
|
||||
UNIQUE KEY uk_fin_vehicle_cost_request (operator_id,action,request_key),
|
||||
CONSTRAINT fk_fin_vehicle_revision_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用不可变审计';
|
||||
|
||||
CREATE TABLE fin_vehicle_cost_payment (
|
||||
id BIGINT NOT NULL COMMENT '公司车辆付款主键',
|
||||
cost_id BIGINT NOT NULL COMMENT '所属公司费用',
|
||||
payment_no VARCHAR(64) NOT NULL COMMENT '公司付款业务编号',
|
||||
amount DECIMAL(17,2) NOT NULL COMMENT 'CNY实际付款金额',
|
||||
payment_json JSON NOT NULL COMMENT '实际付款及凭证不可变快照',
|
||||
created_at DATETIME(3) NOT NULL COMMENT '付款登记时间UTC',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_fin_vehicle_payment_no (payment_no),
|
||||
KEY idx_fin_vehicle_payment_cost (cost_id,id),
|
||||
CONSTRAINT fk_fin_vehicle_payment_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id),
|
||||
CONSTRAINT chk_fin_vehicle_payment_amount CHECK (amount>0)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆实际付款记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 公司车辆运营成本
|
||||
version: 1.0.0
|
||||
description: >-
|
||||
公司费用归平台财务主体PLATFORM_DEFAULT,不进入客户费用池、客户账单、客户应付单或客户钱包。
|
||||
来源接收只生成PENDING;独立入账后才产生应付,付款只登记有凭证的实际CNY支付。
|
||||
原币支持CNY/USD,来源提供业务月份有效汇率与不可变折算快照;Finance复核金额守恒。
|
||||
未付款可作废;已付款禁止直接改写、作废或替换。更高来源版本仅在PENDING/VOIDED且未付款时接收。
|
||||
security:
|
||||
- bearerAuth: []
|
||||
x-integration-required:
|
||||
status: 源码合同,仍待共享集成与总控编译验证,不代表可部署
|
||||
numbers: FinanceDocumentNumberGenerator登记VC公司费用与VP付款业务号前缀
|
||||
file: BusinessFileCategory登记FINANCE_VEHICLE_VOUCHER;平台0、费用ID复合归属、非空JPG/PNG、10MiB、签名校验
|
||||
event: CompanyVehicleCostSubmitted v1;LASTLEG直投内部事件入口;aggregateType=COMPANY_VEHICLE_COST,aggregateId=sourceType:sourceNo,aggregateVersion=sourceVersion;平台信封tenantId为字符串0,不携带customerId
|
||||
delivery: 注册EventTypes/EventDeliveryRoute及Lastleg Relay;只有匹配eventId/payloadHash的APPLIED或DUPLICATE代表接收完成,不能显示为已入账/已付款
|
||||
lastleg: 提交时锁真实来源并冻结版本、供应商、金额、月份汇率;映射与Outbox同事务提交;禁止把维修照片或reportedCost自动解释为付款证据
|
||||
sql:
|
||||
fin_vehicle_cost:
|
||||
columns: id BIGINT PK, subject_id BIGINT, cost_no VARCHAR(64), source_type VARCHAR(32), source_no VARCHAR(64), source_version BIGINT, source_hash VARCHAR(71), source_json JSON, supplier_id BIGINT, supplier_name VARCHAR(200), amount DECIMAL(17,2), paid_amount DECIMAL(17,2), status VARCHAR(32), record_version BIGINT, created_at DATETIME(3), updated_at DATETIME(3)
|
||||
unique: [cost_no, 'subject_id + source_type + source_no']
|
||||
checks: 正数主体和供应商;amount>0;0<=paid_amount<=amount;状态PENDING/APPROVED/PARTIALLY_PAID/PAID/VOIDED;无customer_id/tenant_id
|
||||
indexes: [subject_id + updated_at + id]
|
||||
fin_vehicle_cost_revision:
|
||||
columns: id BIGINT PK, cost_id BIGINT, record_version BIGINT, action VARCHAR(32), request_key VARCHAR(128), request_hash VARCHAR(71), operator_id BIGINT, operator_name VARCHAR(128), reason VARCHAR(1000), snapshot_json JSON, occurred_at DATETIME(3)
|
||||
unique: ['cost_id + record_version', 'operator_id + action + request_key']
|
||||
description: RECEIVE/APPROVE/VOID/PAY不可变审计及幂等记录,和主记录及付款同事务提交
|
||||
fin_vehicle_cost_payment:
|
||||
columns: id BIGINT PK, cost_id BIGINT, payment_no VARCHAR(64), amount DECIMAL(17,2), payment_json JSON, created_at DATETIME(3)
|
||||
unique: [payment_no]
|
||||
indexes: [cost_id + id]
|
||||
description: 不可变实际付款凭证快照;amount>0;付款与余额、审计同事务
|
||||
paths:
|
||||
/api/platform/finance/v1/vehicle-costs:
|
||||
get:
|
||||
description: platform:finance:vehicle-cost:list;只查询公司成本
|
||||
parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}]
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Page'}
|
||||
/api/platform/finance/v1/vehicle-costs/{costNo}:
|
||||
parameters: [{$ref: '#/components/parameters/CostNo'}]
|
||||
get:
|
||||
description: platform:finance:vehicle-cost:list
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Cost'}
|
||||
/api/platform/finance/v1/vehicle-costs/{costNo}/history:
|
||||
parameters: [{$ref: '#/components/parameters/CostNo'}]
|
||||
get:
|
||||
description: platform:finance:vehicle-cost:list;不可变版本与当时金额快照
|
||||
parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}]
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Page'}
|
||||
/api/platform/finance/v1/vehicle-costs/{costNo}/approve:
|
||||
parameters: [{$ref: '#/components/parameters/CostNo'}]
|
||||
post:
|
||||
description: platform:finance:vehicle-cost:approve;PENDING入账为APPROVED,复核供应商;不是付款
|
||||
parameters: [{$ref: '#/components/parameters/Key'}]
|
||||
requestBody: {$ref: '#/components/requestBodies/Command'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Cost'}
|
||||
/api/platform/finance/v1/vehicle-costs/{costNo}/void:
|
||||
parameters: [{$ref: '#/components/parameters/CostNo'}]
|
||||
post:
|
||||
description: platform:finance:vehicle-cost:void;仅未付款PENDING/APPROVED可作废;更高来源版本可重新待入账
|
||||
parameters: [{$ref: '#/components/parameters/Key'}]
|
||||
requestBody: {$ref: '#/components/requestBodies/Command'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Cost'}
|
||||
/api/platform/finance/v1/vehicle-costs/{costNo}/payments:
|
||||
parameters: [{$ref: '#/components/parameters/CostNo'}]
|
||||
get:
|
||||
description: platform:finance:vehicle-cost:list;CNY付款历史
|
||||
parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}]
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Page'}
|
||||
post:
|
||||
description: platform:finance:vehicle-cost:pay;仅APPROVED/PARTIALLY_PAID,付款金额不超过未付余额,必须有本费用的公司付款凭证
|
||||
parameters: [{$ref: '#/components/parameters/Key'}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/Payment'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Cost'}
|
||||
/api/platform/finance/v1/vehicle-costs/{costNo}/vouchers:
|
||||
parameters: [{$ref: '#/components/parameters/CostNo'}]
|
||||
post:
|
||||
description: platform:finance:vehicle-cost:upload;上传不是付款,不生成付款流水
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file: {type: string, format: binary}
|
||||
responses:
|
||||
'200': {description: ApiResponse,data为本费用专属凭证元数据}
|
||||
/api/platform/finance/v1/vehicle-costs/{costNo}/vouchers/{fileId}/content:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/CostNo'}
|
||||
- {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
get:
|
||||
description: platform:finance:vehicle-cost:list;校验平台、文件类别和费用归属
|
||||
responses:
|
||||
'200': {description: 图片字节及File响应头}
|
||||
/internal/finance/v1/vehicle-costs/events:
|
||||
post:
|
||||
security: [{internalService: []}]
|
||||
description: 内部身份;同eventId异载荷拒绝,同来源版本异内容拒绝,旧来源版本拒绝;业务与Inbox同事务提交后ACK
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [eventId, eventType, eventVersion, occurredAt, tenantId, aggregateType, aggregateId, aggregateVersion, traceId, correlationId, payload, payloadHash]
|
||||
properties:
|
||||
eventId: {type: string, format: uuid}
|
||||
eventType: {type: string, enum: [CompanyVehicleCostSubmitted]}
|
||||
eventVersion: {type: integer, enum: [1]}
|
||||
occurredAt: {type: string, format: date-time}
|
||||
tenantId: {type: string, enum: ['0']}
|
||||
aggregateType: {type: string, enum: [COMPANY_VEHICLE_COST]}
|
||||
aggregateId: {type: string}
|
||||
aggregateVersion: {type: integer, minimum: 1}
|
||||
traceId: {type: string}
|
||||
correlationId: {type: string}
|
||||
payloadHash: {type: string}
|
||||
payload: {$ref: '#/components/schemas/Source'}
|
||||
responses:
|
||||
'200': {description: 'ApiResponse<DeliveryAck>;eventId、payloadHash与请求一致,outcome为APPLIED/DUPLICATE;仅表示接收完成'}
|
||||
/internal/finance/v1/vehicle-costs/sources/query:
|
||||
post:
|
||||
security: [{internalService: []}]
|
||||
description: 最多100组来源批量查询,未接收来源不返回;来源不存在不能解释为已付款或零成本
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [sources]
|
||||
properties:
|
||||
sources:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 100
|
||||
items:
|
||||
type: object
|
||||
required: [sourceType, sourceNo]
|
||||
properties:
|
||||
sourceType: {type: string, enum: [REPAIR, RESCUE, TRIP_DIRECT]}
|
||||
sourceNo: {type: string, maxLength: 64}
|
||||
responses:
|
||||
'200': {description: 'ApiResponse<List<Cost>>'}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
|
||||
internalService: {type: apiKey, in: header, name: X-Internal-Service-Token}
|
||||
parameters:
|
||||
CostNo: {name: costNo, in: path, required: true, schema: {type: string}}
|
||||
Page: {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
|
||||
Size: {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
|
||||
Key: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
requestBodies:
|
||||
Command:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [expectedVersion]
|
||||
properties:
|
||||
expectedVersion: {type: integer, format: int64, minimum: 0}
|
||||
reason: {type: string, maxLength: 1000}
|
||||
responses:
|
||||
Cost:
|
||||
description: 业务响应;FIN_400参数/归属、FIN_404不存在、FIN_409状态/版本/幂等冲突
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/Cost'}
|
||||
Page:
|
||||
description: ApiResponse;data含records/page/size/total/pages,列表Cost、历史History或付款Paid
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
page: {type: integer, format: int64}
|
||||
size: {type: integer, format: int64}
|
||||
total: {type: integer, format: int64}
|
||||
pages: {type: integer, format: int64}
|
||||
records:
|
||||
type: array
|
||||
items:
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/Cost'}
|
||||
- {$ref: '#/components/schemas/History'}
|
||||
- {$ref: '#/components/schemas/Paid'}
|
||||
schemas:
|
||||
Envelope:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
requestId: {type: string}
|
||||
traceId: {type: string}
|
||||
data: {type: object}
|
||||
Source:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [sourceType, sourceNo, sourceVersion, vehicleId, vehiclePlate, supplierId, feeName, businessDate, originalCurrency, originalAmount, exchangeRate, exchangeRateMonth, amount]
|
||||
properties:
|
||||
sourceType: {type: string, enum: [REPAIR, RESCUE, TRIP_DIRECT]}
|
||||
sourceNo: {type: string, maxLength: 64}
|
||||
sourceVersion: {type: integer, format: int64, minimum: 1}
|
||||
vehicleId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
vehiclePlate: {type: string, maxLength: 64}
|
||||
tripNo: {type: string, nullable: true, maxLength: 64, description: TRIP_DIRECT必须提供真实车次号}
|
||||
supplierId: {type: integer, format: int64, minimum: 1}
|
||||
feeName: {type: string, maxLength: 200}
|
||||
businessDate: {type: string, format: date}
|
||||
originalCurrency: {type: string, enum: [CNY, USD]}
|
||||
originalAmount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01}
|
||||
exchangeRate: {type: number, minimum: 0, exclusiveMinimum: true, description: CNY必须为1;USD来自业务月份有效汇率}
|
||||
exchangeRateMonth: {type: string, pattern: '^[0-9]{4}-[0-9]{2}$'}
|
||||
amount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01, description: CNY折算金额,与原币乘汇率四舍五入到分严格核平}
|
||||
remark: {type: string, nullable: true, maxLength: 1000}
|
||||
Cost:
|
||||
type: object
|
||||
properties:
|
||||
costNo: {type: string}
|
||||
subjectCode: {type: string, enum: [PLATFORM_DEFAULT]}
|
||||
source: {$ref: '#/components/schemas/Source'}
|
||||
supplierName: {type: string}
|
||||
currency: {type: string, enum: [CNY]}
|
||||
status: {type: string, enum: [PENDING, APPROVED, PARTIALLY_PAID, PAID, VOIDED]}
|
||||
paidAmount: {type: number}
|
||||
outstandingAmount: {type: number, nullable: true, description: 未入账或已作废时为空,不能当已知零应付}
|
||||
recordVersion: {type: integer, format: int64}
|
||||
Payment:
|
||||
type: object
|
||||
required: [expectedVersion, amount, paidAt, method, voucherFileIds]
|
||||
properties:
|
||||
expectedVersion: {type: integer, format: int64, minimum: 0}
|
||||
amount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01, description: CNY实际付款}
|
||||
paidAt: {type: string, format: date-time}
|
||||
method: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER]}
|
||||
referenceNo: {type: string, maxLength: 128}
|
||||
voucherFileIds: {type: array, minItems: 1, maxItems: 20, uniqueItems: true, items: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
remark: {type: string, maxLength: 1000}
|
||||
History:
|
||||
type: object
|
||||
properties:
|
||||
recordVersion: {type: integer, format: int64}
|
||||
action: {type: string, enum: [RECEIVE, APPROVE, VOID, PAY]}
|
||||
reason: {type: string}
|
||||
operatorName: {type: string}
|
||||
occurredAt: {type: string, format: date-time}
|
||||
snapshot: {$ref: '#/components/schemas/Cost'}
|
||||
Paid:
|
||||
type: object
|
||||
properties:
|
||||
paymentNo: {type: string}
|
||||
currency: {type: string, enum: [CNY]}
|
||||
amount: {type: number}
|
||||
paidAt: {type: string, format: date-time}
|
||||
method: {type: string}
|
||||
referenceNo: {type: string, nullable: true}
|
||||
voucherFileIds: {type: array, items: {type: string}}
|
||||
operatorName: {type: string}
|
||||
recordedAt: {type: string, format: date-time}
|
||||
remark: {type: string, nullable: true}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.finance.fleet;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class VehicleCostAuthorizationTest {
|
||||
@Test void actualMethodSecurityDoesNotLetReadersApprovePayOrVoid() {
|
||||
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
|
||||
var controller = context.getBean(PlatformVehicleCostController.class);
|
||||
var service = context.getBean(VehicleCostService.class);
|
||||
var user = mock(AuthenticatedUser.class); when(user.userId()).thenReturn(8L); when(user.displayName()).thenReturn("财务人员");
|
||||
var request = new VehicleCostModels.Command(0L, null);
|
||||
login("platform:finance:vehicle-cost:list"); controller.detail("VC-ONE");
|
||||
assertThatThrownBy(() -> controller.approve("VC-ONE", user, "approve-one", request)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class);
|
||||
assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class);
|
||||
login("platform:finance:vehicle-cost:approve"); controller.approve("VC-ONE", user, "approve-one", request);
|
||||
assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class);
|
||||
login("platform:finance:vehicle-cost:pay"); controller.pay("VC-ONE", user, "pay-one", null);
|
||||
assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class);
|
||||
login("platform:finance:vehicle-cost:void"); controller.voidCost("VC-ONE", user, "void-one", request);
|
||||
login(); assertThatThrownBy(() -> controller.detail("VC-ONE")).isInstanceOf(AccessDeniedException.class);
|
||||
verify(service).detail("VC-ONE"); verify(service).approve("VC-ONE", 8L, "财务人员", "approve-one", request);
|
||||
verify(service).pay("VC-ONE", 8L, "财务人员", "pay-one", null);
|
||||
verify(service).voidCost("VC-ONE", 8L, "财务人员", "void-one", request);
|
||||
verifyNoMoreInteractions(service);
|
||||
} finally { SecurityContextHolder.clearContext(); }
|
||||
}
|
||||
private static void login(String... permissions) {
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused",
|
||||
java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));
|
||||
}
|
||||
@Test void companyCostActionsHaveIndependentPermissionsAndReadsUseListPermission() throws Exception {
|
||||
assertThat(PlatformVehicleCostController.class.getMethod("detail", String.class).getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:finance:vehicle-cost:list')");
|
||||
for (var entry : java.util.Map.of("approve", "approve", "voidCost", "void").entrySet()) {
|
||||
assertThat(PlatformVehicleCostController.class.getMethod(entry.getKey(), String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Command.class)
|
||||
.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:" + entry.getValue() + "')");
|
||||
}
|
||||
assertThat(PlatformVehicleCostController.class.getMethod("pay", String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Payment.class)
|
||||
.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:pay')");
|
||||
}
|
||||
@Test void internalEndpointsRejectMissingOrWrongServiceIdentityBeforeServiceCalls() {
|
||||
var service = mock(VehicleCostService.class);
|
||||
var controller = new InternalVehicleCostController(service, "test-internal-token");
|
||||
assertThatThrownBy(() -> controller.receive(null, null)).hasMessageContaining("令牌");
|
||||
assertThatThrownBy(() -> controller.sources("wrong", null)).hasMessageContaining("令牌");
|
||||
assertThatThrownBy(() -> new InternalVehicleCostController(service, "").receive("", null)).hasMessageContaining("令牌");
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
@Configuration @EnableMethodSecurity static class Config {
|
||||
@Bean VehicleCostService costs() { return mock(VehicleCostService.class); }
|
||||
@Bean VehicleCostVouchers vouchers() { return mock(VehicleCostVouchers.class); }
|
||||
@Bean PlatformVehicleCostController controller(VehicleCostService costs, VehicleCostVouchers vouchers) {
|
||||
return new PlatformVehicleCostController(costs, vouchers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.finance.fleet;
|
||||
|
||||
import com.baiyue.finance.service.FinanceEventException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.hamcrest.Matchers.*;
|
||||
|
||||
class VehicleCostHttpContractTest {
|
||||
@Test void fleetBusinessErrorsKeepFinanceCodesAndDiagnosticIds() throws Exception {
|
||||
var service = mock(VehicleCostService.class);
|
||||
when(service.detail("VC-ONE")).thenThrow(new FinanceEventException("FIN_409", "公司车辆费用版本已变化"));
|
||||
var mvc = MockMvcBuilders.standaloneSetup(new PlatformVehicleCostController(service, mock(VehicleCostVouchers.class)))
|
||||
.setControllerAdvice(new VehicleCostExceptionHandler()).build();
|
||||
mvc.perform(get("/api/platform/finance/v1/vehicle-costs/VC-ONE"))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("FIN_409"))
|
||||
.andExpect(jsonPath("$.message").value("公司车辆费用版本已变化"))
|
||||
.andExpect(jsonPath("$.requestId", not(emptyOrNullString())))
|
||||
.andExpect(jsonPath("$.traceId", not(emptyOrNullString())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.baiyue.finance.fleet;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.event.*;
|
||||
import com.baiyue.finance.client.WarehouseSupplierClient;
|
||||
import com.baiyue.finance.service.FinanceDocumentNumberGenerator;
|
||||
import com.baiyue.finance.service.FinanceSupplierValidator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import jakarta.validation.Validation;
|
||||
import jakarta.validation.ValidatorFactory;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.aop.framework.ProxyFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.datasource.*;
|
||||
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
|
||||
import org.springframework.transaction.interceptor.TransactionInterceptor;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class VehicleCostPersistenceTest {
|
||||
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
private JdbcTemplate jdbc;
|
||||
private VehicleCostService service;
|
||||
private FinanceSupplierValidator suppliers;
|
||||
private VehicleCostVouchers vouchers;
|
||||
private ValidatorFactory validators;
|
||||
private static final VehicleCostModels.Query PAGE = new VehicleCostModels.Query(1, 20);
|
||||
@BeforeEach void setup() {
|
||||
var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", "");
|
||||
jdbc = new JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE fin_subject(id BIGINT PRIMARY KEY,subject_code VARCHAR(64),status VARCHAR(20))");
|
||||
jdbc.update("INSERT INTO fin_subject VALUES(1,'PLATFORM_DEFAULT','ACTIVE')");
|
||||
jdbc.execute("CREATE TABLE fin_inbox_event(event_id VARCHAR(128),source_system VARCHAR(32),event_type VARCHAR(64),payload CLOB,payload_hash VARCHAR(71),status VARCHAR(16),result_resource_id BIGINT,processed_at TIMESTAMP(3),UNIQUE(source_system,event_id))");
|
||||
jdbc.execute("CREATE TABLE fin_vehicle_cost(id BIGINT PRIMARY KEY,subject_id BIGINT NOT NULL,cost_no VARCHAR(64) NOT NULL UNIQUE,source_type VARCHAR(32) NOT NULL,source_no VARCHAR(64) NOT NULL,source_version BIGINT NOT NULL,source_hash VARCHAR(71) NOT NULL,source_json CLOB NOT NULL,supplier_id BIGINT NOT NULL,supplier_name VARCHAR(200) NOT NULL,amount DECIMAL(17,2) NOT NULL,paid_amount DECIMAL(17,2) NOT NULL,status VARCHAR(32) NOT NULL,record_version BIGINT NOT NULL,created_at TIMESTAMP(3),updated_at TIMESTAMP(3),UNIQUE(subject_id,source_type,source_no),CHECK(paid_amount>=0 AND paid_amount<=amount))");
|
||||
jdbc.execute("CREATE TABLE fin_vehicle_cost_revision(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,record_version BIGINT NOT NULL,action VARCHAR(32) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,operator_id BIGINT NOT NULL,operator_name VARCHAR(128) NOT NULL,reason VARCHAR(1000),snapshot_json CLOB NOT NULL,occurred_at TIMESTAMP(3),UNIQUE(cost_id,record_version),UNIQUE(operator_id,action,request_key))");
|
||||
jdbc.execute("CREATE TABLE fin_vehicle_cost_payment(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,payment_no VARCHAR(64) NOT NULL UNIQUE,amount DECIMAL(17,2) NOT NULL,payment_json CLOB NOT NULL,created_at TIMESTAMP(3))");
|
||||
suppliers = mock(FinanceSupplierValidator.class);
|
||||
when(suppliers.requireActive(8L, null)).thenReturn(new WarehouseSupplierClient.SupplierSnapshot(8L, "维修供应商", null, "ACTIVE"));
|
||||
vouchers = mock(VehicleCostVouchers.class);
|
||||
var numbers = mock(FinanceDocumentNumberGenerator.class); var sequence = new AtomicInteger();
|
||||
when(numbers.next(anyString())).thenAnswer(call -> call.getArgument(0) + "-" + sequence.incrementAndGet());
|
||||
validators = Validation.buildDefaultValidatorFactory();
|
||||
var target = new VehicleCostService(jdbc, json, validators.getValidator(), suppliers, numbers, vouchers);
|
||||
var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource()));
|
||||
service = (VehicleCostService) proxy.getProxy();
|
||||
}
|
||||
@AfterEach void close() { validators.close(); }
|
||||
private VehicleCostModels.Source source(long version, String amount) {
|
||||
return new VehicleCostModels.Source("REPAIR", "VR-ONE", version, "101", "TEST-PLATE", "TR-ONE", 8,
|
||||
"车辆维修", LocalDate.of(2026, 1, 1), "USD", new BigDecimal(amount), new BigDecimal("7.000000"), "2026-01",
|
||||
new BigDecimal(amount).multiply(new BigDecimal("7")).setScale(2), null);
|
||||
}
|
||||
private EventEnvelope event(VehicleCostModels.Source source) {
|
||||
var payload = json.valueToTree(source);
|
||||
return new EventEnvelope(UUID.randomUUID().toString(), VehicleCostModels.EVENT_TYPE, 1, Instant.now(), "0",
|
||||
"COMPANY_VEHICLE_COST", source.sourceType() + ":" + source.sourceNo(), source.sourceVersion(), null,
|
||||
UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload,
|
||||
EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)));
|
||||
}
|
||||
private VehicleCostModels.Cost receive() { service.receive(event(source(1, "10"))); return service.list(PAGE).records().get(0); }
|
||||
private VehicleCostModels.Payment payment(long version, String amount) {
|
||||
return new VehicleCostModels.Payment(version, new BigDecimal(amount), Instant.parse("2026-01-02T00:00:00Z"),
|
||||
"BANK_TRANSFER", "PAY-REF", List.of("11"), null);
|
||||
}
|
||||
@Test void acceptedSourceIsNotBookedOrPaidAndReplayRequiresTheSameHash() {
|
||||
var first = event(source(1, "10"));
|
||||
assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED);
|
||||
assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE);
|
||||
var other = event(source(1, "11"));
|
||||
var conflict = new EventEnvelope(first.eventId(), other.eventType(), 1, other.occurredAt(), "0", other.aggregateType(), other.aggregateId(), 1,
|
||||
null, other.traceId(), other.correlationId(), null, other.payload(), other.payloadHash());
|
||||
assertThatThrownBy(() -> service.receive(conflict)).hasMessageContaining("不同公司费用");
|
||||
var row = service.list(PAGE).records().get(0);
|
||||
assertThat(row.status()).isEqualTo("PENDING"); assertThat(row.outstandingAmount()).isNull();
|
||||
assertThat(row.paidAmount()).isZero(); assertThat(row.source().amount()).isEqualByComparingTo("70");
|
||||
assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1);
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isEqualTo(1);
|
||||
}
|
||||
@Test void missingVersionCannotApproveOrPayTheInitialRecord() throws Exception {
|
||||
var row = receive();
|
||||
var missing = json.readValue("{}", VehicleCostModels.Command.class);
|
||||
assertThat(missing.expectedVersion()).isNull();
|
||||
assertThatThrownBy(() -> service.approve(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数");
|
||||
assertThatThrownBy(() -> service.voidCost(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数");
|
||||
var pay = new VehicleCostModels.Payment(null, BigDecimal.ONE, Instant.parse("2026-01-02T00:00:00Z"), "CASH", null, List.of("11"), null);
|
||||
assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "missing-version", pay)).hasMessageContaining("参数");
|
||||
assertThat(service.detail(row.costNo()).status()).isEqualTo("PENDING");
|
||||
assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1);
|
||||
}
|
||||
@Test void failedSourceAuditCannotAcknowledgeOrPersistHalfAnEvent() {
|
||||
var event = event(source(1, "10"));
|
||||
jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_source_audit CHECK(action<>'RECEIVE')");
|
||||
assertThatThrownBy(() -> service.receive(event)).isInstanceOf(RuntimeException.class);
|
||||
assertThat(service.list(PAGE).total()).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero();
|
||||
jdbc.execute("ALTER TABLE fin_vehicle_cost_revision DROP CONSTRAINT reject_source_audit");
|
||||
assertThat(service.receive(event).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED);
|
||||
assertThat(service.list(PAGE).total()).isEqualTo(1);
|
||||
}
|
||||
@Test void concurrentDuplicateEventsOnlyPersistOneSourceAndOneHistory() throws Exception {
|
||||
var event = event(source(1, "10")); var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
var a = executor.submit(() -> service.receive(event)); var b = executor.submit(() -> service.receive(event));
|
||||
assertThat(List.of(a.get(10, TimeUnit.SECONDS).outcome(), b.get(10, TimeUnit.SECONDS).outcome()))
|
||||
.containsExactlyInAnyOrder(DeliveryAck.Outcome.APPLIED, DeliveryAck.Outcome.DUPLICATE);
|
||||
} finally { executor.shutdownNow(); }
|
||||
assertThat(service.list(PAGE).total()).isEqualTo(1);
|
||||
}
|
||||
@Test void partialAndFullPaymentConserveAmountsAndReplayNeverPaysTwice() {
|
||||
var row = receive(); var no = row.costNo();
|
||||
assertThatThrownBy(() -> service.pay(no, 1, "财务", "before-approve", payment(0, "1"))).hasMessageContaining("已入账");
|
||||
var approved = service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
|
||||
assertThat(approved.outstandingAmount()).isEqualByComparingTo("70");
|
||||
var partial = service.pay(no, 1, "财务", "payment-one", payment(1, "20"));
|
||||
assertThat(partial.status()).isEqualTo("PARTIALLY_PAID"); assertThat(partial.outstandingAmount()).isEqualByComparingTo("50");
|
||||
assertThat(service.pay(no, 1, "财务", "payment-one", payment(1, "20"))).isEqualTo(partial);
|
||||
assertThatThrownBy(() -> service.pay(no, 1, "财务", "payment-one", payment(1, "21"))).hasMessageContaining("幂等键");
|
||||
assertThatThrownBy(() -> service.pay(no, 1, "财务", "too-much", payment(2, "51"))).hasMessageContaining("未付余额");
|
||||
assertThatThrownBy(() -> service.pay(no, 1, "财务", "stale-version", payment(1, "1"))).hasMessageContaining("版本");
|
||||
var paid = service.pay(no, 1, "财务", "payment-two", payment(2, "50"));
|
||||
assertThat(paid.status()).isEqualTo("PAID"); assertThat(paid.outstandingAmount()).isZero();
|
||||
assertThat(service.payments(no, PAGE).total()).isEqualTo(2);
|
||||
assertThatThrownBy(() -> service.voidCost(no, 1, "财务", "void-paid", new VehicleCostModels.Command(3L, null))).hasMessageContaining("已付款");
|
||||
assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("不能替换");
|
||||
}
|
||||
@Test void unpaidBookedCostRequiresVoidBeforeNewVersionAndRetainsSnapshots() {
|
||||
var row = receive(); var no = row.costNo();
|
||||
service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
|
||||
assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("先作废");
|
||||
service.voidCost(no, 1, "财务", "void-one", new VehicleCostModels.Command(1L, "金额重新核对"));
|
||||
service.receive(event(source(2, "20")));
|
||||
var latest = service.detail(no);
|
||||
assertThat(latest.status()).isEqualTo("PENDING"); assertThat(latest.source().amount()).isEqualByComparingTo("140");
|
||||
assertThat(service.history(no, PAGE).records()).hasSize(4);
|
||||
assertThat(service.history(no, PAGE).records().get(3).snapshot().source().amount()).isEqualByComparingTo("70");
|
||||
assertThatThrownBy(() -> service.receive(event(source(1, "10")))).hasMessageContaining("过期");
|
||||
}
|
||||
@Test void auditFailureRollsBackPaymentAndBalances() {
|
||||
var row = receive(); var no = row.costNo();
|
||||
service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
|
||||
jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_payment_audit CHECK(action<>'PAY')");
|
||||
assertThatThrownBy(() -> service.pay(no, 1, "财务", "failing-payment", payment(1, "20"))).isInstanceOf(RuntimeException.class);
|
||||
assertThat(service.detail(no).paidAmount()).isZero(); assertThat(service.detail(no).recordVersion()).isEqualTo(1);
|
||||
assertThat(service.payments(no, PAGE).records()).isEmpty();
|
||||
}
|
||||
@Test void voucherOrSupplierFailureDoesNotCreatePayment() {
|
||||
var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
|
||||
doThrow(new IllegalStateException("凭证归属不符")).when(vouchers).validate(anyLong(), anyList());
|
||||
assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("归属");
|
||||
assertThat(service.payments(row.costNo(), PAGE).total()).isZero();
|
||||
reset(vouchers); when(suppliers.requireActive(8L, null)).thenThrow(new IllegalStateException("供应商不可用"));
|
||||
assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("不可用");
|
||||
assertThat(service.detail(row.costNo()).paidAmount()).isZero();
|
||||
}
|
||||
@Test void concurrentPaymentsCannotOverpay() throws Exception {
|
||||
var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null));
|
||||
var executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Callable<Boolean> first = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-a", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } };
|
||||
Callable<Boolean> second = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-b", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } };
|
||||
var a = executor.submit(first); var b = executor.submit(second);
|
||||
assertThat(List.of(a.get(10, TimeUnit.SECONDS), b.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false);
|
||||
} finally { executor.shutdownNow(); }
|
||||
assertThat(service.detail(row.costNo()).paidAmount()).isEqualByComparingTo("50");
|
||||
assertThat(service.detail(row.costNo()).outstandingAmount()).isEqualByComparingTo("20");
|
||||
}
|
||||
@Test void rejectsClientScopeBadExchangeAndBadSourceBeforeWritingAnything() {
|
||||
var valid = event(source(1, "10"));
|
||||
var customer = new EventEnvelope(valid.eventId(), valid.eventType(), 1, valid.occurredAt(), "12", valid.aggregateType(), valid.aggregateId(), 1,
|
||||
null, valid.traceId(), valid.correlationId(), null, valid.payload(), valid.payloadHash());
|
||||
assertThatThrownBy(() -> service.receive(customer)).hasMessageContaining("归属");
|
||||
var tree = (com.fasterxml.jackson.databind.node.ObjectNode) valid.payload().deepCopy(); tree.put("amount", 71);
|
||||
var invalid = new EventEnvelope(UUID.randomUUID().toString(), valid.eventType(), 1, valid.occurredAt(), "0", valid.aggregateType(), valid.aggregateId(), 1,
|
||||
null, valid.traceId(), valid.correlationId(), null, tree, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(tree)));
|
||||
assertThatThrownBy(() -> service.receive(invalid)).hasMessageContaining("不守恒");
|
||||
assertThat(service.list(PAGE).total()).isZero();
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.baiyue.finance.fleet;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class VehicleCostVouchersTest {
|
||||
@Test void onlyPlatformFilesBoundToThisCompanyCostCanProvePayment() {
|
||||
var client = mock(VehicleCostFileClient.class);
|
||||
var service = new VehicleCostVouchers(client, "test-internal-token");
|
||||
when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success(
|
||||
new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L)));
|
||||
service.validate(5, List.of("11"));
|
||||
for (var wrong : List.of(
|
||||
new VehicleCostFileClient.Metadata(11L, "CUSTOMER", 12L, "FINANCE_VOUCHER", "12", "payment.png", "image/png", 100L),
|
||||
new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "6", "payment.png", "image/png", 100L),
|
||||
new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FLEET_VEHICLE_RECORD_PHOTO", "5", "repair.png", "image/png", 100L))) {
|
||||
when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success(wrong));
|
||||
assertThatThrownBy(() -> service.validate(5, List.of("11"))).hasMessageContaining("归属");
|
||||
}
|
||||
}
|
||||
@Test void absentConfigurationOrDuplicateVouchersFailClosed() {
|
||||
var client = mock(VehicleCostFileClient.class);
|
||||
assertThatThrownBy(() -> new VehicleCostVouchers(client, "").validate(5, List.of("11"))).hasMessageContaining("不可用");
|
||||
assertThatThrownBy(() -> new VehicleCostVouchers(client, "test").validate(5, List.of("11", "11"))).hasMessageContaining("重复");
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
@Test void fileServiceBusinessErrorBodyCannotBeForwardedAsPaymentEvidence() {
|
||||
var client = mock(VehicleCostFileClient.class);
|
||||
var service = new VehicleCostVouchers(client, "test");
|
||||
when(client.metadata("test", 5, 11)).thenReturn(ApiResponse.success(
|
||||
new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L)));
|
||||
when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok()
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
assertThatThrownBy(() -> service.download(5, 11)).hasMessageContaining("不可用");
|
||||
when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok()
|
||||
.contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[100]));
|
||||
assertThat(service.download(5, 11).getBody()).hasSize(100);
|
||||
}
|
||||
}
|
||||
+11
@@ -56,4 +56,15 @@ class FinanceDocumentNumberGeneratorTest {
|
||||
.isInstanceOf(FinanceEventException.class)
|
||||
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vehicleCostsAndPaymentsUseIndependentSequenceKeys() {
|
||||
JdbcTemplate jdbc = mock(JdbcTemplate.class);
|
||||
when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L);
|
||||
FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK);
|
||||
assertThat(generator.next("VC")).isEqualTo("VC-260818-0001");
|
||||
assertThat(generator.next("VP")).isEqualTo("VP-260818-0001");
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818"));
|
||||
verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user