Files

297 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package accountant
// Monatsabrechnung (Spec 5.3, Roadmap 6d Schritt 4): je Kunde die
// Pauschale und je Posten (Mandant, Protokoll, Router über alle Payees und
// Knoten) Klasse, Produkt, Betrag – als CSV für die Buchhaltung (Lexware
// Office). Preise sind optional und nicht buchhaltungsrelevant.
import (
"cmp"
"context"
"encoding/csv"
"fmt"
"io"
"slices"
"strings"
"time"
"sultan/pkg/ledger"
)
// ItemFlatRate ist der Posten-Name der Pauschale eines Kunden.
const ItemFlatRate = "Pauschale"
// BillLine ist eine Zeile der Monatsabrechnung.
type BillLine struct {
Month string // JJJJ-MM
Customer string // "" = Kunde unbekannt
CustomerName string
Group string
Cycle string // ledger.Cycle*
Item string // ItemFlatRate oder <mandant>/<protokoll>/<router>
Hosts string // durch Leerzeichen getrennt
Tenant string
Class string // ledger.Class*; "" = nicht zugeordnet
Product string
From, To string // JJJJ-MM-TT; To leer = läuft weiter
Amount *int64 // Monatsbetrag in Cent; nil = unbekannt (kein Preis/keine Zuordnung)
PeriodAmount *int64 // Rechnungsbetrag je Zahlungsperiode (×3 bzw. ×12); nil = unbekannt
Shadow *int64 // Schattenpreis (free/internal): was es kosten würde
Note string
CustomerNote string
}
// Billing berechnet die Abrechnung eines Monats (JJJJ-MM, Ortszeit) aus der
// ganzen Historie.
func Billing(ctx context.Context, l *ledger.Ledger, month string) ([]BillLine, error) {
spans, err := l.RouteHistory(ctx, ledger.HistoryFilter{})
if err != nil {
return nil, err
}
return billFrom(ctx, l, month, spans, time.Local)
}
type item struct {
key ItemKey
first time.Time
ended time.Time // Nullzeit = läuft
open bool // mindestens ein Ort läuft noch
latest ledger.RouteSpan
}
func cycleFactor(c string) (int64, string) {
switch c {
case ledger.CycleMonthly:
return 1, ""
case ledger.CycleQuarterly:
return 3, "Quartalszahler: Rechnung = 3 × Monatsbetrag"
case ledger.CycleYearly:
return 12, "Jahreszahler: Monatsbetrag = 1/12 der Jahresrechnung"
}
return 0, "noch nicht in Zahlweise (m, q, j) eingeordnet!"
}
func ptr(v int64) *int64 { return &v }
func billFrom(ctx context.Context, l *ledger.Ledger, month string, spans []ledger.RouteSpan, loc *time.Location) ([]BillLine, error) {
start, err := time.ParseInLocation("2006-01", month, loc)
if err != nil || !ledger.ValidMonth(month) {
return nil, fmt.Errorf("Monat %q ungültig (JJJJ-MM)", month)
}
end := start.AddDate(0, 1, 0)
daysInMonth := int64(end.Add(-time.Hour).Day())
customers, err := l.ListCustomers(ctx)
if err != nil {
return nil, err
}
byID := map[string]ledger.Customer{}
for _, c := range customers {
byID[c.ID] = c
}
resolver, err := l.CustomerResolver(ctx)
if err != nil {
return nil, err
}
cat, err := l.Catalog(ctx)
if err != nil {
return nil, err
}
assignList, err := l.ListAssignments(ctx)
if err != nil {
return nil, err
}
assigns := map[ItemKey]ledger.Assignment{}
for _, a := range assignList {
assigns[ItemKey{a.Tenant, a.Protocol, a.Router}] = a
}
// Orte zu Posten zusammenfassen.
items := map[ItemKey]*item{}
for _, s := range spans {
k := ItemKey{s.Tenant, s.Protocol, s.Router}
it := items[k]
if it == nil {
it = &item{key: k, first: s.FirstSeen, latest: s}
items[k] = it
}
if s.FirstSeen.Before(it.first) {
it.first = s.FirstSeen
}
if s.Ended.IsZero() {
it.open = true
} else if s.Ended.After(it.ended) {
it.ended = s.Ended
}
if !s.FirstSeen.Before(it.latest.FirstSeen) {
it.latest = s
}
}
for _, it := range items {
if it.open {
it.ended = time.Time{}
}
}
var lines []BillLine
activeCustomers := map[string]bool{}
for _, it := range items {
a, assigned := assigns[it.key]
startMonth := time.Date(it.first.In(loc).Year(), it.first.In(loc).Month(), 1, 0, 0, 0, 0, loc)
if assigned && a.Start != "" {
if t, err := time.ParseInLocation("2006-01", a.Start, loc); err == nil {
startMonth = t
}
}
if start.Before(startMonth) || (!it.ended.IsZero() && it.ended.Before(start)) {
continue // nicht in diesem Monat
}
custID := resolver.Resolve(it.latest.Payee, it.key.Tenant)
cust, known := byID[custID]
ln := BillLine{
Month: month, Customer: custID, CustomerName: cust.Name, Group: cust.Group, Cycle: cust.Cycle,
Item: it.key.Tenant + "/" + it.key.Protocol + "/" + it.key.Router,
Hosts: strings.Join(it.latest.Hosts, " "), Tenant: it.key.Tenant,
From: it.first.In(loc).Format(time.DateOnly), CustomerNote: cust.Note,
}
var notes []string
if !known {
ln.Customer = ""
notes = append(notes, "Kunde unbekannt – sultan accountant customer map")
} else {
activeCustomers[custID] = true
}
// Klasse und Produkt: Zuordnung > Produkt (Zuordnung, sonst Label) > Pauschale > nicht zugeordnet.
ln.Product = it.latest.Product
if assigned && a.Product != "" {
ln.Product = a.Product
}
var price *int64
if ln.Product != "" {
if v, ok := cat.At(ln.Product, custID, month); ok {
ln.Class, price = v.Class, v.Price
} else {
notes = append(notes, "Produkt "+ln.Product+" nicht im Katalog")
}
}
if assigned && a.Class != "" {
ln.Class = a.Class
}
if ln.Class == "" && ln.Product == "" && known && cust.FlatRate != nil {
ln.Class = ledger.ClassIncluded
}
switch ln.Class {
case ledger.ClassBillable:
if price != nil {
ln.Amount = ptr(*price)
} else {
notes = append(notes, "kein Preis hinterlegt")
}
case ledger.ClassIncluded:
ln.Amount = ptr(0)
case ledger.ClassFree, ledger.ClassInternal:
ln.Amount, ln.Shadow = ptr(0), price
case "":
if !slices.ContainsFunc(notes, func(n string) bool { return strings.Contains(n, "nicht im Katalog") }) {
notes = append(notes, "nicht zugeordnet – Label sultan.accountant.billing oder sultan accountant assign")
}
}
if !it.ended.IsZero() && it.ended.Before(end) {
ln.To = it.ended.In(loc).Format(time.DateOnly)
used := int64(it.ended.In(loc).Day())
n := fmt.Sprintf("beendet am %s (Ultimo abgerechnet)", it.ended.In(loc).Format("02.01."))
if ln.Amount != nil && *ln.Amount > 0 {
n += fmt.Sprintf("; tagesgenau %d/%d Tage = %s", used, daysInMonth, FormatMoney((*ln.Amount*used+daysInMonth/2)/daysInMonth))
}
notes = append(notes, n)
}
factor, cycleNote := cycleFactor(cust.Cycle)
if known && cycleNote != "" {
notes = append(notes, cycleNote)
}
if ln.Amount != nil && factor > 0 {
ln.PeriodAmount = ptr(*ln.Amount * factor)
}
ln.Note = strings.Join(notes, "; ")
lines = append(lines, ln)
}
// Pauschale je Kunde.
for _, c := range customers {
if c.FlatRate == nil {
continue
}
ln := BillLine{Month: month, Customer: c.ID, CustomerName: c.Name, Group: c.Group, Cycle: c.Cycle,
Item: ItemFlatRate, Amount: ptr(*c.FlatRate), CustomerNote: c.Note}
var notes []string
factor, cycleNote := cycleFactor(c.Cycle)
if factor > 0 {
ln.PeriodAmount = ptr(*c.FlatRate * factor)
}
if cycleNote != "" {
notes = append(notes, cycleNote)
}
if !activeCustomers[c.ID] {
notes = append(notes, "keine aktiven URLs in diesem Monat")
}
ln.Note = strings.Join(notes, "; ")
lines = append(lines, ln)
}
slices.SortFunc(lines, func(a, b BillLine) int {
if (a.Customer == "") != (b.Customer == "") {
if a.Customer == "" {
return 1
}
return -1
}
if c := strings.Compare(a.Customer, b.Customer); c != 0 {
return c
}
if (a.Item == ItemFlatRate) != (b.Item == ItemFlatRate) {
if a.Item == ItemFlatRate {
return -1
}
return 1
}
return cmp.Or(strings.Compare(a.Hosts, b.Hosts), strings.Compare(a.Item, b.Item))
})
return lines, nil
}
var cycleNames = map[string]string{
ledger.CycleMonthly: "monatlich", ledger.CycleQuarterly: "quartalsweise", ledger.CycleYearly: "jährlich",
ledger.CycleUnclassified: "nicht eingeordnet",
}
// WriteBillCSV schreibt die Abrechnung als CSV (UTF-8 mit BOM, Semikolon,
// Dezimalkomma) – so liest sie Excel bzw. LibreOffice ohne Importdialog.
func WriteBillCSV(w io.Writer, lines []BillLine) error {
if _, err := io.WriteString(w, "\uFEFF"); err != nil {
return err
}
cw := csv.NewWriter(w)
cw.Comma = ';'
money := func(p *int64) string {
if p == nil {
return ""
}
return FormatMoney(*p)
}
cw.Write([]string{"Monat", "Kunde", "Kundenname", "Gruppe", "Zahlweise", "Posten", "Hosts", "Mandant", "Klasse", "Produkt",
"Von", "Bis", "Monatsbetrag", "Periodenbetrag", "Schattenpreis", "Hinweis", "Notiz"})
for _, l := range lines {
cycle := cycleNames[l.Cycle]
if l.Customer == "" {
cycle = ""
}
cw.Write([]string{l.Month, l.Customer, l.CustomerName, l.Group, cycle, l.Item, l.Hosts, l.Tenant, l.Class, l.Product,
l.From, l.To, money(l.Amount), money(l.PeriodAmount), money(l.Shadow), l.Note, l.CustomerNote})
}
cw.Flush()
return cw.Error()
}