Eingabe von Lagerorten
All checks were successful
release-tag / release-image (push) Successful in 2m9s

This commit is contained in:
2026-05-30 08:45:46 +02:00
parent 9fb4300410
commit d884331c61
4 changed files with 267 additions and 4 deletions

View File

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

137
db.go
View File

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

View File

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

View File

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