- 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]>
121 lines
3.2 KiB
Go
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)
|
|
}
|