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

+319
View File
@@ -0,0 +1,319 @@
package cli
import (
"errors"
"fmt"
"os"
"slices"
"strings"
"text/tabwriter"
"time"
"sultan/pkg/accountant"
"sultan/pkg/ledger"
)
func classHelp() string {
return "Klasse: included (in der Pauschale), billable (Zusatz-Service), free (Give-away), internal"
}
func parseOptionalPrice(raw string, set bool) (*int64, error) {
if !set {
return nil, nil
}
cents, err := accountant.ParseMoney(raw)
if err != nil {
return nil, usageError{err}
}
return &cents, nil
}
func productAdd(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product add", env)
id := fs.String("id", "", "Produkt-ID (a–z, 0–9, - und _), z. B. olat")
name := fs.String("name", "", "Name")
class := fs.String("class", "", classHelp())
price := fs.String("price", "", "Preis je Monat in Euro (optional, nicht buchhaltungsrelevant), z. B. 49,00")
if err := parse(fs, args, "id", "name", "class"); err != nil {
return err
}
p, err := parseOptionalPrice(*price, *price != "")
if err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.AddProduct(env.ctx, ledger.Product{ID: *id, Name: *name, Class: *class, Price: p}); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Produkt %s angelegt\n", *id)
return nil
}
// productSet: neue Version ab einem Monat; ohne --class bleibt die Klasse,
// ohne --price/--no-price der Preis der bis dahin gültigen Version.
func productSet(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product set", env)
id := fs.String("id", "", "Produkt-ID")
from := fs.String("from", "", "gültig ab Monat JJJJ-MM (z. B. 2027-03)")
class := fs.String("class", "", classHelp()+" (Default: unverändert)")
price := fs.String("price", "", "Preis je Monat in Euro (Default: unverändert)")
noPrice := fs.Bool("no-price", false, "ab dann ohne Preis")
if err := parse(fs, args, "id", "from"); err != nil {
return err
}
if *price != "" && *noPrice {
return usageError{errors.New("--price und --no-price schließen sich aus")}
}
if !ledger.ValidMonth(*from) {
return usageError{fmt.Errorf("--from %q ungültig (JJJJ-MM)", *from)}
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
cat, err := l.Catalog(env.ctx)
if err != nil {
return err
}
cur, ok := cat.At(*id, "", *from)
if !ok {
return fmt.Errorf("Produkt %s nicht gefunden", *id)
}
if *class != "" {
cur.Class = *class
}
switch {
case *noPrice:
cur.Price = nil
case *price != "":
if cur.Price, err = parseOptionalPrice(*price, true); err != nil {
return err
}
}
if err := l.SetProductVersion(env.ctx, *id, *from, cur.Class, cur.Price); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Produkt %s ab %s: %s %s\n", *id, *from, cur.Class, priceText(cur.Price))
return nil
}
func priceText(p *int64) string {
if p == nil {
return "ohne Preis"
}
return accountant.FormatMoney(*p)
}
func productPrice(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product price", env)
id := fs.String("id", "", "Produkt-ID")
customer := fs.String("customer", "", "Kunden-ID")
price := fs.String("price", "", "Kundenpreis je Monat in Euro, z. B. 39,00")
from := fs.String("from", time.Now().Format("2006-01"), "gültig ab Monat JJJJ-MM")
if err := parse(fs, args, "id", "customer", "price"); err != nil {
return err
}
p, err := parseOptionalPrice(*price, true)
if err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.SetCustomerPrice(env.ctx, *id, *customer, *from, *p); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Kundenpreis %s für %s ab %s: %s\n", *id, *customer, *from, accountant.FormatMoney(*p))
return nil
}
func productList(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product list", env)
if err := parse(fs, args); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
products, err := l.ListProducts(env.ctx)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tCLASS\tPRICE\tVERSIONS")
for _, p := range products {
var versions []string
for _, v := range p.Versions[1:] {
versions = append(versions, fmt.Sprintf("ab %s: %s %s", v.From, v.Class, priceText(v.Price)))
}
price := "-"
if p.Price != nil {
price = accountant.FormatMoney(*p.Price)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", p.ID, p.Name, p.Class, price, strings.Join(versions, "; "))
}
return tw.Flush()
}
func productDelete(env tokenEnv, args []string) error {
fs, dir := accountantFlags("product delete", env)
id := fs.String("id", "", "Produkt-ID")
if err := parse(fs, args, "id"); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
if err := l.DeleteProduct(env.ctx, *id); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Produkt %s gelöscht\n", *id)
return nil
}
// assign: Zuordnung eines Postens, gefunden über --host oder direkt über
// --tenant/--router.
func assign(env tokenEnv, args []string) error {
fs, dir := accountantFlags("assign", env)
host := fs.String("host", "", "Host einer Route, z. B. olat.kunde.de")
tenant := fs.String("tenant", "", "alternativ: Mandant …")
router := fs.String("router", "", "… und Router")
protocol := fs.String("protocol", "http", "Protokoll bei --tenant/--router (http oder tcp)")
product := fs.String("product", "", "Produkt aus dem Katalog (Vorrang vor dem Label sultan.accountant.billing)")
class := fs.String("class", "", classHelp()+" (Vorrang vor dem Produkt)")
start := fs.String("start", "", "Abrechnungsbeginn JJJJ-MM (Default: Monat des ersten Auftauchens)")
clear := fs.Bool("clear", false, "Zuordnung entfernen")
if err := parse(fs, args); err != nil {
return err
}
if (*host == "") == (*tenant == "" || *router == "") {
return usageError{errors.New("entweder --host oder --tenant mit --router angeben")}
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
key := accountant.ItemKey{Tenant: *tenant, Protocol: *protocol, Router: *router}
if *host != "" {
keys, err := accountant.ItemsForHost(env.ctx, l, *host)
if err != nil {
return err
}
switch len(keys) {
case 0:
return fmt.Errorf("keine Route mit Host %s bekannt", *host)
case 1:
key = keys[0]
default:
var names []string
for _, k := range keys {
names = append(names, k.Tenant+"/"+k.Protocol+"/"+k.Router)
}
return fmt.Errorf("Host %s gehört zu mehreren Posten (%s) – --tenant und --router angeben", *host, strings.Join(names, ", "))
}
}
if *clear {
if err := l.DeleteAssignment(env.ctx, key.Tenant, key.Protocol, key.Router); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Zuordnung von %s/%s/%s entfernt\n", key.Tenant, key.Protocol, key.Router)
return nil
}
a := ledger.Assignment{Tenant: key.Tenant, Protocol: key.Protocol, Router: key.Router, Product: *product, Class: *class, Start: *start}
if err := l.SetAssignment(env.ctx, a); err != nil {
return err
}
fmt.Fprintf(env.stdout, "Posten %s/%s/%s zugeordnet\n", key.Tenant, key.Protocol, key.Router)
return nil
}
func assignments(env tokenEnv, args []string) error {
fs, dir := accountantFlags("assignments", env)
if err := parse(fs, args); err != nil {
return err
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
list, err := l.ListAssignments(env.ctx)
if err != nil {
return err
}
tw := tabwriter.NewWriter(env.stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "TENANT\tROUTER\tHOSTS\tPRODUCT\tCLASS\tSTART")
for _, a := range list {
var hosts []string
spans, _ := l.RouteHistory(env.ctx, ledger.HistoryFilter{Tenant: a.Tenant})
for _, s := range spans {
if s.Protocol == a.Protocol && s.Router == a.Router {
for _, h := range s.Hosts {
if !slices.Contains(hosts, h) {
hosts = append(hosts, h)
}
}
}
}
fmt.Fprintf(tw, "%s\t%s/%s\t%s\t%s\t%s\t%s\n", a.Tenant, a.Protocol, a.Router, cmpOrDash(strings.Join(hosts, ",")),
cmpOrDash(a.Product), cmpOrDash(a.Class), cmpOrDash(a.Start))
}
return tw.Flush()
}
func billing(env tokenEnv, args []string) error {
fs, dir := accountantFlags("billing", env)
month := fs.String("month", "", "Abrechnungsmonat JJJJ-MM")
out := fs.String("out", "", "CSV in diese Datei schreiben (Default: stdout)")
if err := parse(fs, args, "month"); err != nil {
return err
}
if !ledger.ValidMonth(*month) {
return usageError{fmt.Errorf("--month %q ungültig (JJJJ-MM)", *month)}
}
l, err := openLedger(env, *dir)
if err != nil {
return err
}
defer l.Close()
lines, err := accountant.Billing(env.ctx, l, *month)
if err != nil {
return err
}
w := env.stdout
if *out != "" {
f, err := os.Create(*out)
if err != nil {
return err
}
defer f.Close()
w = f
}
if err := accountant.WriteBillCSV(w, lines); err != nil {
return err
}
var open int
for _, ln := range lines {
if (ln.Class == "" && ln.Item != accountant.ItemFlatRate) || ln.Customer == "" {
open++
}
}
fmt.Fprintf(env.stderr, "%d Zeilen für %s", len(lines), *month)
if open > 0 {
fmt.Fprintf(env.stderr, ", davon %d ohne Zuordnung bzw. Kunde (Spalte Hinweis)", open)
}
fmt.Fprintln(env.stderr)
return nil
}