From f78274ed845304bfa9ab5049c920f07959ef0786 Mon Sep 17 00:00:00 2001 From: jbergner Date: Sat, 30 May 2026 15:33:35 +0200 Subject: [PATCH] Dasdhboard und Abrechnung --- .env.example | 9 +++ README.md | 93 +++++++++++++++++++++++++ config.go | 6 ++ db.go | 34 +++++++++ discord.go | 18 +++-- main.go | 8 +++ trading_discord.go | 66 ++++++++++++++++++ web_dashboard.go | 170 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 web_dashboard.go diff --git a/.env.example b/.env.example index d1eca6e..eedb477 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,12 @@ TRADING_SHARE_TRADE=10 TRADING_SHARE_HAULING=5 TRADING_SHARE_EVENT=0 TRADING_SHARE_INTERN=0 + +# Trading settlement DMs tell members where to transfer their org share. +TRADING_PAYOUT_RECIPIENT=B1tK1ll3r + +# Optional internal web dashboard. Leave DASHBOARD_ADDR empty to disable. +# Example local: 127.0.0.1:8080. Example Docker: 0.0.0.0:8080 +DASHBOARD_ADDR=0.0.0.0:8080 +# Optional shared token. Dashboard URL then needs ?token=YourSecretToken or Authorization: Bearer YourSecretToken +DASHBOARD_TOKEN=ChangeMePlease \ No newline at end of file diff --git a/README.md b/README.md index b78d27c..5508102 100644 --- a/README.md +++ b/README.md @@ -248,3 +248,96 @@ Bei Aufträgen und Lagerbeständen sind jetzt diese Einheiten auswählbar: - `SCU` - `cSCU` - `Stück` + +## Trading-Modul + +Das Trading-Modul ersetzt eine Excel-Erfassung für monatliche Trading-/Hauling-Runs. + +### Neue Konfiguration + +```env +DISCORD_TRADING_ROLE_IDS=456789012345678901 +DISCORD_TRADING_REPORT_CHANNEL_ID=TradingReportChannelIDOptional +TRADING_SHIPS=Caterpillar,C2 Hercules,Freelancer MAX,Hull A,Hull C,Taurus +TRADING_COMMODITIES=Gold,Beryl,Diamond,Laranite,Agricium,Quantanium,Processed Food,Medical Supplies +TRADING_SHARE_TRADE=10 +TRADING_SHARE_HAULING=5 +TRADING_SHARE_EVENT=0 +TRADING_SHARE_INTERN=0 +``` + +Die Prozentwerte können als `10` oder `0.10` angegeben werden. Beide Varianten bedeuten 10%. + +### Neue Commands + +```text +/trading_add +/trading_liste +/trading_delete +/trading_report +/trading_abrechnung +``` + +`/trading_add` darf von Mitgliedern mit `DISCORD_TRADING_ROLE_IDS` sowie Team/Admin genutzt werden. Schiff und Ware werden aus den Umgebungsvariablen per Autocomplete vorgeschlagen. + +Gewinnberechnung: + +```text +Gewinn = (Verkaufswert - Einkaufswert - Kosten) × Wiederholungen +Orga-Anteil = max(0, Gewinn × Prozentwert des Zwecks) +Transportierte SCU = SCU × Wiederholungen +Kosten gesamt = Kosten × Wiederholungen +``` + +`/trading_report monat:<1-12> jahr:` darf nur von Admins genutzt werden und postet eine hübsche Embed-Auswertung in `DISCORD_TRADING_REPORT_CHANNEL_ID`. + +`/trading_delete id:` ist ein Admin-Kommando. Einträge werden soft-deleted, damit sie nicht mehr in Listen/Reports auftauchen, aber nachvollziehbar bleiben. + +## Trading-Abrechnung per DM + +Admins können am Monatsende jedem Mitglied mit positivem Orga-Anteil automatisch eine private Abrechnung senden: + +```text +/trading_abrechnung monat:<1-12> jahr: +``` + +Der Bot fasst pro Nutzer zusammen: + +- Anzahl Runs +- Gewinn +- transportierte SCU +- zu überweisender Orga-Anteil +- Empfänger aus `TRADING_PAYOUT_RECIPIENT` + +Neue Konfiguration: + +```env +TRADING_PAYOUT_RECIPIENT=Jan / Orga-Kasse +``` + +Mitglieder ohne Orga-Anteil werden übersprungen. Falls ein Mitglied keine DMs zulässt, meldet der Bot diese fehlgeschlagene Zustellung in der Command-Antwort und schreibt Details ins Log. + +## Internes Web-Dashboard + +Optional kann ein kleines Web-Dashboard gestartet werden. Es zeigt: + +- Trading-Monatswerte +- Abrechnung pro Nutzer +- aktive Aufträge +- archivierte Aufträge +- Lagerbestände + +Aktivierung in `.env`: + +```env +DASHBOARD_ADDR=127.0.0.1:8080 +DASHBOARD_TOKEN=ChangeMePlease +``` + +Aufruf: + +```text +http://127.0.0.1:8080/?token=ChangeMePlease +``` + +Für Docker sollte `DASHBOARD_ADDR=0.0.0.0:8080` gesetzt werden. Das Dashboard ist als internes Admin-/Team-Werkzeug gedacht. Stelle es nicht ungeschützt öffentlich ins Internet. Nutze mindestens `DASHBOARD_TOKEN`; für produktiven Betrieb idealerweise zusätzlich Reverse Proxy mit HTTPS und Zugriffsbeschränkung. diff --git a/config.go b/config.go index 03573ec..d85f94a 100644 --- a/config.go +++ b/config.go @@ -24,6 +24,9 @@ type Config struct { TradingOrgSharePercent map[string]float64 DatabasePath string ThreadDeleteAfter time.Duration + DashboardAddr string + DashboardToken string + TradingPayoutRecipient string } func loadConfig() Config { @@ -43,6 +46,9 @@ func loadConfig() Config { TradingOrgSharePercent: tradingShareConfig(), DatabasePath: getenv("DATABASE_PATH", "orders.db"), ThreadDeleteAfter: durationHoursEnv("THREAD_DELETE_AFTER_HOURS", 72), + DashboardAddr: getenv("DASHBOARD_ADDR", ""), + DashboardToken: os.Getenv("DASHBOARD_TOKEN"), + TradingPayoutRecipient: getenv("TRADING_PAYOUT_RECIPIENT", "Orga-Kasse"), } if cfg.Token == "" || cfg.AppID == "" || cfg.InternalOrderChannelID == "" { log.Fatal("Bitte DISCORD_TOKEN, DISCORD_APP_ID und DISCORD_INTERNAL_ORDER_CHANNEL_ID setzen") diff --git a/db.go b/db.go index b1876d2..03e2e3c 100644 --- a/db.go +++ b/db.go @@ -813,3 +813,37 @@ func sortTradingSummary(sum *TradingSummary) { } sort.Slice(sum.PurposeSummary, func(i, j int) bool { return sum.PurposeSummary[i].Purpose < sum.PurposeSummary[j].Purpose }) } + +func (s *Store) SummarizeTradingByUser(month, year int) ([]TradingTraderSummary, error) { + runs, err := s.ListTradingRuns(TradingListFilter{Month: month, Year: year, Limit: 100000}) + if err != nil { + return nil, err + } + traderMap := map[string]*TradingTraderSummary{} + for _, r := range runs { + key := r.UserID + if key == "" { + key = r.UserName + } + tr := traderMap[key] + if tr == nil { + tr = &TradingTraderSummary{UserID: r.UserID, UserName: r.UserName} + traderMap[key] = tr + } + tr.Profit += tradingProfit(r) + tr.OrgShare += tradingOrgShare(r) + tr.SCU += r.SCU * float64(r.Repetitions) + tr.Runs++ + } + out := make([]TradingTraderSummary, 0, len(traderMap)) + for _, tr := range traderMap { + out = append(out, *tr) + } + sort.Slice(out, func(i, j int) bool { + if out[i].OrgShare == out[j].OrgShare { + return strings.ToLower(out[i].UserName) < strings.ToLower(out[j].UserName) + } + return out[i].OrgShare > out[j].OrgShare + }) + return out, nil +} diff --git a/discord.go b/discord.go index 5d2aa60..81dd1b5 100644 --- a/discord.go +++ b/discord.go @@ -185,6 +185,8 @@ func (b *Bot) handleCommand(s *discordgo.Session, i *discordgo.InteractionCreate b.cmdTradingDelete(s, i) case "trading_report": b.cmdTradingReport(s, i) + case "trading_abrechnung": + b.cmdTradingSettlement(s, i) } } @@ -758,15 +760,19 @@ func (b *Bot) notifyParticipants(o *Order, body string) { } } func (b *Bot) dm(userID, body string) { - ch, err := b.dg.UserChannelCreate(userID) - if err != nil { - log.Println("dm channel failed:", err) - return - } - if _, err := b.dg.ChannelMessageSend(ch.ID, body); err != nil { + if err := b.dmErr(userID, body); err != nil { log.Println("dm send failed:", err) } } + +func (b *Bot) dmErr(userID, body string) error { + ch, err := b.dg.UserChannelCreate(userID) + if err != nil { + return err + } + _, err = b.dg.ChannelMessageSend(ch.ID, body) + return err +} func (b *Bot) audit(body string) { log.Println("AUDIT", body) if b.cfg.AuditChannelID != "" { diff --git a/main.go b/main.go index 3cd47e4..65e5c61 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,12 @@ package main import ( + "context" "log" "os" "os/signal" "syscall" + "time" ) func main() { @@ -21,9 +23,15 @@ func main() { if err := bot.Start(); err != nil { log.Fatal(err) } + dashboard := StartDashboard(cfg, store) log.Println("Bot läuft. Beenden mit Ctrl+C.") stop := make(chan os.Signal, 1) signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM) <-stop bot.Stop() + if dashboard != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = dashboard.Shutdown(ctx) + } } diff --git a/trading_discord.go b/trading_discord.go index aa63ef4..eb2e3de 100644 --- a/trading_discord.go +++ b/trading_discord.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "log" "math" "sort" "strings" @@ -42,6 +43,10 @@ func tradingCommands() []*discordgo.ApplicationCommand { {Name: "monat", Description: "Monat 1-12", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(1), MaxValue: float64(12)}, {Name: "jahr", Description: "Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(2000), MaxValue: float64(2200)}, }}, + {Name: "trading_abrechnung", Description: "Admin: sendet Mitgliedern ihre monatliche Trading-Abrechnung per DM", Options: []*discordgo.ApplicationCommandOption{ + {Name: "monat", Description: "Monat 1-12", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(1), MaxValue: float64(12)}, + {Name: "jahr", Description: "Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(2000), MaxValue: float64(2200)}, + }}, } } @@ -332,3 +337,64 @@ func optUser(i *discordgo.InteractionCreate, s *discordgo.Session, name string) } func roundMoney(v float64) int64 { return int64(math.Round(v)) } + +func (b *Bot) cmdTradingSettlement(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + b.replyEphemeral(s, i, "Nur Admins dürfen Trading-Abrechnungen versenden.") + return + } + month := int(optInt(i, "monat")) + year := int(optInt(i, "jahr")) + users, err := b.store.SummarizeTradingByUser(month, year) + if err != nil { + b.replyEphemeral(s, i, "Abrechnung konnte nicht erstellt werden: "+err.Error()) + return + } + if len(users) == 0 { + b.replyEphemeral(s, i, "Für diesen Monat gibt es keine Trading-Einträge.") + return + } + + sent, skipped, failed := 0, 0, 0 + var failedNames []string + for _, u := range users { + if u.OrgShare <= 0 { + skipped++ + continue + } + if strings.TrimSpace(u.UserID) == "" { + failed++ + failedNames = append(failedNames, u.UserName) + continue + } + body := b.tradingSettlementText(u, month, year) + if err := b.dmErr(u.UserID, body); err != nil { + failed++ + failedNames = append(failedNames, u.UserName) + log.Println("trading settlement dm failed:", u.UserName, err) + continue + } + sent++ + } + + msg := fmt.Sprintf("Abrechnung %02d/%d versendet. Erfolgreich: %d, übersprungen ohne Orga-Anteil: %d, fehlgeschlagen: %d.", month, year, sent, skipped, failed) + if len(failedNames) > 0 { + msg += "\nFehlgeschlagen: " + strings.Join(failedNames, ", ") + } + b.audit(fmt.Sprintf("Trading-Abrechnung %02d/%d durch %s versendet: %d erfolgreich, %d ohne Anteil, %d fehlgeschlagen", month, year, userString(i), sent, skipped, failed)) + b.replyEphemeral(s, i, msg) +} + +func (b *Bot) tradingSettlementText(u TradingTraderSummary, month, year int) string { + return fmt.Sprintf( + "Abrechnung Trading %02d/%d\n\nHallo %s,\n\nfür deine erfassten Trading-Runs ergibt sich folgende Abrechnung:\n\nRuns: %d\nGewinn: %s\nTransportierte SCU: %s\nDein Orga-Anteil: %s\n\nBitte überweise den Orga-Anteil an: %s\n\nDanke dir!", + month, + year, + u.UserName, + u.Runs, + formatAUEC(u.Profit), + formatAmount(u.SCU), + formatAUEC(u.OrgShare), + b.cfg.TradingPayoutRecipient, + ) +} diff --git a/web_dashboard.go b/web_dashboard.go new file mode 100644 index 0000000..345bf2c --- /dev/null +++ b/web_dashboard.go @@ -0,0 +1,170 @@ +package main + +import ( + "fmt" + "html/template" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +type dashboardData struct { + Now time.Time + Month int + Year int + Orders []Order + ArchivedOrders []Order + Inventory []InventoryItem + TradingSummary *TradingSummary + TradingUsers []TradingTraderSummary + Token string + TradingPayee string + Error string +} + +func StartDashboard(cfg Config, store *Store) *http.Server { + if strings.TrimSpace(cfg.DashboardAddr) == "" { + return nil + } + mux := http.NewServeMux() + h := &dashboardHandler{cfg: cfg, store: store} + mux.HandleFunc("/", h.index) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) }) + srv := &http.Server{Addr: cfg.DashboardAddr, Handler: mux} + go func() { + log.Println("Web-Dashboard läuft auf", cfg.DashboardAddr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Println("dashboard failed:", err) + } + }() + return srv +} + +type dashboardHandler struct { + cfg Config + store *Store +} + +func (h *dashboardHandler) authorized(r *http.Request) bool { + if strings.TrimSpace(h.cfg.DashboardToken) == "" { + return true + } + token := r.URL.Query().Get("token") + if token == "" { + auth := r.Header.Get("Authorization") + token = strings.TrimPrefix(auth, "Bearer ") + } + return token == h.cfg.DashboardToken +} + +func (h *dashboardHandler) index(w http.ResponseWriter, r *http.Request) { + if !h.authorized(r) { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + now := time.Now() + month := intFromQuery(r, "monat", int(now.Month())) + year := intFromQuery(r, "jahr", now.Year()) + data := dashboardData{Now: now, Month: month, Year: year, Token: r.URL.Query().Get("token"), TradingPayee: h.cfg.TradingPayoutRecipient} + var errs []string + if orders, err := h.store.ListOrders("", 25); err == nil { + data.Orders = orders + } else { + errs = append(errs, "Aufträge: "+err.Error()) + } + if archived, err := h.store.ListArchivedOrders("", 10); err == nil { + data.ArchivedOrders = archived + } else { + errs = append(errs, "Archiv: "+err.Error()) + } + if inv, err := h.store.ListInventory("", 100); err == nil { + data.Inventory = inv + } else { + errs = append(errs, "Lager: "+err.Error()) + } + if sum, err := h.store.SummarizeTradingRuns(month, year); err == nil { + data.TradingSummary = sum + } else { + errs = append(errs, "Trading: "+err.Error()) + } + if users, err := h.store.SummarizeTradingByUser(month, year); err == nil { + data.TradingUsers = users + } else { + errs = append(errs, "Abrechnung: "+err.Error()) + } + data.Error = strings.Join(errs, " | ") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := dashboardTemplate.Execute(w, data); err != nil { + log.Println("dashboard render failed:", err) + } +} + +func intFromQuery(r *http.Request, key string, fallback int) int { + v := strings.TrimSpace(r.URL.Query().Get(key)) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} + +var dashboardTemplate = template.Must(template.New("dashboard").Funcs(template.FuncMap{ + "status": statusLabel, + "purpose": tradingPurposeLabel, + "amount": formatAmount, + "auec": formatAUEC, + "profit": tradingProfit, + "org": tradingOrgShare, + "totalSCU": func(r TradingRun) float64 { return r.SCU * float64(r.Repetitions) }, + "location": func(v string) string { + if strings.TrimSpace(v) == "" { + return "-" + } + return v + }, + "monthName": func(m int) string { return time.Month(m).String() }, + "formatPercent": func(v float64) string { return fmt.Sprintf("%.1f%%", v*100) }, +}).Parse(` + + + + +SF Trading Dashboard + + + +

