Files
carvalho-finances/apps/api/internal/service/import.go
T
Mlcavalho1andClaude Sonnet 4.6 00356971c7 feat: #46 auto-categorização no import por histórico de descrições
- TransactionRepository.CategoryByDescription: busca a categoria mais
  recente para cada descrição normalizada no histórico do perfil
- ImportService.Preview: pré-preenche category_id em linhas não-duplicatas
  cuja descrição já foi categorizada antes (case-insensitive)
- Duplicatas não recebem sugestão
- 2 novos testes: auto-categorize + duplicate-not-categorized

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-28 22:49:44 -03:00

121 lines
3.2 KiB
Go

package service
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/parser"
"financeiro-carvalho/internal/repository"
)
func normalizeDesc(s string) string {
return strings.ToLower(strings.Join(strings.Fields(s), " "))
}
type ImportService struct {
repo repository.TransactionRepository
}
func NewImportService(repo repository.TransactionRepository) *ImportService {
return &ImportService{repo: repo}
}
// Preview parses the file, marks duplicates, and pre-fills category_id from history.
func (s *ImportService) Preview(ctx context.Context, filename string, r io.Reader, csvMapping *model.CSVMapping) ([]model.ImportRow, []string, error) {
rows, parseErrs, err := s.parse(filename, r, csvMapping)
if err != nil {
return nil, nil, err
}
// Collect unique descriptions for history lookup
descs := make([]string, 0, len(rows))
seen := make(map[string]bool)
for _, row := range rows {
n := normalizeDesc(row.Description)
if !seen[n] {
descs = append(descs, n)
seen[n] = true
}
}
catByDesc, _ := s.repo.CategoryByDescription(ctx, descs)
for i := range rows {
dup, err := s.isDuplicate(ctx, &rows[i])
if err != nil {
return nil, nil, fmt.Errorf("dedup check: %w", err)
}
rows[i].IsDuplicate = dup
// Pre-fill category from history for non-duplicate rows that have no category yet
if !dup && rows[i].CategoryID == nil {
if catID, ok := catByDesc[normalizeDesc(rows[i].Description)]; ok {
rows[i].CategoryID = &catID
}
}
}
return rows, parseErrs, nil
}
// Confirm saves the non-duplicate rows and logs the operation.
func (s *ImportService) Confirm(ctx context.Context, filename string, rows []model.ImportRow, parseErrCount int) (model.ImportResult, error) {
imported, err := s.repo.BulkInsert(ctx, rows)
if err != nil {
return model.ImportResult{}, err
}
duplicates := 0
for _, r := range rows {
if r.IsDuplicate {
duplicates++
}
}
format := strings.TrimPrefix(strings.ToLower(filepath.Ext(filename)), ".")
if format != "ofx" && format != "csv" {
format = "csv"
}
_ = s.repo.SaveImportLog(ctx, filename, format, imported, duplicates, parseErrCount)
return model.ImportResult{
Imported: imported,
Duplicates: duplicates,
Errors: parseErrCount,
}, nil
}
func (s *ImportService) parse(filename string, r io.Reader, csvMapping *model.CSVMapping) ([]model.ImportRow, []string, error) {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".ofx":
rows, err := parser.ParseOFX(r)
return rows, nil, err
case ".csv":
m := model.CSVMapping{
DateColumn: 0,
AmountColumn: 1,
DescriptionColumn: 2,
DateFormat: "02/01/2006",
HasHeader: true,
DecimalSeparator: ",",
FieldSeparator: ";",
}
if csvMapping != nil {
m = *csvMapping
}
rows, errs := parser.ParseCSV(r, m)
return rows, errs, nil
default:
return nil, nil, fmt.Errorf("unsupported file format: %q (use .ofx or .csv)", ext)
}
}
func (s *ImportService) isDuplicate(ctx context.Context, row *model.ImportRow) (bool, error) {
if row.ExternalID != "" {
return s.repo.IsExternalIDKnown(ctx, row.ExternalID)
}
return s.repo.IsDuplicate(ctx, row.Date, row.Description, row.Amount)
}