Files
sftrading/db.go
jbergner 720708f9d3
All checks were successful
release-tag / release-image (push) Successful in 2m8s
Deutsch und Cleanup der Kanäle
2026-05-29 21:48:19 +02:00

474 lines
17 KiB
Go

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, &notnull, &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)
}