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,299 @@
|
||||
package ledger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"sultan/pkg/policy"
|
||||
"sultan/pkg/store"
|
||||
)
|
||||
|
||||
// Zahlweisen eines Kunden (Spec 5.3).
|
||||
const (
|
||||
CycleUnclassified = "" // Feedback der Buchhaltung fehlt noch
|
||||
CycleMonthly = "monthly" // monatlich
|
||||
CycleQuarterly = "quarterly" // quartalsweise
|
||||
CycleYearly = "yearly" // jährlich
|
||||
)
|
||||
|
||||
// Customer ist ein Kunde des Hosters; Mandanten der Payees werden ihm
|
||||
// zugeordnet.
|
||||
type Customer struct {
|
||||
ID string
|
||||
Name string
|
||||
Group string // Kostenverursacher-Gruppe zum Clustern (extern, intern, familie …)
|
||||
Note string // persönliche Nebenabreden
|
||||
Cycle string // Cycle*
|
||||
// FlatRate: Pauschale je Monat in Cent; nil = keine Pauschale.
|
||||
FlatRate *int64
|
||||
// Auto: automatisch aus einer Mandanten-ID angelegt und noch nie von Hand
|
||||
// bearbeitet (Name folgt dann dem Mandanten).
|
||||
Auto bool
|
||||
}
|
||||
|
||||
// TenantRef ist ein Mandant aus einem Bericht.
|
||||
type TenantRef struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Mapping ordnet einen Mandanten (auf einem Payee oder, Payee leer, auf
|
||||
// allen) einem Kunden zu.
|
||||
type Mapping struct {
|
||||
Payee string
|
||||
Tenant string
|
||||
Customer string
|
||||
}
|
||||
|
||||
func validCycle(c string) bool {
|
||||
switch c {
|
||||
case CycleUnclassified, CycleMonthly, CycleQuarterly, CycleYearly:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Customer) validate() error {
|
||||
c.Name, c.Group, c.Note = strings.TrimSpace(c.Name), strings.TrimSpace(c.Group), strings.TrimSpace(c.Note)
|
||||
switch {
|
||||
case !policy.ValidID(c.ID):
|
||||
return invalid("Kunden-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", c.ID)
|
||||
case c.Name == "":
|
||||
return invalid("Name fehlt")
|
||||
case !validCycle(c.Cycle):
|
||||
return invalid("Zahlweise %q unbekannt", c.Cycle)
|
||||
case c.FlatRate != nil && *c.FlatRate < 0:
|
||||
return invalid("Pauschale negativ")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const customerColumns = `id, name, grp, note, cycle, flat_rate, auto`
|
||||
|
||||
func scanCustomer(row interface{ Scan(...any) error }) (Customer, error) {
|
||||
var c Customer
|
||||
var flat sql.NullInt64
|
||||
err := row.Scan(&c.ID, &c.Name, &c.Group, &c.Note, &c.Cycle, &flat, &c.Auto)
|
||||
if flat.Valid {
|
||||
c.FlatRate = &flat.Int64
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
func flatArg(f *int64) any {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return *f
|
||||
}
|
||||
|
||||
// AddCustomer legt einen Kunden von Hand an.
|
||||
func (l *Ledger) AddCustomer(ctx context.Context, c Customer) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM customers WHERE id = ?`, c.ID); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return fmt.Errorf("Kunde %s: %w", c.ID, store.ErrExists)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO customers (`+customerColumns+`) VALUES (?, ?, ?, ?, ?, ?, 0)`,
|
||||
c.ID, c.Name, c.Group, c.Note, c.Cycle, flatArg(c.FlatRate)); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateCustomer übernimmt alle Felder; danach gilt der Kunde als von Hand
|
||||
// gepflegt (Auto = false).
|
||||
func (l *Ledger) UpdateCustomer(ctx context.Context, c Customer) error {
|
||||
if err := c.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := l.db.ExecContext(ctx, `UPDATE customers SET name = ?, grp = ?, note = ?, cycle = ?, flat_rate = ?, auto = 0
|
||||
WHERE id = ?`, c.Name, c.Group, c.Note, c.Cycle, flatArg(c.FlatRate), c.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Kunde %s: %w", c.ID, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCustomer liefert einen Kunden.
|
||||
func (l *Ledger) GetCustomer(ctx context.Context, id string) (Customer, error) {
|
||||
c, err := scanCustomer(l.db.QueryRowContext(ctx, `SELECT `+customerColumns+` FROM customers WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Customer{}, fmt.Errorf("Kunde %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// ListCustomers liefert alle Kunden, sortiert nach ID.
|
||||
func (l *Ledger) ListCustomers(ctx context.Context) ([]Customer, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT `+customerColumns+` FROM customers ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Customer
|
||||
for rows.Next() {
|
||||
c, err := scanCustomer(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteCustomer löscht einen Kunden samt seinen Zuordnungen.
|
||||
func (l *Ledger) DeleteCustomer(ctx context.Context, id string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM customers WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Kunde %s: %w", id, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureCustomers legt für Mandanten eines Berichts, die keinem Kunden
|
||||
// zugeordnet sind, automatisch einen Kunden mit der Mandanten-ID an.
|
||||
// Automatisch angelegte Kunden übernehmen Namensänderungen des Mandanten;
|
||||
// von Hand gepflegte bleiben unberührt. Ungültige IDs werden übersprungen.
|
||||
func (l *Ledger) EnsureCustomers(ctx context.Context, payeeID string, tenants []TenantRef) error {
|
||||
tx, err := l.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, t := range tenants {
|
||||
if !policy.ValidID(t.ID) {
|
||||
continue
|
||||
}
|
||||
mapped, err := rowExists(ctx, tx, `SELECT 1 FROM customer_map WHERE tenant = ? AND (payee_id = ? OR payee_id = '')`, t.ID, payeeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mapped {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(t.Name)
|
||||
if name == "" {
|
||||
name = t.ID
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO customers (id, name, grp, note, cycle, flat_rate, auto)
|
||||
VALUES (?, ?, '', '', '', NULL, 1)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name WHERE customers.auto = 1`, t.ID, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// MapTenant ordnet den Mandanten tenant (auf payee; "" = auf allen Payees)
|
||||
// dem Kunden customer zu; eine bestehende Zuordnung wird ersetzt.
|
||||
func (l *Ledger) MapTenant(ctx context.Context, payee, tenant, customer string) error {
|
||||
if !policy.ValidID(tenant) {
|
||||
return invalid("Mandanten-ID %q ungültig", tenant)
|
||||
}
|
||||
if _, err := l.GetCustomer(ctx, customer); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := l.db.ExecContext(ctx, `INSERT INTO customer_map (payee_id, tenant, customer_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(payee_id, tenant) DO UPDATE SET customer_id = excluded.customer_id`, payee, tenant, customer)
|
||||
return err
|
||||
}
|
||||
|
||||
// UnmapTenant entfernt eine Zuordnung.
|
||||
func (l *Ledger) UnmapTenant(ctx context.Context, payee, tenant string) error {
|
||||
res, err := l.db.ExecContext(ctx, `DELETE FROM customer_map WHERE payee_id = ? AND tenant = ?`, payee, tenant)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n == 0 {
|
||||
return fmt.Errorf("Zuordnung %s/%s: %w", payee, tenant, store.ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListMappings liefert alle Zuordnungen, sortiert nach Mandant und Payee.
|
||||
func (l *Ledger) ListMappings(ctx context.Context) ([]Mapping, error) {
|
||||
rows, err := l.db.QueryContext(ctx, `SELECT payee_id, tenant, customer_id FROM customer_map ORDER BY tenant, payee_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Mapping
|
||||
for rows.Next() {
|
||||
var m Mapping
|
||||
if err := rows.Scan(&m.Payee, &m.Tenant, &m.Customer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CustomerResolver löst (Payee, Mandant) zum Kunden auf: Zuordnung für den
|
||||
// Payee > Zuordnung für alle Payees > Kunde mit der Mandanten-ID > "".
|
||||
type CustomerResolver struct {
|
||||
byPayee map[[2]string]string
|
||||
byTenant map[string]string
|
||||
customers map[string]bool
|
||||
}
|
||||
|
||||
// CustomerResolver liest Kunden und Zuordnungen für viele Auflösungen.
|
||||
func (l *Ledger) CustomerResolver(ctx context.Context) (*CustomerResolver, error) {
|
||||
r := &CustomerResolver{byPayee: map[[2]string]string{}, byTenant: map[string]string{}, customers: map[string]bool{}}
|
||||
maps, err := l.ListMappings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range maps {
|
||||
if m.Payee == "" {
|
||||
r.byTenant[m.Tenant] = m.Customer
|
||||
} else {
|
||||
r.byPayee[[2]string{m.Payee, m.Tenant}] = m.Customer
|
||||
}
|
||||
}
|
||||
cs, err := l.ListCustomers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range cs {
|
||||
r.customers[c.ID] = true
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Resolve liefert die Kunden-ID ("" = keinem Kunden zugeordnet).
|
||||
func (r *CustomerResolver) Resolve(payee, tenant string) string {
|
||||
if c, ok := r.byPayee[[2]string{payee, tenant}]; ok {
|
||||
return c
|
||||
}
|
||||
if c, ok := r.byTenant[tenant]; ok {
|
||||
return c
|
||||
}
|
||||
if r.customers[tenant] {
|
||||
return tenant
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in new issue
Block a user