Update mit Trading-Modul
All checks were successful
release-tag / release-image (push) Successful in 2m6s

This commit is contained in:
2026-05-30 12:05:35 +02:00
parent d884331c61
commit 9b02905000
7 changed files with 746 additions and 22 deletions

View File

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

26
.env.example_ Normal file
View File

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

View File

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

205
db.go
View File

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

View File

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

View File

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

334
trading_discord.go Normal file
View File

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