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,393 @@
package service
import (
"encoding/csv"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"
"accounting-app/internal/models"
"accounting-app/internal/repository"
)
// Import service errors
var (
ErrInvalidFileFormat = errors.New("invalid file format")
ErrEmptyFile = errors.New("file is empty")
ErrInvalidHeader = errors.New("invalid or missing header row")
ErrInvalidRowData = errors.New("invalid row data")
)
// ImportResult represents the result of a batch import operation
type ImportResult struct {
TotalRows int `json:"total_rows"`
SuccessCount int `json:"success_count"`
FailedCount int `json:"failed_count"`
Errors []ImportError `json:"errors,omitempty"`
Transactions []uint `json:"transaction_ids,omitempty"`
}
// ImportError represents an error that occurred during import
type ImportError struct {
Row int `json:"row"`
Column string `json:"column,omitempty"`
Message string `json:"message"`
}
// TransactionImportRow represents a single row of transaction data to import
type TransactionImportRow struct {
Date string `json:"date"` // Required: YYYY-MM-DD format
Amount float64 `json:"amount"` // Required: positive number
Type string `json:"type"` // Required: income/expense/transfer
Category string `json:"category"` // Required: category name
Account string `json:"account"` // Required: account name
Note string `json:"note"` // Optional
Currency string `json:"currency"` // Optional: defaults to CNY
ToAccount string `json:"to_account"` // Optional: for transfers
}
// ImportService handles batch import of transactions
type ImportService struct {
transactionRepo *repository.TransactionRepository
categoryRepo *repository.CategoryRepository
accountRepo *repository.AccountRepository
}
// NewImportService creates a new ImportService instance
func NewImportService(
transactionRepo *repository.TransactionRepository,
categoryRepo *repository.CategoryRepository,
accountRepo *repository.AccountRepository,
) *ImportService {
return &ImportService{
transactionRepo: transactionRepo,
categoryRepo: categoryRepo,
accountRepo: accountRepo,
}
}
// ImportFromCSV imports transactions from a CSV file
// Expected CSV format: date,amount,type,category,account,note,currency,to_account
func (s *ImportService) ImportFromCSV(userID uint, reader io.Reader) (*ImportResult, error) {
csvReader := csv.NewReader(reader)
csvReader.TrimLeadingSpace = true
// Read header row
header, err := csvReader.Read()
if err != nil {
if err == io.EOF {
return nil, ErrEmptyFile
}
return nil, fmt.Errorf("failed to read header: %w", err)
}
// Validate and map header columns
columnMap, err := s.parseHeader(header)
if err != nil {
return nil, err
}
result := &ImportResult{
Errors: make([]ImportError, 0),
Transactions: make([]uint, 0),
}
rowNum := 1 // Start from 1 (after header)
for {
record, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
result.Errors = append(result.Errors, ImportError{
Row: rowNum,
Message: fmt.Sprintf("failed to read row: %v", err),
})
result.FailedCount++
rowNum++
continue
}
result.TotalRows++
rowNum++
// Parse row data
row, parseErr := s.parseRow(record, columnMap, rowNum)
if parseErr != nil {
result.Errors = append(result.Errors, *parseErr)
result.FailedCount++
continue
}
// Create transaction
txID, createErr := s.createTransaction(userID, row, rowNum)
if createErr != nil {
result.Errors = append(result.Errors, *createErr)
result.FailedCount++
continue
}
result.SuccessCount++
result.Transactions = append(result.Transactions, txID)
}
return result, nil
}
// parseHeader validates and maps CSV header columns
func (s *ImportService) parseHeader(header []string) (map[string]int, error) {
columnMap := make(map[string]int)
requiredColumns := []string{"date", "amount", "type", "category", "account"}
for i, col := range header {
normalizedCol := strings.ToLower(strings.TrimSpace(col))
columnMap[normalizedCol] = i
}
// Check required columns
for _, required := range requiredColumns {
if _, ok := columnMap[required]; !ok {
return nil, fmt.Errorf("%w: missing required column '%s'", ErrInvalidHeader, required)
}
}
return columnMap, nil
}
// parseRow parses a CSV row into TransactionImportRow
func (s *ImportService) parseRow(record []string, columnMap map[string]int, rowNum int) (*TransactionImportRow, *ImportError) {
getValue := func(col string) string {
if idx, ok := columnMap[col]; ok && idx < len(record) {
return strings.TrimSpace(record[idx])
}
return ""
}
row := &TransactionImportRow{
Date: getValue("date"),
Type: getValue("type"),
Category: getValue("category"),
Account: getValue("account"),
Note: getValue("note"),
Currency: getValue("currency"),
ToAccount: getValue("to_account"),
}
// Parse amount
amountStr := getValue("amount")
if amountStr == "" {
return nil, &ImportError{Row: rowNum, Column: "amount", Message: "amount is required"}
}
amount, err := strconv.ParseFloat(amountStr, 64)
if err != nil {
return nil, &ImportError{Row: rowNum, Column: "amount", Message: "invalid amount format"}
}
row.Amount = amount
// Validate required fields
if row.Date == "" {
return nil, &ImportError{Row: rowNum, Column: "date", Message: "date is required"}
}
if row.Type == "" {
return nil, &ImportError{Row: rowNum, Column: "type", Message: "type is required"}
}
if row.Category == "" {
return nil, &ImportError{Row: rowNum, Column: "category", Message: "category is required"}
}
if row.Account == "" {
return nil, &ImportError{Row: rowNum, Column: "account", Message: "account is required"}
}
return row, nil
}
// createTransaction creates a transaction from import row data
func (s *ImportService) createTransaction(userID uint, row *TransactionImportRow, rowNum int) (uint, *ImportError) {
// Parse date
date, err := time.Parse("2006-01-02", row.Date)
if err != nil {
// Try alternative formats
date, err = time.Parse("2006/01/02", row.Date)
if err != nil {
return 0, &ImportError{Row: rowNum, Column: "date", Message: "invalid date format, expected YYYY-MM-DD"}
}
}
// Parse transaction type
txType, err := s.parseTransactionType(row.Type)
if err != nil {
return 0, &ImportError{Row: rowNum, Column: "type", Message: err.Error()}
}
// Find category by name
category, err := s.categoryRepo.GetByName(userID, row.Category)
if err != nil {
return 0, &ImportError{Row: rowNum, Column: "category", Message: fmt.Sprintf("category '%s' not found", row.Category)}
}
// Find account by name
account, err := s.accountRepo.GetByName(userID, row.Account)
if err != nil {
return 0, &ImportError{Row: rowNum, Column: "account", Message: fmt.Sprintf("account '%s' not found", row.Account)}
}
// Parse currency
currency := models.CurrencyCNY
if row.Currency != "" {
currency = models.Currency(strings.ToUpper(row.Currency))
}
// Create transaction
tx := &models.Transaction{
UserID: userID,
Amount: row.Amount,
Type: txType,
CategoryID: category.ID,
AccountID: account.ID,
Currency: currency,
TransactionDate: date,
Note: row.Note,
}
// Handle transfer transactions
if txType == models.TransactionTypeTransfer && row.ToAccount != "" {
toAccount, err := s.accountRepo.GetByName(userID, row.ToAccount)
if err != nil {
return 0, &ImportError{Row: rowNum, Column: "to_account", Message: fmt.Sprintf("to_account '%s' not found", row.ToAccount)}
}
tx.ToAccountID = &toAccount.ID
}
// Save transaction
if err := s.transactionRepo.Create(tx); err != nil {
return 0, &ImportError{Row: rowNum, Message: fmt.Sprintf("failed to create transaction: %v", err)}
}
return tx.ID, nil
}
// parseTransactionType converts string to TransactionType
func (s *ImportService) parseTransactionType(typeStr string) (models.TransactionType, error) {
switch strings.ToLower(typeStr) {
case "income", "收入":
return models.TransactionTypeIncome, nil
case "expense", "支出":
return models.TransactionTypeExpense, nil
case "transfer", "转账":
return models.TransactionTypeTransfer, nil
default:
return "", fmt.Errorf("invalid transaction type '%s', expected income/expense/transfer", typeStr)
}
}
// GenerateCSVTemplate generates a CSV template for import
func (s *ImportService) GenerateCSVTemplate() string {
header := "date,amount,type,category,account,note,currency,to_account\n"
example := "2024-01-15,100.00,expense,餐饮,现金,午餐,CNY,\n"
example += "2024-01-16,5000.00,income,工资,银行<E993B6>?月薪,CNY,\n"
example += "2024-01-17,200.00,transfer,转账,银行<E993B6>?转到支付<E694AF>?CNY,支付宝\n"
return header + example
}
// ValidateImportData validates import data without creating transactions
func (s *ImportService) ValidateImportData(userID uint, reader io.Reader) (*ImportResult, error) {
csvReader := csv.NewReader(reader)
csvReader.TrimLeadingSpace = true
// Read header row
header, err := csvReader.Read()
if err != nil {
if err == io.EOF {
return nil, ErrEmptyFile
}
return nil, fmt.Errorf("failed to read header: %w", err)
}
// Validate and map header columns
columnMap, err := s.parseHeader(header)
if err != nil {
return nil, err
}
result := &ImportResult{
Errors: make([]ImportError, 0),
}
rowNum := 1
for {
record, err := csvReader.Read()
if err == io.EOF {
break
}
if err != nil {
result.Errors = append(result.Errors, ImportError{
Row: rowNum,
Message: fmt.Sprintf("failed to read row: %v", err),
})
result.FailedCount++
rowNum++
continue
}
result.TotalRows++
rowNum++
// Parse and validate row data
row, parseErr := s.parseRow(record, columnMap, rowNum)
if parseErr != nil {
result.Errors = append(result.Errors, *parseErr)
result.FailedCount++
continue
}
// Validate references exist
if validateErr := s.validateRow(userID, row, rowNum); validateErr != nil {
result.Errors = append(result.Errors, *validateErr)
result.FailedCount++
continue
}
result.SuccessCount++
}
return result, nil
}
// validateRow validates that all references in a row exist
func (s *ImportService) validateRow(userID uint, row *TransactionImportRow, rowNum int) *ImportError {
// Validate date format
_, err := time.Parse("2006-01-02", row.Date)
if err != nil {
_, err = time.Parse("2006/01/02", row.Date)
if err != nil {
return &ImportError{Row: rowNum, Column: "date", Message: "invalid date format"}
}
}
// Validate transaction type
if _, err := s.parseTransactionType(row.Type); err != nil {
return &ImportError{Row: rowNum, Column: "type", Message: err.Error()}
}
// Validate category exists
if _, err := s.categoryRepo.GetByName(userID, row.Category); err != nil {
return &ImportError{Row: rowNum, Column: "category", Message: fmt.Sprintf("category '%s' not found", row.Category)}
}
// Validate account exists
if _, err := s.accountRepo.GetByName(userID, row.Account); err != nil {
return &ImportError{Row: rowNum, Column: "account", Message: fmt.Sprintf("account '%s' not found", row.Account)}
}
// Validate to_account for transfers
if strings.ToLower(row.Type) == "transfer" && row.ToAccount != "" {
if _, err := s.accountRepo.GetByName(userID, row.ToAccount); err != nil {
return &ImportError{Row: rowNum, Column: "to_account", Message: fmt.Sprintf("to_account '%s' not found", row.ToAccount)}
}
}
return nil
}