Files

320 lines
9.2 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 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
}