SF Trading Dashboard

Stand: {{.Now.Format "02.01.2006 15:04"}}
+
+{{if .Error}}
Hinweis: {{.Error}}
{{end}} +
+
+{{if .Token}}{{end}} + + + +Filter betrifft Trading-Auswertung und Abrechnung. +
+
+{{with .TradingSummary}} +
+
Trading-Einträge
{{.Count}}
+
Gewinn gesamt
{{auec .GrossProfit}}
+
Orga-Anteil
{{auec .OrgShare}}
+
Transportierte SCU
{{amount .TotalSCU}}
+
Kosten gesamt
{{auec .TotalCosts}}
+
+{{end}} +

Abrechnung {{printf "%02d/%d" .Month .Year}}

Empfänger: {{.TradingPayee}}
{{range .TradingUsers}}{{else}}{{end}}
NutzerRunsGewinnSCUOrga-Anteil
{{.UserName}}{{.Runs}}{{auec .Profit}}{{amount .SCU}}{{auec .OrgShare}}
Keine Daten
+

Aktive Aufträge

{{range .Orders}}{{else}}{{end}}
IDWareMengeFristStatusEinreicher
#{{.ID}}{{.Commodity}}{{amount .QuantityAmount}} {{.QuantityUnit}}{{.Deadline}}{{status .Status}}{{.SubmitterName}}
Keine aktiven Aufträge
+

Lager

{{range .Inventory}}{{else}}{{end}}
IDWareQualitätMengeOrtNotiz
#{{.ID}}{{.Name}}{{.Quality}}{{amount .QuantityAmount}} {{.QuantityUnit}}{{location .Location}}{{.Note}}
Kein Lagerbestand
+

Archivierte Aufträge

{{range .ArchivedOrders}}{{else}}{{end}}
IDWareStatusAktualisiert
#{{.ID}}{{.Commodity}}{{status .Status}}{{.UpdatedAt.Format "02.01.2006 15:04"}}
Keine archivierten Aufträge
+
+ +`))