From 00356971c7b4b83abedf3f58615bf80f61451aec Mon Sep 17 00:00:00 2001 From: Mlcarvalho1 Date: Thu, 28 May 2026 22:49:44 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20#46=20auto-categoriza=C3=A7=C3=A3o=20no?= =?UTF-8?q?=20import=20por=20hist=C3=B3rico=20de=20descri=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/internal/repository/transaction.go | 38 ++++++ apps/api/internal/service/dashboard_test.go | 3 + apps/api/internal/service/import.go | 24 +++- apps/api/internal/service/import_test.go | 131 ++++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 apps/api/internal/service/import_test.go diff --git a/apps/api/internal/repository/transaction.go b/apps/api/internal/repository/transaction.go index 92f7c4d..e4fbed6 100644 --- a/apps/api/internal/repository/transaction.go +++ b/apps/api/internal/repository/transaction.go @@ -15,6 +15,8 @@ type TransactionRepository interface { IsExternalIDKnown(ctx context.Context, externalID string) (bool, error) BulkInsert(ctx context.Context, rows []model.ImportRow) (int, error) SaveImportLog(ctx context.Context, filename, format string, imported, duplicates, errors int) error + // CategoryByDescription returns the most recently used category_id for each normalized description. + CategoryByDescription(ctx context.Context, descriptions []string) (map[string]int, error) } type transactionRepo struct{ db *pgxpool.Pool } @@ -82,6 +84,42 @@ func (r *transactionRepo) BulkInsert(ctx context.Context, rows []model.ImportRow return count, tx.Commit(ctx) } +func (r *transactionRepo) CategoryByDescription(ctx context.Context, descriptions []string) (map[string]int, error) { + if len(descriptions) == 0 { + return map[string]int{}, nil + } + pid := middleware.ProfileIDFromCtx(ctx) + + normed := make([]string, len(descriptions)) + for i, d := range descriptions { + normed[i] = normalizeDesc(d) + } + + rows, err := r.db.Query(ctx, ` + SELECT DISTINCT ON (LOWER(description)) LOWER(description), category_id + FROM transactions + WHERE profile_id = $1 + AND category_id IS NOT NULL + AND LOWER(description) = ANY($2) + ORDER BY LOWER(description), date DESC, id DESC + `, pid, normed) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]int) + for rows.Next() { + var desc string + var catID int + if err := rows.Scan(&desc, &catID); err != nil { + return nil, err + } + out[desc] = catID + } + return out, rows.Err() +} + func (r *transactionRepo) SaveImportLog(ctx context.Context, filename, format string, imported, duplicates, errors int) error { _, err := r.db.Exec(ctx, ` INSERT INTO import_logs (filename, format, imported_count, duplicate_count, error_count) diff --git a/apps/api/internal/service/dashboard_test.go b/apps/api/internal/service/dashboard_test.go index e204263..bcebdf1 100644 --- a/apps/api/internal/service/dashboard_test.go +++ b/apps/api/internal/service/dashboard_test.go @@ -22,6 +22,9 @@ func (m *mockImportTxRepo) BulkInsert(_ context.Context, rows []model.ImportRow) func (m *mockImportTxRepo) SaveImportLog(_ context.Context, _, _ string, _, _, _ int) error { return nil } +func (m *mockImportTxRepo) CategoryByDescription(_ context.Context, _ []string) (map[string]int, error) { + return map[string]int{}, nil +} type mockDashboardRepo struct { income float64 diff --git a/apps/api/internal/service/import.go b/apps/api/internal/service/import.go index bd8eed0..f68f3b1 100644 --- a/apps/api/internal/service/import.go +++ b/apps/api/internal/service/import.go @@ -12,6 +12,10 @@ import ( "financeiro-carvalho/internal/repository" ) +func normalizeDesc(s string) string { + return strings.ToLower(strings.Join(strings.Fields(s), " ")) +} + type ImportService struct { repo repository.TransactionRepository } @@ -20,19 +24,37 @@ func NewImportService(repo repository.TransactionRepository) *ImportService { return &ImportService{repo: repo} } -// Preview parses the file and marks duplicates without saving anything. +// 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 } diff --git a/apps/api/internal/service/import_test.go b/apps/api/internal/service/import_test.go new file mode 100644 index 0000000..34d449b --- /dev/null +++ b/apps/api/internal/service/import_test.go @@ -0,0 +1,131 @@ +package service_test + +import ( + "context" + "strings" + "testing" + + "financeiro-carvalho/internal/model" + "financeiro-carvalho/internal/service" +) + +// mockImportRepo implements repository.TransactionRepository for import tests. +type mockImportRepo struct { + catByDesc map[string]int // normalized desc → category_id +} + +func (m *mockImportRepo) IsDuplicate(_ context.Context, _, _ string, _ float64) (bool, error) { + return false, nil +} +func (m *mockImportRepo) IsExternalIDKnown(_ context.Context, _ string) (bool, error) { + return false, nil +} +func (m *mockImportRepo) BulkInsert(_ context.Context, rows []model.ImportRow) (int, error) { + return len(rows), nil +} +func (m *mockImportRepo) SaveImportLog(_ context.Context, _, _ string, _, _, _ int) error { + return nil +} +func (m *mockImportRepo) CategoryByDescription(_ context.Context, descs []string) (map[string]int, error) { + out := map[string]int{} + for _, d := range descs { + if id, ok := m.catByDesc[d]; ok { + out[d] = id + } + } + return out, nil +} + +var csvMapping = model.CSVMapping{ + DateColumn: 0, + AmountColumn: 1, + DescriptionColumn: 2, + DateFormat: "02/01/2006", + HasHeader: false, + DecimalSeparator: ",", + FieldSeparator: ";", +} + +func TestPreview_AutoCategorize(t *testing.T) { + repo := &mockImportRepo{ + catByDesc: map[string]int{ + "padaria do ze": 3, + "uber *trip": 7, + }, + } + svc := service.NewImportService(repo) + + csv := "01/03/2024;-45,00;PADARIA DO ZE\n02/03/2024;-22,50;UBER *TRIP\n03/03/2024;-99,00;LOJA NOVA XYZ\n" + + rows, _, err := svc.Preview(context.Background(), "fatura.csv", strings.NewReader(csv), &csvMapping) + if err != nil { + t.Fatalf("preview error: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + + // PADARIA DO ZE → category 3 + if rows[0].CategoryID == nil || *rows[0].CategoryID != 3 { + t.Errorf("row[0] (PADARIA DO ZE) expected category 3, got %v", rows[0].CategoryID) + } + // UBER *TRIP → category 7 + if rows[1].CategoryID == nil || *rows[1].CategoryID != 7 { + t.Errorf("row[1] (UBER *TRIP) expected category 7, got %v", rows[1].CategoryID) + } + // LOJA NOVA XYZ → no suggestion + if rows[2].CategoryID != nil { + t.Errorf("row[2] (LOJA NOVA XYZ) expected no category, got %v", rows[2].CategoryID) + } +} + +func TestPreview_DuplicateNotAutoCategorizated(t *testing.T) { + // row that IS a duplicate should not get category pre-filled + repo := &mockImportRepoDup{ + catByDesc: map[string]int{"padaria do ze": 3}, + } + svc := service.NewImportService(repo) + + csv := "01/03/2024;-45,00;PADARIA DO ZE\n" + + rows, _, err := svc.Preview(context.Background(), "fatura.csv", strings.NewReader(csv), &csvMapping) + if err != nil { + t.Fatalf("preview error: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if !rows[0].IsDuplicate { + t.Error("expected row to be marked duplicate") + } + if rows[0].CategoryID != nil { + t.Errorf("duplicate should not get category, got %v", rows[0].CategoryID) + } +} + +// mockImportRepoDup marks all rows as duplicate (by date+amount+desc). +type mockImportRepoDup struct { + catByDesc map[string]int +} + +func (m *mockImportRepoDup) IsDuplicate(_ context.Context, _, _ string, _ float64) (bool, error) { + return true, nil // always duplicate +} +func (m *mockImportRepoDup) IsExternalIDKnown(_ context.Context, _ string) (bool, error) { + return false, nil +} +func (m *mockImportRepoDup) BulkInsert(_ context.Context, rows []model.ImportRow) (int, error) { + return len(rows), nil +} +func (m *mockImportRepoDup) SaveImportLog(_ context.Context, _, _ string, _, _, _ int) error { + return nil +} +func (m *mockImportRepoDup) CategoryByDescription(_ context.Context, descs []string) (map[string]int, error) { + out := map[string]int{} + for _, d := range descs { + if id, ok := m.catByDesc[d]; ok { + out[d] = id + } + } + return out, nil +}