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 }