init
This commit is contained in:
392
internal/service/pdf_export_service.go
Normal file
392
internal/service/pdf_export_service.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user