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