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]>
100 lines
1.9 KiB
Go
100 lines
1.9 KiB
Go
package parser
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"financeiro-carvalho/internal/model"
|
|
)
|
|
|
|
func ParseCSV(r io.Reader, m model.CSVMapping) ([]model.ImportRow, []string) {
|
|
if m.DateFormat == "" {
|
|
m.DateFormat = "02/01/2006"
|
|
}
|
|
if m.DecimalSeparator == "" {
|
|
m.DecimalSeparator = ","
|
|
}
|
|
fieldSep := ','
|
|
if m.FieldSeparator == ";" {
|
|
fieldSep = ';'
|
|
}
|
|
|
|
reader := csv.NewReader(r)
|
|
reader.Comma = fieldSep
|
|
reader.LazyQuotes = true
|
|
reader.TrimLeadingSpace = true
|
|
|
|
var rows []model.ImportRow
|
|
var errs []string
|
|
lineNum := 0
|
|
|
|
for {
|
|
record, err := reader.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
lineNum++
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("linha %d: %v", lineNum, err))
|
|
continue
|
|
}
|
|
if lineNum == 1 && m.HasHeader {
|
|
continue
|
|
}
|
|
|
|
maxCol := max3(m.DateColumn, m.AmountColumn, m.DescriptionColumn)
|
|
if len(record) <= maxCol {
|
|
errs = append(errs, fmt.Sprintf("linha %d: colunas insuficientes (%d)", lineNum, len(record)))
|
|
continue
|
|
}
|
|
|
|
dateStr := strings.TrimSpace(record[m.DateColumn])
|
|
amtStr := strings.TrimSpace(record[m.AmountColumn])
|
|
desc := cleanDescription(record[m.DescriptionColumn])
|
|
|
|
t, err := time.Parse(m.DateFormat, dateStr)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("linha %d: data inválida %q", lineNum, dateStr))
|
|
continue
|
|
}
|
|
|
|
if m.DecimalSeparator == "," {
|
|
amtStr = strings.ReplaceAll(amtStr, ".", "")
|
|
amtStr = strings.ReplaceAll(amtStr, ",", ".")
|
|
}
|
|
amt, err := strconv.ParseFloat(amtStr, 64)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("linha %d: valor inválido %q", lineNum, amtStr))
|
|
continue
|
|
}
|
|
|
|
txType := "expense"
|
|
if amt > 0 {
|
|
txType = "income"
|
|
}
|
|
|
|
rows = append(rows, model.ImportRow{
|
|
Date: t.Format("2006-01-02"),
|
|
Amount: math.Abs(amt),
|
|
Description: desc,
|
|
Type: txType,
|
|
})
|
|
}
|
|
return rows, errs
|
|
}
|
|
|
|
func max3(a, b, c int) int {
|
|
if b > a {
|
|
a = b
|
|
}
|
|
if c > a {
|
|
a = c
|
|
}
|
|
return a
|
|
}
|