Files
Novault-backend/internal/service/excel_export_service.go
2026-01-25 21:59:00 +08:00

606 lines
18 KiB
Go

package service
import (
"fmt"
"time"
"accounting-app/internal/models"
"accounting-app/internal/repository"
"github.com/xuri/excelize/v2"
)
// ExcelExportService handles Excel export functionality
type ExcelExportService struct {
reportRepo *repository.ReportRepository
transactionRepo *repository.TransactionRepository
exchangeRateRepo *repository.ExchangeRateRepository
}
// NewExcelExportService creates a new ExcelExportService instance
func NewExcelExportService(reportRepo *repository.ReportRepository, transactionRepo *repository.TransactionRepository, exchangeRateRepo *repository.ExchangeRateRepository) *ExcelExportService {
return &ExcelExportService{
reportRepo: reportRepo,
transactionRepo: transactionRepo,
exchangeRateRepo: exchangeRateRepo,
}
}
// ExportReportToExcel generates an Excel report with transaction details and summary statistics
func (s *ExcelExportService) ExportReportToExcel(userID uint, req ExportReportRequest) ([]byte, error) {
// Create new Excel file
f := excelize.NewFile()
defer f.Close()
// Create sheets
summarySheet := "Summary"
transactionsSheet := "Transactions"
categoriesSheet := "Categories"
// Rename default sheet to Summary
f.SetSheetName("Sheet1", summarySheet)
// Create other sheets
_, err := f.NewSheet(transactionsSheet)
if err != nil {
return nil, fmt.Errorf("failed to create transactions sheet: %w", err)
}
_, err = f.NewSheet(categoriesSheet)
if err != nil {
return nil, fmt.Errorf("failed to create categories sheet: %w", err)
}
// Get data
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)
}
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)
}
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)
}
transactions, err := s.transactionRepo.GetByDateRange(userID, req.StartDate, req.EndDate)
if err != nil {
return nil, fmt.Errorf("failed to get transactions: %w", err)
}
// Populate sheets
if err := s.populateSummarySheet(f, summarySheet, summary, req); err != nil {
return nil, fmt.Errorf("failed to populate summary sheet: %w", err)
}
if err := s.populateTransactionsSheet(f, transactionsSheet, transactions); err != nil {
return nil, fmt.Errorf("failed to populate transactions sheet: %w", err)
}
if err := s.populateCategoriesSheet(f, categoriesSheet, categoryExpenseSummary, categoryIncomeSummary); err != nil {
return nil, fmt.Errorf("failed to populate categories sheet: %w", err)
}
// Set active sheet to Summary
f.SetActiveSheet(0)
// Save to buffer
buf, err := f.WriteToBuffer()
if err != nil {
return nil, fmt.Errorf("failed to write Excel file: %w", err)
}
return buf.Bytes(), nil
}
// populateSummarySheet populates the summary sheet with report metadata and summary statistics
func (s *ExcelExportService) populateSummarySheet(f *excelize.File, sheetName string, summary *summaryData, req ExportReportRequest) error {
// Define styles
titleStyle, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
Size: 16,
},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
},
})
if err != nil {
return err
}
headerStyle, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
Size: 12,
},
Fill: excelize.Fill{
Type: "pattern",
Color: []string{"#D3D3D3"},
Pattern: 1,
},
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
},
})
if err != nil {
return err
}
valueStyle, err := f.NewStyle(&excelize.Style{
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
NumFmt: 2, // 0.00 format
})
if err != nil {
return err
}
// Set column widths
f.SetColWidth(sheetName, "A", "A", 25)
f.SetColWidth(sheetName, "B", "B", 20)
// Title
f.SetCellValue(sheetName, "A1", "Financial Report")
f.SetCellStyle(sheetName, "A1", "B1", titleStyle)
f.MergeCell(sheetName, "A1", "B1")
// Report period
row := 3
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Report Period:")
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("%s to %s", req.StartDate.Format("2006-01-02"), req.EndDate.Format("2006-01-02")))
row++
// Generated date
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Generated:")
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), time.Now().Format("2006-01-02 15:04:05"))
row++
// Currency
currencyStr := "Mixed"
if req.TargetCurrency != nil {
currencyStr = string(*req.TargetCurrency)
}
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Currency:")
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), currencyStr)
row += 2
// Summary statistics header
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Summary Statistics")
f.SetCellStyle(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("B%d", row), headerStyle)
f.MergeCell(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("B%d", row))
row++
// Total Income
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Total Income")
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), summary.TotalIncome)
f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), valueStyle)
row++
// Total Expense
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Total Expense")
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), summary.TotalExpense)
f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), valueStyle)
row++
// Balance
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Balance")
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), summary.Balance)
f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), valueStyle)
// Apply bold style to balance
balanceStyle, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
},
Alignment: &excelize.Alignment{
Horizontal: "right",
Vertical: "center",
},
NumFmt: 2,
})
if err != nil {
return err
}
f.SetCellStyle(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("B%d", row), balanceStyle)
return nil
}
// populateTransactionsSheet populates the transactions sheet with transaction details
func (s *ExcelExportService) populateTransactionsSheet(f *excelize.File, sheetName string, transactions []models.Transaction) error {
// Define styles
headerStyle, err := f.NewStyle(&excelize.Style{
Fill: excelize.Fill{
Type: "pattern",
Color: []string{"#4472C4"},
Pattern: 1,
},
Font: &excelize.Font{
Bold: true,
Color: "#FFFFFF",
},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "#000000", Style: 1},
{Type: "top", Color: "#000000", Style: 1},
{Type: "bottom", Color: "#000000", Style: 1},
{Type: "right", Color: "#000000", Style: 1},
},
})
if err != nil {
return err
}
// Set column widths
f.SetColWidth(sheetName, "A", "A", 12) // Date
f.SetColWidth(sheetName, "B", "B", 10) // Type
f.SetColWidth(sheetName, "C", "C", 20) // Category
f.SetColWidth(sheetName, "D", "D", 20) // Account
f.SetColWidth(sheetName, "E", "E", 12) // Amount
f.SetColWidth(sheetName, "F", "F", 10) // Currency
f.SetColWidth(sheetName, "G", "G", 40) // Note
// Headers
headers := []string{"Date", "Type", "Category", "Account", "Amount", "Currency", "Note"}
for i, header := range headers {
cell := fmt.Sprintf("%s1", string(rune('A'+i)))
f.SetCellValue(sheetName, cell, header)
f.SetCellStyle(sheetName, cell, cell, headerStyle)
}
// Data rows
for i, txn := range transactions {
row := i + 2
// Date
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), txn.TransactionDate.Format("2006-01-02"))
// Type
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), string(txn.Type))
// Category
categoryName := ""
if txn.Category.Name != "" {
categoryName = txn.Category.Name
}
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), categoryName)
// Account
accountName := ""
if txn.Account.Name != "" {
accountName = txn.Account.Name
}
f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), accountName)
// Amount
f.SetCellValue(sheetName, fmt.Sprintf("E%d", row), txn.Amount)
// Currency
f.SetCellValue(sheetName, fmt.Sprintf("F%d", row), string(txn.Currency))
// Note
f.SetCellValue(sheetName, fmt.Sprintf("G%d", row), txn.Note)
}
// Apply table style
if len(transactions) > 0 {
lastRow := len(transactions) + 1
// Add borders to all cells
for row := 2; row <= lastRow; row++ {
for col := 'A'; col <= 'G'; col++ {
cell := fmt.Sprintf("%c%d", col, row)
style, _ := f.NewStyle(&excelize.Style{
Border: []excelize.Border{
{Type: "left", Color: "#D3D3D3", Style: 1},
{Type: "top", Color: "#D3D3D3", Style: 1},
{Type: "bottom", Color: "#D3D3D3", Style: 1},
{Type: "right", Color: "#D3D3D3", Style: 1},
},
})
f.SetCellStyle(sheetName, cell, cell, style)
}
}
// Format amount column
amountStyle, _ := f.NewStyle(&excelize.Style{
NumFmt: 2, // 0.00 format
Border: []excelize.Border{
{Type: "left", Color: "#D3D3D3", Style: 1},
{Type: "top", Color: "#D3D3D3", Style: 1},
{Type: "bottom", Color: "#D3D3D3", Style: 1},
{Type: "right", Color: "#D3D3D3", Style: 1},
},
})
f.SetCellStyle(sheetName, "E2", fmt.Sprintf("E%d", lastRow), amountStyle)
}
// Freeze header row
f.SetPanes(sheetName, &excelize.Panes{
Freeze: true,
XSplit: 0,
YSplit: 1,
TopLeftCell: "A2",
ActivePane: "bottomLeft",
})
return nil
}
// populateCategoriesSheet populates the categories sheet with category breakdown
func (s *ExcelExportService) populateCategoriesSheet(f *excelize.File, sheetName string, expenseCategories, incomeCategories []categoryData) error {
// Define styles
titleStyle, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
Size: 14,
},
Alignment: &excelize.Alignment{
Horizontal: "left",
Vertical: "center",
},
})
if err != nil {
return err
}
headerStyle, err := f.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
Color: "#FFFFFF",
},
Fill: excelize.Fill{
Type: "pattern",
Color: []string{"#4472C4"},
Pattern: 1,
},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
},
Border: []excelize.Border{
{Type: "left", Color: "#000000", Style: 1},
{Type: "top", Color: "#000000", Style: 1},
{Type: "bottom", Color: "#000000", Style: 1},
{Type: "right", Color: "#000000", Style: 1},
},
})
if err != nil {
return err
}
// Set column widths
f.SetColWidth(sheetName, "A", "A", 25)
f.SetColWidth(sheetName, "B", "B", 15)
f.SetColWidth(sheetName, "C", "C", 12)
f.SetColWidth(sheetName, "D", "D", 15)
row := 1
// Expense Categories Section
if len(expenseCategories) > 0 {
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Expense by Category")
f.SetCellStyle(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), titleStyle)
row++
// Headers
headers := []string{"Category", "Amount", "Count", "Percentage"}
for i, header := range headers {
cell := fmt.Sprintf("%s%d", string(rune('A'+i)), row)
f.SetCellValue(sheetName, cell, header)
f.SetCellStyle(sheetName, cell, cell, headerStyle)
}
row++
// Data
for _, cat := range expenseCategories {
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), cat.CategoryName)
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), cat.TotalAmount)
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), cat.Count)
f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), fmt.Sprintf("%.1f%%", cat.Percentage))
// Apply borders
for col := 'A'; col <= 'D'; col++ {
cell := fmt.Sprintf("%c%d", col, row)
style, _ := f.NewStyle(&excelize.Style{
Border: []excelize.Border{
{Type: "left", Color: "#D3D3D3", Style: 1},
{Type: "top", Color: "#D3D3D3", Style: 1},
{Type: "bottom", Color: "#D3D3D3", Style: 1},
{Type: "right", Color: "#D3D3D3", Style: 1},
},
})
f.SetCellStyle(sheetName, cell, cell, style)
}
// Format amount column
amountStyle, _ := f.NewStyle(&excelize.Style{
NumFmt: 2,
Border: []excelize.Border{
{Type: "left", Color: "#D3D3D3", Style: 1},
{Type: "top", Color: "#D3D3D3", Style: 1},
{Type: "bottom", Color: "#D3D3D3", Style: 1},
{Type: "right", Color: "#D3D3D3", Style: 1},
},
})
f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), amountStyle)
row++
}
row += 2
}
// Income Categories Section
if len(incomeCategories) > 0 {
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), "Income by Category")
f.SetCellStyle(sheetName, fmt.Sprintf("A%d", row), fmt.Sprintf("A%d", row), titleStyle)
row++
// Headers
headers := []string{"Category", "Amount", "Count", "Percentage"}
for i, header := range headers {
cell := fmt.Sprintf("%s%d", string(rune('A'+i)), row)
f.SetCellValue(sheetName, cell, header)
f.SetCellStyle(sheetName, cell, cell, headerStyle)
}
row++
// Data
for _, cat := range incomeCategories {
f.SetCellValue(sheetName, fmt.Sprintf("A%d", row), cat.CategoryName)
f.SetCellValue(sheetName, fmt.Sprintf("B%d", row), cat.TotalAmount)
f.SetCellValue(sheetName, fmt.Sprintf("C%d", row), cat.Count)
f.SetCellValue(sheetName, fmt.Sprintf("D%d", row), fmt.Sprintf("%.1f%%", cat.Percentage))
// Apply borders
for col := 'A'; col <= 'D'; col++ {
cell := fmt.Sprintf("%c%d", col, row)
style, _ := f.NewStyle(&excelize.Style{
Border: []excelize.Border{
{Type: "left", Color: "#D3D3D3", Style: 1},
{Type: "top", Color: "#D3D3D3", Style: 1},
{Type: "bottom", Color: "#D3D3D3", Style: 1},
{Type: "right", Color: "#D3D3D3", Style: 1},
},
})
f.SetCellStyle(sheetName, cell, cell, style)
}
// Format amount column
amountStyle, _ := f.NewStyle(&excelize.Style{
NumFmt: 2,
Border: []excelize.Border{
{Type: "left", Color: "#D3D3D3", Style: 1},
{Type: "top", Color: "#D3D3D3", Style: 1},
{Type: "bottom", Color: "#D3D3D3", Style: 1},
{Type: "right", Color: "#D3D3D3", Style: 1},
},
})
f.SetCellStyle(sheetName, fmt.Sprintf("B%d", row), fmt.Sprintf("B%d", row), amountStyle)
row++
}
}
return nil
}
// getTransactionSummary retrieves transaction summary for the report (reusing from PDF service)
func (s *ExcelExportService) 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 (reusing from PDF service)
func (s *ExcelExportService) 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
}