aboutsummaryrefslogtreecommitdiffstats
path: root/internal/multicase/multicase.go
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-10-27 17:19:57 +0100
committerSimen A. W. Olsen <hello@simenandre.no>2026-10-27 17:19:57 +0100
commit0571b30b6da27dc4c9e617403deab09b974ac641 (patch)
treef3dfab18e22a48d06c64e563ba9bcf478c01a5bc /internal/multicase/multicase.go
downloadaccounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.tar.gz
accounted-multicase-intgr-0571b30b6da27dc4c9e617403deab09b974ac641.zip
feat: send Multicase buntfil vouchers to AccountedHEADmain
Diffstat (limited to 'internal/multicase/multicase.go')
-rw-r--r--internal/multicase/multicase.go227
1 files changed, 227 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
+}