297 lines
8.7 KiB
Go
297 lines
8.7 KiB
Go
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()
|
||
}
|