This commit is contained in:
2026-08-11 18:06:26 +08:00
parent a02dd3a422
commit 192964419f
101 changed files with 8892 additions and 1952 deletions
@@ -0,0 +1,34 @@
package com.baiyue.finance.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
@FeignClient(name = "firstleg-service", contextId = "financeFirstlegFeeSourceClient",
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1")
public interface FirstlegFeeSourceClient {
@GetMapping("/inbounds/{inboundId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> inbound(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long inboundId, @PathVariable long feeId);
@GetMapping("/containers/{containerId}/fees/{feeId}/source-snapshot")
ApiResponse<Snapshot> container(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long containerId, @PathVariable long feeId);
record Snapshot(long tenantId, String sourceType, long feeId, int feeVersion,
Long inboundId, String inboundCode, Long orderId, Long containerId,
Long containerBookingId, String containerNo, long customerId,
String customerName, Long feeItemId, String feeItemCode,
String feeItemName, String direction, LocalDate businessDate,
String currency, BigDecimal totalAmount, String reviewStatus,
String publicationStatus, boolean settlementIncluded,
LocalDateTime publishedAt, BigDecimal chargeableQuantity,
String chargeUnit, BigDecimal actualGrossWeightKg,
BigDecimal actualVolumeCbm, String sourceHash, int snapshotVersion) {}
}
@@ -3,33 +3,52 @@ package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Tag(name = "客户财务只读")
@RestController
@RequestMapping("/api/client/finance/v1")
public class ClientFinanceController {
private final FinanceService service;
public ClientFinanceController(FinanceService service) { this.service = service; }
private final FinanceQueryService queryService;
public ClientFinanceController(FinanceService service, FinanceQueryService queryService) {
this.service = service;
this.queryService = queryService;
}
@GetMapping("/bills")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询账单")
public ApiResponse<?> bills(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listClientBills(user.tenantId())); }
public ApiResponse<?> bills(@AuthenticationPrincipal AuthenticatedUser user) { return FinanceResponses.success(queryService.statements(user.tenantId(), true)); }
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('merchant:finance:statement:view')")
@Operation(summary = "客户只读查看账单详情")
public ApiResponse<Map<String, Object>> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return ApiResponse.success(service.clientBill(user.tenantId(), billId)); }
public ApiResponse<?> bill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId) { return FinanceResponses.success(queryService.statement(user.tenantId(), billId, true)); }
@GetMapping("/receipts")
@PreAuthorize("hasAuthority('merchant:finance:statement:view')")
@Operation(summary = "客户只读查询收款记录")
public ApiResponse<?> receipts(@AuthenticationPrincipal AuthenticatedUser user) { return ApiResponse.success(service.listReceipts(user.tenantId())); }
public ApiResponse<?> receipts(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(queryService.clientReceipts(user.tenantId()));
}
@GetMapping("/statements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询对账单")
public ApiResponse<?> statements(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(queryService.statements(user.tenantId(), true));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('merchant:finance:statement:list')")
@Operation(summary = "客户只读查询结算应收")
public ApiResponse<?> receivableSettlements(@AuthenticationPrincipal AuthenticatedUser user) {
return FinanceResponses.success(queryService.receivableSettlements(user.tenantId(), true));
}
}
@@ -0,0 +1,60 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.service.FinanceEventException;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.time.Instant;
import java.util.UUID;
/** Finance-owned error codes stay stable even when the common result catalog evolves. */
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "com.baiyue.finance.controller")
public class FinanceExceptionHandler {
@ExceptionHandler(FinanceEventException.class)
public ResponseEntity<ApiResponse<Void>> event(FinanceEventException error) {
return ResponseEntity.ok(response(error.code(), error.getMessage()));
}
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> business(BizException error) {
ResultCode result = error.getResultCode();
HttpStatus status = result == ResultCode.SERVICE_UNAVAILABLE ? HttpStatus.SERVICE_UNAVAILABLE : HttpStatus.OK;
return ResponseEntity.status(status).body(response(financeCode(result), error.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ApiResponse<Void>> validation(IllegalArgumentException error) {
return ResponseEntity.ok(response("FIN_400", error.getMessage()));
}
private static String financeCode(ResultCode result) {
return switch (result) {
case NOT_FOUND -> "FIN_404";
case CONFLICT -> "FIN_409";
case SERVICE_UNAVAILABLE -> "FIN_503";
case VALIDATION_FAILED, BAD_REQUEST -> "FIN_400";
default -> "FIN_001";
};
}
private static ApiResponse<Void> response(String code, String message) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
String traceId = first(MDC.get("traceId"), MDC.get("trace_id"));
if (!StringUtils.hasText(traceId)) traceId = requestId;
return new ApiResponse<>(code, message, null, requestId, traceId, Instant.now());
}
private static String first(String first, String second) {
return StringUtils.hasText(first) ? first : second;
}
}
@@ -0,0 +1,23 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import org.slf4j.MDC;
import org.springframework.util.StringUtils;
import java.util.UUID;
final class FinanceResponses {
private FinanceResponses() {}
static <T> ApiResponse<T> success(T data) {
String requestId = first(MDC.get("requestId"), MDC.get("request_id"));
if (!StringUtils.hasText(requestId)) requestId = UUID.randomUUID().toString();
String traceId = first(MDC.get("traceId"), MDC.get("trace_id"));
if (!StringUtils.hasText(traceId)) traceId = requestId;
return ApiResponse.success(data, requestId, traceId);
}
private static String first(String first, String second) {
return StringUtils.hasText(first) ? first : second;
}
}
@@ -0,0 +1,36 @@
package com.baiyue.finance.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.baiyue.finance.service.FinanceFeeSourceEventService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
@RestController
@RequiredArgsConstructor
@RequestMapping("/internal/finance/v1/fee-source-events")
public class InternalFinanceFeeSourceController {
private final FinanceFeeSourceEventService service;
@Value("${baiyue.security.internal-service-token}")
private String expectedToken;
@PostMapping
public ApiResponse<Map<String, Long>> consume(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody FinanceFeeSourceEvent event) {
if (token == null || expectedToken == null || expectedToken.isBlank()
|| !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new com.baiyue.finance.service.FinanceEventException("FIN-EVT-401", "内部服务令牌无效");
}
return FinanceResponses.success(Map.of("feeSourceId", service.consume(event)));
}
}
@@ -4,6 +4,7 @@ import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.FinanceQueryService;
import com.baiyue.finance.client.FinanceFileClient;
import com.baiyue.finance.client.FirstlegCostSnapshotClient;
import io.swagger.v3.oas.annotations.Operation;
@@ -16,6 +17,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.beans.factory.annotation.Autowired;
@Tag(name = "人工财务台账")
@RestController
@@ -24,8 +26,58 @@ public class PlatformFinanceController {
private final FinanceService service;
private final FinanceFileClient fileClient;
private final FirstlegCostSnapshotClient costSnapshotClient;
private final FinanceQueryService queryService;
@Value("${baiyue.security.internal-service-token:}") private String fileToken;
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient, FirstlegCostSnapshotClient costSnapshotClient) { this.service = service; this.fileClient = fileClient; this.costSnapshotClient = costSnapshotClient; }
@Autowired
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient,
FirstlegCostSnapshotClient costSnapshotClient, FinanceQueryService queryService) {
this.service = service;
this.fileClient = fileClient;
this.costSnapshotClient = costSnapshotClient;
this.queryService = queryService;
}
/** Kept for direct unit tests of write behavior. */
public PlatformFinanceController(FinanceService service, FinanceFileClient fileClient,
FirstlegCostSnapshotClient costSnapshotClient) {
this(service, fileClient, costSnapshotClient, null);
}
@GetMapping("/customer-accounts/{customerId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户人工应收账户及流水")
public ApiResponse<?> customerAccount(@PathVariable long customerId) {
return FinanceResponses.success(queryService.customerAccount(customerId));
}
@GetMapping("/fee-pool")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户应收应付费用池")
public ApiResponse<?> feePool(@RequestParam long customerId,
@RequestParam(required = false) String direction) {
return FinanceResponses.success(queryService.feePool(customerId, direction));
}
@GetMapping("/statements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "客户对账单")
public ApiResponse<?> statements(@RequestParam long customerId) {
return FinanceResponses.success(queryService.statements(customerId, false));
}
@GetMapping("/receivable-settlements")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "结算单应收")
public ApiResponse<?> receivableSettlements(@RequestParam long customerId) {
return FinanceResponses.success(queryService.receivableSettlements(customerId, false));
}
@GetMapping("/payable-settlements")
@PreAuthorize("hasAuthority('platform:finance:cost:view')")
@Operation(summary = "结算单应付")
public ApiResponse<?> payableSettlements(@RequestParam long customerId) {
return FinanceResponses.success(queryService.payableSettlements(customerId));
}
@PostMapping(value = "/vouchers", consumes = "multipart/form-data")
@PreAuthorize("hasAnyAuthority('platform:finance:receipt:create','platform:finance:cost:create')")
@@ -33,60 +85,65 @@ public class PlatformFinanceController {
@RequestParam long customerId,
@RequestPart("file") MultipartFile file) {
if (customerId <= 0) throw new IllegalArgumentException("客户范围无效");
return fileClient.upload(fileToken, user.userId(), customerId, customerId, file);
var response = fileClient.upload(fileToken, user.userId(), customerId, customerId, file);
if (response == null || response.data() == null) throw new IllegalStateException("File服务未返回有效上传结果");
return FinanceResponses.success(response.data());
}
@GetMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('platform:finance:customer-terms:view')")
@Operation(summary = "查询客户账期")
public ApiResponse<Map<String, Object>> terms(@PathVariable long tenantId) { return ApiResponse.success(service.customerTerms(tenantId)); }
public ApiResponse<?> terms(@PathVariable long tenantId) { return FinanceResponses.success(termsView(tenantId, service.customerTerms(tenantId))); }
@GetMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
@Operation(summary = "查询平台账单")
public ApiResponse<?> bills(@RequestParam long customerId) { return ApiResponse.success(service.listBills(customerId)); }
public ApiResponse<?> bills(@RequestParam long customerId) { return FinanceResponses.success(queryService.statements(customerId, false)); }
@PutMapping("/customers/{tenantId}/terms")
@PreAuthorize("hasAuthority('platform:finance:customer-terms:update')")
@BusinessAudit(event = "FINANCE_CUSTOMER_TERMS_UPDATE", resourceType = "CUSTOMER")
public ApiResponse<Map<String, Object>> saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) {
return ApiResponse.success(service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key));
public ApiResponse<?> saveTerms(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @RequestHeader("Idempotency-Key") String key, @RequestBody TermsRequest request) {
return FinanceResponses.success(termsView(tenantId, service.saveCustomerTerms(tenantId, user.userId(), request.customerName(), request.creditDays(), key)));
}
@PostMapping("/bills")
@PreAuthorize("hasAuthority('platform:finance:bill:create')")
@BusinessAudit(event = "FINANCE_BILL_CREATE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
public ApiResponse<?> createBill(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CreateBillRequest request) {
var lines = request.lines().stream().map(line -> new FinanceService.BillLine("FIRSTLEG_FEE", line.sourceFeeId(), line.sourceFeeVersion(), null, line.orderId(), line.inboundId(), "MANUAL", line.feeItemName(), line.amount())).toList();
var internal = new FinanceService.BillRequest(null, 1L, request.billDate(), null, lines);
return ApiResponse.success(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key));
return FinanceResponses.success(resourceView(service.createBill(request.customerId(), user.userId(), "客户" + request.customerId(), internal, key)));
}
@PostMapping("/bills/{billId}/issue")
@PreAuthorize("hasAuthority('platform:finance:bill:issue')")
@BusinessAudit(event = "FINANCE_BILL_ISSUE", resourceType = "FINANCE_BILL")
public ApiResponse<Map<String, Object>> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return ApiResponse.success(service.issueBillById(user.userId(), billId, request.expectedVersion(), key));
public ApiResponse<?> issueBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody VersionRequest request) {
return FinanceResponses.success(resourceView(service.issueBillById(user.userId(), billId, request.expectedVersion(), key)));
}
@GetMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
public ApiResponse<Map<String, Object>> bill(@PathVariable long billId) { return ApiResponse.success(service.billById(billId)); }
public ApiResponse<?> bill(@PathVariable long billId) { return FinanceResponses.success(queryService.platformStatement(billId)); }
@GetMapping("/bills/{billId}/revisions")
@PreAuthorize("hasAuthority('platform:finance:bill:view')")
public ApiResponse<?> revisions(@PathVariable long billId, @RequestParam long customerId) {
return ApiResponse.success(service.billRevisions(customerId, billId));
return FinanceResponses.success(queryService.billRevisions(customerId, billId));
}
@GetMapping("/receipts")
@PreAuthorize("hasAuthority('platform:finance:receipt:view')")
public ApiResponse<?> receipts(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listReceipts(customerId)); }
public ApiResponse<?> receipts(@RequestParam(required = false) Long customerId) {
if (customerId == null) throw new IllegalArgumentException("查询收款记录必须指定客户");
return FinanceResponses.success(queryService.platformReceipts(customerId));
}
@PostMapping("/receipts")
@PreAuthorize("hasAuthority('platform:finance:receipt:create')")
@BusinessAudit(event = "FINANCE_RECEIPT_CREATE", resourceType = "FINANCE_RECEIPT")
public ApiResponse<Map<String, Object>> receipt(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReceiptRequest request) {
public ApiResponse<?> receipt(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ReceiptRequest request) {
if (request.amount() == null || request.amount().signum() <= 0) throw new IllegalArgumentException("收款金额必须大于0");
if (request.receivedDate() == null) throw new IllegalArgumentException("到账日期不能为空");
if (request.allocations() == null || request.allocations().isEmpty()) throw new IllegalArgumentException("收款至少需要分配一张账单");
@@ -95,17 +152,20 @@ public class PlatformFinanceController {
if (request.amount().compareTo(allocated) != 0) throw new IllegalArgumentException("收款金额必须等于分配金额合计");
var allocations = request.allocations().stream().map(a -> new FinanceService.ReceiptAllocation(a.billId(), a.amount())).toList();
var internal = new FinanceService.ReceiptRequest(null, 1L, request.receivedDate().atStartOfDay(), request.paymentMethod(), null, request.voucherFileIds(), request.remark(), allocations);
return ApiResponse.success(service.createReceipt(request.customerId(), user.userId(), internal, key));
return FinanceResponses.success(resourceView(service.createReceipt(request.customerId(), user.userId(), internal, key)));
}
@GetMapping("/cost-entries")
@PreAuthorize("hasAuthority('platform:finance:cost:view')")
public ApiResponse<?> costs(@RequestParam(required = false) Long customerId) { return ApiResponse.success(service.listCosts(customerId)); }
public ApiResponse<?> costs(@RequestParam(required = false) Long customerId) {
if (customerId == null) throw new IllegalArgumentException("查询成本记录必须指定客户");
return FinanceResponses.success(queryService.payableSettlements(customerId));
}
@PostMapping("/cost-entries")
@PreAuthorize("hasAuthority('platform:finance:cost:create')")
@BusinessAudit(event = "FINANCE_COST_CREATE", resourceType = "FINANCE_COST")
public ApiResponse<Map<String, Object>> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) {
public ApiResponse<?> cost(@AuthenticationPrincipal AuthenticatedUser user, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody CostRequest request) {
if (request.currency() != null && !"CNY".equals(request.currency())) throw new IllegalArgumentException("首期仅支持CNY");
var internal = new FinanceService.CostRequest(null, request.costType(), request.orderId(), null, request.containerId(), null, null, "MANUAL", request.expenseName(), request.amount(), request.voucherFileIds(), request.remark());
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
@@ -118,20 +178,42 @@ public class PlatformFinanceController {
}
volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), v.inboundId(), snapshot.containerId(), snapshot.customerId(), v.volumeCbm())).toList();
}
return ApiResponse.success(service.createCost(request.customerId(), user.userId(), internal, volumes, key));
return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key)));
}
@PutMapping("/bills/{billId}")
@PreAuthorize("hasAuthority('platform:finance:bill:update')")
public ApiResponse<Map<String,Object>> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody UpdateBillRequest request) { return ApiResponse.success(service.updateBill(user.userId(), billId, request.customerId(), request.billDate(), request.lines(), request.expectedVersion(), key)); }
public ApiResponse<?> update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody UpdateBillRequest request) { return FinanceResponses.success(resourceView(service.updateBill(user.userId(), billId, request.customerId(), request.billDate(), request.lines(), request.expectedVersion(), key))); }
@PostMapping("/bills/{billId}/adjust")
@PreAuthorize("hasAuthority('platform:finance:bill:adjust')")
public ApiResponse<Map<String,Object>> adjust(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody AdjustBillRequest request) { return ApiResponse.success(service.adjustBill(user.userId(), billId, request.lines(), request.expectedVersion(), request.reason(), key)); }
public ApiResponse<?> adjust(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody AdjustBillRequest request) { return FinanceResponses.success(resourceView(service.adjustBill(user.userId(), billId, request.lines(), request.expectedVersion(), request.reason(), key))); }
@PostMapping("/bills/{billId}/void")
@PreAuthorize("hasAuthority('platform:finance:bill:void')")
public ApiResponse<Map<String,Object>> voidBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody ReasonRequest request) { return ApiResponse.success(service.voidBill(user.userId(), billId, request.expectedVersion(), request.reason(), key)); }
public ApiResponse<?> voidBill(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long billId, @RequestHeader("Idempotency-Key") String key, @RequestBody ReasonRequest request) { return FinanceResponses.success(resourceView(service.voidBill(user.userId(), billId, request.expectedVersion(), request.reason(), key))); }
private static com.baiyue.finance.dto.FinanceReadModels.CustomerTerms termsView(long customerId, Map<String, Object> row) {
Object name = row.getOrDefault("customer_name_snapshot", row.get("customerNameSnapshot"));
Object days = row.getOrDefault("credit_days", row.get("creditDays"));
return new com.baiyue.finance.dto.FinanceReadModels.CustomerTerms(customerId, name == null ? null : name.toString(),
days == null ? 30 : ((Number) days).intValue(), String.valueOf(row.getOrDefault("status", "ACTIVE")));
}
private static com.baiyue.finance.dto.FinanceReadModels.ResourceResult resourceView(Map<String, Object> row) {
long id = number(row.get("id"), 0L).longValue();
Object no = first(row, "bill_no", "receipt_no", "cost_no");
Object version = row.get("version");
return new com.baiyue.finance.dto.FinanceReadModels.ResourceResult(id, no == null ? null : no.toString(),
String.valueOf(row.getOrDefault("status", "DRAFT")), version instanceof Number n ? n.intValue() : null);
}
private static Object first(Map<String, Object> row, String... keys) {
for (String key : keys) if (row.get(key) != null) return row.get(key);
return null;
}
private static Number number(Object value, Number fallback) { return value instanceof Number n ? n : fallback; }
public record TermsRequest(String customerName, Integer creditDays) {}
public record CreateBillRequest(long customerId, java.time.LocalDate billDate, java.util.List<BillLineInput> lines) {}
@@ -0,0 +1,12 @@
package com.baiyue.finance.dto;
import java.time.LocalDateTime;
public record FinanceFeeSourceEvent(
String eventId, String eventType, int eventVersion, String producer,
LocalDateTime occurredAt, String tenantId, String aggregateType,
String aggregateId, int sequence, String idempotencyKey,
String payloadHash, Payload payload
) {
public record Payload(String inboundId, String containerId, String feeId, int feeVersion) {}
}
@@ -0,0 +1,146 @@
package com.baiyue.finance.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/** Stable read projections used by the Finance pages. */
public final class FinanceReadModels {
private FinanceReadModels() {}
public record CustomerAccount(
long customerId,
String currency,
String accountMode,
BigDecimal totalReceivable,
BigDecimal totalReceived,
BigDecimal outstandingAmount,
List<AccountEntry> entries) {}
public record AccountEntry(
String entryType,
long resourceId,
String resourceNo,
LocalDateTime occurredAt,
BigDecimal debitAmount,
BigDecimal creditAmount,
String currency,
String status) {}
public record FeePoolItem(
String poolItemId,
String direction,
String sourceType,
long sourceId,
int sourceVersion,
Long orderId,
Long inboundId,
Long containerId,
String feeItemName,
BigDecimal amount,
String currency,
String status,
LocalDateTime createdAt) {}
public record Statement(
long id,
long customerId,
String billNo,
String customerName,
LocalDate billDate,
LocalDate dueDate,
BigDecimal totalAmount,
BigDecimal receivedAmount,
BigDecimal outstandingAmount,
String currency,
String status,
int version) {}
public record StatementDetail(Statement statement, List<StatementLine> lines) {}
public record StatementLine(
long id,
String sourceType,
long sourceId,
int sourceVersion,
Long orderId,
Long inboundId,
String feeItemCode,
String feeItemName,
BigDecimal amount,
String currency) {}
public record ReceivableSettlement(
long id,
long customerId,
String settlementNo,
LocalDate billDate,
LocalDate dueDate,
BigDecimal receivableAmount,
BigDecimal receivedAmount,
BigDecimal outstandingAmount,
String currency,
String status,
int version) {}
public record PayableSettlement(
long id,
long customerId,
String settlementNo,
String costType,
Long orderId,
Long inboundId,
Long containerId,
String supplierName,
String feeItemName,
BigDecimal payableAmount,
BigDecimal paidAmount,
BigDecimal outstandingAmount,
String currency,
String status,
LocalDateTime createdAt,
int version) {}
/** Client-safe receipt view. Voucher and operator/internal fields are deliberately absent. */
public record ClientReceipt(
long id,
String receiptNo,
BigDecimal receivedAmount,
LocalDateTime receivedAt,
String paymentMethod,
String currency,
String status,
List<ClientReceiptAllocation> allocations) {}
public record ClientReceiptAllocation(long billId, String billNo, BigDecimal appliedAmount) {}
public record PlatformReceipt(
long id,
long customerId,
String receiptNo,
BigDecimal receivedAmount,
LocalDateTime receivedAt,
String paymentMethod,
String voucherNo,
List<String> voucherFileIds,
String currency,
String status,
String remark,
int version,
List<ClientReceiptAllocation> allocations) {}
public record BillRevision(
long id,
long billId,
int version,
String reason,
String billSnapshot,
String lineSnapshot,
long createdBy,
LocalDateTime createdAt) {}
public record CustomerTerms(long customerId, String customerName, int creditDays, String status) {}
public record ResourceResult(long id, String resourceNo, String status, Integer version) {}
}
@@ -0,0 +1,12 @@
package com.baiyue.finance.service;
public final class FinanceEventException extends RuntimeException {
private final String code;
public FinanceEventException(String code, String message) {
super(message);
this.code = code;
}
public String code() { return code; }
}
@@ -0,0 +1,153 @@
package com.baiyue.finance.service;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.baiyue.finance.dto.FinanceFeeSourceEvent;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class FinanceFeeSourceEventService {
private static final Set<String> EVENT_TYPES = Set.of(
"FIRSTLEG_FEE_PUBLISHED", "FIRSTLEG_FEE_ADJUSTED", "FIRSTLEG_FEE_VOIDED");
private final JdbcTemplate jdbc;
private final FirstlegFeeSourceClient firstleg;
private final ObjectMapper objectMapper;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@Transactional
public long consume(FinanceFeeSourceEvent event) {
validateEnvelope(event);
String eventJson = json(event);
String requestHash = sha256(eventJson);
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT payload_hash,status,result_resource_id FROM fin_inbox_event WHERE source_system=? AND event_id=? FOR UPDATE",
"FIRSTLEG", event.eventId());
if (!existing.isEmpty()) {
Map<String, Object> row = existing.get(0);
if (!requestHash.equals(row.get("payload_hash"))) {
throw error("FIN-EVT-002", "同一事件ID对应不同请求内容");
}
if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id) {
return id.longValue();
}
throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败");
}
jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')",
event.eventId(), "FIRSTLEG", event.eventType(), eventJson, requestHash);
FirstlegFeeSourceClient.Snapshot snapshot = loadSnapshot(event);
validateSnapshot(event, snapshot);
long resourceId = applyEvent(event, snapshot);
jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?",
resourceId, "FIRSTLEG", event.eventId());
return resourceId;
}
private long applyEvent(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot snapshot) {
List<Map<String, Object>> sameVersion = jdbc.queryForList(
"SELECT id,source_hash,status FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=? FOR UPDATE",
snapshot.feeId(), snapshot.feeVersion());
if (!sameVersion.isEmpty()) {
Map<String, Object> row = sameVersion.get(0);
if (!snapshot.sourceHash().equals(row.get("source_hash"))) {
throw error("FIN-EVT-004", "同一来源费用版本快照不一致");
}
long id = ((Number) row.get("id")).longValue();
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=?", id);
}
return id;
}
if ("FIRSTLEG_FEE_VOIDED".equals(event.eventType())) {
throw error("FIN-EVT-005", "作废事件对应的费用来源版本不存在");
}
jdbc.update("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE source_system='FIRSTLEG' AND source_fee_id=? AND status='AVAILABLE'",
snapshot.feeId());
jdbc.update("INSERT INTO fin_fee_source(source_system,source_fee_id,source_fee_version,customer_id,source_type,source_no,direction,order_id,inbound_id,container_id,fee_item_code,fee_item_name,amount,currency_code,source_hash,source_snapshot,status) VALUES ('FIRSTLEG',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'AVAILABLE')",
snapshot.feeId(), snapshot.feeVersion(), snapshot.customerId(), snapshot.sourceType(),
snapshot.sourceType().equals("INBOUND_FEE") ? snapshot.inboundCode() : snapshot.containerNo(),
snapshot.direction(), snapshot.orderId(), snapshot.inboundId(), snapshot.containerId(),
snapshot.feeItemCode(), snapshot.feeItemName(), snapshot.totalAmount(), snapshot.currency(),
snapshot.sourceHash(), json(snapshot));
return jdbc.queryForObject("SELECT id FROM fin_fee_source WHERE source_system='FIRSTLEG' AND source_fee_id=? AND source_fee_version=?",
Long.class, snapshot.feeId(), snapshot.feeVersion());
}
private FirstlegFeeSourceClient.Snapshot loadSnapshot(FinanceFeeSourceEvent event) {
FinanceFeeSourceEvent.Payload payload = event.payload();
var response = payload.inboundId() != null
? firstleg.inbound(internalToken, positive(payload.inboundId(), "inboundId"), positive(payload.feeId(), "feeId"))
: firstleg.container(internalToken, positive(payload.containerId(), "containerId"), positive(payload.feeId(), "feeId"));
if (response == null || response.data() == null || !"200".equals(response.code())) {
throw error("FIN-EVT-006", "Firstleg费用来源快照不可用");
}
return response.data();
}
private void validateEnvelope(FinanceFeeSourceEvent event) {
if (event == null || blank(event.eventId()) || !EVENT_TYPES.contains(event.eventType())
|| !"firstleg-service".equals(event.producer()) || event.payload() == null
|| blank(event.tenantId()) || blank(event.payload().feeId())) {
throw error("FIN-EVT-001", "费用来源事件信封无效");
}
boolean inbound = event.payload().inboundId() != null;
boolean container = event.payload().containerId() != null;
if (inbound == container || !sha256(json(event.payload())).equals(event.payloadHash())) {
throw error("FIN-EVT-001", "费用来源事件载荷无效");
}
}
private void validateSnapshot(FinanceFeeSourceEvent event, FirstlegFeeSourceClient.Snapshot value) {
long tenantId = positive(event.tenantId(), "tenantId");
long feeId = positive(event.payload().feeId(), "feeId");
if (value.tenantId() != tenantId || value.customerId() != tenantId) throw error("FIN-EVT-007", "费用来源客户归属不一致");
if (value.feeId() != feeId || value.feeVersion() != event.payload().feeVersion()) throw error("FIN-EVT-008", "费用来源版本不一致");
if (!"APPROVED".equals(value.reviewStatus()) || !value.settlementIncluded()) throw error("FIN-EVT-009", "费用未审核通过或未参与结算");
if (!"FIRSTLEG_FEE_VOIDED".equals(event.eventType()) && !"PUBLISHED".equals(value.publicationStatus())) {
throw error("FIN-EVT-015", "费用来源尚未发布");
}
if (!"CNY".equals(value.currency()) || value.totalAmount() == null || value.totalAmount().signum() <= 0) throw error("FIN-EVT-010", "费用金额或币种无效");
if (!Set.of("RECEIVABLE", "PAYABLE").contains(value.direction())) throw error("FIN-EVT-011", "费用方向无效");
if (!Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(value.sourceType()) || blank(value.feeItemCode()) || blank(value.feeItemName()) || blank(value.sourceHash())) throw error("FIN-EVT-012", "费用来源快照字段不完整");
if ("INBOUND_FEE".equals(value.sourceType()) && (value.inboundId() == null || !String.valueOf(value.inboundId()).equals(event.payload().inboundId()))) throw error("FIN-EVT-013", "入库单归属不一致");
if ("CONTAINER_FEE".equals(value.sourceType()) && (value.containerId() == null || !String.valueOf(value.containerId()).equals(event.payload().containerId()))) throw error("FIN-EVT-014", "柜子归属不一致");
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException e) { throw error("FIN-EVT-001", "事件序列化失败"); }
}
private static long positive(String value, String field) {
try {
long parsed = Long.parseLong(value);
if (parsed > 0) return parsed;
} catch (RuntimeException ignored) { }
throw error("FIN-EVT-001", field + "无效");
}
private static String sha256(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
}
private static boolean blank(String value) { return value == null || value.isBlank(); }
private static FinanceEventException error(String code, String message) { return new FinanceEventException(code, message); }
}
@@ -0,0 +1,197 @@
package com.baiyue.finance.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.finance.dto.FinanceReadModels.AccountEntry;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ClientReceiptAllocation;
import com.baiyue.finance.dto.FinanceReadModels.BillRevision;
import com.baiyue.finance.dto.FinanceReadModels.CustomerAccount;
import com.baiyue.finance.dto.FinanceReadModels.FeePoolItem;
import com.baiyue.finance.dto.FinanceReadModels.PayableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.PlatformReceipt;
import com.baiyue.finance.dto.FinanceReadModels.ReceivableSettlement;
import com.baiyue.finance.dto.FinanceReadModels.Statement;
import com.baiyue.finance.dto.FinanceReadModels.StatementDetail;
import com.baiyue.finance.dto.FinanceReadModels.StatementLine;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/** Typed query facade for the five Finance pages and client-safe read APIs. */
@Service
public class FinanceQueryService {
private static final String CNY = "CNY";
private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public FinanceQueryService(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc;
this.objectMapper = objectMapper;
}
public CustomerAccount customerAccount(long customerId) {
requireCustomer(customerId);
BigDecimal total = amount("SELECT COALESCE(SUM(total_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
BigDecimal received = amount("SELECT COALESCE(SUM(received_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
BigDecimal outstanding = amount("SELECT COALESCE(SUM(outstanding_amount),0) FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", customerId);
List<AccountEntry> entries = new ArrayList<>();
entries.addAll(jdbc.query("SELECT id,bill_no,created_at,total_amount,currency_code,status FROM fin_bill WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
new AccountEntry("RECEIVABLE", rs.getLong("id"), rs.getString("bill_no"), time(rs, "created_at"),
rs.getBigDecimal("total_amount"), BigDecimal.ZERO, rs.getString("currency_code"), rs.getString("status")), customerId));
entries.addAll(jdbc.query("SELECT id,receipt_no,received_at,received_amount,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status<>'VOIDED'", (rs, rowNum) ->
new AccountEntry("RECEIPT", rs.getLong("id"), rs.getString("receipt_no"), time(rs, "received_at"),
BigDecimal.ZERO, rs.getBigDecimal("received_amount"), rs.getString("currency_code"), rs.getString("status")), customerId));
entries.sort(Comparator.comparing(AccountEntry::occurredAt, Comparator.nullsLast(Comparator.reverseOrder())));
return new CustomerAccount(customerId, CNY, "MANUAL_RECEIVABLE_LEDGER", total, received, outstanding, List.copyOf(entries));
}
public List<FeePoolItem> feePool(long customerId, String direction) {
requireCustomer(customerId);
String normalized = direction == null || direction.isBlank() ? "ALL" : direction.trim().toUpperCase(java.util.Locale.ROOT);
if (!List.of("ALL", "RECEIVABLE", "PAYABLE").contains(normalized)) throw bad("费用方向只能是RECEIVABLE或PAYABLE");
List<FeePoolItem> result = new ArrayList<>();
String sourceSql = "SELECT id,direction,source_type,source_fee_id,source_fee_version,order_id,inbound_id,container_id,fee_item_name,amount,currency_code,status,created_at FROM fin_fee_source WHERE customer_id=? AND status='AVAILABLE'";
Object[] sourceArgs;
if ("ALL".equals(normalized)) {
sourceSql += " ORDER BY created_at DESC,id DESC";
sourceArgs = new Object[]{customerId};
} else {
sourceSql += " AND direction=? ORDER BY created_at DESC,id DESC";
sourceArgs = new Object[]{customerId, normalized};
}
result.addAll(jdbc.query(sourceSql, (rs, rowNum) -> {
String rowDirection = rs.getString("direction");
return new FeePoolItem(rowDirection + ":" + rs.getLong("id"), rowDirection,
rs.getString("source_type"), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("amount"), rs.getString("currency_code"),
rs.getString("status"), time(rs, "created_at"));
}, sourceArgs));
if (!"RECEIVABLE".equals(normalized)) {
result.addAll(jdbc.query("SELECT id,order_id,inbound_id,container_id,fee_item_name,cost_amount,currency_code,status,created_at FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC", (rs, rowNum) ->
new FeePoolItem("PAYABLE:" + rs.getLong("id"), "PAYABLE", "MANUAL_COST", rs.getLong("id"), 1,
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"),
rs.getString("fee_item_name"), rs.getBigDecimal("cost_amount"), rs.getString("currency_code"), rs.getString("status"), time(rs, "created_at")), customerId));
}
result.sort(Comparator.comparing(FeePoolItem::createdAt, Comparator.nullsLast(Comparator.reverseOrder())));
return List.copyOf(result);
}
public List<Statement> statements(long customerId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
return jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE tenant_id=?" + visible + " ORDER BY bill_date DESC,id DESC",
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), customerId);
}
public StatementDetail statement(long customerId, long billId, boolean clientVisibleOnly) {
requireCustomer(customerId);
String visible = clientVisibleOnly ? " AND status IN ('ISSUED','PARTIALLY_PAID','PAID')" : "";
List<Statement> rows = jdbc.query("SELECT id,tenant_id,bill_no,customer_name_snapshot,bill_date,due_date,total_amount,received_amount,outstanding_amount,currency_code,status,version FROM fin_bill WHERE tenant_id=? AND id=?" + visible,
(rs, rowNum) -> new Statement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("bill_no"), rs.getString("customer_name_snapshot"),
rs.getDate("bill_date").toLocalDate(), rs.getDate("due_date").toLocalDate(), rs.getBigDecimal("total_amount"), rs.getBigDecimal("received_amount"),
rs.getBigDecimal("outstanding_amount"), rs.getString("currency_code"), rs.getString("status"), rs.getInt("version")), customerId, billId);
if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "账单不存在或不属于客户");
List<StatementLine> lines = jdbc.query("SELECT id,source_type,source_id,source_version,order_id,inbound_id,fee_item_code,fee_item_name,amount,currency_code FROM fin_bill_line WHERE tenant_id=? AND bill_id=? ORDER BY id",
(rs, rowNum) -> new StatementLine(rs.getLong("id"), rs.getString("source_type"), rs.getLong("source_id"), rs.getInt("source_version"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), rs.getString("fee_item_code"), rs.getString("fee_item_name"),
rs.getBigDecimal("amount"), rs.getString("currency_code")), customerId, billId);
return new StatementDetail(rows.get(0), lines);
}
public StatementDetail platformStatement(long billId) {
Long customerId = jdbc.queryForObject("SELECT tenant_id FROM fin_bill WHERE id=?", Long.class, billId);
if (customerId == null) throw new BizException(ResultCode.NOT_FOUND, "账单不存在");
return statement(customerId, billId, false);
}
public List<BillRevision> billRevisions(long customerId, long billId) {
statement(customerId, billId, false);
return jdbc.query("SELECT id,bill_id,version,reason,bill_snapshot,line_snapshot,created_by,created_at FROM fin_bill_revision WHERE tenant_id=? AND bill_id=? ORDER BY version DESC,id DESC",
(rs, rowNum) -> new BillRevision(rs.getLong("id"), rs.getLong("bill_id"), rs.getInt("version"), rs.getString("reason"),
rs.getString("bill_snapshot"), rs.getString("line_snapshot"), rs.getLong("created_by"), time(rs, "created_at")), customerId, billId);
}
public List<ReceivableSettlement> receivableSettlements(long customerId, boolean clientVisibleOnly) {
return statements(customerId, clientVisibleOnly).stream().map(row -> new ReceivableSettlement(
row.id(), row.customerId(), row.billNo(), row.billDate(), row.dueDate(), row.totalAmount(), row.receivedAmount(),
row.outstandingAmount(), row.currency(), row.status(), row.version())).toList();
}
public List<PayableSettlement> payableSettlements(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,tenant_id,cost_no,cost_type,order_id,inbound_id,container_id,supplier_name_snapshot,fee_item_name,cost_amount,currency_code,status,created_at,version FROM fin_cost WHERE tenant_id=? ORDER BY created_at DESC,id DESC",
(rs, rowNum) -> {
BigDecimal amount = rs.getBigDecimal("cost_amount");
return new PayableSettlement(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("cost_no"), rs.getString("cost_type"),
nullableLong(rs, "order_id"), nullableLong(rs, "inbound_id"), nullableLong(rs, "container_id"), rs.getString("supplier_name_snapshot"),
rs.getString("fee_item_name"), amount, BigDecimal.ZERO, amount, rs.getString("currency_code"), rs.getString("status"),
time(rs, "created_at"), rs.getInt("version"));
}, customerId);
}
public List<ClientReceipt> clientReceipts(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,receipt_no,received_amount,received_at,payment_method,currency_code,status FROM fin_receipt WHERE tenant_id=? AND status='CONFIRMED' ORDER BY received_at DESC,id DESC",
(rs, rowNum) -> new ClientReceipt(rs.getLong("id"), rs.getString("receipt_no"), rs.getBigDecimal("received_amount"),
time(rs, "received_at"), rs.getString("payment_method"), rs.getString("currency_code"), rs.getString("status"), allocations(customerId, rs.getLong("id"))), customerId);
}
public List<PlatformReceipt> platformReceipts(long customerId) {
requireCustomer(customerId);
return jdbc.query("SELECT id,tenant_id,receipt_no,received_amount,received_at,payment_method,voucher_no,voucher_file_ids,currency_code,status,remark,version FROM fin_receipt WHERE tenant_id=? ORDER BY received_at DESC,id DESC",
(rs, rowNum) -> new PlatformReceipt(rs.getLong("id"), rs.getLong("tenant_id"), rs.getString("receipt_no"), rs.getBigDecimal("received_amount"),
time(rs, "received_at"), rs.getString("payment_method"), rs.getString("voucher_no"), fileIds(rs.getString("voucher_file_ids")),
rs.getString("currency_code"), rs.getString("status"), rs.getString("remark"), rs.getInt("version"), allocations(customerId, rs.getLong("id"))), customerId);
}
private List<String> fileIds(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
} catch (Exception error) {
throw new IllegalStateException("财务凭证文件字段损坏", error);
}
}
private List<ClientReceiptAllocation> allocations(long customerId, long receiptId) {
return jdbc.query("SELECT rb.bill_id,b.bill_no,rb.applied_amount FROM fin_receipt_bill rb JOIN fin_bill b ON b.id=rb.bill_id AND b.tenant_id=rb.tenant_id WHERE rb.tenant_id=? AND rb.receipt_id=? ORDER BY rb.id",
(rs, rowNum) -> new ClientReceiptAllocation(rs.getLong("bill_id"), rs.getString("bill_no"), rs.getBigDecimal("applied_amount")), customerId, receiptId);
}
private BigDecimal amount(String sql, long customerId) {
BigDecimal value = jdbc.queryForObject(sql, BigDecimal.class, customerId);
return value == null ? BigDecimal.ZERO : value;
}
private static Long nullableLong(ResultSet rs, String column) throws SQLException {
long value = rs.getLong(column);
return rs.wasNull() ? null : value;
}
private static LocalDateTime time(ResultSet rs, String column) throws SQLException {
Timestamp timestamp = rs.getTimestamp(column);
return timestamp == null ? null : timestamp.toLocalDateTime();
}
private static void requireCustomer(long customerId) {
if (customerId <= 0) throw bad("客户范围无效");
}
private static BizException bad(String message) {
return new BizException(ResultCode.BAD_REQUEST, message);
}
}
@@ -194,8 +194,8 @@ public class FinanceService {
}
private void validateVoucherFiles(long tenantId, List<String> fileIds) {
if (fileIds == null || fileIds.isEmpty()) throw bad("收款凭证不能为空");
if (fileClient == null) return;
if (fileIds == null || fileIds.isEmpty()) throw bad("财务凭证不能为空");
if (fileClient == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "File服务校验能力不可用");
for (String raw : fileIds) {
long id; try { id = Long.parseLong(raw); } catch (Exception ex) { throw bad("凭证文件ID无效"); }
var response = fileClient.metadata(fileToken, tenantId, tenantId, id);
@@ -298,12 +298,23 @@ public class FinanceService {
private Long reserve(long tenantId, long operatorId, String operation, String key, Object request) {
if (!StringUtils.hasText(key) || key.length() > 128) throw bad("必须提供有效的 Idempotency-Key");
String hash = hash(request);
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", tenantId, operatorId, operation, key);
String hash = hash(new IdempotencyRequest(httpMethod(operation), requestPath(operation), request));
List<Map<String,Object>> rows = jdbc.queryForList("SELECT request_hash,status,result_resource_id,updated_at FROM fin_idempotency_record WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=?", tenantId, operatorId, operation, key);
if (!rows.isEmpty()) {
Map<String,Object> row = rows.get(0);
if (!hash.equals(row.get("request_hash"))) throw new BizException(ResultCode.CONFLICT, "Idempotency-Key已用于其他请求");
if ("SUCCEEDED".equals(row.get("status")) && row.get("result_resource_id") != null) return ((Number) row.get("result_resource_id")).longValue();
if ("FAILED".equals(row.get("status"))) {
int recovered = jdbc.update("UPDATE fin_idempotency_record SET status='PROCESSING',error_code=NULL WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='FAILED'",
tenantId, operatorId, operation, key);
if (recovered == 1) return null;
}
LocalDateTime updatedAt = idempotencyUpdatedAt(row.get("updated_at"));
if ("PROCESSING".equals(row.get("status")) && updatedAt != null && updatedAt.isBefore(LocalDateTime.now().minusMinutes(5))) {
int recovered = jdbc.update("UPDATE fin_idempotency_record SET updated_at=CURRENT_TIMESTAMP(3) WHERE tenant_id=? AND operator_id=? AND operation=? AND idempotency_key=? AND status='PROCESSING' AND updated_at=?",
tenantId, operatorId, operation, key, updatedAt);
if (recovered == 1) return null;
}
throw new BizException(ResultCode.CONFLICT, "相同请求正在处理中");
}
jdbc.update("INSERT INTO fin_idempotency_record(tenant_id,operator_id,operation,idempotency_key,request_hash,status) VALUES(?,?,?,?,?,'PROCESSING')", tenantId, operatorId, operation, key, hash);
@@ -315,6 +326,30 @@ public class FinanceService {
}
private static String temporaryNo(String prefix) { return prefix + "-PENDING-" + java.util.UUID.randomUUID(); }
private static String stableNo(String prefix, long id) { return prefix + "-" + String.format(java.util.Locale.ROOT, "%020d", id); }
private static LocalDateTime idempotencyUpdatedAt(Object value) {
if (value instanceof LocalDateTime dateTime) return dateTime;
if (value instanceof java.sql.Timestamp timestamp) return timestamp.toLocalDateTime();
return null;
}
private static String httpMethod(String operation) {
return switch (operation) {
case "CUSTOMER_TERMS_SAVE", "BILL_UPDATE" -> "PUT";
default -> "POST";
};
}
private static String requestPath(String operation) {
return switch (operation) {
case "CUSTOMER_TERMS_SAVE" -> "/api/platform/finance/v1/customers/{customerId}/terms";
case "BILL_CREATE" -> "/api/platform/finance/v1/bills";
case "BILL_ISSUE" -> "/api/platform/finance/v1/bills/{billId}/issue";
case "RECEIPT_CREATE" -> "/api/platform/finance/v1/receipts";
case "COST_CREATE" -> "/api/platform/finance/v1/cost-entries";
case "BILL_UPDATE" -> "/api/platform/finance/v1/bills/{billId}";
case "BILL_ADJUST" -> "/api/platform/finance/v1/bills/{billId}/adjust";
case "BILL_VOID" -> "/api/platform/finance/v1/bills/{billId}/void";
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
};
}
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
@@ -357,4 +392,5 @@ public class FinanceService {
public record ContainerOrderVolume(long orderId, Long inboundId, long containerId, long customerId, BigDecimal volumeCbm) {}
public record CostAllocation(ContainerOrderVolume order, BigDecimal totalVolumeCbm, BigDecimal rate,
BigDecimal amount, boolean roundingAdjustment) {}
private record IdempotencyRequest(String method, String path, Object body) {}
}