320 lines
9.2 KiB
Go
320 lines
9.2 KiB
Go
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 ¢s, 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
|
||
}
|