// 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 }