Accountant: Inventar, where, Checkmk, Abrechnung
This commit is contained in:
1 parent
9f16148cde
commit
dd93277750
59 files changed
+7522
-113
No files matched your search
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user