package main import ( "database/sql" "errors" "fmt" "math" "strings" "time" _ "github.com/glebarez/sqlite" ) type Store struct{ db *sql.DB } func NewStore(path string) (*Store, error) { db, err := sql.Open("sqlite", path) if err != nil { return nil, err } if _, err := db.Exec(`PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;`); err != nil { return nil, err } s := &Store{db: db} return s, s.migrate() } func (s *Store) Close() error { return s.db.Close() } func (s *Store) migrate() error { _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, submitter_id TEXT NOT NULL, submitter_name TEXT NOT NULL, acceptor_id TEXT DEFAULT '', acceptor_name TEXT DEFAULT '', commodity TEXT NOT NULL, quality INTEGER NOT NULL CHECK(quality >= 0 AND quality <= 1000), quantity TEXT NOT NULL DEFAULT '', quantity_amount REAL NOT NULL DEFAULT 0, quantity_unit TEXT NOT NULL DEFAULT '', deadline TEXT NOT NULL, delivery_place TEXT NOT NULL, max_budget_auec INTEGER NOT NULL, status TEXT NOT NULL, channel_id TEXT DEFAULT '', message_id TEXT DEFAULT '', public_channel_id TEXT DEFAULT '', public_message_id TEXT DEFAULT '', internal_channel_id TEXT DEFAULT '', internal_message_id TEXT DEFAULT '', thread_id TEXT DEFAULT '', last_feedback TEXT DEFAULT '', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_submitter ON orders(submitter_id); CREATE INDEX IF NOT EXISTS idx_orders_acceptor ON orders(acceptor_id); CREATE TABLE IF NOT EXISTS inventory ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, normalized_name TEXT NOT NULL, quality INTEGER NOT NULL CHECK(quality >= 0 AND quality <= 1000), quantity_amount REAL NOT NULL CHECK(quantity_amount >= 0), quantity_unit TEXT NOT NULL, location TEXT DEFAULT '', note TEXT DEFAULT '', updated_by_id TEXT NOT NULL, updated_by_name TEXT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE(normalized_name, quality, quantity_unit, location) ); CREATE INDEX IF NOT EXISTS idx_inventory_lookup ON inventory(normalized_name, quality, quantity_unit); CREATE TABLE IF NOT EXISTS order_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_id INTEGER, actor_id TEXT NOT NULL, actor_name TEXT NOT NULL, event_type TEXT NOT NULL, message TEXT NOT NULL, created_at DATETIME NOT NULL, FOREIGN KEY(order_id) REFERENCES orders(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_order_events_order ON order_events(order_id); `) if err != nil { return err } return s.ensureColumns() } func (s *Store) ensureColumns() error { columns := map[string]string{ "quantity_amount": "ALTER TABLE orders ADD COLUMN quantity_amount REAL NOT NULL DEFAULT 0", "quantity_unit": "ALTER TABLE orders ADD COLUMN quantity_unit TEXT NOT NULL DEFAULT ''", "public_channel_id": "ALTER TABLE orders ADD COLUMN public_channel_id TEXT DEFAULT ''", "public_message_id": "ALTER TABLE orders ADD COLUMN public_message_id TEXT DEFAULT ''", "internal_channel_id": "ALTER TABLE orders ADD COLUMN internal_channel_id TEXT DEFAULT ''", "internal_message_id": "ALTER TABLE orders ADD COLUMN internal_message_id TEXT DEFAULT ''", "thread_id": "ALTER TABLE orders ADD COLUMN thread_id TEXT DEFAULT ''", } for col, stmt := range columns { if !s.columnExists("orders", col) { if _, err := s.db.Exec(stmt); err != nil { return err } } } return nil } func (s *Store) columnExists(table, column string) bool { rows, err := s.db.Query(`PRAGMA table_info(` + table + `)`) if err != nil { return false } defer rows.Close() for rows.Next() { var cid int var name, typ string var notnull int var dflt any var pk int if rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk) == nil && name == column { return true } } return false } func normalizeName(v string) string { return strings.ToLower(strings.TrimSpace(v)) } func (s *Store) CreateOrder(o *Order, actorID, actorName string) (int64, error) { now := time.Now().UTC() tx, err := s.db.Begin() if err != nil { return 0, err } defer tx.Rollback() res, err := tx.Exec(`INSERT INTO orders (submitter_id, submitter_name, commodity, quality, quantity, quantity_amount, quantity_unit, deadline, delivery_place, max_budget_auec, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, o.SubmitterID, o.SubmitterName, o.Commodity, o.Quality, quantityString(o.QuantityAmount, o.QuantityUnit), o.QuantityAmount, o.QuantityUnit, o.Deadline, o.DeliveryPlace, o.MaxBudgetAUEC, StatusOpen, now, now) if err != nil { return 0, err } id, err := res.LastInsertId() if err != nil { return 0, err } if err := addEventTx(tx, id, actorID, actorName, EventOrderCreated, "Auftrag erstellt"); err != nil { return 0, err } return id, tx.Commit() } func (s *Store) SetMessages(id int64, publicChannelID, publicMessageID, internalChannelID, internalMessageID, threadID string) error { _, err := s.db.Exec(`UPDATE orders SET public_channel_id=?, public_message_id=?, internal_channel_id=?, internal_message_id=?, thread_id=?, channel_id=?, message_id=?, updated_at=? WHERE id=?`, publicChannelID, publicMessageID, internalChannelID, internalMessageID, threadID, internalChannelID, internalMessageID, time.Now().UTC(), id) return err } func (s *Store) GetOrder(id int64) (*Order, error) { row := s.db.QueryRow(`SELECT id, submitter_id, submitter_name, acceptor_id, acceptor_name, commodity, quality, quantity_amount, quantity_unit, deadline, delivery_place, max_budget_auec, status, public_channel_id, public_message_id, internal_channel_id, internal_message_id, thread_id, created_at, updated_at, last_feedback FROM orders WHERE id=?`, id) return scanOrder(row) } func scanOrder(scanner interface{ Scan(dest ...any) error }) (*Order, error) { o := &Order{} if err := scanner.Scan(&o.ID, &o.SubmitterID, &o.SubmitterName, &o.AcceptorID, &o.AcceptorName, &o.Commodity, &o.Quality, &o.QuantityAmount, &o.QuantityUnit, &o.Deadline, &o.DeliveryPlace, &o.MaxBudgetAUEC, &o.Status, &o.PublicChannelID, &o.PublicMessageID, &o.InternalChannelID, &o.InternalMessageID, &o.ThreadID, &o.CreatedAt, &o.UpdatedAt, &o.LastFeedback); err != nil { return nil, err } return o, nil } func (s *Store) ListOrders(status string, limit int) ([]Order, error) { return s.listOrders(status, limit, false) } func (s *Store) ListArchivedOrders(status string, limit int) ([]Order, error) { return s.listOrders(status, limit, true) } func (s *Store) listOrders(status string, limit int, archived bool) ([]Order, error) { if limit <= 0 || limit > 50 { limit = 10 } query := `SELECT id, submitter_id, submitter_name, acceptor_id, acceptor_name, commodity, quality, quantity_amount, quantity_unit, deadline, delivery_place, max_budget_auec, status, public_channel_id, public_message_id, internal_channel_id, internal_message_id, thread_id, created_at, updated_at, last_feedback FROM orders` args := []any{} if status != "" { query += ` WHERE status=?` args = append(args, status) } else if archived { query += ` WHERE status IN (?, ?, ?)` args = append(args, StatusCompleted, StatusDeclined, StatusCancelled) } else { query += ` WHERE status NOT IN (?, ?, ?)` args = append(args, StatusCompleted, StatusDeclined, StatusCancelled) } query += ` ORDER BY updated_at DESC LIMIT ?` args = append(args, limit) rows, err := s.db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var out []Order for rows.Next() { o, err := scanOrder(rows) if err != nil { return nil, err } out = append(out, *o) } return out, rows.Err() } func (s *Store) ListOrdersWithDeletableThreads(cutoff time.Time, limit int) ([]Order, error) { if limit <= 0 || limit > 100 { limit = 50 } rows, err := s.db.Query(`SELECT id, submitter_id, submitter_name, acceptor_id, acceptor_name, commodity, quality, quantity_amount, quantity_unit, deadline, delivery_place, max_budget_auec, status, public_channel_id, public_message_id, internal_channel_id, internal_message_id, thread_id, created_at, updated_at, last_feedback FROM orders WHERE thread_id <> '' AND status IN (?, ?, ?) AND updated_at <= ? ORDER BY updated_at ASC LIMIT ?`, StatusCompleted, StatusDeclined, StatusCancelled, cutoff.UTC(), limit) if err != nil { return nil, err } defer rows.Close() var out []Order for rows.Next() { o, err := scanOrder(rows) if err != nil { return nil, err } out = append(out, *o) } return out, rows.Err() } func (s *Store) ClearThreadID(orderID int64) error { _, err := s.db.Exec(`UPDATE orders SET thread_id='', updated_at=? WHERE id=?`, time.Now().UTC(), orderID) return err } func (s *Store) AcceptOrder(id int64, userID, userName string) (*Order, error) { tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() res, err := tx.Exec(`UPDATE orders SET status=?, acceptor_id=?, acceptor_name=?, updated_at=? WHERE id=? AND status=?`, StatusAccepted, userID, userName, time.Now().UTC(), id, StatusOpen) if err != nil { return nil, err } n, _ := res.RowsAffected() if n == 0 { return nil, errors.New("auftrag ist nicht mehr offen") } if err := addEventTx(tx, id, userID, userName, EventOrderAccepted, "Auftrag angenommen"); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return s.GetOrder(id) } func (s *Store) DeclineOrder(id int64, userID, userName, feedback string) (*Order, error) { tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() res, err := tx.Exec(`UPDATE orders SET status=?, last_feedback=?, updated_at=? WHERE id=? AND status=?`, StatusDeclined, feedback, time.Now().UTC(), id, StatusOpen) if err != nil { return nil, err } n, _ := res.RowsAffected() if n == 0 { return nil, errors.New("auftrag ist nicht mehr offen") } if err := addEventTx(tx, id, userID, userName, EventOrderDeclined, feedback); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return s.GetOrder(id) } func (s *Store) SetStatus(id int64, status, feedback, actorID, actorName string) (*Order, error) { tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() res, err := tx.Exec(`UPDATE orders SET status=?, last_feedback=?, updated_at=? WHERE id=?`, status, feedback, time.Now().UTC(), id) if err != nil { return nil, err } n, _ := res.RowsAffected() if n == 0 { return nil, sql.ErrNoRows } if err := addEventTx(tx, id, actorID, actorName, EventOrderStatus, fmt.Sprintf("Status auf %s gesetzt: %s", statusLabel(status), feedback)); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return s.GetOrder(id) } func (s *Store) AddEvent(orderID int64, actorID, actorName, eventType, message string) error { _, err := s.db.Exec(`INSERT INTO order_events(order_id, actor_id, actor_name, event_type, message, created_at) VALUES (?, ?, ?, ?, ?, ?)`, nullableOrderID(orderID), actorID, actorName, eventType, message, time.Now().UTC()) return err } func addEventTx(tx *sql.Tx, orderID int64, actorID, actorName, eventType, message string) error { _, err := tx.Exec(`INSERT INTO order_events(order_id, actor_id, actor_name, event_type, message, created_at) VALUES (?, ?, ?, ?, ?, ?)`, nullableOrderID(orderID), actorID, actorName, eventType, message, time.Now().UTC()) return err } func nullableOrderID(id int64) any { if id <= 0 { return nil } return id } func (s *Store) UpsertInventory(item InventoryItem, delta bool) (*InventoryItem, error) { now := time.Now().UTC() item.Name = strings.TrimSpace(item.Name) item.Location = strings.TrimSpace(item.Location) item.NormalizedName = normalizeName(item.Name) tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() var existing InventoryItem err = tx.QueryRow(`SELECT id, name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at FROM inventory WHERE normalized_name=? AND quality=? AND quantity_unit=? AND location=?`, item.NormalizedName, item.Quality, item.QuantityUnit, item.Location).Scan(&existing.ID, &existing.Name, &existing.NormalizedName, &existing.Quality, &existing.QuantityAmount, &existing.QuantityUnit, &existing.Location, &existing.Note, &existing.UpdatedByID, &existing.UpdatedByName, &existing.CreatedAt, &existing.UpdatedAt) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } var result InventoryItem if errors.Is(err, sql.ErrNoRows) { if item.QuantityAmount <= 0 { return nil, errors.New("kein ausreichender Lagerbestand vorhanden") } res, err := tx.Exec(`INSERT INTO inventory(name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, item.Name, item.NormalizedName, item.Quality, item.QuantityAmount, item.QuantityUnit, item.Location, item.Note, item.UpdatedByID, item.UpdatedByName, now, now) if err != nil { return nil, err } id, _ := res.LastInsertId() result = item result.ID = id result.CreatedAt = now result.UpdatedAt = now } else { newQty := item.QuantityAmount if delta { newQty = existing.QuantityAmount + item.QuantityAmount } if newQty < 0 { return nil, errors.New("lagerbestand darf nicht negativ werden") } result = existing result.Name = item.Name result.QuantityAmount = newQty result.Note = item.Note result.UpdatedByID = item.UpdatedByID result.UpdatedByName = item.UpdatedByName result.UpdatedAt = now if newQty == 0 { if _, err := tx.Exec(`DELETE FROM inventory WHERE id=?`, existing.ID); err != nil { return nil, err } } else { if _, err := tx.Exec(`UPDATE inventory SET name=?, quantity_amount=?, note=?, updated_by_id=?, updated_by_name=?, updated_at=? WHERE id=?`, item.Name, newQty, item.Note, item.UpdatedByID, item.UpdatedByName, now, existing.ID); err != nil { return nil, err } } } if err := addEventTx(tx, 0, item.UpdatedByID, item.UpdatedByName, EventInventoryChanged, fmt.Sprintf("Lager %s Q%d %s %s", item.Name, item.Quality, formatAmount(item.QuantityAmount), item.QuantityUnit)); err != nil { return nil, err } if err := tx.Commit(); err != nil { return nil, err } return &result, nil } func (s *Store) GetInventoryByKey(normalized string, quality int, unit, location string) (*InventoryItem, error) { row := s.db.QueryRow(`SELECT id, name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at FROM inventory WHERE normalized_name=? AND quality=? AND quantity_unit=? AND location=?`, normalized, quality, unit, location) return scanInventory(row) } func scanInventory(scanner interface{ Scan(dest ...any) error }) (*InventoryItem, error) { it := &InventoryItem{} if err := scanner.Scan(&it.ID, &it.Name, &it.NormalizedName, &it.Quality, &it.QuantityAmount, &it.QuantityUnit, &it.Location, &it.Note, &it.UpdatedByID, &it.UpdatedByName, &it.CreatedAt, &it.UpdatedAt); err != nil { return nil, err } return it, nil } func (s *Store) ListInventory(query string, limit int) ([]InventoryItem, error) { if limit <= 0 || limit > 50 { limit = 20 } q := `SELECT id, name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at FROM inventory WHERE quantity_amount > 0` args := []any{} if strings.TrimSpace(query) != "" { q += ` AND (normalized_name LIKE ? OR location LIKE ?)` like := "%" + normalizeName(query) + "%" args = append(args, like, like) } q += ` ORDER BY normalized_name, quality DESC LIMIT ?` args = append(args, limit) rows, err := s.db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() var out []InventoryItem for rows.Next() { it, err := scanInventory(rows) if err != nil { return nil, err } out = append(out, *it) } return out, rows.Err() } func (s *Store) FindInventoryForOrder(o *Order) ([]InventoryMatch, float64, error) { rows, err := s.db.Query(`SELECT id, name, normalized_name, quality, quantity_amount, quantity_unit, location, note, updated_by_id, updated_by_name, created_at, updated_at FROM inventory WHERE normalized_name=? AND quality>=? AND quantity_unit=? AND quantity_amount > 0 ORDER BY quality DESC, quantity_amount DESC`, normalizeName(o.Commodity), o.Quality, o.QuantityUnit) if err != nil { return nil, 0, err } defer rows.Close() var matches []InventoryMatch var total float64 for rows.Next() { it, err := scanInventory(rows) if err != nil { return nil, 0, err } total += it.QuantityAmount matches = append(matches, InventoryMatch{Item: *it, RequestedQuantity: o.QuantityAmount, Available: it.QuantityAmount >= o.QuantityAmount}) } return matches, total, rows.Err() } func quantityString(amount float64, unit string) string { return fmt.Sprintf("%s %s", formatAmount(amount), unit) } func formatAmount(v float64) string { if math.Abs(v-math.Round(v)) < 0.00001 { return fmt.Sprintf("%.0f", v) } return fmt.Sprintf("%.2f", v) }