diff --git a/README.md b/README.md index 80d6daa..b78d27c 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,26 @@ Team/Admin kann den Status manuell setzen: ## Lager Commands Nur Admins können Lagerbestände verändern. Team/Admin kann Lager prüfen und anzeigen. +Lagerorte werden separat verwaltet und bei den `ort`-Feldern per Autocomplete vorgeschlagen. Dadurch entstehen keine Tippfehler wie `Orison`, `orison` und `Orison ` als getrennte Lagerorte. + +### `/lagerort_add` + +Team/Admin kann interne Lagerorte anlegen. Diese Orte sind danach bei `/lager_add`, `/lager_remove` und `/lagerort_remove` als Auswahl verfügbar. + +```text +/lagerort_add name:Baijini Point +/lagerort_add name:Orison +``` + +### `/lagerort_remove` + +Team/Admin kann Lagerorte löschen, aber nur wenn dort kein aktiver Bestand mehr liegt. + +```text +/lagerort_remove ort:Baijini Point +``` + +Wenn noch Bestand vorhanden ist, lehnt der Bot das Löschen ab. ### `/lager_add` @@ -115,7 +135,7 @@ Fügt Bestand hinzu oder setzt ihn absolut. /lager_add ware:Gold qualitaet:900 menge:100 einheit:SCU ort:Orison modus:setzen ``` -`ort` ist ein Pflichtfeld und bezeichnet den internen Lagerort. Er wird nur in Team-/Admin-Ausgaben, der internen Lagerprüfung und im Auditlog angezeigt. Öffentliche Auftragsposts und Endnutzer-DMs enthalten keine Lagerorte. +`ort` ist ein Pflichtfeld und muss vorher mit `/lagerort_add` angelegt worden sein. Discord schlägt vorhandene Lagerorte beim Tippen automatisch vor. Der Ort wird nur in Team-/Admin-Ausgaben, der internen Lagerprüfung und im Auditlog angezeigt. Öffentliche Auftragsposts und Endnutzer-DMs enthalten keine Lagerorte. Das Auditlog zeigt Bestandsänderungen mit altem und neuem Wert, z. B.: @@ -131,7 +151,7 @@ Reduziert Bestand. Der Bot verhindert negative Bestände. /lager_remove ware:Gold qualitaet:900 menge:10 einheit:SCU ort:Orison ``` -Auch hier ist `ort` ein Pflichtfeld und bezieht sich auf genau diese Lagerposition. Dadurch werden Bestände an verschiedenen Orten sauber getrennt geführt. +Auch hier ist `ort` ein Pflichtfeld und wird aus den angelegten Lagerorten vorgeschlagen. Dadurch werden Bestände an verschiedenen Orten sauber getrennt geführt. ### `/lager_liste` @@ -164,6 +184,7 @@ Intern speichert der Bot weiterhin stabile technische Statuswerte wie `open`, `a - `orders`: Aufträge und Discord-Message-Referenzen - `inventory`: interne Lagerpositionen +- `storage_locations`: verwaltete Lagerorte für Autocomplete und Validierung - `order_events`: Audit-Log für Aufträge und Lageränderungen SQLite wird mit WAL-Modus und `busy_timeout` betrieben, damit kleine produktive Deployments robuster laufen. diff --git a/db.go b/db.go index 7b2d3ee..18b68df 100644 --- a/db.go +++ b/db.go @@ -76,6 +76,17 @@ CREATE TABLE IF NOT EXISTS inventory ( ); CREATE INDEX IF NOT EXISTS idx_inventory_lookup ON inventory(normalized_name, quality, quantity_unit); +CREATE TABLE IF NOT EXISTS storage_locations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL UNIQUE, + created_by_id TEXT NOT NULL DEFAULT '', + created_by_name TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_storage_locations_name ON storage_locations(normalized_name); + CREATE TABLE IF NOT EXISTS order_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER, @@ -91,6 +102,12 @@ CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events(order_id); if 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 +WHERE TRIM(location) <> ''`); err != nil { + return err + } return s.ensureColumns() } @@ -337,6 +354,16 @@ func (s *Store) UpsertInventory(item InventoryItem, delta bool) (*InventoryItem, item.Name = strings.TrimSpace(item.Name) item.Location = strings.TrimSpace(item.Location) item.NormalizedName = normalizeName(item.Name) + if item.Location == "" { + return nil, errors.New("lagerort ist ein pflichtfeld") + } + exists, err := s.StorageLocationExists(item.Location) + if err != nil { + return nil, err + } + if !exists { + return nil, errors.New("lagerort existiert nicht. Bitte zuerst mit /lagerort_add anlegen") + } tx, err := s.db.Begin() if err != nil { @@ -462,6 +489,116 @@ func (s *Store) FindInventoryForOrder(o *Order) ([]InventoryMatch, float64, erro return matches, total, rows.Err() } +func scanStorageLocation(scanner interface{ Scan(dest ...any) error }) (*StorageLocation, error) { + loc := &StorageLocation{} + if err := scanner.Scan(&loc.ID, &loc.Name, &loc.NormalizedName, &loc.CreatedByID, &loc.CreatedByName, &loc.CreatedAt, &loc.UpdatedAt); err != nil { + return nil, err + } + return loc, nil +} + +func (s *Store) CreateStorageLocation(name, actorID, actorName string) (*StorageLocation, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("lagerort darf nicht leer sein") + } + now := time.Now().UTC() + normalized := normalizeName(name) + _, err := s.db.Exec(`INSERT INTO storage_locations(name, normalized_name, created_by_id, created_by_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, name, normalized, actorID, actorName, now, now) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return nil, errors.New("lagerort existiert bereits") + } + return nil, err + } + return s.GetStorageLocationByName(name) +} + +func (s *Store) GetStorageLocationByName(name string) (*StorageLocation, error) { + row := s.db.QueryRow(`SELECT id, name, normalized_name, created_by_id, created_by_name, created_at, updated_at FROM storage_locations WHERE normalized_name=?`, normalizeName(name)) + return scanStorageLocation(row) +} + +func (s *Store) StorageLocationExists(name string) (bool, error) { + if strings.TrimSpace(name) == "" { + return false, nil + } + var exists int + err := s.db.QueryRow(`SELECT 1 FROM storage_locations WHERE normalized_name=? LIMIT 1`, normalizeName(name)).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (s *Store) DeleteStorageLocationIfEmpty(name string) (*StorageLocation, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("lagerort darf nicht leer sein") + } + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + row := tx.QueryRow(`SELECT id, name, normalized_name, created_by_id, created_by_name, created_at, updated_at FROM storage_locations WHERE normalized_name=?`, normalizeName(name)) + loc, err := scanStorageLocation(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, errors.New("lagerort nicht gefunden") + } + return nil, err + } + + var used int + if err := tx.QueryRow(`SELECT COUNT(*) FROM inventory WHERE lower(TRIM(location))=? AND quantity_amount > 0`, loc.NormalizedName).Scan(&used); err != nil { + return nil, err + } + if used > 0 { + return nil, fmt.Errorf("lagerort ist nicht leer (%d aktive Lagerpositionen)", used) + } + + if _, err := tx.Exec(`DELETE FROM storage_locations WHERE id=?`, loc.ID); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return loc, nil +} + +func (s *Store) ListStorageLocations(query string, limit int) ([]StorageLocation, error) { + if limit <= 0 || limit > 25 { + limit = 25 + } + q := `SELECT id, name, normalized_name, created_by_id, created_by_name, created_at, updated_at FROM storage_locations` + args := []any{} + if strings.TrimSpace(query) != "" { + q += ` WHERE normalized_name LIKE ?` + args = append(args, "%"+normalizeName(query)+"%") + } + q += ` ORDER BY name COLLATE NOCASE LIMIT ?` + args = append(args, limit) + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []StorageLocation + for rows.Next() { + loc, err := scanStorageLocation(rows) + if err != nil { + return nil, err + } + out = append(out, *loc) + } + return out, rows.Err() +} + func quantityString(amount float64, unit string) string { return fmt.Sprintf("%s %s", formatAmount(amount), unit) } diff --git a/discord.go b/discord.go index f5c9ecf..7015dd0 100644 --- a/discord.go +++ b/discord.go @@ -72,10 +72,12 @@ func (b *Bot) registerCommands() error { {Name: "auftrag_status_setzen", Description: "Admin/Team: setzt den Status eines Auftrags", Options: []*discordgo.ApplicationCommandOption{{Name: "id", Description: "Auftrags-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true}, {Name: "status", Description: "Neuer Status", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: statusChoices()}, {Name: "notiz", Description: "Interne Notiz", Type: discordgo.ApplicationCommandOptionString, Required: false}}}, {Name: "auftrag_abbrechen", Description: "Bricht einen Auftrag ab", Options: []*discordgo.ApplicationCommandOption{{Name: "id", Description: "Auftrags-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true}, {Name: "grund", Description: "Optionaler Grund", Type: discordgo.ApplicationCommandOptionString, Required: false}}}, {Name: "auftrag_nachricht", Description: "Sendet eine Nachricht an Einreicher und Annehmer", Options: []*discordgo.ApplicationCommandOption{{Name: "id", Description: "Auftrags-ID", Type: discordgo.ApplicationCommandOptionInteger, Required: true}, {Name: "text", Description: "Nachricht", Type: discordgo.ApplicationCommandOptionString, Required: true}}}, - {Name: "lager_add", Description: "Admin: Lagerbestand hinzufügen oder Bestand setzen", Options: []*discordgo.ApplicationCommandOption{{Name: "ware", Description: "Commodity oder Item", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "qualitaet", Description: "Qualität 0-1000", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0), MaxValue: float64(1000)}, {Name: "menge", Description: "Menge als Zahl", Type: discordgo.ApplicationCommandOptionNumber, Required: true}, {Name: "einheit", Description: "SCU, cSCU oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: unitChoices()}, {Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "notiz", Description: "Interne Notiz", Type: discordgo.ApplicationCommandOptionString, Required: false}, {Name: "modus", Description: "addiert oder setzt Bestand", Type: discordgo.ApplicationCommandOptionString, Required: false, Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "addieren", Value: "add"}, {Name: "setzen", Value: "set"}}}}}, - {Name: "lager_remove", Description: "Admin: Lagerbestand reduzieren", Options: []*discordgo.ApplicationCommandOption{{Name: "ware", Description: "Commodity oder Item", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "qualitaet", Description: "Qualität 0-1000", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0), MaxValue: float64(1000)}, {Name: "menge", Description: "Menge als Zahl", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0.0001)}, {Name: "einheit", Description: "SCU, cSCU oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: unitChoices()}, {Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "notiz", Description: "Interne Notiz", Type: discordgo.ApplicationCommandOptionString, Required: false}}}, + {Name: "lager_add", Description: "Admin: Lagerbestand hinzufügen oder Bestand setzen", Options: []*discordgo.ApplicationCommandOption{{Name: "ware", Description: "Commodity oder Item", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "qualitaet", Description: "Qualität 0-1000", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0), MaxValue: float64(1000)}, {Name: "menge", Description: "Menge als Zahl", Type: discordgo.ApplicationCommandOptionNumber, Required: true}, {Name: "einheit", Description: "SCU, cSCU oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: unitChoices()}, {Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true}, {Name: "notiz", Description: "Interne Notiz", Type: discordgo.ApplicationCommandOptionString, Required: false}, {Name: "modus", Description: "addiert oder setzt Bestand", Type: discordgo.ApplicationCommandOptionString, Required: false, Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "addieren", Value: "add"}, {Name: "setzen", Value: "set"}}}}}, + {Name: "lager_remove", Description: "Admin: Lagerbestand reduzieren", Options: []*discordgo.ApplicationCommandOption{{Name: "ware", Description: "Commodity oder Item", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "qualitaet", Description: "Qualität 0-1000", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0), MaxValue: float64(1000)}, {Name: "menge", Description: "Menge als Zahl", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0.0001)}, {Name: "einheit", Description: "SCU, cSCU oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: unitChoices()}, {Name: "ort", Description: "Lagerort", Type: discordgo.ApplicationCommandOptionString, Required: true, Autocomplete: true}, {Name: "notiz", Description: "Interne Notiz", Type: discordgo.ApplicationCommandOptionString, Required: false}}}, {Name: "lager_liste", Description: "Admin/Team: zeigt internes Lager", Options: []*discordgo.ApplicationCommandOption{{Name: "suche", Description: "Ware oder Ort", Type: discordgo.ApplicationCommandOptionString, Required: false}, {Name: "limit", Description: "Maximal 50", Type: discordgo.ApplicationCommandOptionInteger, Required: false, MinValue: ptrFloat(1), MaxValue: float64(50)}}}, {Name: "lager_check", Description: "Admin/Team: prüft Lagerbestand für Ware", Options: []*discordgo.ApplicationCommandOption{{Name: "ware", Description: "Commodity oder Item", Type: discordgo.ApplicationCommandOptionString, Required: true}, {Name: "qualitaet", Description: "Mindestqualität 0-1000", Type: discordgo.ApplicationCommandOptionInteger, Required: true, MinValue: ptrFloat(0), MaxValue: float64(1000)}, {Name: "menge", Description: "Benötigte Menge", Type: discordgo.ApplicationCommandOptionNumber, Required: true, MinValue: ptrFloat(0.0001)}, {Name: "einheit", Description: "SCU, cSCU oder Stück", Type: discordgo.ApplicationCommandOptionString, Required: true, Choices: unitChoices()}}}, + {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}}}, } for _, cmd := range commands { if _, err := b.dg.ApplicationCommandCreate(b.cfg.AppID, b.cfg.GuildID, cmd); err != nil { @@ -132,6 +134,8 @@ func (b *Bot) onInteraction(s *discordgo.Session, i *discordgo.InteractionCreate switch i.Type { case discordgo.InteractionApplicationCommand: b.handleCommand(s, i) + case discordgo.InteractionApplicationCommandAutocomplete: + b.handleAutocomplete(s, i) case discordgo.InteractionMessageComponent: b.handleButton(s, i) } @@ -168,6 +172,10 @@ func (b *Bot) handleCommand(s *discordgo.Session, i *discordgo.InteractionCreate b.cmdInventoryList(s, i) case "lager_check": b.cmdInventoryCheck(s, i) + case "lagerort_add": + b.cmdStorageLocationAdd(s, i) + case "lagerort_remove": + b.cmdStorageLocationRemove(s, i) } } @@ -396,6 +404,15 @@ func (b *Bot) cmdInventoryAdd(s *discordgo.Session, i *discordgo.InteractionCrea b.replyEphemeral(s, i, "Lagerort ist ein Pflichtfeld.") return } + exists, err := b.store.StorageLocationExists(item.Location) + if err != nil { + b.replyEphemeral(s, i, "Lagerort konnte nicht geprüft werden: "+err.Error()) + return + } + if !exists { + b.replyEphemeral(s, i, "Lagerort existiert nicht. Bitte zuerst mit /lagerort_add anlegen.") + return + } oldAmount := 0.0 if existing, err := b.store.GetInventoryByKey(normalizeName(item.Name), item.Quality, item.QuantityUnit, item.Location); err == nil { oldAmount = existing.QuantityAmount @@ -448,6 +465,84 @@ func (b *Bot) cmdInventoryCheck(s *discordgo.Session, i *discordgo.InteractionCr b.replyEphemeral(s, i, b.inventoryText(o)) } +func (b *Bot) cmdStorageLocationAdd(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isTeamOrAdmin(i) { + b.replyEphemeral(s, i, "Nur Team/Admin kann Lagerorte anlegen.") + return + } + name := strings.TrimSpace(optString(i, "name")) + if name == "" { + b.replyEphemeral(s, i, "Lagerort darf nicht leer sein.") + return + } + loc, err := b.store.CreateStorageLocation(name, userID(i), userString(i)) + if err != nil { + b.replyEphemeral(s, i, "Lagerort konnte nicht angelegt werden: "+err.Error()) + return + } + b.audit(fmt.Sprintf("Lagerort angelegt durch %s: %s", userString(i), loc.Name)) + b.replyEphemeral(s, i, fmt.Sprintf("Lagerort angelegt: %s", loc.Name)) +} + +func (b *Bot) cmdStorageLocationRemove(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !b.isTeamOrAdmin(i) { + b.replyEphemeral(s, i, "Nur Team/Admin kann Lagerorte löschen.") + return + } + name := strings.TrimSpace(optString(i, "ort")) + if name == "" { + b.replyEphemeral(s, i, "Lagerort darf nicht leer sein.") + return + } + loc, err := b.store.DeleteStorageLocationIfEmpty(name) + if err != nil { + b.replyEphemeral(s, i, "Lagerort konnte nicht gelöscht werden: "+err.Error()) + return + } + b.audit(fmt.Sprintf("Lagerort gelöscht durch %s: %s", userString(i), loc.Name)) + b.replyEphemeral(s, i, fmt.Sprintf("Lagerort gelöscht: %s", loc.Name)) +} + +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) + return + } + 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) +} + +func (b *Bot) respondAutocomplete(s *discordgo.Session, i *discordgo.InteractionCreate, choices []*discordgo.ApplicationCommandOptionChoice) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionApplicationCommandAutocompleteResult, + Data: &discordgo.InteractionResponseData{Choices: choices}, + }) +} + +func focusedOption(i *discordgo.InteractionCreate) (string, string) { + for _, opt := range i.ApplicationCommandData().Options { + if opt.Focused { + return opt.Name, opt.StringValue() + } + } + return "", "" +} + func (b *Bot) handleButton(s *discordgo.Session, i *discordgo.InteractionCreate) { customID := i.MessageComponentData().CustomID parts := strings.Split(customID, ":") diff --git a/models.go b/models.go index 57f8be8..393067a 100644 --- a/models.go +++ b/models.go @@ -65,3 +65,13 @@ type InventoryMatch struct { RequestedQuantity float64 Available bool } + +type StorageLocation struct { + ID int64 + Name string + NormalizedName string + CreatedByID string + CreatedByName string + CreatedAt time.Time + UpdatedAt time.Time +}