Accountant: Inventar, where, Checkmk, Abrechnung

This commit is contained in:
br committed 2026-10-18 17:08:58 +02:00
1 parent 9f16148cde
commit dd93277750
59 files changed
+7522 -113

No files matched your search

+296
View File
@@ -0,0 +1,296 @@
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()
}