diff --git a/.env.example b/.env.example index 81d109a..d1eca6e 100644 --- a/.env.example +++ b/.env.example @@ -7,20 +7,39 @@ DISCORD_APP_ID=ApplicationIDHere DISCORD_GUILD_ID=OptionalTestGuildIDForFastCommandRegistration # Public channel for sanitized order overview. Leave empty if only the internal team should see order posts. -DISCORD_PUBLIC_ORDER_CHANNEL_ID=PublicOrderChannelIDOptional # 1509819718922670271 +DISCORD_PUBLIC_ORDER_CHANNEL_ID=PublicOrderChannelIDOptional # Private/internal channel for team operations. The bot posts buttons and warehouse checks here. -DISCORD_INTERNAL_ORDER_CHANNEL_ID=PrivateTeamOrderChannelIDRequired # 1509819679487688817 +DISCORD_INTERNAL_ORDER_CHANNEL_ID=PrivateTeamOrderChannelIDRequired # Optional audit/log channel for admin traceability. -DISCORD_AUDIT_CHANNEL_ID=AuditLogChannelIDOptional # 1509819633551802409 +DISCORD_AUDIT_CHANNEL_ID=AuditLogChannelIDOptional # Comma-separated role IDs. Admin role can modify inventory and force status changes. -DISCORD_ADMIN_ROLE_IDS=123456789012345678,234567890123456789 # 1509819305188003931 +DISCORD_ADMIN_ROLE_IDS=123456789012345678,234567890123456789 # Comma-separated role IDs. Team role can see internal orders, accept/decline, check warehouse. -DISCORD_TEAM_ROLE_IDS=345678901234567890 # 1509819434330755233 +DISCORD_TEAM_ROLE_IDS=345678901234567890 -DATABASE_PATH=/data/orders.db +DATABASE_PATH=orders.db +# After this many hours, internal threads for archived orders are deleted. +# Applies to: abgeschlossen, abgelehnt, abgebrochen. Set 0 to disable cleanup. THREAD_DELETE_AFTER_HOURS=72 + +# Trading module +# Members with one of these roles may create Trading-Runs. Team/Admin can also use the module. +DISCORD_TRADING_ROLE_IDS=456789012345678901 + +# Channel where /trading_report posts monthly results. Falls back to audit/internal channel when empty. +DISCORD_TRADING_REPORT_CHANNEL_ID=TradingReportChannelIDOptional + +# Comma-separated autocomplete lists. +TRADING_SHIPS=Caterpillar,C2 Hercules,Freelancer MAX,Hull A,Hull C,Taurus +TRADING_COMMODITIES=Gold,Beryl,Diamond,Laranite,Agricium,Quantanium,Processed Food,Medical Supplies + +# Organization share per purpose. Supports either 10 or 0.10 for 10%. +TRADING_SHARE_TRADE=10 +TRADING_SHARE_HAULING=5 +TRADING_SHARE_EVENT=0 +TRADING_SHARE_INTERN=0 diff --git a/.env.example_ b/.env.example_ new file mode 100644 index 0000000..81d109a --- /dev/null +++ b/.env.example_ @@ -0,0 +1,26 @@ +# Discord Developer Portal / Bot +DISCORD_TOKEN=BotTokenHere +DISCORD_APP_ID=ApplicationIDHere + +# Optional: set for fast slash-command registration during development. +# Empty = global commands, can take longer to propagate. +DISCORD_GUILD_ID=OptionalTestGuildIDForFastCommandRegistration + +# Public channel for sanitized order overview. Leave empty if only the internal team should see order posts. +DISCORD_PUBLIC_ORDER_CHANNEL_ID=PublicOrderChannelIDOptional # 1509819718922670271 + +# Private/internal channel for team operations. The bot posts buttons and warehouse checks here. +DISCORD_INTERNAL_ORDER_CHANNEL_ID=PrivateTeamOrderChannelIDRequired # 1509819679487688817 + +# Optional audit/log channel for admin traceability. +DISCORD_AUDIT_CHANNEL_ID=AuditLogChannelIDOptional # 1509819633551802409 + +# Comma-separated role IDs. Admin role can modify inventory and force status changes. +DISCORD_ADMIN_ROLE_IDS=123456789012345678,234567890123456789 # 1509819305188003931 + +# Comma-separated role IDs. Team role can see internal orders, accept/decline, check warehouse. +DISCORD_TEAM_ROLE_IDS=345678901234567890 # 1509819434330755233 + +DATABASE_PATH=/data/orders.db + +THREAD_DELETE_AFTER_HOURS=72 diff --git a/config.go b/config.go index 145a688..03573ec 100644 --- a/config.go +++ b/config.go @@ -17,6 +17,11 @@ type Config struct { AuditChannelID string AdminRoleIDs []string TeamRoleIDs []string + TradingRoleIDs []string + TradingReportChannelID string + TradingShips []string + TradingCommodities []string + TradingOrgSharePercent map[string]float64 DatabasePath string ThreadDeleteAfter time.Duration } @@ -31,6 +36,11 @@ func loadConfig() Config { AuditChannelID: os.Getenv("DISCORD_AUDIT_CHANNEL_ID"), AdminRoleIDs: splitCSV(os.Getenv("DISCORD_ADMIN_ROLE_IDS")), TeamRoleIDs: splitCSV(os.Getenv("DISCORD_TEAM_ROLE_IDS")), + TradingRoleIDs: splitCSV(os.Getenv("DISCORD_TRADING_ROLE_IDS")), + TradingReportChannelID: getenv("DISCORD_TRADING_REPORT_CHANNEL_ID", os.Getenv("DISCORD_AUDIT_CHANNEL_ID")), + TradingShips: splitCSV(os.Getenv("TRADING_SHIPS")), + TradingCommodities: splitCSV(os.Getenv("TRADING_COMMODITIES")), + TradingOrgSharePercent: tradingShareConfig(), DatabasePath: getenv("DATABASE_PATH", "orders.db"), ThreadDeleteAfter: durationHoursEnv("THREAD_DELETE_AFTER_HOURS", 72), } @@ -73,3 +83,36 @@ func durationHoursEnv(key string, fallbackHours int) time.Duration { } return time.Duration(hours) * time.Hour } + +func floatEnv(key string, fallback float64) float64 { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + parsed, err := strconv.ParseFloat(strings.ReplaceAll(v, ",", "."), 64) + if err != nil { + log.Printf("WARNUNG: %s ist ungültig, verwende %.2f", key, fallback) + return fallback + } + return parsed +} + +func tradingShareConfig() map[string]float64 { + return map[string]float64{ + TradingPurposeTrade: normalizePercent(floatEnv("TRADING_SHARE_TRADE", 0)), + TradingPurposeHauling: normalizePercent(floatEnv("TRADING_SHARE_HAULING", 0)), + TradingPurposeEvent: normalizePercent(floatEnv("TRADING_SHARE_EVENT", 0)), + TradingPurposeIntern: normalizePercent(floatEnv("TRADING_SHARE_INTERN", 0)), + } +} + +func normalizePercent(v float64) float64 { + // Erlaubt sowohl "10" als auch "0.10" in der .env. + if v > 1 { + return v / 100 + } + if v < 0 { + return 0 + } + return v +} diff --git a/db.go b/db.go index 18b68df..b1876d2 100644 --- a/db.go +++ b/db.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "sort" "strings" "time" @@ -102,6 +103,9 @@ CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events(order_id); if err != nil { return err } + if err := s.createTradingSchema(); err != nil { + return err + } if _, err := s.db.Exec(`INSERT OR IGNORE INTO storage_locations(name, normalized_name, created_by_id, created_by_name, created_at, updated_at) SELECT DISTINCT TRIM(location), lower(TRIM(location)), 'migration', 'migration', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP FROM inventory @@ -608,3 +612,204 @@ func formatAmount(v float64) string { } return fmt.Sprintf("%.2f", v) } + +func (s *Store) createTradingSchema() error { + _, err := s.db.Exec(` +CREATE TABLE IF NOT EXISTS trading_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + purpose TEXT NOT NULL, + ship TEXT NOT NULL, + buy_value INTEGER NOT NULL DEFAULT 0, + sell_value INTEGER NOT NULL DEFAULT 0, + costs INTEGER NOT NULL DEFAULT 0, + repetitions INTEGER NOT NULL DEFAULT 1, + scu REAL NOT NULL DEFAULT 0, + distance REAL NOT NULL DEFAULT 0, + scu_grade TEXT NOT NULL DEFAULT '', + commodity TEXT NOT NULL, + post_a TEXT NOT NULL DEFAULT '', + post_b TEXT NOT NULL DEFAULT '', + note TEXT NOT NULL DEFAULT '', + month INTEGER NOT NULL CHECK(month >= 1 AND month <= 12), + year INTEGER NOT NULL CHECK(year >= 2000 AND year <= 2200), + org_percent REAL NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL, + deleted_at DATETIME, + deleted_by_id TEXT NOT NULL DEFAULT '' +); +CREATE INDEX IF NOT EXISTS idx_trading_month_year ON trading_runs(year, month); +CREATE INDEX IF NOT EXISTS idx_trading_user ON trading_runs(user_id); +CREATE INDEX IF NOT EXISTS idx_trading_deleted ON trading_runs(deleted_at); +`) + return err +} + +func (s *Store) CreateTradingRun(t *TradingRun) (int64, error) { + now := time.Now().UTC() + if t.Month < 1 || t.Month > 12 { + return 0, errors.New("monat muss zwischen 1 und 12 liegen") + } + if t.Year < 2000 || t.Year > 2200 { + return 0, errors.New("jahr muss zwischen 2000 und 2200 liegen") + } + if t.Repetitions <= 0 { + t.Repetitions = 1 + } + res, err := s.db.Exec(`INSERT INTO trading_runs +(user_id, user_name, purpose, ship, buy_value, sell_value, costs, repetitions, scu, distance, scu_grade, commodity, post_a, post_b, note, month, year, org_percent, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + t.UserID, t.UserName, t.Purpose, t.Ship, t.BuyValue, t.SellValue, t.Costs, t.Repetitions, t.SCU, t.Distance, t.SCUGrade, t.Commodity, t.PostA, t.PostB, t.Note, t.Month, t.Year, t.OrgPercent, now, now) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func scanTradingRun(scanner interface{ Scan(dest ...any) error }) (*TradingRun, error) { + t := &TradingRun{} + if err := scanner.Scan(&t.ID, &t.UserID, &t.UserName, &t.Purpose, &t.Ship, &t.BuyValue, &t.SellValue, &t.Costs, &t.Repetitions, &t.SCU, &t.Distance, &t.SCUGrade, &t.Commodity, &t.PostA, &t.PostB, &t.Note, &t.Month, &t.Year, &t.OrgPercent, &t.CreatedAt, &t.UpdatedAt, &t.DeletedAt, &t.DeletedByID); err != nil { + return nil, err + } + return t, nil +} + +func (s *Store) GetTradingRun(id int64) (*TradingRun, error) { + row := s.db.QueryRow(`SELECT id, user_id, user_name, purpose, ship, buy_value, sell_value, costs, repetitions, scu, distance, scu_grade, commodity, post_a, post_b, note, month, year, org_percent, created_at, updated_at, deleted_at, deleted_by_id FROM trading_runs WHERE id=?`, id) + return scanTradingRun(row) +} + +func (s *Store) DeleteTradingRun(id int64, actorID string) (*TradingRun, error) { + t, err := s.GetTradingRun(id) + if err != nil { + return nil, err + } + if t.DeletedAt.Valid { + return nil, errors.New("trading-run ist bereits gelöscht") + } + _, err = s.db.Exec(`UPDATE trading_runs SET deleted_at=?, deleted_by_id=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, time.Now().UTC(), actorID, time.Now().UTC(), id) + if err != nil { + return nil, err + } + return s.GetTradingRun(id) +} + +func (s *Store) ListTradingRuns(f TradingListFilter) ([]TradingRun, error) { + if f.Limit <= 0 { + f.Limit = 20 + } + if f.Limit > 5000 { + f.Limit = 5000 + } + q := `SELECT id, user_id, user_name, purpose, ship, buy_value, sell_value, costs, repetitions, scu, distance, scu_grade, commodity, post_a, post_b, note, month, year, org_percent, created_at, updated_at, deleted_at, deleted_by_id FROM trading_runs WHERE deleted_at IS NULL` + args := []any{} + if f.Month > 0 { + q += ` AND month=?` + args = append(args, f.Month) + } + if f.Year > 0 { + q += ` AND year=?` + args = append(args, f.Year) + } + if strings.TrimSpace(f.UserID) != "" { + q += ` AND user_id=?` + args = append(args, f.UserID) + } + q += ` ORDER BY year DESC, month DESC, created_at DESC LIMIT ?` + args = append(args, f.Limit) + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []TradingRun + for rows.Next() { + t, err := scanTradingRun(rows) + if err != nil { + return nil, err + } + out = append(out, *t) + } + return out, rows.Err() +} + +func (s *Store) SummarizeTradingRuns(month, year int) (*TradingSummary, error) { + runs, err := s.ListTradingRuns(TradingListFilter{Month: month, Year: year, Limit: 100000}) + if err != nil { + return nil, err + } + sum := &TradingSummary{Month: month, Year: year, Count: len(runs)} + traderMap := map[string]*TradingTraderSummary{} + purposeMap := map[string]*TradingPurposeSummary{} + for _, r := range runs { + profit := tradingProfit(r) + share := tradingOrgShare(r) + scu := r.SCU * float64(r.Repetitions) + costs := r.Costs * r.Repetitions + distance := r.Distance * float64(r.Repetitions) + sum.GrossProfit += profit + sum.OrgShare += share + sum.TotalSCU += scu + sum.TotalCosts += costs + sum.TotalDistance += distance + + 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 += profit + tr.OrgShare += share + tr.SCU += scu + tr.Runs++ + + ps := purposeMap[r.Purpose] + if ps == nil { + ps = &TradingPurposeSummary{Purpose: r.Purpose} + purposeMap[r.Purpose] = ps + } + ps.Profit += profit + ps.OrgShare += share + ps.SCU += scu + ps.Runs++ + } + for _, tr := range traderMap { + sum.TopTraders = append(sum.TopTraders, *tr) + } + for _, ps := range purposeMap { + sum.PurposeSummary = append(sum.PurposeSummary, *ps) + } + sortTradingSummary(sum) + return sum, nil +} + +func tradingProfit(r TradingRun) int64 { + return (r.SellValue - r.BuyValue - r.Costs) * r.Repetitions +} + +func tradingOrgShare(r TradingRun) int64 { + profit := tradingProfit(r) + if profit <= 0 || r.OrgPercent <= 0 { + return 0 + } + return int64(math.Round(float64(profit) * r.OrgPercent)) +} + +func sortTradingSummary(sum *TradingSummary) { + sort.Slice(sum.TopTraders, func(i, j int) bool { + if sum.TopTraders[i].Profit == sum.TopTraders[j].Profit { + return sum.TopTraders[i].UserName < sum.TopTraders[j].UserName + } + return sum.TopTraders[i].Profit > sum.TopTraders[j].Profit + }) + if len(sum.TopTraders) > 3 { + sum.TopTraders = sum.TopTraders[:3] + } + sort.Slice(sum.PurposeSummary, func(i, j int) bool { return sum.PurposeSummary[i].Purpose < sum.PurposeSummary[j].Purpose }) +} diff --git a/discord.go b/discord.go index 7015dd0..5d2aa60 100644 --- a/discord.go +++ b/discord.go @@ -79,6 +79,7 @@ func (b *Bot) registerCommands() error { {Name: "lagerort_add", Description: "Admin/Team: legt einen Lagerort an", Options: []*discordgo.ApplicationCommandOption{{Name: "name", Description: "Name des Lagerorts", Type: discordgo.ApplicationCommandOptionString, Required: true}}}, {Name: "lagerort_remove", Description: "Admin/Team: löscht einen leeren Lagerort", Options: []*discordgo.ApplicationCommandOption{{Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true}}}, } + commands = append(commands, tradingCommands()...) for _, cmd := range commands { if _, err := b.dg.ApplicationCommandCreate(b.cfg.AppID, b.cfg.GuildID, cmd); err != nil { return fmt.Errorf("command %s: %w", cmd.Name, err) @@ -176,6 +177,14 @@ func (b *Bot) handleCommand(s *discordgo.Session, i *discordgo.InteractionCreate b.cmdStorageLocationAdd(s, i) case "lagerort_remove": b.cmdStorageLocationRemove(s, i) + case "trading_add": + b.cmdTradingAdd(s, i) + case "trading_liste": + b.cmdTradingList(s, i) + case "trading_delete": + b.cmdTradingDelete(s, i) + case "trading_report": + b.cmdTradingReport(s, i) } } @@ -506,25 +515,40 @@ func (b *Bot) cmdStorageLocationRemove(s *discordgo.Session, i *discordgo.Intera func (b *Bot) handleAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate) { cmd := i.ApplicationCommandData().Name focusedName, focusedValue := focusedOption(i) - if focusedName != "ort" || (cmd != "lager_add" && cmd != "lager_remove" && cmd != "lagerort_remove") { - b.respondAutocomplete(s, i, nil) + + if focusedName == "ort" && (cmd == "lager_add" || cmd == "lager_remove" || cmd == "lagerort_remove") { + if !b.isTeamOrAdmin(i) { + b.respondAutocomplete(s, i, nil) + return + } + locations, err := b.store.ListStorageLocations(focusedValue, 25) + if err != nil { + log.Println("location autocomplete failed:", err) + b.respondAutocomplete(s, i, nil) + return + } + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(locations)) + for _, loc := range locations { + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: loc.Name, Value: loc.Name}) + } + b.respondAutocomplete(s, i, choices) return } - if !b.isTeamOrAdmin(i) { - b.respondAutocomplete(s, i, nil) + + if cmd == "trading_add" && (focusedName == "schiff" || focusedName == "ware") { + if !b.canUseTrading(i) { + b.respondAutocomplete(s, i, nil) + return + } + if focusedName == "schiff" { + b.respondAutocomplete(s, i, autocompleteFromList(b.cfg.TradingShips, focusedValue, 25)) + return + } + b.respondAutocomplete(s, i, autocompleteFromList(b.cfg.TradingCommodities, focusedValue, 25)) return } - locations, err := b.store.ListStorageLocations(focusedValue, 25) - if err != nil { - log.Println("location autocomplete failed:", err) - b.respondAutocomplete(s, i, nil) - return - } - choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(locations)) - for _, loc := range locations { - choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: loc.Name, Value: loc.Name}) - } - b.respondAutocomplete(s, i, choices) + + b.respondAutocomplete(s, i, nil) } func (b *Bot) respondAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate, choices []*discordgo.ApplicationCommandOptionChoice) { diff --git a/models.go b/models.go index 393067a..b591933 100644 --- a/models.go +++ b/models.go @@ -1,6 +1,9 @@ package main -import "time" +import ( + "database/sql" + "time" +) const ( StatusOpen = "open" @@ -75,3 +78,73 @@ type StorageLocation struct { CreatedAt time.Time UpdatedAt time.Time } + +const ( + TradingPurposeTrade = "trade" + TradingPurposeHauling = "hauling" + TradingPurposeEvent = "event" + TradingPurposeIntern = "intern" +) + +type TradingRun struct { + ID int64 + UserID string + UserName string + Purpose string + Ship string + BuyValue int64 + SellValue int64 + Costs int64 + Repetitions int64 + SCU float64 + Distance float64 + SCUGrade string + Commodity string + PostA string + PostB string + Note string + Month int + Year int + OrgPercent float64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt sql.NullTime + DeletedByID string +} + +type TradingListFilter struct { + Month int + Year int + UserID string + Limit int +} + +type TradingSummary struct { + Month int + Year int + Count int + GrossProfit int64 + OrgShare int64 + TotalSCU float64 + TotalCosts int64 + TotalDistance float64 + TopTraders []TradingTraderSummary + PurposeSummary []TradingPurposeSummary +} + +type TradingTraderSummary struct { + UserID string + UserName string + Profit int64 + OrgShare int64 + SCU float64 + Runs int +} + +type TradingPurposeSummary struct { + Purpose string + Profit int64 + OrgShare int64 + SCU float64 + Runs int +} diff --git a/trading_discord.go b/trading_discord.go new file mode 100644 index 0000000..aa63ef4 --- /dev/null +++ b/trading_discord.go @@ -0,0 +1,334 @@ +package main + +import ( + "fmt" + "math" + "sort" + "strings" + "time" + + "github.com/bwmarrin/discordgo" +) + +func tradingCommands() []*discordgo.ApplicationCommand { + return []*discordgo.ApplicationCommand{ + {Name: "trading_add", Description: "Erfasst einen Trading-Run", Options: []*discordgo.ApplicationCommandOption{ + {Name: "zweck", Description: "Zweck des Runs", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: tradingPurposeChoices()}, + {Name: "schiff", Description: "Schiff", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true}, + {Name: "einkaufswert", Description: "Einkaufswert in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)}, + {Name: "verkaufswert", Description: "Verkaufswert in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)}, + {Name: "kosten", Description: "Kosten in aUEC", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0)}, + {Name: "wiederholungen", Description: "Anzahl Wiederholungen", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(1)}, + {Name: "scu", Description: "Transportierte SCU je Wiederholung", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0)}, + {Name: "distanz", Description: "Distanz je Wiederholung", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0)}, + {Name: "scu_gr", Description: "SCU-Größe / Container-Größe", Type: discordgo.ApplicationCommandOptionString, Required: true}, + {Name: "ware", Description: "Ware", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true}, + {Name: "tradingpost_a", Description: "Start / Einkauf", Type: discordgo.ApplicationCommandOptionString, Required: true}, + {Name: "tradingpost_b", Description: "Ziel / Verkauf", Type: discordgo.ApplicationCommandOptionString, Required: true}, + {Name: "monat", Description: "Monat 1-12, Standard aktueller Monat", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(12)}, + {Name: "jahr", Description: "Jahr, Standard aktuelles Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(2000), MaxValue: float64(2200)}, + {Name: "bemerkung", Description: "Optionale Bemerkung", Type: discordgo.ApplicationCommandOptionString, Required: false}, + }}, + {Name: "trading_liste", Description: "Zeigt Trading-Einträge mit Filter", Options: []*discordgo.ApplicationCommandOption{ + {Name: "monat", Description: "Monat 1-12", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(12)}, + {Name: "jahr", Description: "Jahr", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(2000), MaxValue: float64(2200)}, + {Name: "nutzer", Description: "Discord-Nutzer", Type: discordgo.ApplicationCommandOptionUser, Required: false}, + {Name: "limit", Description: "Maximal 50", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(50)}, + }}, + {Name: "trading_delete", Description: "Admin: löscht einen Trading-Eintrag", Options: []*discordgo.ApplicationCommandOption{ + {Name: "id", Description: "Trading-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true}, + }}, + {Name: "trading_report", Description: "Admin: postet die Monatsauswertung", 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)}, + }}, + } +} + +func tradingPurposeChoices() []*discordgo.ApplicationCommandOptionChoice { + return []*discordgo.ApplicationCommandOptionChoice{ + {Name: "Trade", Value: TradingPurposeTrade}, + {Name: "Hauling", Value: TradingPurposeHauling}, + {Name: "Event", Value: TradingPurposeEvent}, + {Name: "Intern", Value: TradingPurposeIntern}, + } +} + +func (b *Bot) cmdTradingAdd(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.canUseTrading(i) { + b.replyEphemeral(s, i, "Du hast keine Berechtigung, Trading-Runs einzutragen.") + return + } + now := time.Now() + month := int(optIntDefault(i, "monat", int64(now.Month()))) + year := int(optIntDefault(i, "jahr", int64(now.Year()))) + purpose := optString(i, "zweck") + ship := strings.TrimSpace(optString(i, "schiff")) + commodity := strings.TrimSpace(optString(i, "ware")) + if !validTradingPurpose(purpose) { + b.replyEphemeral(s, i, "Ungültiger Zweck.") + return + } + if ship == "" || commodity == "" { + b.replyEphemeral(s, i, "Schiff und Ware sind Pflichtfelder.") + return + } + if len(b.cfg.TradingShips) > 0 && !containsFold(b.cfg.TradingShips, ship) { + b.replyEphemeral(s, i, "Dieses Schiff ist nicht in TRADING_SHIPS konfiguriert.") + return + } + if len(b.cfg.TradingCommodities) > 0 && !containsFold(b.cfg.TradingCommodities, commodity) { + b.replyEphemeral(s, i, "Diese Ware ist nicht in TRADING_COMMODITIES konfiguriert.") + return + } + + run := &TradingRun{ + UserID: userID(i), + UserName: userString(i), + Purpose: purpose, + Ship: ship, + BuyValue: optInt(i, "einkaufswert"), + SellValue: optInt(i, "verkaufswert"), + Costs: optInt(i, "kosten"), + Repetitions: optInt(i, "wiederholungen"), + SCU: optFloat(i, "scu"), + Distance: optFloat(i, "distanz"), + SCUGrade: strings.TrimSpace(optString(i, "scu_gr")), + Commodity: commodity, + PostA: strings.TrimSpace(optString(i, "tradingpost_a")), + PostB: strings.TrimSpace(optString(i, "tradingpost_b")), + Note: strings.TrimSpace(optString(i, "bemerkung")), + Month: month, + Year: year, + OrgPercent: b.cfg.TradingOrgSharePercent[purpose], + } + id, err := b.store.CreateTradingRun(run) + if err != nil { + b.replyEphemeral(s, i, "Trading-Run konnte nicht gespeichert werden: "+err.Error()) + return + } + run.ID = id + b.audit(fmt.Sprintf("Trading-Run #%d eingetragen durch %s: %s, Gewinn %s aUEC, Orga-Anteil %s aUEC", id, userString(i), run.Commodity, formatInt(tradingProfit(*run)), formatInt(tradingOrgShare(*run)))) + b.replyEphemeral(s, i, fmt.Sprintf("Trading-Run #%d gespeichert. Gewinn: %s aUEC, Orga-Anteil: %s aUEC.", id, formatInt(tradingProfit(*run)), formatInt(tradingOrgShare(*run)))) +} + +func (b *Bot) cmdTradingList(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.canUseTrading(i) { + b.replyEphemeral(s, i, "Du hast keine Berechtigung, Trading-Runs anzuzeigen.") + return + } + filter := TradingListFilter{ + Month: int(optInt(i, "monat")), + Year: int(optInt(i, "jahr")), + Limit: int(optIntDefault(i, "limit", 20)), + } + if u := optUser(i, s, "nutzer"); u != nil { + filter.UserID = u.ID + } + if filter.UserID == "" && !b.isTeamOrAdmin(i) { + filter.UserID = userID(i) + } + runs, err := b.store.ListTradingRuns(filter) + if err != nil { + b.replyEphemeral(s, i, err.Error()) + return + } + if len(runs) == 0 { + b.replyEphemeral(s, i, "Keine Trading-Einträge gefunden.") + return + } + b.replyEphemeral(s, i, "Trading-Einträge\n"+tradingTable(runs)) +} + +func (b *Bot) cmdTradingDelete(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + b.replyEphemeral(s, i, "Nur Admins dürfen Trading-Einträge löschen.") + return + } + id := optInt(i, "id") + run, err := b.store.DeleteTradingRun(id, userID(i)) + if err != nil { + b.replyEphemeral(s, i, "Eintrag konnte nicht gelöscht werden: "+err.Error()) + return + } + b.audit(fmt.Sprintf("Trading-Run #%d gelöscht durch %s. Ursprünglicher Nutzer: %s", id, userString(i), run.UserName)) + b.replyEphemeral(s, i, fmt.Sprintf("Trading-Run #%d wurde gelöscht.", id)) +} + +func (b *Bot) cmdTradingReport(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isAdmin(i) { + b.replyEphemeral(s, i, "Nur Admins dürfen Trading-Reports posten.") + return + } + month := int(optInt(i, "monat")) + year := int(optInt(i, "jahr")) + summary, err := b.store.SummarizeTradingRuns(month, year) + if err != nil { + b.replyEphemeral(s, i, "Report konnte nicht erstellt werden: "+err.Error()) + return + } + if summary.Count == 0 { + b.replyEphemeral(s, i, "Für diesen Monat gibt es keine Trading-Einträge.") + return + } + channelID := b.cfg.TradingReportChannelID + if channelID == "" { + channelID = b.cfg.InternalOrderChannelID + } + _, err = s.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{Embeds: []*discordgo.MessageEmbed{b.tradingReportEmbed(summary)}}) + if err != nil { + b.replyEphemeral(s, i, "Report konnte nicht gepostet werden: "+err.Error()) + return + } + b.audit(fmt.Sprintf("Trading-Report %02d/%d gepostet durch %s", month, year, userString(i))) + b.replyEphemeral(s, i, fmt.Sprintf("Trading-Report %02d/%d wurde gepostet.", month, year)) +} + +func (b *Bot) tradingReportEmbed(sum *TradingSummary) *discordgo.MessageEmbed { + fields := []*discordgo.MessageEmbedField{ + {Name: "Einträge", Value: fmt.Sprintf("%d", sum.Count), Inline: true}, + {Name: "Gewinn gesamt", Value: formatAUEC(sum.GrossProfit), Inline: true}, + {Name: "Orga-Anteil", Value: formatAUEC(sum.OrgShare), Inline: true}, + {Name: "Transportierte SCU", Value: formatAmount(sum.TotalSCU), Inline: true}, + {Name: "Kosten gesamt", Value: formatAUEC(sum.TotalCosts), Inline: true}, + {Name: "Distanz gesamt", Value: formatAmount(sum.TotalDistance), Inline: true}, + {Name: "Top 3 Trader", Value: topTraderText(sum.TopTraders), Inline: false}, + {Name: "Nach Zweck", Value: purposeSummaryText(sum.PurposeSummary), Inline: false}, + } + return &discordgo.MessageEmbed{Title: fmt.Sprintf("Trading-Auswertung %02d/%d", sum.Month, sum.Year), Description: "Monatliche Zusammenfassung der erfassten Trading-Runs.", Color: 0x2ecc71, Fields: fields, Timestamp: time.Now().UTC().Format(time.RFC3339)} +} + +func topTraderText(items []TradingTraderSummary) string { + if len(items) == 0 { + return "Keine Daten" + } + var lines []string + for idx, t := range items { + name := t.UserName + if t.UserID != "" { + name = fmt.Sprintf("<@%s>", t.UserID) + } + lines = append(lines, fmt.Sprintf("%d. %s — Gewinn %s, Orga %s, SCU %s, Runs %d", idx+1, name, formatAUEC(t.Profit), formatAUEC(t.OrgShare), formatAmount(t.SCU), t.Runs)) + } + return strings.Join(lines, "\n") +} + +func purposeSummaryText(items []TradingPurposeSummary) string { + if len(items) == 0 { + return "Keine Daten" + } + var lines []string + for _, p := range items { + lines = append(lines, fmt.Sprintf("%s — Gewinn %s, Orga %s, SCU %s, Runs %d", tradingPurposeLabel(p.Purpose), formatAUEC(p.Profit), formatAUEC(p.OrgShare), formatAmount(p.SCU), p.Runs)) + } + return strings.Join(lines, "\n") +} + +func tradingTable(runs []TradingRun) string { + var b strings.Builder + b.WriteString("```text\n") + b.WriteString(fmt.Sprintf("%-4s %-7s %-15s %-9s %-14s %-12s %-12s %-8s\n", "ID", "Monat", "Nutzer", "Zweck", "Ware", "Gewinn", "Orga", "SCU")) + b.WriteString(strings.Repeat("-", 90) + "\n") + for _, r := range runs { + line := fmt.Sprintf("%-4d %02d/%-4d %-15s %-9s %-14s %-12s %-12s %-8s\n", r.ID, r.Month, r.Year, truncateText(r.UserName, 15), tradingPurposeLabel(r.Purpose), truncateText(r.Commodity, 14), formatInt(tradingProfit(r)), formatInt(tradingOrgShare(r)), formatAmount(r.SCU*float64(r.Repetitions))) + if b.Len()+len(line)+4 > 1900 { + b.WriteString("… Ausgabe gekürzt\n") + break + } + b.WriteString(line) + } + b.WriteString("```") + return b.String() +} + +func (b *Bot) canUseTrading(i *discordgo.InteractionCreate) bool { + return b.isTeamOrAdmin(i) || hasAnyRole(i, b.cfg.TradingRoleIDs) +} + +func validTradingPurpose(v string) bool { + switch v { + case TradingPurposeTrade, TradingPurposeHauling, TradingPurposeEvent, TradingPurposeIntern: + return true + default: + return false + } +} + +func tradingPurposeLabel(v string) string { + switch v { + case TradingPurposeTrade: + return "Trade" + case TradingPurposeHauling: + return "Hauling" + case TradingPurposeEvent: + return "Event" + case TradingPurposeIntern: + return "Intern" + default: + return v + } +} + +func formatAUEC(v int64) string { return formatInt(v) + " aUEC" } + +func formatInt(v int64) string { + neg := v < 0 + if neg { + v = -v + } + s := fmt.Sprintf("%d", v) + for i := len(s) - 3; i > 0; i -= 3 { + s = s[:i] + "." + s[i:] + } + if neg { + return "-" + s + } + return s +} + +func containsFold(values []string, needle string) bool { + needle = strings.TrimSpace(needle) + for _, v := range values { + if strings.EqualFold(strings.TrimSpace(v), needle) { + return true + } + } + return false +} + +func autocompleteFromList(values []string, query string, limit int) []*discordgo.ApplicationCommandOptionChoice { + query = strings.ToLower(strings.TrimSpace(query)) + var filtered []string + for _, v := range values { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if query == "" || strings.Contains(strings.ToLower(v), query) { + filtered = append(filtered, v) + } + } + sort.Slice(filtered, func(i, j int) bool { return strings.ToLower(filtered[i]) < strings.ToLower(filtered[j]) }) + if limit <= 0 || limit > 25 { + limit = 25 + } + if len(filtered) > limit { + filtered = filtered[:limit] + } + choices := make([]*discordgo.ApplicationCommandOptionChoice, 0, len(filtered)) + for _, v := range filtered { + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{Name: v, Value: v}) + } + return choices +} + +func optUser(i *discordgo.InteractionCreate, s *discordgo.Session, name string) *discordgo.User { + for _, o := range i.ApplicationCommandData().Options { + if o.Name == name { + return o.UserValue(s) + } + } + return nil +} + +func roundMoney(v float64) int64 { return int64(math.Round(v)) }