优化
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+36
@@ -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)));
|
||||
}
|
||||
}
|
||||
+103
-21
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user