This commit is contained in:
2026-08-11 18:06:26 +08:00
parent a02dd3a422
commit 192964419f
101 changed files with 8892 additions and 1952 deletions
@@ -0,0 +1,34 @@
package com.baiyue.finance.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@FeignClient(name = "firstleg-service", contextId = "financeFirstlegFeeSourceClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1")
public interface FirstlegFeeSourceClient {
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> inbound(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long inboundId, @PathVariable long feeId);
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long containerId, @PathVariable long feeId);
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
Long inboundId, String inboundCode, Long orderId, Long containerId,
Long containerBookingId, String containerNo, long customerId,
String customerName, Long feeItemId, String feeItemCode,
String feeItemName, String direction, LocalDate businessDate,
String currency, BigDecimal totalAmount, String reviewStatus,
String publicationStatus, boolean settlementIncluded,
LocalDateTime publishedAt, BigDecimal chargeableQuantity,
String chargeUnit, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {}
}
@@ -3,33 +3,52 @@ package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "客户财务只读")
@RestController
@RequestMapping("/api/client/finance/v1")
public class ClientFinanceController {
private final FinanceService service;
public ClientFinanceController(FinanceService service) { this.service = service; }
private final FinanceQueryService queryService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService) {
this.service = service;
this.queryService = queryService;
}
@GetMapping("/bills")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询账单")
public ApiResponse<?> bills(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listClientBills(user.tenantId())); }
public ApiResponse<?> bills(@AuthenticationPrincipal AuthenticatedUser user) { return FinanceResponses.success(queryService.statements(user.tenantId(), true)); }
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('merchant:finance:statement:view')")
@Operation(summary = "客户只读查看账单详情")
public ApiResponse<Map<String, Object>> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return ApiResponse.success(service.clientBill(user.tenantId(), billId)); }
public ApiResponse<?> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return FinanceResponses.success(queryService.statement(user.tenantId(), billId, true)); }
@GetMapping("/receipts")
@PreAuthorize("hasAuthority('merchant:finance:statement:view')")
@Operation(summary = "客户只读查询收款记录")
public ApiResponse<?> receipts(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listReceipts(user.tenantId())); }
public ApiResponse<?> receipts(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(queryService.clientReceipts(user.tenantId()));
}
@GetMapping("/statements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询对账单")
public ApiResponse<?> statements(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(queryService.statements(user.tenantId(), true));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询结算应收")
public ApiResponse<?> receivableSettlements(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(queryService.receivableSettlements(user.tenantId(), true));
}
}
@@ -0,0 +1,60 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.service.FinanceEventException;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
import java.util.UUID;
/** Finance-owned error codes stay stable even when the common result catalog evolves. */
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "com.baiyue.finance.controller")
public class FinanceExceptionHandler {
@ExceptionHandler(FinanceEventException.class)
public ResponseEntity<ApiResponse<Void>> event(FinanceEventException error) {
return ResponseEntity.ok(response(error.code(), error.getMessage()));
}
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> business(BizException error) {
ResultCode result = error.getResultCode();
HttpStatus status = result == ResultCode.SERVICE_UNAVAILABLE ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK;
return ResponseEntity.status(status).body(response(financeCode(result), error.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> validation(IllegalArgumentException error) {
return ResponseEntity.ok(response("FIN_400", error.getMessage()));
}
private static String financeCode(ResultCode result) {
return switch (result) {
case NOT_FOUND -> "FIN_404";
case CONFLICT -> "FIN_409";
case SERVICE_UNAVAILABLE -> "FIN_503";
case VALIDATION_FAILED, BAD_REQUEST -> "FIN_400";
default -> "FIN_001";
};
}
private static ApiResponse<Void> response(String code, String message) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
String traceId = first(MDC.get("traceId"), MDC.get("trace_id"));
if (!StringUtils.hasText(traceId)) traceId = requestId;
return new ApiResponse<>(code, message, null, requestId, traceId, Instant.now());
}
private static String first(String first, String second) {
return StringUtils.hasText(first) ? first : second;
}
}
@@ -0,0 +1,23 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import org.slf4j.MDC;
import org.springframework.util.StringUtils;
import java.util.UUID;
final class FinanceResponses {
private FinanceResponses() {}
static <T> ApiResponse<T> success(T data) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
String traceId = first(MDC.get("traceId"), MDC.get("trace_id"));
if (!StringUtils.hasText(traceId)) traceId = requestId;
return ApiResponse.success(data, requestId, traceId);
}
private static String first(String first, String second) {
return StringUtils.hasText(first) ? first : second;
}
}
@@ -0,0 +1,36 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/internal/finance/v1/fee-source-events")
public class InternalFinanceFeeSourceController {
private final FinanceFeeSourceEventService service;
@Value("${baiyue.security.internal-service-token}")
private String expectedToken;
@PostMapping
public ApiResponse<Map<String, Long>> consume(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody FinanceFeeSourceEvent event) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new com.baiyue.finance.service.FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
}
return FinanceResponses.success(Map.of("feeSourceId", service.consume(event)));
}
}
@@ -4,6 +4,7 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import io.swagger.v3.oas.annotations.Operation;
@@ -16,6 +17,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.beans.factory.annotation.Autowired;
@Tag(name = "人工财务台账")
@RestController
@@ -24,8 +26,58 @@ public class PlatformFinanceController {
private final FinanceService service;
private final FinanceFileClient fileClient;
private final FirstlegCostSnapshotClient costSnapshotClient;
private final FinanceQueryService queryService;
@Value("${baiyue.security.internal-service-token:}") private String fileToken;
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient, FirstlegCostSnapshotClient costSnapshotClient) { this.service = service; this.fileClient = fileClient; this.costSnapshotClient = costSnapshotClient; }
@Autowired
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient,
FirstlegCostSnapshotClient costSnapshotClient, FinanceQueryService queryService) {
this.service = service;
this.fileClient = fileClient;
this.costSnapshotClient = costSnapshotClient;
this.queryService = queryService;
}
/** Kept for direct unit tests of write behavior. */
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient,
FirstlegCostSnapshotClient costSnapshotClient) {
this(service, fileClient, costSnapshotClient, null);
}
@GetMapping("/customer-accounts/{customerId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户人工应收账户及流水")
public ApiResponse<?> customerAccount(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccount(customerId));
}
@GetMapping("/fee-pool")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户应收应付费用池")
public ApiResponse<?> feePool(@RequestParam long customerId,
@RequestParam(required = false) String direction) {
return FinanceResponses.success(queryService.feePool(customerId, direction));
}
@GetMapping("/statements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户对账单")
public ApiResponse<?> statements(@RequestParam long customerId) {
return FinanceResponses.success(queryService.statements(customerId, false));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "结算单应收")
public ApiResponse<?> receivableSettlements(@RequestParam long customerId) {
return FinanceResponses.success(queryService.receivableSettlements(customerId, false));
}
@GetMapping("/payable-settlements")
@PreAuthorize("hasAuthority('platform:finance:cost:view')")
@Operation(summary = "结算单应付")
public ApiResponse<?> payableSettlements(@RequestParam long customerId) {
return FinanceResponses.success(queryService.payableSettlements(customerId));
}
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')")
@@ -33,60 +85,65 @@ public class PlatformFinanceController {
@RequestParam long customerId,
@RequestPart("file") MultipartFile file) {
if (customerId <= 0) throw new IllegalArgumentException("客户范围无效");
return fileClient.upload(fileToken, user.userId(), customerId, customerId, file);
var response = fileClient.upload(fileToken, user.userId(), customerId, customerId, file);
if (response == null || response.data() == null) throw new IllegalStateException("File服务未返回有效上传结果");
return FinanceResponses.success(response.data());
}
@GetMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('platform:finance:customer-terms:view')")
@Operation(summary = "查询客户账期")
public ApiResponse<Map<String, Object>> terms(@PathVariable long tenantId) { return ApiResponse.success(service.customerTerms(tenantId)); }
public ApiResponse<?> terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); }
@GetMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "查询平台账单")
public ApiResponse<?> bills(@RequestParam long customerId) { return ApiResponse.success(service.listBills(customerId)); }
public ApiResponse<?> bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); }
@PutMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('platform:finance:customer-terms:update')")
@BusinessAudit(event = "FINANCE_CUSTOMER_TERMS_UPDATE", resourceType = "CUSTOMER")
public ApiResponse<Map<String, Object>> saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) {
return ApiResponse.success(service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key));
public ApiResponse<?> saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) {
return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)));
}
@PostMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:create')")
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
public ApiResponse<?> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
var lines = request.lines().stream().map(line -> new FinanceService.BillLine("FIRSTLEG_FEE", line.sourceFeeId(), line.sourceFeeVersion(), null, line.orderId(), line.inboundId(), "MANUAL", line.feeItemName(), line.amount())).toList();
var internal = new FinanceService.BillRequest(null, 1L, request.billDate(), null, lines);
return ApiResponse.success(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key));
return FinanceResponses.success(resourceView(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key)));
}
@PostMapping("/bills/{billId}/issue")
@PreAuthorize("hasAuthority('platform:finance:bill:issue')")
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return ApiResponse.success(service.issueBillById(user.userId(), billId, request.expectedVersion(), key));
public ApiResponse<?> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return FinanceResponses.success(resourceView(service.issueBillById(user.userId(), billId, request.expectedVersion(), key)));
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
public ApiResponse<Map<String, Object>> bill(@PathVariable long billId) { return ApiResponse.success(service.billById(billId)); }
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@GetMapping("/bills/{billId}/revisions")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) {
return ApiResponse.success(service.billRevisions(customerId, billId));
return FinanceResponses.success(queryService.billRevisions(customerId, billId));
}
@GetMapping("/receipts")
@PreAuthorize("hasAuthority('platform:finance:receipt:view')")
public ApiResponse<?> receipts(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listReceipts(customerId)); }
public ApiResponse<?> receipts(@RequestParam(required = false) Long customerId) {
if (customerId == null) throw new IllegalArgumentException("查询收款记录必须指定客户");
return FinanceResponses.success(queryService.platformReceipts(customerId));
}
@PostMapping("/receipts")
@PreAuthorize("hasAuthority('platform:finance:receipt:create')")
@BusinessAudit(event = "FINANCE_RECEIPT_CREATE", resourceType = "FINANCE_RECEIPT")
public ApiResponse<Map<String, Object>> receipt(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReceiptRequest request) {
public ApiResponse<?> receipt(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReceiptRequest request) {
if (request.amount() == null || request.amount().signum() <= 0) throw new IllegalArgumentException("收款金额必须大于0");
if (request.receivedDate() == null) throw new IllegalArgumentException("到账日期不能为空");
if (request.allocations() == null || request.allocations().isEmpty()) throw new IllegalArgumentException("收款至少需要分配一张账单");
@@ -95,17 +152,20 @@ public class PlatformFinanceController {
if (request.amount().compareTo(allocated) != 0) throw new IllegalArgumentException("收款金额必须等于分配金额合计");
var allocations = request.allocations().stream().map(a -> new FinanceService.ReceiptAllocation(a.billId(), a.amount())).toList();
var internal = new FinanceService.ReceiptRequest(null, 1L, request.receivedDate().atStartOfDay(), request.paymentMethod(), null, request.voucherFileIds(), request.remark(), allocations);
return ApiResponse.success(service.createReceipt(request.customerId(), user.userId(), internal, key));
return FinanceResponses.success(resourceView(service.createReceipt(request.customerId(), user.userId(), internal, key)));
}
@GetMapping("/cost-entries")
@PreAuthorize("hasAuthority('platform:finance:cost:view')")
public ApiResponse<?> costs(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listCosts(customerId)); }
public ApiResponse<?> costs(@RequestParam(required = false) Long customerId) {
if (customerId == null) throw new IllegalArgumentException("查询成本记录必须指定客户");
return FinanceResponses.success(queryService.payableSettlements(customerId));
}
@PostMapping("/cost-entries")
@PreAuthorize("hasAuthority('platform:finance:cost:create')")
@BusinessAudit(event = "FINANCE_COST_CREATE", resourceType = "FINANCE_COST")
public ApiResponse<Map<String, Object>> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) {
public ApiResponse<?> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) {
if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY");
var internal = new FinanceService.CostRequest(null, request.costType(), request.orderId(), null, request.containerId(), null, null, "MANUAL", request.expenseName(), request.amount(), request.voucherFileIds(), request.remark());
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
@@ -118,20 +178,42 @@ public class PlatformFinanceController {
}
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), v.inboundId(), snapshot.containerId(), snapshot.customerId(), v.volumeCbm())).toList();
}
return ApiResponse.success(service.createCost(request.customerId(), user.userId(), internal, volumes, key));
return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key)));
}
@PutMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:bill:update')")
public ApiResponse<Map<String,Object>> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody UpdateBillRequest request) { return ApiResponse.success(service.updateBill(user.userId(), billId, request.customerId(), request.billDate(), request.lines(), request.expectedVersion(), key)); }
public ApiResponse<?> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody UpdateBillRequest request) { return FinanceResponses.success(resourceView(service.updateBill(user.userId(), billId, request.customerId(), request.billDate(), request.lines(), request.expectedVersion(), key))); }
@PostMapping("/bills/{billId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:bill:adjust')")
public ApiResponse<Map<String,Object>> adjust(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody AdjustBillRequest request) { return ApiResponse.success(service.adjustBill(user.userId(), billId, request.lines(), request.expectedVersion(), request.reason(), key)); }
public ApiResponse<?> adjust(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody AdjustBillRequest request) { return FinanceResponses.success(resourceView(service.adjustBill(user.userId(), billId, request.lines(), request.expectedVersion(), request.reason(), key))); }
@PostMapping("/bills/{billId}/void")
@PreAuthorize("hasAuthority('platform:finance:bill:void')")
public ApiResponse<Map<String,Object>> voidBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody ReasonRequest request) { return ApiResponse.success(service.voidBill(user.userId(), billId, request.expectedVersion(), request.reason(), key)); }
public ApiResponse<?> voidBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody ReasonRequest request) { return FinanceResponses.success(resourceView(service.voidBill(user.userId(), billId, request.expectedVersion(), request.reason(), key))); }
private static com.baiyue.finance.dto.FinanceReadModels.CustomerTerms termsView(long customerId, Map<String, Object> row) {
Object name = row.getOrDefault("customer_name_snapshot", row.get("customerNameSnapshot"));
Object days = row.getOrDefault("credit_days", row.get("creditDays"));
return new com.baiyue.finance.dto.FinanceReadModels.CustomerTerms(customerId, name == null ? null : name.toString(),
days == null ? 30 : ((Number) days).intValue(), String.valueOf(row.getOrDefault("status", "ACTIVE")));
}
private static com.baiyue.finance.dto.FinanceReadModels.ResourceResult resourceView(Map<String, Object> row) {
long id = number(row.get("id"), 0L).longValue();
Object no = first(row, "bill_no", "receipt_no", "cost_no");
Object version = row.get("version");
return new com.baiyue.finance.dto.FinanceReadModels.ResourceResult(id, no == null ? null : no.toString(),
String.valueOf(row.getOrDefault("status", "DRAFT")), version instanceof Number n ? n.intValue() : null);
}
private static Object first(Map<String, Object> row, String... keys) {
for (String key : keys) if (row.get(key) != null) return row.get(key);
return null;
}
private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; }
public record TermsRequest(String customerName, Integer creditDays) {}
public record CreateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List<BillLineInput> lines) {}
@@ -0,0 +1,12 @@
package com.baiyue.finance.dto;
import java.time.LocalDateTime;
public record FinanceFeeSourceEvent(
String eventId, String eventType, int eventVersion, String producer,
LocalDateTime occurredAt, String tenantId, String aggregateType,
String aggregateId, int sequence, String idempotencyKey,
String payloadHash, Payload payload
) {
public record Payload(String inboundId, String containerId, String feeId, int feeVersion) {}
}
@@ -0,0 +1,146 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Stable read projections used by the Finance pages. */
public final class FinanceReadModels {
private FinanceReadModels() {}
public record CustomerAccount(
long customerId,
String currency,
String accountMode,
BigDecimal totalReceivable,
BigDecimal totalReceived,
BigDecimal outstandingAmount,
List<AccountEntry> entries) {}
public record AccountEntry(
String entryType,
long resourceId,
String resourceNo,
LocalDateTime occurredAt,
BigDecimal debitAmount,
BigDecimal creditAmount,
String currency,
String status) {}
public record FeePoolItem(
String poolItemId,
String direction,
String sourceType,
long sourceId,
int sourceVersion,
Long orderId,
Long inboundId,
Long containerId,
String feeItemName,
BigDecimal amount,
String currency,
String status,
LocalDateTime createdAt) {}
public record Statement(
long id,
long customerId,
String billNo,
String customerName,
LocalDate billDate,
LocalDate dueDate,
BigDecimal totalAmount,
BigDecimal receivedAmount,
BigDecimal outstandingAmount,
String currency,
String status,
int version) {}
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
public record StatementLine(
long id,
String sourceType,
long sourceId,
int sourceVersion,
Long orderId,
Long inboundId,
String feeItemCode,
String feeItemName,
BigDecimal amount,
String currency) {}
public record ReceivableSettlement(
long id,
long customerId,
String settlementNo,
LocalDate billDate,
LocalDate dueDate,
BigDecimal receivableAmount,
BigDecimal receivedAmount,
BigDecimal outstandingAmount,
String currency,
String status,
int version) {}
public record PayableSettlement(
long id,
long customerId,
String settlementNo,
String costType,
Long orderId,
Long inboundId,
Long containerId,
String supplierName,
String feeItemName,
BigDecimal payableAmount,
BigDecimal paidAmount,
BigDecimal outstandingAmount,
String currency,
String status,
LocalDateTime createdAt,
int version) {}
/** Client-safe receipt view. Voucher and operator/internal fields are deliberately absent. */
public record ClientReceipt(
long id,
String receiptNo,
BigDecimal receivedAmount,
LocalDateTime receivedAt,
String paymentMethod,
String currency,
String status,
List<ClientReceiptAllocation> allocations) {}
public record ClientReceiptAllocation(long billId, String billNo, BigDecimal appliedAmount) {}
public record PlatformReceipt(
long id,
long customerId,
String receiptNo,
BigDecimal receivedAmount,
LocalDateTime receivedAt,
String paymentMethod,
String voucherNo,
List<String> voucherFileIds,
String currency,
String status,
String remark,
int version,
List<ClientReceiptAllocation> allocations) {}
public record BillRevision(
long id,
long billId,
int version,
String reason,
String billSnapshot,
String lineSnapshot,
long createdBy,
LocalDateTime createdAt) {}
public record CustomerTerms(long customerId, String customerName, int creditDays, String status) {}
public record ResourceResult(long id, String resourceNo, String status, Integer version) {}
}
@@ -0,0 +1,12 @@
package com.baiyue.finance.service;
public final class FinanceEventException extends RuntimeException {
private final String code;
public FinanceEventException(String code, String message) {
super(message);
this.code = code;
}
public String code() { return code; }
}
@@ -0,0 +1,153 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceEventService {
private static final Set<String> EVENT_TYPES = Set.of(
"FIRSTLEG_FEE_PUBLISHED", "FIRSTLEG_FEE_ADJUSTED", "FIRSTLEG_FEE_VOIDED");
private final JdbcTemplate jdbc;
private final FirstlegFeeSourceClient firstleg;
private final ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@Transactional
public long consume(FinanceFeeSourceEvent event) {
validateEnvelope(event);
String eventJson = json(event);
String requestHash = sha256(eventJson);
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT payload_hash,status,result_resource_id FROM fin_inbox_event WHERE source_system=? AND event_id=? FOR UPDATE",
"FIRSTLEG", event.eventId());
if (!existing.isEmpty()) {
Map<String, Object> row = existing.get(0);
if (!requestHash.equals(row.get("payload_hash"))) {
throw error("FIN-EVT-002", "同一事件ID对应不同请求内容");
}
if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id) {
return id.longValue();
}
throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败");
}
jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')",
event.eventId(), "FIRSTLEG", event.eventType(), eventJson, requestHash);
FirstlegFeeSourceClient.Snapshot snapshot = loadSnapshot(event);
validateSnapshot(event, snapshot);
long resourceId = applyEvent(event, snapshot);
jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?",
resourceId, "FIRSTLEG", event.eventId());
return resourceId;
}
private long applyEvent(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot snapshot) {
List<Map<String, Object>> sameVersion = jdbc.queryForList(
"SELECT id,source_hash,status FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=? FOR UPDATE",
snapshot.feeId(), snapshot.feeVersion());
if (!sameVersion.isEmpty()) {
Map<String, Object> row = sameVersion.get(0);
if (!snapshot.sourceHash().equals(row.get("source_hash"))) {
throw error("FIN-EVT-004", "同一来源费用版本快照不一致");
}
long id = ((Number) row.get("id")).longValue();
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", id);
}
return id;
}
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
}
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'",
snapshot.feeId());
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(),
snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(),
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(),
snapshot.sourceHash(), json(snapshot));
return jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?",
Long.class, snapshot.feeId(), snapshot.feeVersion());
}
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
FinanceFeeSourceEvent.Payload payload = event.payload();
var response = payload.inboundId() != null
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"), positive(payload.feeId(), "feeId"))
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"), positive(payload.feeId(), "feeId"));
if (response == null || response.data() == null || !"200".equals(response.code())) {
throw error("FIN-EVT-006", "Firstleg费用来源快照不可用");
}
return response.data();
}
private void validateEnvelope(FinanceFeeSourceEvent event) {
if (event == null || blank(event.eventId()) || !EVENT_TYPES.contains(event.eventType())
|| !"firstleg-service".equals(event.producer()) || event.payload() == null
|| blank(event.tenantId()) || blank(event.payload().feeId())) {
throw error("FIN-EVT-001", "费用来源事件信封无效");
}
boolean inbound = event.payload().inboundId() != null;
boolean container = event.payload().containerId() != null;
if (inbound == container || !sha256(json(event.payload())).equals(event.payloadHash())) {
throw error("FIN-EVT-001", "费用来源事件载荷无效");
}
}
private void validateSnapshot(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot value) {
long tenantId = positive(event.tenantId(), "tenantId");
long feeId = positive(event.payload().feeId(), "feeId");
if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致");
if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致");
if (!"APPROVED".equals(value.reviewStatus()) || !value.settlementIncluded()) throw error("FIN-EVT-009", "费用未审核通过或未参与结算");
if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) {
throw error("FIN-EVT-015", "费用来源尚未发布");
}
if (!"CNY".equals(value.currency()) || value.totalAmount() == null || value.totalAmount().signum() <= 0) throw error("FIN-EVT-010", "费用金额或币种无效");
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); }
}
private static long positive(String value, String field) {
try {
long parsed = Long.parseLong(value);
if (parsed > 0) return parsed;
} catch (RuntimeException ignored) { }
throw error("FIN-EVT-001", field + "无效");
}
private static String sha256(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}
@@ -0,0 +1,197 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.dto.FinanceReadModels.AccountEntry;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.dto.FinanceReadModels.BillRevision;
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccount;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.dto.FinanceReadModels.PayableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.PlatformReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ReceivableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.Statement;
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/** Typed query facade for the five Finance pages and client-safe read APIs. */
@Service
public class FinanceQueryService {
private static final String CNY = "CNY";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
public CustomerAccount customerAccount(long customerId) {
requireCustomer(customerId);
BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
BigDecimal received = amount("SELECT COALESCE(SUM(received_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
List<AccountEntry> entries = new ArrayList<>();
entries.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,currency_code,status FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("bill_no"), time(rs, "created_at"),
rs.getBigDecimal("total_amount"), BigDecimal.ZERO, rs.getString("currency_code"), rs.getString("status")), customerId));
entries.addAll(jdbc.query("SELECT id,receipt_no,received_at,received_amount,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
new AccountEntry("RECEIPT", rs.getLong("id"), rs.getString("receipt_no"), time(rs, "received_at"),
BigDecimal.ZERO, rs.getBigDecimal("received_amount"), rs.getString("currency_code"), rs.getString("status")), customerId));
entries.sort(Comparator.comparing(AccountEntry::occurredAt, Comparator.nullsLast(Comparator.reverseOrder())));
return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries));
}
public List<FeePoolItem> feePool(long customerId, String direction) {
requireCustomer(customerId);
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
List<FeePoolItem> result = new ArrayList<>();
String sourceSql = "SELECT id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,status,created_at FROM fin_fee_source WHERE customer_id=? AND status='AVAILABLE'";
Object[] sourceArgs;
if ("ALL".equals(normalized)) {
sourceSql += " ORDER BY created_at DESC,id DESC";
sourceArgs = new Object[]{customerId};
} else {
sourceSql += " AND direction=? ORDER BY created_at DESC,id DESC";
sourceArgs = new Object[]{customerId, normalized};
}
result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> {
String rowDirection = rs.getString("direction");
return new FeePoolItem(rowDirection + ":" + rs.getLong("id"), rowDirection,
rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getString("currency_code"),
rs.getString("status"), time(rs, "created_at"));
}, sourceArgs));
if (!"RECEIVABLE".equals(normalized)) {
result.addAll(jdbc.query("SELECT id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC", (rs, rowNum) ->
new FeePoolItem("PAYABLE:" + rs.getLong("id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1,
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), customerId));
}
result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder())));
return List.copyOf(result);
}
public List<Statement> statements(long customerId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
return jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE tenant_id=?" + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), customerId);
}
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
List<StatementLine> lines = jdbc.query("SELECT id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",
(rs, rowNum) -> new StatementLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_id"), rs.getInt("source_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), rs.getString("fee_item_code"), rs.getString("fee_item_name"),
rs.getBigDecimal("amount"), rs.getString("currency_code")), customerId, billId);
return new StatementDetail(rows.get(0), lines);
}
public StatementDetail platformStatement(long billId) {
Long customerId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId);
if (customerId == null) throw new BizException(ResultCode.NOT_FOUND, "账单不存在");
return statement(customerId, billId, false);
}
public List<BillRevision> billRevisions(long customerId, long billId) {
statement(customerId, billId, false);
return jdbc.query("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC",
(rs, rowNum) -> new BillRevision(rs.getLong("id"), rs.getLong("bill_id"), rs.getInt("version"), rs.getString("reason"),
rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"), time(rs, "created_at")), customerId, billId);
}
public List<ReceivableSettlement> receivableSettlements(long customerId, boolean clientVisibleOnly) {
return statements(customerId, clientVisibleOnly).stream().map(row -> new ReceivableSettlement(
row.id(), row.customerId(), row.billNo(), row.billDate(), row.dueDate(), row.totalAmount(), row.receivedAmount(),
row.outstandingAmount(), row.currency(), row.status(), row.version())).toList();
}
public List<PayableSettlement> payableSettlements(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC",
(rs, rowNum) -> {
BigDecimal amount = rs.getBigDecimal("cost_amount");
return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"),
rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"),
time(rs, "created_at"), rs.getInt("version"));
}, customerId);
}
public List<ClientReceipt> clientReceipts(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,receipt_no,received_amount,received_at,payment_method,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED' ORDER BY received_at DESC,id DESC",
(rs, rowNum) -> new ClientReceipt(rs.getLong("id"), rs.getString("receipt_no"), rs.getBigDecimal("received_amount"),
time(rs, "received_at"), rs.getString("payment_method"), rs.getString("currency_code"), rs.getString("status"), allocations(customerId, rs.getLong("id"))), customerId);
}
public List<PlatformReceipt> platformReceipts(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,tenant_id,receipt_no,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,currency_code,status,remark,version FROM fin_receipt WHERE tenant_id=? ORDER BY received_at DESC,id DESC",
(rs, rowNum) -> new PlatformReceipt(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("receipt_no"), rs.getBigDecimal("received_amount"),
time(rs, "received_at"), rs.getString("payment_method"), rs.getString("voucher_no"), fileIds(rs.getString("voucher_file_ids")),
rs.getString("currency_code"), rs.getString("status"), rs.getString("remark"), rs.getInt("version"), allocations(customerId, rs.getLong("id"))), customerId);
}
private List<String> fileIds(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception error) {
throw new IllegalStateException("财务凭证文件字段损坏", error);
}
}
private List<ClientReceiptAllocation> allocations(long customerId, long receiptId) {
return jdbc.query("SELECT rb.bill_id,b.bill_no,rb.applied_amount FROM fin_receipt_bill rb JOIN fin_bill b ON b.id=rb.bill_id AND b.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.receipt_id=? ORDER BY rb.id",
(rs, rowNum) -> new ClientReceiptAllocation(rs.getLong("bill_id"), rs.getString("bill_no"), rs.getBigDecimal("applied_amount")), customerId, receiptId);
}
private BigDecimal amount(String sql, long customerId) {
BigDecimal value = jdbc.queryForObject(sql, BigDecimal.class, customerId);
return value == null ? BigDecimal.ZERO : value;
}
private static Long nullableLong(ResultSet rs, String column) throws SQLException {
long value = rs.getLong(column);
return rs.wasNull() ? null : value;
}
private static LocalDateTime time(ResultSet rs, String column) throws SQLException {
Timestamp timestamp = rs.getTimestamp(column);
return timestamp == null ? null : timestamp.toLocalDateTime();
}
private static void requireCustomer(long customerId) {
if (customerId <= 0) throw bad("客户范围无效");
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
}
@@ -194,8 +194,8 @@ public class FinanceService {
}
private void validateVoucherFiles(long tenantId, List<String> fileIds) {
if (fileIds == null || fileIds.isEmpty()) throw bad("收款凭证不能为空");
if (fileClient == null) return;
if (fileIds == null || fileIds.isEmpty()) throw bad("财务凭证不能为空");
if (fileClient == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "File服务校验能力不可用");
for (String raw : fileIds) {
long id; try { id = Long.parseLong(raw); } catch (Exception ex) { throw bad("凭证文件ID无效"); }
var response = fileClient.metadata(fileToken, tenantId, tenantId, id);
@@ -298,12 +298,23 @@ public class FinanceService {
private Long reserve(long tenantId, long operatorId, String operation, String key, Object request) {
if (!StringUtils.hasText(key) || key.length() > 128) throw bad("必须提供有效的 Idempotency-Key");
String hash = hash(request);
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", tenantId, operatorId, operation, key);
String hash = hash(new IdempotencyRequest(httpMethod(operation), requestPath(operation), request));
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", tenantId, operatorId, operation, key);
if (!rows.isEmpty()) {
Map<String,Object> row = rows.get(0);
if (!hash.equals(row.get("request_hash"))) throw new BizException(ResultCode.CONFLICT, "Idempotency-Key已用于其他请求");
if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue();
if ("FAILED".equals(row.get("status"))) {
int recovered = jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
tenantId, operatorId, operation, key);
if (recovered == 1) return null;
}
LocalDateTime updatedAt = idempotencyUpdatedAt(row.get("updated_at"));
if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))) {
int recovered = jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?",
tenantId, operatorId, operation, key, updatedAt);
if (recovered == 1) return null;
}
throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中");
}
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", tenantId, operatorId, operation, key, hash);
@@ -315,6 +326,30 @@ public class FinanceService {
}
private static String temporaryNo(String prefix) { return prefix + "-PENDING-" + java.util.UUID.randomUUID(); }
private static String stableNo(String prefix, long id) { return prefix + "-" + String.format(java.util.Locale.ROOT, "%020d", id); }
private static LocalDateTime idempotencyUpdatedAt(Object value) {
if (value instanceof LocalDateTime dateTime) return dateTime;
if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime();
return null;
}
private static String httpMethod(String operation) {
return switch (operation) {
case "CUSTOMER_TERMS_SAVE", "BILL_UPDATE" -> "PUT";
default -> "POST";
};
}
private static String requestPath(String operation) {
return switch (operation) {
case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms";
case "BILL_CREATE" -> "/api/platform/finance/v1/bills";
case "BILL_ISSUE" -> "/api/platform/finance/v1/bills/{billId}/issue";
case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts";
case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries";
case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}";
case "BILL_ADJUST" -> "/api/platform/finance/v1/bills/{billId}/adjust";
case "BILL_VOID" -> "/api/platform/finance/v1/bills/{billId}/void";
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
};
}
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
@@ -357,4 +392,5 @@ public class FinanceService {
public record ContainerOrderVolume(long orderId, Long inboundId, long containerId, long customerId, BigDecimal volumeCbm) {}
public record CostAllocation(ContainerOrderVolume order, BigDecimal totalVolumeCbm, BigDecimal rate,
BigDecimal amount, boolean roundingAdjustment) {}
private record IdempotencyRequest(String method, String path, Object body) {}
}
@@ -0,0 +1,88 @@
-- Firstleg费用来源事件消费模型存量升级(MySQL 8,可重复执行)
USE baiyue_finance;
DROP PROCEDURE IF EXISTS finance_fee_source_add_column;
DELIMITER $$
CREATE PROCEDURE finance_fee_source_add_column(
IN target_table VARCHAR(64), IN target_column VARCHAR(64), IN column_definition TEXT
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = target_table AND column_name = target_column
) THEN
SET @ddl = CONCAT('ALTER TABLE `', target_table, '` ADD COLUMN `', target_column, '` ', column_definition);
PREPARE statement FROM @ddl;
EXECUTE statement;
DEALLOCATE PREPARE statement;
END IF;
END$$
DELIMITER ;
CALL finance_fee_source_add_column('fin_fee_source', 'source_type',
"VARCHAR(32) NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用' AFTER customer_id");
CALL finance_fee_source_add_column('fin_fee_source', 'source_no',
"VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空' AFTER source_type");
CALL finance_fee_source_add_column('fin_fee_source', 'direction',
"VARCHAR(16) NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付' AFTER source_no");
CALL finance_fee_source_add_column('fin_fee_source', 'container_id',
"BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空' AFTER inbound_id");
CALL finance_fee_source_add_column('fin_fee_source', 'fee_item_code',
"VARCHAR(64) NULL COMMENT '来源费用项目编码快照' AFTER container_id");
CALL finance_fee_source_add_column('fin_fee_source', 'source_hash',
"CHAR(64) NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希' AFTER currency_code");
CALL finance_fee_source_add_column('fin_fee_source', 'source_snapshot',
"JSON NULL COMMENT 'Firstleg费用来源完整不可变JSON快照' AFTER source_hash");
CALL finance_fee_source_add_column('fin_fee_source', 'updated_at',
"DATETIME(3) NULL COMMENT '来源状态最后更新时间,数据修改时自动刷新' AFTER created_at");
CALL finance_fee_source_add_column('fin_inbox_event', 'payload_hash',
"CHAR(64) NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测' AFTER payload");
CALL finance_fee_source_add_column('fin_inbox_event', 'result_resource_id',
"BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID' AFTER payload_hash");
CALL finance_fee_source_add_column('fin_inbox_event', 'error_code',
"VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码' AFTER result_resource_id");
DROP PROCEDURE IF EXISTS finance_fee_source_add_column;
-- 产品确认无历史财务数据;以下兼容值仅用于空环境重复迁移时保证结构收敛。
UPDATE fin_fee_source
SET source_type = COALESCE(source_type, 'INBOUND_FEE'),
direction = COALESCE(direction, 'RECEIVABLE'),
fee_item_code = COALESCE(fee_item_code, 'LEGACY'),
source_hash = COALESCE(source_hash, SHA2(CONCAT_WS('|', source_system, source_fee_id, source_fee_version), 256)),
source_snapshot = COALESCE(source_snapshot, JSON_OBJECT('migrated', TRUE)),
updated_at = COALESCE(updated_at, created_at);
UPDATE fin_inbox_event
SET payload_hash = COALESCE(payload_hash, SHA2(CAST(payload AS CHAR), 256));
ALTER TABLE fin_fee_source
MODIFY COLUMN source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用',
MODIFY COLUMN direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
MODIFY COLUMN fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照',
MODIFY COLUMN source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
MODIFY COLUMN source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
MODIFY COLUMN updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新';
ALTER TABLE fin_inbox_event
MODIFY COLUMN payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测',
MODIFY COLUMN status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败';
DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema;
DELIMITER $$
CREATE PROCEDURE finance_fee_source_assert_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_fee_source'
AND column_name IN ('source_type','source_no','direction','container_id','fee_item_code',
'source_hash','source_snapshot','updated_at')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:来源快照字段不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fin_inbox_event'
AND column_name IN ('payload_hash','result_resource_id','error_code')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Finance费用来源迁移失败:Inbox幂等字段不完整';
END IF;
END$$
DELIMITER ;
CALL finance_fee_source_assert_schema();
DROP PROCEDURE IF EXISTS finance_fee_source_assert_schema;
+183 -172
View File
@@ -3,18 +3,26 @@ CREATE DATABASE IF NOT EXISTS baiyue_finance
USE baiyue_finance;
CREATE TABLE IF NOT EXISTS fin_fee_source (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
source_system VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG',
source_fee_id BIGINT UNSIGNED NOT NULL,
source_fee_version INT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
fee_item_name VARCHAR(200) NOT NULL,
amount DECIMAL(20,6) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
source_system VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG' COMMENT '费用来源系统,当前FIRSTLEG表示头程服务',
source_fee_id BIGINT UNSIGNED NOT NULL COMMENT '来源系统费用记录ID',
source_fee_version INT UNSIGNED NOT NULL COMMENT '来源费用版本号,用于固化不可变来源版本',
customer_id BIGINT UNSIGNED NOT NULL COMMENT '费用来源所属客户ID',
source_type VARCHAR(32) NOT NULL COMMENT '来源费用类型:INBOUND_FEE入库单费用、CONTAINER_FEE柜子费用',
source_no VARCHAR(64) NULL COMMENT '来源入库单号或柜号快照,可为空',
direction VARCHAR(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付',
order_id BIGINT UNSIGNED NULL COMMENT '来源头程订单ID,可为空',
inbound_id BIGINT UNSIGNED NULL COMMENT '来源入库单ID,可为空',
container_id BIGINT UNSIGNED NULL COMMENT '来源Firstleg装柜计划ID,可为空',
fee_item_code VARCHAR(64) NOT NULL COMMENT '来源费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '来源费用项目名称快照',
amount DECIMAL(20,6) NOT NULL COMMENT '来源费用金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
source_hash CHAR(64) NOT NULL COMMENT 'Firstleg费用来源完整快照SHA-256哈希',
source_snapshot JSON NOT NULL COMMENT 'Firstleg费用来源完整不可变JSON快照',
status VARCHAR(16) NOT NULL DEFAULT 'AVAILABLE' COMMENT '费用来源状态:AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '来源快照入池时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '来源状态最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_fee_source (source_system, source_fee_id, source_fee_version),
KEY idx_fin_fee_source_customer (customer_id, status),
@@ -23,43 +31,46 @@ CREATE TABLE IF NOT EXISTS fin_fee_source (
) ENGINE=InnoDB COMMENT='Firstleg费用来源快照池;账单人工选择并固化明细';
CREATE TABLE IF NOT EXISTS fin_inbox_event (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id VARCHAR(128) NOT NULL,
source_system VARCHAR(32) NOT NULL,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED',
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
processed_at DATETIME(3) NULL,
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
event_id VARCHAR(128) NOT NULL COMMENT '来源系统事件唯一ID',
source_system VARCHAR(32) NOT NULL COMMENT '事件来源系统标识',
event_type VARCHAR(64) NOT NULL COMMENT '来源事件类型',
payload JSON NOT NULL COMMENT '来源事件原始JSON载荷快照',
payload_hash CHAR(64) NOT NULL COMMENT '规范化事件内容SHA-256哈希,用于同事件ID冲突检测',
result_resource_id BIGINT UNSIGNED NULL COMMENT '成功处理生成或定位的费用来源快照ID',
error_code VARCHAR(64) NULL COMMENT '处理失败时的稳定FIN错误码',
status VARCHAR(16) NOT NULL DEFAULT 'RECEIVED' COMMENT '事件处理状态:RECEIVED已接收、PROCESSED已完成、FAILED处理失败',
received_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件接收时间',
processed_at DATETIME(3) NULL COMMENT '事件处理完成时间,未完成时为空',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_inbox_event (source_system, event_id)
) ENGINE=InnoDB COMMENT='Firstleg费用事件幂等接收记录';
CREATE TABLE IF NOT EXISTS fin_subject (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
subject_code VARCHAR(64) NOT NULL,
subject_name VARCHAR(200) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
subject_code VARCHAR(64) NOT NULL COMMENT '平台财务主体唯一编码',
subject_name VARCHAR(200) NOT NULL COMMENT '平台收付款及核算主体名称',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '财务主体状态,ACTIVE表示启用',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建操作人ID,0表示系统初始化',
updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后修改操作人ID,0表示系统初始化',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_subject_code (subject_code)
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体';
CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
customer_name_snapshot VARCHAR(200) NOT NULL,
credit_days INT UNSIGNED NOT NULL DEFAULT 30 COMMENT '默认30个自然日',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '客户名称快照,客户主数据后续变更不追改',
credit_days INT UNSIGNED NOT NULL DEFAULT 30 COMMENT '客户账期自然日数,默认30天',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '客户结算配置状态,ACTIVE表示启用',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_customer_profile (tenant_id),
CONSTRAINT chk_fin_customer_profile_tenant CHECK (tenant_id > 0),
@@ -67,29 +78,29 @@ CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
) ENGINE=InnoDB COMMENT='客户结算配置;一客户一个结算对象';
CREATE TABLE IF NOT EXISTS fin_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_no VARCHAR(64) NOT NULL,
subject_id BIGINT UNSIGNED NOT NULL,
customer_name_snapshot VARCHAR(200) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
bill_date DATE NOT NULL,
due_date DATE NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/ISSUED/PARTIALLY_PAID/PAID/VOIDED',
total_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
received_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
outstanding_amount DECIMAL(20,6) NOT NULL DEFAULT 0,
remark VARCHAR(500) NULL,
issued_at DATETIME(3) NULL,
issued_by BIGINT UNSIGNED NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
bill_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一账单编号',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '平台财务主体ID',
customer_name_snapshot VARCHAR(200) NOT NULL COMMENT '出账时客户名称快照',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
bill_date DATE NOT NULL COMMENT '账单日期',
due_date DATE NOT NULL COMMENT '到期日期,由账单日期加客户账期计算',
status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '账单状态:DRAFT草稿、ISSUED已出账、PARTIALLY_PAID部分收款、PAID已收清、VOIDED已作废',
total_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '账单全部明细总金额,精度6位小数',
received_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '账单累计已收金额,精度6位小数',
outstanding_amount DECIMAL(20,6) NOT NULL DEFAULT 0 COMMENT '账单未收余额,精度6位小数',
remark VARCHAR(500) NULL COMMENT '平台账单内部备注',
issued_at DATETIME(3) NULL COMMENT '出账时间,未出账时为空',
issued_by BIGINT UNSIGNED NULL COMMENT '出账操作人ID,未出账时为空',
voided_at DATETIME(3) NULL COMMENT '作废时间,未作废时为空',
voided_by BIGINT UNSIGNED NULL COMMENT '作废操作人ID,未作废时为空',
void_reason VARCHAR(500) NULL COMMENT '账单作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_no (tenant_id, bill_no),
KEY idx_fin_bill_customer_status_date (tenant_id, status, bill_date),
@@ -99,20 +110,20 @@ CREATE TABLE IF NOT EXISTS fin_bill (
) ENGINE=InnoDB COMMENT='客户CNY账单;首期账单即应收台账';
CREATE TABLE IF NOT EXISTS fin_bill_line (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_FEE',
source_id BIGINT UNSIGNED NOT NULL,
source_version INT UNSIGNED NOT NULL,
source_no VARCHAR(64) NULL,
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
fee_item_code VARCHAR(64) NOT NULL,
fee_item_name VARCHAR(200) NOT NULL,
amount DECIMAL(20,6) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
bill_id BIGINT UNSIGNED NOT NULL COMMENT '所属账单ID',
source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_FEE' COMMENT '费用来源类型,FIRSTLEG_FEE表示头程费用',
source_id BIGINT UNSIGNED NOT NULL COMMENT '来源费用ID',
source_version INT UNSIGNED NOT NULL COMMENT '出账固化的来源费用版本号',
source_no VARCHAR(64) NULL COMMENT '来源费用单号快照,可为空',
order_id BIGINT UNSIGNED NULL COMMENT '来源头程订单ID,可为空',
inbound_id BIGINT UNSIGNED NULL COMMENT '来源入库单ID,可为空',
fee_item_code VARCHAR(64) NOT NULL COMMENT '出账时费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '出账时费用项目名称快照',
amount DECIMAL(20,6) NOT NULL COMMENT '账单明细金额,精度6位小数',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '账单明细创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_line_source (source_type, source_id, source_version),
KEY idx_fin_bill_line_bill (tenant_id, bill_id),
@@ -122,26 +133,26 @@ CREATE TABLE IF NOT EXISTS fin_bill_line (
) ENGINE=InnoDB COMMENT='账单费用来源快照明细';
CREATE TABLE IF NOT EXISTS fin_receipt (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
receipt_no VARCHAR(64) NOT NULL,
subject_id BIGINT UNSIGNED NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
received_amount DECIMAL(20,6) NOT NULL,
received_at DATETIME(3) NOT NULL,
payment_method VARCHAR(32) NULL,
voucher_no VARCHAR(128) NULL,
voucher_file_ids JSON NOT NULL COMMENT 'File服务文件ID字符串数组',
status VARCHAR(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT 'CONFIRMED/VOIDED',
remark VARCHAR(500) NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
receipt_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一收款编号',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '实际收款的平台财务主体ID',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
received_amount DECIMAL(20,6) NOT NULL COMMENT '本次实际到账金额,精度6位小数',
received_at DATETIME(3) NOT NULL COMMENT '实际到账时间',
payment_method VARCHAR(32) NULL COMMENT '收款方式编码',
voucher_no VARCHAR(128) NULL COMMENT '银行流水号或线下收款凭证号',
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON',
status VARCHAR(16) NOT NULL DEFAULT 'CONFIRMED' COMMENT '收款状态:CONFIRMED已确认、VOIDED已作废',
remark VARCHAR(500) NULL COMMENT '平台收款内部备注',
voided_at DATETIME(3) NULL COMMENT '收款作废时间',
voided_by BIGINT UNSIGNED NULL COMMENT '收款作废操作人ID',
void_reason VARCHAR(500) NULL COMMENT '收款作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_receipt_no (tenant_id, receipt_no),
KEY idx_fin_receipt_customer_time (tenant_id, received_at),
@@ -151,13 +162,13 @@ CREATE TABLE IF NOT EXISTS fin_receipt (
) ENGINE=InnoDB COMMENT='平台财务人工登记收款及凭证';
CREATE TABLE IF NOT EXISTS fin_receipt_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
receipt_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
applied_amount DECIMAL(20,6) NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
receipt_id BIGINT UNSIGNED NOT NULL COMMENT '收款记录ID',
bill_id BIGINT UNSIGNED NOT NULL COMMENT '被分配收款的账单ID',
applied_amount DECIMAL(20,6) NOT NULL COMMENT '本笔收款分配到该账单的金额,精度6位小数',
created_by BIGINT UNSIGNED NOT NULL COMMENT '分配操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '收款分配创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_receipt_bill_pair (receipt_id, bill_id),
KEY idx_fin_receipt_bill_bill (tenant_id, bill_id),
@@ -166,32 +177,32 @@ CREATE TABLE IF NOT EXISTS fin_receipt_bill (
) ENGINE=InnoDB COMMENT='人工收款对应账单金额;UI不使用复杂核销术语';
CREATE TABLE IF NOT EXISTS fin_cost (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
cost_no VARCHAR(64) NOT NULL,
cost_type VARCHAR(24) NOT NULL COMMENT 'SINGLE_ORDER/CONTAINER_SHARED',
order_id BIGINT UNSIGNED NULL,
inbound_id BIGINT UNSIGNED NULL,
container_id BIGINT UNSIGNED NULL,
supplier_id BIGINT UNSIGNED NULL,
supplier_name_snapshot VARCHAR(200) NULL,
fee_item_code VARCHAR(64) NOT NULL,
fee_item_name VARCHAR(200) NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'CNY',
cost_amount DECIMAL(20,6) NOT NULL,
voucher_file_ids JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/CONFIRMED/VOIDED',
remark VARCHAR(500) NULL,
confirmed_at DATETIME(3) NULL,
confirmed_by BIGINT UNSIGNED NULL,
voided_at DATETIME(3) NULL,
voided_by BIGINT UNSIGNED NULL,
void_reason VARCHAR(500) NULL,
version INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NOT NULL,
updated_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
cost_no VARCHAR(64) NOT NULL COMMENT '客户范围内唯一成本编号',
cost_type VARCHAR(24) NOT NULL COMMENT '成本归属类型:SINGLE_ORDER单票成本、CONTAINER_SHARED整柜公共成本',
order_id BIGINT UNSIGNED NULL COMMENT '成本归属的头程订单ID,单票成本必填',
inbound_id BIGINT UNSIGNED NULL COMMENT '成本关联的入库单ID,可为空',
container_id BIGINT UNSIGNED NULL COMMENT 'Firstleg装柜计划ID,整柜公共成本必填',
supplier_id BIGINT UNSIGNED NULL COMMENT 'Warehouse公共往来单位或供应商引用ID',
supplier_name_snapshot VARCHAR(200) NULL COMMENT '登记成本时供应商或成本对象名称快照',
fee_item_code VARCHAR(64) NOT NULL COMMENT '成本费用项目编码快照',
fee_item_name VARCHAR(200) NOT NULL COMMENT '成本费用项目名称快照',
currency_code CHAR(3) NOT NULL DEFAULT 'CNY' COMMENT 'ISO 4217三位币种代码,首期仅CNY',
cost_amount DECIMAL(20,6) NOT NULL COMMENT '成本总金额,精度6位小数',
voucher_file_ids JSON NOT NULL COMMENT 'File服务财务凭证文件ID字符串数组JSON',
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '成本状态:DRAFT草稿、CONFIRMED已确认、VOIDED已作废',
remark VARCHAR(500) NULL COMMENT '平台成本内部备注',
confirmed_at DATETIME(3) NULL COMMENT '成本确认时间',
confirmed_by BIGINT UNSIGNED NULL COMMENT '成本确认操作人ID',
voided_at DATETIME(3) NULL COMMENT '成本作废时间',
voided_by BIGINT UNSIGNED NULL COMMENT '成本作废操作人ID',
void_reason VARCHAR(500) NULL COMMENT '成本作废原因',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间,数据修改时自动刷新',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_cost_no (tenant_id, cost_no),
KEY idx_fin_cost_ticket (tenant_id, order_id, inbound_id),
@@ -206,17 +217,17 @@ CREATE TABLE IF NOT EXISTS fin_cost (
) ENGINE=InnoDB COMMENT='人工成本;单票直接归属,整柜按体积分摊';
CREATE TABLE IF NOT EXISTS fin_cost_allocation (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
cost_id BIGINT UNSIGNED NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
inbound_id BIGINT UNSIGNED NULL,
volume_cbm DECIMAL(20,6) NOT NULL,
total_volume_cbm DECIMAL(20,6) NOT NULL,
allocation_rate DECIMAL(20,10) NOT NULL,
allocated_amount DECIMAL(20,6) NOT NULL,
rounding_adjustment TINYINT NOT NULL DEFAULT 0 COMMENT '1表示尾差归集行',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
cost_id BIGINT UNSIGNED NOT NULL COMMENT '整柜公共成本ID',
order_id BIGINT UNSIGNED NOT NULL COMMENT '参与分摊的头程订单ID',
inbound_id BIGINT UNSIGNED NULL COMMENT '参与分摊订单关联的入库单ID,可为空',
volume_cbm DECIMAL(20,6) NOT NULL COMMENT '该订单分摊时有效体积快照,单位CBM、精度6位',
total_volume_cbm DECIMAL(20,6) NOT NULL COMMENT '柜内有效订单总体积快照,单位CBM、精度6位',
allocation_rate DECIMAL(20,10) NOT NULL COMMENT '订单体积占总体积的分摊比例,精度10位',
allocated_amount DECIMAL(20,6) NOT NULL COMMENT '分摊到该订单的成本金额,精度6位',
rounding_adjustment TINYINT NOT NULL DEFAULT 0 COMMENT '尾差归集标记:0未归集、1该行为实际尾差归集行',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变分摊快照创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_cost_allocation_order (cost_id, order_id),
KEY idx_fin_cost_allocation_query (tenant_id, order_id),
@@ -225,50 +236,50 @@ CREATE TABLE IF NOT EXISTS fin_cost_allocation (
) ENGINE=InnoDB COMMENT='整柜成本按有效体积分摊的可审计结果';
CREATE TABLE IF NOT EXISTS fin_bill_revision (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
bill_id BIGINT UNSIGNED NOT NULL,
version INT UNSIGNED NOT NULL,
reason VARCHAR(500) NULL,
bill_snapshot JSON NOT NULL,
line_snapshot JSON NOT NULL,
created_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
bill_id BIGINT UNSIGNED NOT NULL COMMENT '被调整账单ID',
version INT UNSIGNED NOT NULL COMMENT '调整前账单乐观锁版本号',
reason VARCHAR(500) NULL COMMENT '本次账单调整原因',
bill_snapshot JSON NOT NULL COMMENT '调整前账单主记录不可变JSON快照',
line_snapshot JSON NOT NULL COMMENT '调整前全部账单明细不可变JSON数组快照',
created_by BIGINT UNSIGNED NOT NULL COMMENT '执行调整的操作人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '修订快照创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_bill_revision (bill_id, version),
KEY idx_fin_bill_revision_tenant (tenant_id, bill_id)
) ENGINE=InnoDB COMMENT='出账后调整前的不可变账单及明细版本快照';
CREATE TABLE IF NOT EXISTS fin_idempotency_record (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operation VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'PROCESSING/SUCCEEDED/FAILED',
result_resource_id BIGINT UNSIGNED NULL,
error_code VARCHAR(64) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '发起幂等写操作的操作人ID',
operation VARCHAR(64) NOT NULL COMMENT '幂等隔离的财务操作类型',
idempotency_key VARCHAR(128) NOT NULL COMMENT '调用方提供的幂等键',
request_hash CHAR(64) NOT NULL COMMENT '规范化完整请求的SHA-256十六进制哈希',
status VARCHAR(16) NOT NULL COMMENT '幂等处理状态:PROCESSING处理中、SUCCEEDED成功、FAILED失败',
result_resource_id BIGINT UNSIGNED NULL COMMENT '成功操作生成或更新的业务资源ID',
error_code VARCHAR(64) NULL COMMENT '失败操作的稳定业务错误码',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等请求首次登记时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '幂等处理状态最后更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_fin_idempotency (tenant_id, operator_id, operation, idempotency_key)
) ENGINE=InnoDB COMMENT='Finance人工写操作幂等记录';
CREATE TABLE IF NOT EXISTS fin_audit_log (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
resource_type VARCHAR(32) NOT NULL,
resource_id BIGINT UNSIGNED NOT NULL,
action VARCHAR(64) NOT NULL,
reason VARCHAR(500) NULL,
before_data JSON NULL,
after_data JSON NULL,
operator_id BIGINT UNSIGNED NOT NULL,
operator_name_snapshot VARCHAR(128) NOT NULL,
request_id VARCHAR(128) NULL,
trace_id VARCHAR(128) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
resource_type VARCHAR(32) NOT NULL COMMENT '被审计财务资源类型',
resource_id BIGINT UNSIGNED NOT NULL COMMENT '被审计财务资源ID',
action VARCHAR(64) NOT NULL COMMENT '审计业务动作类型',
reason VARCHAR(500) NULL COMMENT '操作原因或说明',
before_data JSON NULL COMMENT '操作前业务数据JSON快照',
after_data JSON NULL COMMENT '操作后业务数据JSON快照',
operator_id BIGINT UNSIGNED NOT NULL COMMENT '操作人ID',
operator_name_snapshot VARCHAR(128) NOT NULL COMMENT '操作发生时的操作人名称快照',
request_id VARCHAR(128) NULL COMMENT '入口请求ID,用于问题定位',
trace_id VARCHAR(128) NULL COMMENT '分布式调用链追踪ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变审计记录创建时间',
PRIMARY KEY (id),
KEY idx_fin_audit_resource (tenant_id, resource_type, resource_id, created_at),
KEY idx_fin_audit_operator (operator_id, created_at)
@@ -19,7 +19,7 @@ class FinanceContractTest {
assertTrue(sql.contains("currency_code CHECK (currency_code = 'CNY')") || sql.contains("currency_code = 'CNY'"));
assertTrue(sql.contains("fin_fee_source") && sql.contains("fin_inbox_event"));
assertTrue(sql.contains("fin_bill_revision"));
assertTrue(sql.contains("PROCESSING/SUCCEEDED/FAILED"));
assertTrue(sql.contains("PROCESSING") && sql.contains("SUCCEEDED") && sql.contains("FAILED"));
String service = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinanceService.java"));
assertTrue(service.contains("status='PROCESSING'") && service.contains("stableNo"));
assertTrue(service.contains("request_hash") && service.contains("result_resource_id") && service.contains("Idempotency-Key已用于其他请求"));
@@ -0,0 +1,31 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.controller.FinanceExceptionHandler;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceExceptionHandlerTest {
private final FinanceExceptionHandler handler = new FinanceExceptionHandler();
@Test
void returnsStableFinanceConflictCodeAndTraceFields() {
var response = handler.business(new BizException(ResultCode.CONFLICT, "幂等键冲突"));
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo("FIN_409");
assertThat(response.getBody().requestId()).isNotBlank();
assertThat(response.getBody().traceId()).isNotBlank();
}
@Test
void dependencyFailureKeepsInfrastructureHttpStatus() {
var response = handler.business(new BizException(ResultCode.SERVICE_UNAVAILABLE, "File服务不可用"));
assertThat(response.getStatusCode().value()).isEqualTo(503);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo("FIN_503");
}
}
@@ -0,0 +1,67 @@
package com.baiyue.finance;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.service.FinanceQueryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeePoolDirectionBehaviorTest {
@Test
void receivablePoolNeverContainsPayableSource() {
DirectionJdbc jdbc = new DirectionJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
List<FeePoolItem> result = service.feePool(7L, "RECEIVABLE");
assertThat(result).isNotEmpty().allMatch(item -> "RECEIVABLE".equals(item.direction()));
assertThat(jdbc.sourceDirections).containsExactly("RECEIVABLE");
assertThat(jdbc.costQueryCount).isZero();
}
@Test
void payablePoolNeverContainsReceivableSource() {
DirectionJdbc jdbc = new DirectionJdbc();
FinanceQueryService service = new FinanceQueryService(jdbc, new ObjectMapper());
List<FeePoolItem> result = service.feePool(7L, "PAYABLE");
assertThat(result).isNotEmpty().allMatch(item -> "PAYABLE".equals(item.direction()));
assertThat(jdbc.sourceDirections).containsExactly("PAYABLE");
assertThat(jdbc.costQueryCount).isEqualTo(1);
}
private static final class DirectionJdbc extends JdbcTemplate {
private final List<String> sourceDirections = new ArrayList<>();
private int costQueryCount;
@Override
@SuppressWarnings("unchecked")
public <T> List<T> query(String sql, RowMapper<T> mapper, Object... args) {
if (sql.contains("FROM fin_fee_source")) {
String direction = String.valueOf(args[1]);
sourceDirections.add(direction);
return (List<T>) List.of(item(direction, "FIRSTLEG_FEE", 1L));
}
if (sql.contains("FROM fin_cost")) {
costQueryCount++;
return (List<T>) List.of(item("PAYABLE", "MANUAL_COST", 2L));
}
return List.of();
}
private static FeePoolItem item(String direction, String sourceType, long sourceId) {
return new FeePoolItem(direction + ":" + sourceId, direction, sourceType, sourceId, 1,
11L, 21L, null, "清关费", new BigDecimal("100.00"), "CNY", "AVAILABLE",
LocalDateTime.of(2026, 8, 11, 1, 0));
}
}
}
@@ -0,0 +1,197 @@
package com.baiyue.finance;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FinanceFeeSourceEventServiceTest {
private final JdbcTemplate jdbc = mock(JdbcTemplate.class);
private final FirstlegFeeSourceClient firstleg = mock(FirstlegFeeSourceClient.class);
private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules();
private final FinanceFeeSourceEventService service = new FinanceFeeSourceEventService(jdbc, firstleg, mapper);
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "internalToken", "test-token");
}
@Test
void importsApprovedSettlementInboundFee() {
FinanceFeeSourceEvent event = inboundEvent("published-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
when(firstleg.inbound("test-token", 21, 31)).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).inbound("test-token", 21, 31);
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void importsApprovedSettlementContainerFeeWithCustomerOwnership() {
FinanceFeeSourceEvent event = containerEvent("container-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
givenNewEvent();
when(firstleg.container("test-token", 41, 31)).thenReturn(ApiResponse.success(snapshot("CONTAINER_FEE", 7, 1, null, 41L)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(firstleg).container("test-token", 41, 31);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
}
@Test
void adjustmentCreatesNewImmutableVersionAndSupersedesAvailableVersion() {
FinanceFeeSourceEvent event = inboundEvent("adjusted-2", "FIRSTLEG_FEE_ADJUSTED", 2, 7);
givenNewEvent();
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
assertThat(service.consume(event)).isEqualTo(101L);
verify(jdbc).update(contains("status='SUPERSEDED'"), eq(31L));
verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void voidMarksExistingVersionWithoutDeletingHistory() {
FinanceFeeSourceEvent event = inboundEvent("voided-2", "FIRSTLEG_FEE_VOIDED", 2, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("voided-2"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 7, 2, 21L, null)));
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), eq(2)))
.thenReturn(List.of(Map.of("id", 88L, "source_hash", "source-hash", "status", "AVAILABLE")));
assertThat(service.consume(event)).isEqualTo(88L);
verify(jdbc).update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", 88L);
verify(jdbc, never()).update(startsWith("DELETE"), any(Object[].class));
}
@Test
void sameEventAndContentReturnsFirstResourceWithoutCallingFirstlegAgain() throws Exception {
FinanceFeeSourceEvent event = inboundEvent("same-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
String requestHash = sha256(mapper.writeValueAsString(event));
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("same-1")))
.thenReturn(List.of(Map.of("payload_hash", requestHash, "status", "PROCESSED", "result_resource_id", 66L)));
assertThat(service.consume(event)).isEqualTo(66L);
verifyNoInteractions(firstleg);
}
@Test
void sameEventIdWithDifferentContentReturnsStableConflictCode() {
FinanceFeeSourceEvent event = inboundEvent("conflict-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("conflict-1")))
.thenReturn(List.of(Map.of("payload_hash", "different", "status", "PROCESSED", "result_resource_id", 66L)));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-002");
}
@Test
void rejectsSnapshotBelongingToAnotherCustomer() {
FinanceFeeSourceEvent event = inboundEvent("tenant-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("tenant-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(ApiResponse.success(snapshot("INBOUND_FEE", 8, 1, 21L, null)));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-007");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void rejectsApprovedButUnpublishedNonVoidedFee() {
FinanceFeeSourceEvent event = inboundEvent("draft-1", "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq("draft-1"))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "DRAFT", new BigDecimal("100.00"))));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-015");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
@Test
void rejectsZeroAmount() {
assertInvalidAmount("zero-1", BigDecimal.ZERO);
}
@Test
void rejectsNegativeAmount() {
assertInvalidAmount("negative-1", new BigDecimal("-0.01"));
}
private void givenNewEvent() {
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), anyString())).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,source_hash,status"), eq(31L), anyInt())).thenReturn(List.of());
when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq(31L), anyInt())).thenReturn(101L);
}
private FinanceFeeSourceEvent inboundEvent(String id, String type, int version, long tenant) {
return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload("21", null, "31", version));
}
private FinanceFeeSourceEvent containerEvent(String id, String type, int version, long tenant) {
return event(id, type, version, tenant, new FinanceFeeSourceEvent.Payload(null, "41", "31", version));
}
private FinanceFeeSourceEvent event(String id, String type, int version, long tenant, FinanceFeeSourceEvent.Payload payload) {
try {
return new FinanceFeeSourceEvent(id, type, 1, "firstleg-service", LocalDateTime.of(2026, 8, 11, 1, 2),
String.valueOf(tenant), payload.inboundId() == null ? "CONTAINER" : "INBOUND",
payload.inboundId() == null ? payload.containerId() : payload.inboundId(), version,
id, sha256(mapper.writeValueAsString(payload)), payload);
} catch (Exception error) {
throw new AssertionError(error);
}
}
private FirstlegFeeSourceClient.Snapshot snapshot(String sourceType, long customerId, int version, Long inboundId, Long containerId) {
return snapshot(sourceType, customerId, version, inboundId, containerId, "PUBLISHED", new BigDecimal("100.00"));
}
private FirstlegFeeSourceClient.Snapshot snapshot(String sourceType, long customerId, int version,
Long inboundId, Long containerId,
String publicationStatus, BigDecimal amount) {
return new FirstlegFeeSourceClient.Snapshot(customerId, sourceType, 31, version, inboundId,
inboundId == null ? null : "IB-21", 51L, containerId, null,
containerId == null ? null : "C-41", customerId, "客户", 61L, "CUSTOMS",
"清关费", "RECEIVABLE", LocalDate.of(2026, 8, 11), "CNY", amount,
"APPROVED", publicationStatus, true, LocalDateTime.of(2026, 8, 11, 1, 0),
BigDecimal.ONE, "SHIPMENT", BigDecimal.TEN, BigDecimal.ONE, "source-hash", version);
}
private void assertInvalidAmount(String eventId, BigDecimal amount) {
FinanceFeeSourceEvent event = inboundEvent(eventId, "FIRSTLEG_FEE_PUBLISHED", 1, 7);
when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("FIRSTLEG"), eq(eventId))).thenReturn(List.of());
when(firstleg.inbound(anyString(), eq(21L), eq(31L))).thenReturn(
ApiResponse.success(snapshot("INBOUND_FEE", 7, 1, 21L, null, "PUBLISHED", amount)));
assertThatThrownBy(() -> service.consume(event))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-010");
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
}
private static String sha256(String value) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));
}
}
@@ -0,0 +1,20 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceMigrationContractTest {
@Test
void moduleMigrationAddsImmutableSourceAndInboxIdempotencyFields() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/fee-source-event-migration.sql"), StandardCharsets.UTF_8);
assertThat(sql).contains("information_schema.columns", "source_type", "direction", "container_id",
"source_hash", "source_snapshot", "payload_hash", "result_resource_id", "error_code");
assertThat(sql).contains("AVAILABLE可结算", "SUPERSEDED已被新版本替代", "VOIDED已作废");
assertThat(sql).contains("finance_fee_source_assert_schema");
}
}
@@ -0,0 +1,78 @@
package com.baiyue.finance;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import com.baiyue.finance.controller.ClientFinanceController;
import com.baiyue.finance.controller.PlatformFinanceController;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.service.FinanceService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FinanceReadApiBehaviorTest {
@Mock FinanceService service;
@Mock FinanceQueryService queryService;
@Mock FinanceFileClient fileClient;
@Mock FirstlegCostSnapshotClient snapshotClient;
@Test
void clientReceiptUsesAuthenticatedCustomerAndDoesNotExposeVoucherOrInternalFields() throws Exception {
AuthenticatedUser user = new AuthenticatedUser(7L, 23L, null, null, "client", "MERCHANT", "WEB", Set.of());
ClientReceipt row = new ClientReceipt(4L, "RCPT-4", new BigDecimal("300.00"), LocalDateTime.of(2026, 8, 11, 10, 0),
"BANK_TRANSFER", "CNY", "CONFIRMED", List.of(new ClientReceiptAllocation(8L, "BILL-8", new BigDecimal("100.00"))));
when(queryService.clientReceipts(23L)).thenReturn(List.of(row));
var response = new ClientFinanceController(service, queryService).receipts(user);
verify(queryService).clientReceipts(23L);
String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response.data());
assertThat(json).doesNotContainIgnoringCase("voucher").doesNotContain("createdBy").doesNotContain("updatedBy").doesNotContain("tenantId");
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
new ClientFinanceController(service, queryService).bill(user, 88L);
verify(queryService).statement(23L, 88L, true);
}
@Test
void platformFeePoolPassesExplicitCustomerScopeAndReturnsTraceFields() {
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
var response = controller.feePool(31L, "RECEIVABLE");
verify(queryService).feePool(31L, "RECEIVABLE");
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
}
@Test
void fivePlatformPageMethodsUseTypedQueryFacade() {
var controller = new PlatformFinanceController(service, fileClient, snapshotClient, queryService);
controller.customerAccount(11L);
controller.statements(11L);
controller.receivableSettlements(11L);
controller.payableSettlements(11L);
verify(queryService).customerAccount(11L);
verify(queryService).statements(11L, false);
verify(queryService).receivableSettlements(11L, false);
verify(queryService).payableSettlements(11L);
}
}
@@ -3,6 +3,8 @@ package com.baiyue.finance;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -71,6 +73,19 @@ class FinanceServiceValidationTest {
assertThrows(RuntimeException.class, () -> service.createReceipt(1L, 2L, request, "k3"));
}
@Test
void receiptFailsClosedWhenFileValidationCapabilityIsUnavailable() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
FinanceService service = new FinanceService(jdbc, new ObjectMapper());
var request = new FinanceService.ReceiptRequest("R-FILE", 1L, null, "BANK_TRANSFER", null, List.of("77"), null,
List.of(new FinanceService.ReceiptAllocation(10L, BigDecimal.ONE)));
BizException error = assertThrows(BizException.class, () -> service.createReceipt(9L, 2L, request, "file-down"));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, error.getResultCode());
verify(jdbc, never()).update(startsWith("INSERT INTO fin_receipt"), (Object[]) any());
}
@Test
void rejectsVoucherFromAnotherCustomer() {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -0,0 +1,60 @@
package com.baiyue.finance;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSqlCommentContractTest {
private static final Pattern TABLE = Pattern.compile("CREATE TABLE IF NOT EXISTS (fin_[a-z0-9_]+) ");
private static final Pattern COLUMN = Pattern.compile("^\\s{2}([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|VARCHAR|CHAR|DECIMAL|DATETIME|DATE|JSON|TINYINT)\\b", Pattern.CASE_INSENSITIVE);
private static final Pattern CHINESE = Pattern.compile("[\\p{IsHan}]");
@Test
void everyFinanceBaselineColumnHasMeaningfulChineseComment() throws Exception {
List<String> lines = Files.readAllLines(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
int tableCount = 0;
int columnCount = 0;
String currentTable = null;
List<String> missing = new ArrayList<>();
for (String line : lines) {
var table = TABLE.matcher(line);
if (table.find()) {
currentTable = table.group(1);
tableCount++;
continue;
}
if (currentTable == null) continue;
if (line.startsWith(") ENGINE=")) {
currentTable = null;
continue;
}
var column = COLUMN.matcher(line);
if (!column.find()) continue;
columnCount++;
int comment = line.indexOf("COMMENT '");
if (comment < 0 || !CHINESE.matcher(line.substring(comment)).find()) {
missing.add(currentTable + "." + column.group(1));
}
}
assertThat(tableCount).isEqualTo(13);
assertThat(columnCount).isEqualTo(184);
assertThat(missing).as("缺少中文业务注释的Finance字段").isEmpty();
}
@Test
void keyIsolationStatusSnapshotAndIdempotencyCommentsUseFrozenMeaning() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8);
assertThat(sql).contains("tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键'");
assertThat(sql).contains("AVAILABLE可结算、SUPERSEDED已被新版本替代、VOIDED已作废");
assertThat(sql).contains("RECEIVED已接收、PROCESSED已完成、FAILED处理失败");
assertThat(sql).contains("Firstleg费用来源完整不可变JSON快照");
assertThat(sql).contains("规范化完整请求的SHA-256十六进制哈希");
}
}
@@ -0,0 +1,43 @@
package com.baiyue.finance;
import com.baiyue.finance.controller.InternalFinanceFeeSourceController;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class InternalFinanceFeeSourceControllerTest {
private final FinanceFeeSourceEventService service = mock(FinanceFeeSourceEventService.class);
private final InternalFinanceFeeSourceController controller = controller();
@Test
void successfulAcknowledgementAlwaysHasTrackingFields() {
FinanceFeeSourceEvent event = mock(FinanceFeeSourceEvent.class);
when(service.consume(event)).thenReturn(91L);
var response = controller.consume("internal-token", event);
assertThat(response.data()).containsEntry("feeSourceId", 91L);
assertThat(response.requestId()).isNotBlank();
assertThat(response.traceId()).isNotBlank();
}
@Test
void missingInternalTokenFailsClosedWithStableCode() {
assertThatThrownBy(() -> controller.consume(null, mock(FinanceFeeSourceEvent.class)))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-401");
}
private InternalFinanceFeeSourceController controller() {
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
ReflectionTestUtils.setField(value, "expectedToken", "internal-token");
return value;
}
}