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

+326
View File
@@ -0,0 +1,326 @@
package ledger
import (
"context"
"database/sql"
"fmt"
"slices"
"strings"
"time"
"sultan/pkg/policy"
"sultan/pkg/store"
)
// Klassen eines Postens (Spec 5.3).
const (
ClassIncluded = "included" // in der Pauschale enthalten
ClassBillable = "billable" // Zusatz-Service, eigene Position
ClassFree = "free" // Give-away, erfasst, derzeit kostenlos
ClassInternal = "internal" // keine Rechnung, Schattenpreis
)
// sinceAlways ist der Beginn der ersten Version eines Produkts.
const sinceAlways = "0000-00"
// ValidClass meldet, ob c eine bekannte Klasse ist.
func ValidClass(c string) bool {
switch c {
case ClassIncluded, ClassBillable, ClassFree, ClassInternal:
return true
}
return false
}
// ValidMonth meldet, ob m die Form JJJJ-MM hat.
func ValidMonth(m string) bool {
_, err := time.Parse("2006-01", m)
return err == nil && len(m) == 7
}
// Product ist ein Eintrag im Katalog; Class und Price beim Anlegen sind die
// erste Version (gilt von Anfang an).
type Product struct {
ID string
Name string
Class string
Price *int64 // Cent je Monat; nil = kein Preis hinterlegt (optional)
Versions []ProductVersion
}
// ProductVersion gilt ab From (JJJJ-MM) bis zur nächsten Version.
type ProductVersion struct {
From string
Class string
Price *int64
}
// Assignment legt für einen Posten (Mandant, Protokoll, Router) Produkt,
// Klasse und/oder Abrechnungsbeginn fest; sie hat Vorrang vor dem Label.
type Assignment struct {
Tenant, Protocol, Router string
Product string // "" = aus dem Label
Class string // "" = aus dem Produkt
Start string // JJJJ-MM; "" = Monat des ersten Auftauchens
}
func priceArg(p *int64) any {
if p == nil {
return nil
}
return *p
}
// AddProduct legt ein Produkt an; Class/Price gelten von Anfang an.
func (l *Ledger) AddProduct(ctx context.Context, p Product) error {
p.Name = strings.TrimSpace(p.Name)
switch {
case !policy.ValidID(p.ID):
return invalid("Produkt-ID %q ungültig (a–z, 0–9, - und _)", p.ID)
case p.Name == "":
return invalid("Name fehlt")
case !ValidClass(p.Class):
return invalid("Klasse %q unbekannt (included, billable, free, internal)", p.Class)
case p.Price != nil && *p.Price < 0:
return invalid("Preis negativ")
}
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM products WHERE id = ?`, p.ID); err != nil {
return err
} else if exists {
return fmt.Errorf("Produkt %s: %w", p.ID, store.ErrExists)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO products (id, name) VALUES (?, ?)`, p.ID, p.Name); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO product_versions (product_id, valid_from, class, price) VALUES (?, ?, ?, ?)`,
p.ID, sinceAlways, p.Class, priceArg(p.Price)); err != nil {
return err
}
return tx.Commit()
}
// SetProductVersion setzt Klasse und Preis eines Produkts ab dem Monat from
// (eine bestehende Version desselben Monats wird ersetzt).
func (l *Ledger) SetProductVersion(ctx context.Context, product, from, class string, price *int64) error {
switch {
case !ValidMonth(from):
return invalid("Monat %q ungültig (JJJJ-MM)", from)
case !ValidClass(class):
return invalid("Klasse %q unbekannt", class)
case price != nil && *price < 0:
return invalid("Preis negativ")
}
if err := l.requireProduct(ctx, product); err != nil {
return err
}
_, err := l.db.ExecContext(ctx, `INSERT INTO product_versions (product_id, valid_from, class, price) VALUES (?, ?, ?, ?)
ON CONFLICT(product_id, valid_from) DO UPDATE SET class = excluded.class, price = excluded.price`,
product, from, class, priceArg(price))
return err
}
// SetCustomerPrice setzt einen Kundenpreis ab dem Monat from; er hat Vorrang
// vor dem Katalogpreis.
func (l *Ledger) SetCustomerPrice(ctx context.Context, product, customer, from string, price int64) error {
switch {
case !ValidMonth(from):
return invalid("Monat %q ungültig (JJJJ-MM)", from)
case price < 0:
return invalid("Preis negativ")
}
if err := l.requireProduct(ctx, product); err != nil {
return err
}
if _, err := l.GetCustomer(ctx, customer); err != nil {
return err
}
_, err := l.db.ExecContext(ctx, `INSERT INTO customer_prices (product_id, customer_id, valid_from, price) VALUES (?, ?, ?, ?)
ON CONFLICT(product_id, customer_id, valid_from) DO UPDATE SET price = excluded.price`, product, customer, from, price)
return err
}
func (l *Ledger) requireProduct(ctx context.Context, id string) error {
var one int
err := l.db.QueryRowContext(ctx, `SELECT 1 FROM products WHERE id = ?`, id).Scan(&one)
if err == sql.ErrNoRows {
return fmt.Errorf("Produkt %s: %w", id, store.ErrNotFound)
}
return err
}
// DeleteProduct löscht ein Produkt samt Versionen und Kundenpreisen.
func (l *Ledger) DeleteProduct(ctx context.Context, id string) error {
res, err := l.db.ExecContext(ctx, `DELETE FROM products WHERE id = ?`, id)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return fmt.Errorf("Produkt %s: %w", id, store.ErrNotFound)
}
return nil
}
// Catalog sind alle Produkte samt Versionen und Kundenpreisen.
type Catalog struct {
names map[string]string
versions map[string][]ProductVersion // nach From aufsteigend
customers map[[2]string][]ProductVersion
}
// Catalog liest den Katalog für viele Abfragen.
func (l *Ledger) Catalog(ctx context.Context) (*Catalog, error) {
c := &Catalog{names: map[string]string{}, versions: map[string][]ProductVersion{}, customers: map[[2]string][]ProductVersion{}}
rows, err := l.db.QueryContext(ctx, `SELECT p.id, p.name, v.valid_from, v.class, v.price FROM products p
JOIN product_versions v ON v.product_id = p.id ORDER BY p.id, v.valid_from`)
if err != nil {
return nil, err
}
for rows.Next() {
var id, name string
var v ProductVersion
var price sql.NullInt64
if err := rows.Scan(&id, &name, &v.From, &v.Class, &price); err != nil {
rows.Close()
return nil, err
}
if price.Valid {
v.Price = &price.Int64
}
c.names[id] = name
c.versions[id] = append(c.versions[id], v)
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = l.db.QueryContext(ctx, `SELECT product_id, customer_id, valid_from, price FROM customer_prices
ORDER BY product_id, customer_id, valid_from`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var product, customer string
var v ProductVersion
var price int64
if err := rows.Scan(&product, &customer, &v.From, &price); err != nil {
return nil, err
}
v.Price = &price
k := [2]string{product, customer}
c.customers[k] = append(c.customers[k], v)
}
return c, rows.Err()
}
func versionAt(vs []ProductVersion, month string) (ProductVersion, bool) {
var out ProductVersion
found := false
for _, v := range vs {
if v.From <= month {
out, found = v, true
}
}
return out, found
}
// At liefert Klasse und Preis eines Produkts im Monat (JJJJ-MM) für einen
// Kunden; der Kundenpreis hat Vorrang vor dem Katalogpreis.
func (c *Catalog) At(product, customer, month string) (ProductVersion, bool) {
v, ok := versionAt(c.versions[product], month)
if !ok {
return ProductVersion{}, false
}
if cp, ok := versionAt(c.customers[[2]string{product, customer}], month); ok {
v.Price = cp.Price
}
v.From = ""
return v, true
}
// Name liefert den Namen eines Produkts ("" wenn unbekannt).
func (c *Catalog) Name(product string) string { return c.names[product] }
// ListProducts liefert alle Produkte mit Versionen; Class/Price sind die
// des aktuellen Monats.
func (l *Ledger) ListProducts(ctx context.Context) ([]Product, error) {
cat, err := l.Catalog(ctx)
if err != nil {
return nil, err
}
month := l.now().UTC().Format("2006-01")
var out []Product
for id, name := range cat.names {
p := Product{ID: id, Name: name, Versions: cat.versions[id]}
if v, ok := versionAt(p.Versions, month); ok {
p.Class, p.Price = v.Class, v.Price
}
out = append(out, p)
}
slices.SortFunc(out, func(a, b Product) int { return strings.Compare(a.ID, b.ID) })
return out, nil
}
// SetAssignment setzt (bzw. ersetzt) die Zuordnung eines Postens.
func (l *Ledger) SetAssignment(ctx context.Context, a Assignment) error {
switch {
case a.Tenant == "" || a.Router == "" || (a.Protocol != "http" && a.Protocol != "tcp"):
return invalid("Posten unvollständig (Mandant, Protokoll http/tcp, Router)")
case a.Product == "" && a.Class == "" && a.Start == "":
return invalid("nichts zugeordnet (Produkt, Klasse oder Beginn angeben)")
case a.Class != "" && !ValidClass(a.Class):
return invalid("Klasse %q unbekannt", a.Class)
case a.Start != "" && !ValidMonth(a.Start):
return invalid("Beginn %q ungültig (JJJJ-MM)", a.Start)
}
if a.Product != "" {
if err := l.requireProduct(ctx, a.Product); err != nil {
return err
}
}
_, err := l.db.ExecContext(ctx, `INSERT INTO assignments (tenant, protocol, router, product_id, class, start)
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(tenant, protocol, router) DO UPDATE SET
product_id = excluded.product_id, class = excluded.class, start = excluded.start`,
a.Tenant, a.Protocol, a.Router, a.Product, a.Class, a.Start)
return err
}
// ListAssignments liefert alle Zuordnungen, sortiert nach Mandant, Router.
func (l *Ledger) ListAssignments(ctx context.Context) ([]Assignment, error) {
rows, err := l.db.QueryContext(ctx, `SELECT tenant, protocol, router, product_id, class, start FROM assignments
ORDER BY tenant, router, protocol`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Assignment
for rows.Next() {
var a Assignment
if err := rows.Scan(&a.Tenant, &a.Protocol, &a.Router, &a.Product, &a.Class, &a.Start); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// DeleteAssignment entfernt die Zuordnung eines Postens.
func (l *Ledger) DeleteAssignment(ctx context.Context, tenant, protocol, router string) error {
res, err := l.db.ExecContext(ctx, `DELETE FROM assignments WHERE tenant = ? AND protocol = ? AND router = ?`,
tenant, protocol, router)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return fmt.Errorf("Zuordnung %s/%s/%s: %w", tenant, protocol, router, store.ErrNotFound)
}
return nil
}