Files

327 lines
10 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 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
}