diff options
| author | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-27 17:19:57 +0100 |
|---|---|---|
| committer | Simen A. W. Olsen <hello@simenandre.no> | 2026-10-27 17:19:57 +0100 |
| commit | 0571b30b6da27dc4c9e617403deab09b974ac641 (patch) | |
| tree | f3dfab18e22a48d06c64e563ba9bcf478c01a5bc /internal/multicase | |
| download | accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.tar.gz accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.zip | |
Diffstat (limited to 'internal/multicase')
| -rw-r--r-- | internal/multicase/multicase.go | 227 | ||||
| -rw-r--r-- | internal/multicase/multicase_test.go | 68 |
2 files changed, 295 insertions, 0 deletions
diff --git a/internal/multicase/multicase.go b/internal/multicase/multicase.go new file mode 100644 index 0000000..68efc7a --- /dev/null +++ b/internal/multicase/multicase.go @@ -0,0 +1,227 @@ +// Package multicase parses a Multicase "buntfil" export and groups its +// posting lines into vouchers (bilag). +package multicase + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" +) + +// Line is one posting line. Amount is in øre of the Multicase company's base +// currency (the Belop column); positive is debit, negative is credit. +type Line struct { + Row int + Account string + Amount int64 + VATCode string + CustomerNo string + SupplierNo string + Department string + Project string + Reference string + Currency string + CurrencyAmt string + VoucherType string + VoucherText string + VoucherNo string + Company string + Date time.Time +} + +// Voucher is every line sharing a voucher type and voucher number. +type Voucher struct { + Company string + Type string // BilagsArt, e.g. "11" + TypeText string // BilagsArtTekst, e.g. "Faktura" + Number string // Bilagsnummer; "0" for lagerkorrigering + Batch string // BuntHodeID + Date time.Time + Text string + Lines []Line +} + +// Key identifies the voucher across runs. +func (v Voucher) Key() string { + if unnumbered(v.Number) { + return "multicase:" + v.Type + ":bunt-" + v.Batch + } + return "multicase:" + v.Type + ":" + v.Number +} + +// Label is a human-readable name for the voucher. +func (v Voucher) Label() string { + if unnumbered(v.Number) { + return v.TypeText + " (bunt " + v.Batch + ")" + } + return v.TypeText + " " + v.Number +} + +// Inventory corrections carry no voucher number; each batch holds one. +func unnumbered(n string) bool { + return n == "" || n == "0" +} + +// Balance returns the sum of the lines in øre; zero for a balanced voucher. +func (v Voucher) Balance() int64 { + var sum int64 + for _, l := range v.Lines { + sum += l.Amount + } + return sum +} + +var required = []string{"Firma", "Year", "Month", "Day", "Konto", "Bilagsnummer", "Belop", "BilagsArt"} + +// Parse turns the worksheet grid (header row first) into vouchers ordered +// by date, then voucher type and number. +func Parse(grid [][]string) ([]Voucher, error) { + if len(grid) == 0 { + return nil, fmt.Errorf("empty worksheet") + } + col := map[string]int{} + for i, h := range grid[0] { + if _, dup := col[strings.TrimSpace(h)]; !dup { + col[strings.TrimSpace(h)] = i + } + } + for _, h := range required { + if _, ok := col[h]; !ok { + return nil, fmt.Errorf("column %q missing: is this a Multicase buntfil export?", h) + } + } + get := func(row []string, name string) string { + i, ok := col[name] + if !ok || i >= len(row) { + return "" + } + return strings.TrimSpace(row[i]) + } + + byKey := map[string]*Voucher{} + var order []string + for i, row := range grid[1:] { + rowNo := i + 2 + if get(row, "Konto") == "" && get(row, "Belop") == "" { + continue + } + l, err := parseLine(row, rowNo, get) + if err != nil { + return nil, err + } + candidate := Voucher{ + Company: l.Company, + Type: l.VoucherType, + TypeText: get(row, "BilagsArtTekst"), + Number: l.VoucherNo, + Batch: get(row, "BuntHodeID"), + Date: l.Date, + Text: l.VoucherText, + } + key := candidate.Key() + v, ok := byKey[key] + if !ok { + v = &candidate + byKey[key] = v + order = append(order, key) + } + if !l.Date.Equal(v.Date) { + return nil, fmt.Errorf("row %d: %s has lines on both %s and %s", + rowNo, v.Label(), v.Date.Format(time.DateOnly), l.Date.Format(time.DateOnly)) + } + v.Lines = append(v.Lines, l) + } + + out := make([]Voucher, 0, len(order)) + for _, k := range order { + out = append(out, *byKey[k]) + } + sort.SliceStable(out, func(a, b int) bool { + if !out[a].Date.Equal(out[b].Date) { + return out[a].Date.Before(out[b].Date) + } + if out[a].Type != out[b].Type { + return out[a].Type < out[b].Type + } + return numericLess(out[a].Number, out[b].Number) + }) + return out, nil +} + +func parseLine(row []string, rowNo int, get func([]string, string) string) (Line, error) { + amount, err := ParseAmount(get(row, "Belop")) + if err != nil { + return Line{}, fmt.Errorf("row %d: Belop: %w", rowNo, err) + } + date, err := parseDate(get(row, "Year"), get(row, "Month"), get(row, "Day")) + if err != nil { + return Line{}, fmt.Errorf("row %d: %w", rowNo, err) + } + l := Line{ + Row: rowNo, + Account: get(row, "Konto"), + Amount: amount, + VATCode: get(row, "MvaKode"), + CustomerNo: get(row, "Faktura kundenr"), + SupplierNo: get(row, "LevNr."), + Department: get(row, "AvdNr."), + Project: get(row, "Prosjekt"), + Reference: get(row, "BestRef1"), + Currency: get(row, "Valuta"), + CurrencyAmt: get(row, "BelopValuta"), + VoucherType: get(row, "BilagsArt"), + VoucherText: get(row, "Tekst"), + VoucherNo: get(row, "Bilagsnummer"), + Company: get(row, "Firma"), + Date: date, + } + if l.Account == "" || l.VoucherType == "" { + return Line{}, fmt.Errorf("row %d: Konto and BilagsArt are required", rowNo) + } + if unnumbered(l.VoucherNo) && get(row, "BuntHodeID") == "" { + return Line{}, fmt.Errorf("row %d: line has neither Bilagsnummer nor BuntHodeID", rowNo) + } + return l, nil +} + +// ParseAmount converts a decimal amount string to øre, rounding half away +// from zero. Multicase writes currency conversions with three decimals. +func ParseAmount(s string) (int64, error) { + if s == "" { + return 0, nil + } + f, err := strconv.ParseFloat(strings.ReplaceAll(s, ",", "."), 64) + if err != nil { + return 0, err + } + // Round the scaled value to 6 places first so binary noise like + // 44587.125 -> 4458712.499999 still rounds up. + scaled := math.Round(f*100*1e6) / 1e6 + return int64(math.Round(scaled)), nil +} + +func parseDate(y, m, d string) (time.Time, error) { + yi, err1 := strconv.Atoi(y) + mi, err2 := strconv.Atoi(m) + di, err3 := strconv.Atoi(d) + if err1 != nil || err2 != nil || err3 != nil { + return time.Time{}, fmt.Errorf("invalid date %s-%s-%s", y, m, d) + } + t := time.Date(yi, time.Month(mi), di, 0, 0, 0, 0, time.UTC) + if t.Year() != yi || int(t.Month()) != mi || t.Day() != di { + return time.Time{}, fmt.Errorf("invalid date %s-%s-%s", y, m, d) + } + return t, nil +} + +func numericLess(a, b string) bool { + ai, errA := strconv.Atoi(a) + bi, errB := strconv.Atoi(b) + if errA == nil && errB == nil { + return ai < bi + } + return a < b +} diff --git a/internal/multicase/multicase_test.go b/internal/multicase/multicase_test.go new file mode 100644 index 0000000..6c737eb --- /dev/null +++ b/internal/multicase/multicase_test.go @@ -0,0 +1,68 @@ +package multicase + +import "testing" + +var header = []string{"Firma", "Year", "Month", "Day", "Konto", "Bilagsnummer", "Belop", "MvaKode", + "BilagsArt", "BilagsArtTekst", "Tekst", "Faktura kundenr", "LevNr.", "BuntHodeID"} + +func row(account, number, amount, vat, typ, typeText, customer, batch string) []string { + return []string{"Demo AS", "2026", "10", "1", account, number, amount, vat, typ, typeText, "Demo", customer, "", batch} +} + +func TestParse(t *testing.T) { + grid := [][]string{ + header, + row("3000", "100", "-125", "3", "11", "Faktura", "20001", "1"), + row("20001", "100", "125", "", "11", "Faktura", "20001", "1"), + row("1460", "0", "-10", "", "31", "Lagerkorrigering", "", "1"), + row("4301", "0", "10", "", "31", "Lagerkorrigering", "", "1"), + row("1460", "0", "5", "", "31", "Lagerkorrigering", "", "2"), + row("4301", "0", "-5", "", "31", "Lagerkorrigering", "", "2"), + } + vs, err := Parse(grid) + if err != nil { + t.Fatal(err) + } + if len(vs) != 3 { + t.Fatalf("got %d vouchers, want 3", len(vs)) + } + keys := map[string]bool{} + for _, v := range vs { + keys[v.Key()] = true + if v.Balance() != 0 { + t.Errorf("%s does not balance", v.Label()) + } + } + for _, k := range []string{"multicase:11:100", "multicase:31:bunt-1", "multicase:31:bunt-2"} { + if !keys[k] { + t.Errorf("missing voucher %s in %v", k, keys) + } + } + if got := vs[0].Lines[0].Amount; got != -12500 { + t.Errorf("amount = %d øre, want -12500", got) + } +} + +func TestParseRejectsMissingColumns(t *testing.T) { + if _, err := Parse([][]string{{"Konto", "Belop"}}); err == nil { + t.Fatal("expected an error for a file that is not a buntfil") + } +} + +func TestParseAmount(t *testing.T) { + tests := map[string]int64{ + "": 0, + "46.7": 4670, + "-46.7": -4670, + "44587.125": 4458713, + "-4053.375": -405338, + "0.1": 10, + "1,5": 150, + } + for in, want := range tests { + got, err := ParseAmount(in) + if err != nil || got != want { + t.Errorf("ParseAmount(%q) = %d, %v; want %d", in, got, err, want) + } + } +} |
