Dasdhboard und Abrechnung
All checks were successful
release-tag / release-image (push) Successful in 2m18s

This commit is contained in:
2026-05-30 15:33:35 +02:00
parent 9b02905000
commit f78274ed84
8 changed files with 398 additions and 6 deletions

View File

@@ -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

View File

@@ -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:<YYYY>` darf nur von Admins genutzt werden und postet eine hübsche Embed-Auswertung in `DISCORD_TRADING_REPORT_CHANNEL_ID`.
`/trading_delete id:<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:<YYYY>
```
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.

View File

@@ -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")

34
db.go
View File

@@ -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
}

View File

@@ -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 != "" {

View File

@@ -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)
}
}

View File

@@ -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,
)
}

170
web_dashboard.go Normal file
View File

@@ -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(`<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SF Trading Dashboard</title>
<style>
:root{--bg:#0f172a;--panel:#111827;--card:#1f2937;--text:#e5e7eb;--muted:#94a3b8;--accent:#38bdf8;--green:#22c55e;--red:#ef4444;--yellow:#f59e0b}*{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,Segoe UI,sans-serif;background:var(--bg);color:var(--text)}header{padding:24px 28px;border-bottom:1px solid #334155;background:#020617;position:sticky;top:0;z-index:1}h1{margin:0;font-size:24px}main{padding:24px;display:grid;gap:20px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px}.card{background:var(--card);border:1px solid #334155;border-radius:16px;padding:16px;box-shadow:0 10px 24px rgba(0,0,0,.22)}.metric{font-size:28px;font-weight:700}.muted{color:var(--muted)}table{width:100%;border-collapse:collapse;font-size:14px}th,td{padding:9px 8px;border-bottom:1px solid #334155;text-align:left;vertical-align:top}th{color:#cbd5e1;background:#111827}.scroll{overflow:auto}.badge{display:inline-block;border-radius:999px;padding:2px 8px;background:#334155;color:#dbeafe;font-size:12px}.err{background:#7f1d1d;border-color:#fecaca}.filters{display:flex;gap:8px;align-items:end;flex-wrap:wrap}.filters input{background:#020617;color:var(--text);border:1px solid #475569;border-radius:10px;padding:8px}.filters button{background:var(--accent);border:0;border-radius:10px;padding:9px 14px;font-weight:700;color:#00111c}a{color:#7dd3fc}</style>
</head>
<body>
<header><h1>SF Trading Dashboard</h1><div class="muted">Stand: {{.Now.Format "02.01.2006 15:04"}}</div></header>
<main>
{{if .Error}}<section class="card err"><strong>Hinweis:</strong> {{.Error}}</section>{{end}}
<section class="card">
<form class="filters" method="get">
{{if .Token}}<input type="hidden" name="token" value="{{.Token}}">{{end}}
<label>Monat<br><input name="monat" type="number" min="1" max="12" value="{{.Month}}"></label>
<label>Jahr<br><input name="jahr" type="number" min="2000" max="2200" value="{{.Year}}"></label>
<button type="submit">Aktualisieren</button>
<span class="muted">Filter betrifft Trading-Auswertung und Abrechnung.</span>
</form>
</section>
{{with .TradingSummary}}
<section class="grid">
<div class="card"><div class="muted">Trading-Einträge</div><div class="metric">{{.Count}}</div></div>
<div class="card"><div class="muted">Gewinn gesamt</div><div class="metric">{{auec .GrossProfit}}</div></div>
<div class="card"><div class="muted">Orga-Anteil</div><div class="metric">{{auec .OrgShare}}</div></div>
<div class="card"><div class="muted">Transportierte SCU</div><div class="metric">{{amount .TotalSCU}}</div></div>
<div class="card"><div class="muted">Kosten gesamt</div><div class="metric">{{auec .TotalCosts}}</div></div>
</section>
{{end}}
<section class="card"><h2>Abrechnung {{printf "%02d/%d" .Month .Year}}</h2><div class="muted">Empfänger: {{.TradingPayee}}</div><div class="scroll"><table><thead><tr><th>Nutzer</th><th>Runs</th><th>Gewinn</th><th>SCU</th><th>Orga-Anteil</th></tr></thead><tbody>{{range .TradingUsers}}<tr><td>{{.UserName}}</td><td>{{.Runs}}</td><td>{{auec .Profit}}</td><td>{{amount .SCU}}</td><td><strong>{{auec .OrgShare}}</strong></td></tr>{{else}}<tr><td colspan="5" class="muted">Keine Daten</td></tr>{{end}}</tbody></table></div></section>
<section class="card"><h2>Aktive Aufträge</h2><div class="scroll"><table><thead><tr><th>ID</th><th>Ware</th><th>Menge</th><th>Frist</th><th>Status</th><th>Einreicher</th></tr></thead><tbody>{{range .Orders}}<tr><td>#{{.ID}}</td><td>{{.Commodity}}</td><td>{{amount .QuantityAmount}} {{.QuantityUnit}}</td><td>{{.Deadline}}</td><td><span class="badge">{{status .Status}}</span></td><td>{{.SubmitterName}}</td></tr>{{else}}<tr><td colspan="6" class="muted">Keine aktiven Aufträge</td></tr>{{end}}</tbody></table></div></section>
<section class="card"><h2>Lager</h2><div class="scroll"><table><thead><tr><th>ID</th><th>Ware</th><th>Qualität</th><th>Menge</th><th>Ort</th><th>Notiz</th></tr></thead><tbody>{{range .Inventory}}<tr><td>#{{.ID}}</td><td>{{.Name}}</td><td>{{.Quality}}</td><td>{{amount .QuantityAmount}} {{.QuantityUnit}}</td><td>{{location .Location}}</td><td>{{.Note}}</td></tr>{{else}}<tr><td colspan="6" class="muted">Kein Lagerbestand</td></tr>{{end}}</tbody></table></div></section>
<section class="card"><h2>Archivierte Aufträge</h2><div class="scroll"><table><thead><tr><th>ID</th><th>Ware</th><th>Status</th><th>Aktualisiert</th></tr></thead><tbody>{{range .ArchivedOrders}}<tr><td>#{{.ID}}</td><td>{{.Commodity}}</td><td><span class="badge">{{status .Status}}</span></td><td>{{.UpdatedAt.Format "02.01.2006 15:04"}}</td></tr>{{else}}<tr><td colspan="4" class="muted">Keine archivierten Aufträge</td></tr>{{end}}</tbody></table></div></section>
</main>
</body>
</html>`))