This commit is contained in:
2026-01-25 21:59:00 +08:00
parent 7fd537bef3
commit 4cad3f0250
118 changed files with 30473 additions and 0 deletions

View File

@@ -0,0 +1,392 @@
package service
import (
"fmt"
"time"
"accounting-app/internal/models"
"accounting-app/internal/repository"
"github.com/jung-kurt/gofpdf"
)
// PDFExportService handles PDF export functionality
type PDFExportService struct {
reportRepo *repository.ReportRepository
transactionRepo *repository.TransactionRepository
exchangeRateRepo *repository.ExchangeRateRepository
}
// NewPDFExportService creates a new PDFExportService instance
func NewPDFExportService(reportRepo *repository.ReportRepository, transactionRepo *repository.TransactionRepository, exchangeRateRepo *repository.ExchangeRateRepository) *PDFExportService {
return &PDFExportService{
reportRepo: reportRepo,
transactionRepo: transactionRepo,
exchangeRateRepo: exchangeRateRepo,
}
}
// ExportReportRequest represents the request for exporting a report
type ExportReportRequest struct {
StartDate time.Time
EndDate time.Time
TargetCurrency *models.Currency
IncludeCharts bool
}
// ExportReportToPDF generates a PDF report with transaction details and summary statistics
func (s *PDFExportService) ExportReportToPDF(userID uint, req ExportReportRequest) ([]byte, error) {
// Create new PDF document
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetMargins(15, 15, 15)
pdf.AddPage()
// Add Chinese font support (using built-in fonts for now)
pdf.SetFont("Arial", "B", 16)
// Add title
pdf.CellFormat(0, 10, "Financial Report", "", 1, "C", false, 0, "")
pdf.Ln(5)
// Add report period
pdf.SetFont("Arial", "", 10)
periodText := fmt.Sprintf("Period: %s to %s", req.StartDate.Format("2006-01-02"), req.EndDate.Format("2006-01-02"))
pdf.CellFormat(0, 6, periodText, "", 1, "L", false, 0, "")
pdf.CellFormat(0, 6, fmt.Sprintf("Generated: %s", time.Now().Format("2006-01-02 15:04:05")), "", 1, "L", false, 0, "")
pdf.Ln(5)
// Get transaction summary
summary, err := s.getTransactionSummary(userID, req.StartDate, req.EndDate, req.TargetCurrency)
if err != nil {
return nil, fmt.Errorf("failed to get transaction summary: %w", err)
}
// Add summary section
if err := s.addSummarySection(pdf, summary, req.TargetCurrency); err != nil {
return nil, fmt.Errorf("failed to add summary section: %w", err)
}
// Get category summary for expenses
categoryExpenseSummary, err := s.getCategorySummary(userID, req.StartDate, req.EndDate, models.TransactionTypeExpense, req.TargetCurrency)
if err != nil {
return nil, fmt.Errorf("failed to get category expense summary: %w", err)
}
// Add category breakdown section
if err := s.addCategoryBreakdownSection(pdf, categoryExpenseSummary, "Expense by Category"); err != nil {
return nil, fmt.Errorf("failed to add category breakdown section: %w", err)
}
// Get category summary for income
categoryIncomeSummary, err := s.getCategorySummary(userID, req.StartDate, req.EndDate, models.TransactionTypeIncome, req.TargetCurrency)
if err != nil {
return nil, fmt.Errorf("failed to get category income summary: %w", err)
}
// Add income category breakdown section
if err := s.addCategoryBreakdownSection(pdf, categoryIncomeSummary, "Income by Category"); err != nil {
return nil, fmt.Errorf("failed to add income category breakdown section: %w", err)
}
// Get transaction details
transactions, err := s.transactionRepo.GetByDateRange(userID, req.StartDate, req.EndDate)
if err != nil {
return nil, fmt.Errorf("failed to get transactions: %w", err)
}
// Add transaction details section
if err := s.addTransactionDetailsSection(pdf, transactions); err != nil {
return nil, fmt.Errorf("failed to add transaction details section: %w", err)
}
// Output PDF to bytes
var buf []byte
w := &bytesWriter{buf: &buf}
err = pdf.Output(w)
if err != nil {
return nil, fmt.Errorf("failed to generate PDF: %w", err)
}
return buf, nil
}
// bytesWriter is a helper to write PDF output to a byte slice
type bytesWriter struct {
buf *[]byte
}
func (w *bytesWriter) Write(p []byte) (n int, err error) {
*w.buf = append(*w.buf, p...)
return len(p), nil
}
// addSummarySection adds the summary statistics section to the PDF
func (s *PDFExportService) addSummarySection(pdf *gofpdf.Fpdf, summary *summaryData, targetCurrency *models.Currency) error {
pdf.SetFont("Arial", "B", 12)
pdf.CellFormat(0, 8, "Summary Statistics", "", 1, "L", false, 0, "")
pdf.Ln(2)
// Draw summary box
pdf.SetFont("Arial", "", 10)
pdf.SetFillColor(240, 240, 240)
currencySymbol := "$"
if targetCurrency != nil {
currencySymbol = getCurrencySymbol(*targetCurrency)
}
// Total Income
pdf.CellFormat(90, 8, "Total Income:", "1", 0, "L", true, 0, "")
pdf.CellFormat(90, 8, fmt.Sprintf("%s %.2f", currencySymbol, summary.TotalIncome), "1", 1, "R", true, 0, "")
// Total Expense
pdf.CellFormat(90, 8, "Total Expense:", "1", 0, "L", true, 0, "")
pdf.CellFormat(90, 8, fmt.Sprintf("%s %.2f", currencySymbol, summary.TotalExpense), "1", 1, "R", true, 0, "")
// Balance
pdf.SetFont("Arial", "B", 10)
pdf.CellFormat(90, 8, "Balance:", "1", 0, "L", true, 0, "")
pdf.CellFormat(90, 8, fmt.Sprintf("%s %.2f", currencySymbol, summary.Balance), "1", 1, "R", true, 0, "")
pdf.Ln(5)
return nil
}
// addCategoryBreakdownSection adds the category breakdown section to the PDF
func (s *PDFExportService) addCategoryBreakdownSection(pdf *gofpdf.Fpdf, categories []categoryData, title string) error {
if len(categories) == 0 {
return nil
}
pdf.SetFont("Arial", "B", 12)
pdf.CellFormat(0, 8, title, "", 1, "L", false, 0, "")
pdf.Ln(2)
// Table header
pdf.SetFont("Arial", "B", 9)
pdf.SetFillColor(200, 200, 200)
pdf.CellFormat(80, 7, "Category", "1", 0, "L", true, 0, "")
pdf.CellFormat(40, 7, "Amount", "1", 0, "R", true, 0, "")
pdf.CellFormat(30, 7, "Count", "1", 0, "R", true, 0, "")
pdf.CellFormat(30, 7, "Percentage", "1", 1, "R", true, 0, "")
// Table rows
pdf.SetFont("Arial", "", 9)
pdf.SetFillColor(255, 255, 255)
for _, cat := range categories {
pdf.CellFormat(80, 6, cat.CategoryName, "1", 0, "L", false, 0, "")
pdf.CellFormat(40, 6, fmt.Sprintf("%.2f", cat.TotalAmount), "1", 0, "R", false, 0, "")
pdf.CellFormat(30, 6, fmt.Sprintf("%d", cat.Count), "1", 0, "R", false, 0, "")
pdf.CellFormat(30, 6, fmt.Sprintf("%.1f%%", cat.Percentage), "1", 1, "R", false, 0, "")
}
pdf.Ln(5)
return nil
}
// addTransactionDetailsSection adds the transaction details section to the PDF
func (s *PDFExportService) addTransactionDetailsSection(pdf *gofpdf.Fpdf, transactions []models.Transaction) error {
if len(transactions) == 0 {
return nil
}
// Add new page for transaction details
pdf.AddPage()
pdf.SetFont("Arial", "B", 12)
pdf.CellFormat(0, 8, "Transaction Details", "", 1, "L", false, 0, "")
pdf.Ln(2)
// Table header
pdf.SetFont("Arial", "B", 8)
pdf.SetFillColor(200, 200, 200)
pdf.CellFormat(25, 6, "Date", "1", 0, "L", true, 0, "")
pdf.CellFormat(20, 6, "Type", "1", 0, "L", true, 0, "")
pdf.CellFormat(35, 6, "Category", "1", 0, "L", true, 0, "")
pdf.CellFormat(30, 6, "Amount", "1", 0, "R", true, 0, "")
pdf.CellFormat(70, 6, "Note", "1", 1, "L", true, 0, "")
// Table rows
pdf.SetFont("Arial", "", 7)
pdf.SetFillColor(255, 255, 255)
for _, txn := range transactions {
// Check if we need a new page
if pdf.GetY() > 270 {
pdf.AddPage()
// Repeat header
pdf.SetFont("Arial", "B", 8)
pdf.SetFillColor(200, 200, 200)
pdf.CellFormat(25, 6, "Date", "1", 0, "L", true, 0, "")
pdf.CellFormat(20, 6, "Type", "1", 0, "L", true, 0, "")
pdf.CellFormat(35, 6, "Category", "1", 0, "L", true, 0, "")
pdf.CellFormat(30, 6, "Amount", "1", 0, "R", true, 0, "")
pdf.CellFormat(70, 6, "Note", "1", 1, "L", true, 0, "")
pdf.SetFont("Arial", "", 7)
pdf.SetFillColor(255, 255, 255)
}
date := txn.TransactionDate.Format("2006-01-02")
txnType := string(txn.Type)
categoryName := ""
if txn.Category.Name != "" {
categoryName = txn.Category.Name
}
amount := fmt.Sprintf("%.2f", txn.Amount)
note := txn.Note
if len(note) > 40 {
note = note[:37] + "..."
}
pdf.CellFormat(25, 6, date, "1", 0, "L", false, 0, "")
pdf.CellFormat(20, 6, txnType, "1", 0, "L", false, 0, "")
pdf.CellFormat(35, 6, categoryName, "1", 0, "L", false, 0, "")
pdf.CellFormat(30, 6, amount, "1", 0, "R", false, 0, "")
pdf.CellFormat(70, 6, note, "1", 1, "L", false, 0, "")
}
return nil
}
// summaryData holds summary statistics
type summaryData struct {
TotalIncome float64
TotalExpense float64
Balance float64
}
// categoryData holds category breakdown data
type categoryData struct {
CategoryName string
TotalAmount float64
Count int64
Percentage float64
}
// getTransactionSummary retrieves transaction summary for the report
func (s *PDFExportService) getTransactionSummary(userID uint, startDate, endDate time.Time, targetCurrency *models.Currency) (*summaryData, error) {
summaries, err := s.reportRepo.GetTransactionSummaryByCurrency(userID, startDate, endDate)
if err != nil {
return nil, err
}
result := &summaryData{}
// If target currency is specified, convert all to that currency
if targetCurrency != nil {
for _, summary := range summaries {
if summary.Currency == *targetCurrency {
result.TotalIncome += summary.TotalIncome
result.TotalExpense += summary.TotalExpense
} else {
// Get exchange rate
rate, err := s.exchangeRateRepo.GetByCurrencyPairAndDate(summary.Currency, *targetCurrency, time.Now())
if err != nil {
// Try inverse rate
inverseRate, inverseErr := s.exchangeRateRepo.GetByCurrencyPairAndDate(*targetCurrency, summary.Currency, time.Now())
if inverseErr != nil {
// If no rate found, skip this currency
continue
}
rate = &models.ExchangeRate{
FromCurrency: summary.Currency,
ToCurrency: *targetCurrency,
Rate: 1.0 / inverseRate.Rate,
}
}
result.TotalIncome += summary.TotalIncome * rate.Rate
result.TotalExpense += summary.TotalExpense * rate.Rate
}
}
} else {
// No target currency, just sum all (assuming same currency or user doesn't care)
for _, summary := range summaries {
result.TotalIncome += summary.TotalIncome
result.TotalExpense += summary.TotalExpense
}
}
result.Balance = result.TotalIncome - result.TotalExpense
return result, nil
}
// getCategorySummary retrieves category summary for the report
func (s *PDFExportService) getCategorySummary(userID uint, startDate, endDate time.Time, txnType models.TransactionType, targetCurrency *models.Currency) ([]categoryData, error) {
summaries, err := s.reportRepo.GetCategorySummaryByCurrency(userID, startDate, endDate, txnType)
if err != nil {
return nil, err
}
// Group by category and convert currency if needed
categoryMap := make(map[uint]*categoryData)
for _, summary := range summaries {
if categoryMap[summary.CategoryID] == nil {
categoryMap[summary.CategoryID] = &categoryData{
CategoryName: summary.CategoryName,
TotalAmount: 0,
Count: 0,
}
}
amount := summary.TotalAmount
if targetCurrency != nil && summary.Currency != *targetCurrency {
// Get exchange rate
rate, err := s.exchangeRateRepo.GetByCurrencyPairAndDate(summary.Currency, *targetCurrency, time.Now())
if err != nil {
// Try inverse rate
inverseRate, inverseErr := s.exchangeRateRepo.GetByCurrencyPairAndDate(*targetCurrency, summary.Currency, time.Now())
if inverseErr != nil {
// If no rate found, skip this entry
continue
}
rate = &models.ExchangeRate{
FromCurrency: summary.Currency,
ToCurrency: *targetCurrency,
Rate: 1.0 / inverseRate.Rate,
}
}
amount = summary.TotalAmount * rate.Rate
}
categoryMap[summary.CategoryID].TotalAmount += amount
categoryMap[summary.CategoryID].Count += summary.Count
}
// Convert map to slice and calculate percentages
result := make([]categoryData, 0, len(categoryMap))
var total float64
for _, cat := range categoryMap {
total += cat.TotalAmount
result = append(result, *cat)
}
// Calculate percentages
for i := range result {
if total > 0 {
result[i].Percentage = (result[i].TotalAmount / total) * 100
}
}
return result, nil
}
// getCurrencySymbol returns the symbol for a currency
func getCurrencySymbol(currency models.Currency) string {
switch currency {
case models.CurrencyCNY:
return "¥"
case models.CurrencyUSD:
return "$"
case models.CurrencyEUR:
return "€"
case models.CurrencyJPY:
return "¥"
case models.CurrencyGBP:
return "£"
case models.CurrencyHKD:
return "HK$"
default:
return ""
}
}