feat(#18): import de extratos OFX e CSV — parser, dedup, preview e confirmação
Parser OFX tolerante ao formato SGML de bancos brasileiros (sem closing tags, BOM, timezone suffix). Parser CSV com separador de campo/decimal configurável. Dedup por FITID (OFX) ou date+amount+desc normalizado. Fluxo: POST /preview → revisão na UI → POST /confirm salva e loga. Migration 002 adiciona external_id com unique index. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/parser"
|
||||
"financeiro-carvalho/internal/repository"
|
||||
)
|
||||
|
||||
type ImportService struct {
|
||||
repo repository.TransactionRepository
|
||||
}
|
||||
|
||||
func NewImportService(repo repository.TransactionRepository) *ImportService {
|
||||
return &ImportService{repo: repo}
|
||||
}
|
||||
|
||||
// Preview parses the file and marks duplicates without saving anything.
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user