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,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 ¢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
|
||||
}
|
||||
Reference in new issue
Block a user