feat: #46 auto-categorização no import por histórico de descrições
This commit is contained in:
@@ -15,6 +15,8 @@ type TransactionRepository interface {
|
|||||||
IsExternalIDKnown(ctx context.Context, externalID string) (bool, error)
|
IsExternalIDKnown(ctx context.Context, externalID string) (bool, error)
|
||||||
BulkInsert(ctx context.Context, rows []model.ImportRow) (int, error)
|
BulkInsert(ctx context.Context, rows []model.ImportRow) (int, error)
|
||||||
SaveImportLog(ctx context.Context, filename, format string, imported, duplicates, errors 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 }
|
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)
|
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 {
|
func (r *transactionRepo) SaveImportLog(ctx context.Context, filename, format string, imported, duplicates, errors int) error {
|
||||||
_, err := r.db.Exec(ctx, `
|
_, err := r.db.Exec(ctx, `
|
||||||
INSERT INTO import_logs (filename, format, imported_count, duplicate_count, error_count)
|
INSERT INTO import_logs (filename, format, imported_count, duplicate_count, error_count)
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ func (m *mockImportTxRepo) BulkInsert(_ context.Context, rows []model.ImportRow)
|
|||||||
func (m *mockImportTxRepo) SaveImportLog(_ context.Context, _, _ string, _, _, _ int) error {
|
func (m *mockImportTxRepo) SaveImportLog(_ context.Context, _, _ string, _, _, _ int) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (m *mockImportTxRepo) CategoryByDescription(_ context.Context, _ []string) (map[string]int, error) {
|
||||||
|
return map[string]int{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockDashboardRepo struct {
|
type mockDashboardRepo struct {
|
||||||
income float64
|
income float64
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import (
|
|||||||
"financeiro-carvalho/internal/repository"
|
"financeiro-carvalho/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func normalizeDesc(s string) string {
|
||||||
|
return strings.ToLower(strings.Join(strings.Fields(s), " "))
|
||||||
|
}
|
||||||
|
|
||||||
type ImportService struct {
|
type ImportService struct {
|
||||||
repo repository.TransactionRepository
|
repo repository.TransactionRepository
|
||||||
}
|
}
|
||||||
@@ -20,19 +24,37 @@ func NewImportService(repo repository.TransactionRepository) *ImportService {
|
|||||||
return &ImportService{repo: repo}
|
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) {
|
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)
|
rows, parseErrs, err := s.parse(filename, r, csvMapping)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
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 {
|
for i := range rows {
|
||||||
dup, err := s.isDuplicate(ctx, &rows[i])
|
dup, err := s.isDuplicate(ctx, &rows[i])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("dedup check: %w", err)
|
return nil, nil, fmt.Errorf("dedup check: %w", err)
|
||||||
}
|
}
|
||||||
rows[i].IsDuplicate = dup
|
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
|
return rows, parseErrs, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user