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]>
99 lines
2.6 KiB
Go
99 lines
2.6 KiB
Go
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)
|
|
}
|