优化
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) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user