läuft
This commit is contained in:
600
main.go
Normal file
600
main.go
Normal file
@@ -0,0 +1,600 @@
|
||||
// main.go
|
||||
// Einfaches Kücheninventar mit Weboberfläche und persistenter SQLite-Datenbank.
|
||||
// Features:
|
||||
// - Produkte: Name, Hersteller, Größe (Inhaltsmenge), Bild (Upload oder URL), bevorzugter Händler, Mindestbestand
|
||||
// - Bestände entstehen aus Einheiten (Chargen) mit je einem Ablaufdatum
|
||||
// - Automatischer Bestand je Produkt = Anzahl Einheiten mit Status "in"
|
||||
// - Vorschlag: Nächste ablaufende Einheit (global und je Produkt) mit Button "Ausbuchen"
|
||||
// - Warnungen bei Unterschreitung Mindestbestand
|
||||
// - Einkaufslisten pro Händler: Welche Artikel und Menge sind zu beschaffen (Min - Ist, falls > 0)
|
||||
//
|
||||
// Start:
|
||||
// go mod init kitcheninv
|
||||
// go get modernc.org/sqlite
|
||||
// go run .
|
||||
//
|
||||
// Danach im Browser: http://localhost:8080
|
||||
// Uploads liegen im Ordner ./uploads
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
//go:embed templates/* assets/*
|
||||
var embeddedFS embed.FS
|
||||
|
||||
type App struct {
|
||||
DB *sql.DB
|
||||
Templates *template.Template // (legacy – nicht genutzt für Rendering)
|
||||
UploadDir string
|
||||
}
|
||||
|
||||
type Product struct {
|
||||
ID int
|
||||
Name string
|
||||
Manufacturer string
|
||||
Size string
|
||||
ImagePath string // lokal gespeicherter Pfad oder externe URL
|
||||
PreferredVendor string
|
||||
MinStock int
|
||||
CurrentStock int // berechnet
|
||||
}
|
||||
|
||||
type Unit struct {
|
||||
ID int
|
||||
ProductID int
|
||||
ExpiryDate string // YYYY-MM-DD
|
||||
Status string // "in" | "out"
|
||||
AddedAt string
|
||||
Product *Product // optional befüllt bei JOINs
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := os.MkdirAll("uploads", 0o755); err != nil {
|
||||
log.Fatalf("kann Upload-Verzeichnis nicht erstellen: %v", err)
|
||||
}
|
||||
|
||||
dsn := "file:kueche.db?_pragma=busy_timeout(5000)&cache=shared"
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := initDB(db); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Templates werden pro View on-demand zusammengebaut, um Mehrfach-Definitionen zu vermeiden.
|
||||
tmpl := template.New("legacy") // Platzhalter – global ungenutzt
|
||||
|
||||
app := &App{DB: db, Templates: tmpl /* legacy */, UploadDir: "uploads"}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Routen
|
||||
mux.HandleFunc("/", app.handleDashboard)
|
||||
mux.HandleFunc("/products", app.handleProducts)
|
||||
mux.HandleFunc("/products/create", app.handleCreateProduct)
|
||||
mux.HandleFunc("/products/", app.handleProductDetail) // /products/{id}
|
||||
mux.HandleFunc("/units/", app.handleUnitActions) // /units/{id}/checkout
|
||||
mux.HandleFunc("/alerts", app.handleAlerts)
|
||||
mux.HandleFunc("/shopping-list", app.handleShoppingList)
|
||||
|
||||
// Statische Assets (CSS)
|
||||
mux.Handle("/assets/", http.FileServer(http.FS(embeddedFS)))
|
||||
// Uploads
|
||||
uploadFS := http.FileServer(http.Dir(app.UploadDir))
|
||||
mux.Handle("/uploads/", http.StripPrefix("/uploads/", uploadFS))
|
||||
|
||||
addr := ":8080"
|
||||
log.Printf("Server läuft auf http://localhost%v", addr)
|
||||
if err := http.ListenAndServe(addr, securityHeaders(mux)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer-when-downgrade")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// --- DB ---
|
||||
|
||||
func initDB(db *sql.DB) error {
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Schema
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
manufacturer TEXT,
|
||||
size TEXT,
|
||||
image_path TEXT,
|
||||
preferred_vendor TEXT,
|
||||
min_stock INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS units (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
expiry_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'in',
|
||||
added_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_units_product_status ON units(product_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_units_expiry_in ON units(expiry_date) WHERE status = 'in';
|
||||
`
|
||||
_, err := db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
// Hilfsfunktionen
|
||||
|
||||
func atoiDefault(s string, def int) int {
|
||||
i, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func (a *App) render(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
files := []string{"templates/base.gohtml", "templates/partials.gohtml"}
|
||||
switch name {
|
||||
case "dashboard.gohtml":
|
||||
files = append(files, "templates/dashboard.gohtml")
|
||||
case "products.gohtml":
|
||||
files = append(files, "templates/products.gohtml")
|
||||
case "product_create.gohtml":
|
||||
files = append(files, "templates/product_create.gohtml")
|
||||
case "product_detail.gohtml":
|
||||
files = append(files, "templates/product_detail.gohtml")
|
||||
case "alerts.gohtml":
|
||||
files = append(files, "templates/alerts.gohtml")
|
||||
case "shopping_list.gohtml":
|
||||
files = append(files, "templates/shopping_list.gohtml")
|
||||
default:
|
||||
http.Error(w, "Unbekannte Ansicht", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl := template.Must(template.New("base").Funcs(template.FuncMap{
|
||||
"dateHuman": func(iso string) string {
|
||||
if iso == "" {
|
||||
return ""
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", iso)
|
||||
if err != nil {
|
||||
return iso
|
||||
}
|
||||
return t.Format("02.01.2006")
|
||||
},
|
||||
"now": time.Now,
|
||||
}).ParseFS(embeddedFS, files...))
|
||||
if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
|
||||
log.Println("template error:", err)
|
||||
http.Error(w, "Template-Fehler", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) loadProduct(id int) (*Product, error) {
|
||||
row := a.DB.QueryRow(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products WHERE id=?`, id)
|
||||
var p Product
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stock, _ := a.productStock(p.ID)
|
||||
p.CurrentStock = stock
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (a *App) productStock(productID int) (int, error) {
|
||||
row := a.DB.QueryRow(`SELECT COUNT(1) FROM units WHERE product_id=? AND status='in'`, productID)
|
||||
var c int
|
||||
return c, row.Scan(&c)
|
||||
}
|
||||
|
||||
func (a *App) nextExpiringGlobal() (*Unit, error) {
|
||||
row := a.DB.QueryRow(`
|
||||
SELECT u.id, u.product_id, u.expiry_date, u.status, u.added_at,
|
||||
p.id, p.name, p.manufacturer, p.size, p.image_path, p.preferred_vendor, p.min_stock
|
||||
FROM units u
|
||||
JOIN products p ON p.id = u.product_id
|
||||
WHERE u.status='in'
|
||||
ORDER BY u.expiry_date ASC
|
||||
LIMIT 1
|
||||
`)
|
||||
var u Unit
|
||||
var p Product
|
||||
if err := row.Scan(&u.ID, &u.ProductID, &u.ExpiryDate, &u.Status, &u.AddedAt,
|
||||
&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stock, _ := a.productStock(p.ID)
|
||||
p.CurrentStock = stock
|
||||
u.Product = &p
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// alle Produkte laden
|
||||
rows, err := a.DB.Query(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var products []Product
|
||||
for rows.Next() {
|
||||
var p Product
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err == nil {
|
||||
p.CurrentStock, _ = a.productStock(p.ID)
|
||||
products = append(products, p)
|
||||
}
|
||||
}
|
||||
var next *Unit
|
||||
if u, err := a.nextExpiringGlobal(); err == nil {
|
||||
next = u
|
||||
}
|
||||
// Warnungen zählen
|
||||
alerts := 0
|
||||
for _, p := range products {
|
||||
if p.MinStock > 0 && p.CurrentStock < p.MinStock {
|
||||
alerts++
|
||||
}
|
||||
}
|
||||
data := map[string]any{
|
||||
"Products": products,
|
||||
"Next": next,
|
||||
"Alerts": alerts,
|
||||
}
|
||||
a.render(w, "dashboard.gohtml", data)
|
||||
}
|
||||
|
||||
func (a *App) handleProducts(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
rows, err := a.DB.Query(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []Product
|
||||
for rows.Next() {
|
||||
var p Product
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err == nil {
|
||||
p.CurrentStock, _ = a.productStock(p.ID)
|
||||
list = append(list, p)
|
||||
}
|
||||
}
|
||||
data := map[string]any{
|
||||
"Products": list,
|
||||
}
|
||||
a.render(w, "products.gohtml", data)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
http.Redirect(w, r, "/products/create", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (a *App) handleCreateProduct(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
a.render(w, "product_create.gohtml", nil)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseMultipartForm(20 << 20); err != nil { // 20MB
|
||||
http.Error(w, "Ungültiges Formular", 400)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
http.Error(w, "Name ist erforderlich", 400)
|
||||
return
|
||||
}
|
||||
manufacturer := strings.TrimSpace(r.FormValue("manufacturer"))
|
||||
size := strings.TrimSpace(r.FormValue("size"))
|
||||
preferredVendor := strings.TrimSpace(r.FormValue("preferred_vendor"))
|
||||
minStock := atoiDefault(r.FormValue("min_stock"), 0)
|
||||
|
||||
imagePath := strings.TrimSpace(r.FormValue("image_url"))
|
||||
// Datei-Upload hat Priorität, falls vorhanden
|
||||
file, header, err := r.FormFile("image_file")
|
||||
if err == nil && header != nil {
|
||||
defer file.Close()
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if extOK(ext) {
|
||||
fname := fmt.Sprintf("%d_%s", time.Now().UnixNano(), sanitizeFilename(header.Filename))
|
||||
outPath := filepath.Join(a.UploadDir, fname)
|
||||
out, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
http.Error(w, "Upload fehlgeschlagen", 500)
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, file); err != nil {
|
||||
http.Error(w, "Upload fehlgeschlagen", 500)
|
||||
return
|
||||
}
|
||||
imagePath = "/uploads/" + fname
|
||||
}
|
||||
}
|
||||
res, err := a.DB.Exec(`INSERT INTO products(name, manufacturer, size, image_path, preferred_vendor, min_stock) VALUES(?,?,?,?,?,?)`,
|
||||
name, manufacturer, size, imagePath, preferredVendor, minStock)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
id64, _ := res.LastInsertId()
|
||||
http.Redirect(w, r, fmt.Sprintf("/products/%d", id64), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func sanitizeFilename(s string) string {
|
||||
s = strings.ReplaceAll(s, " ", "_")
|
||||
s = strings.ReplaceAll(s, ":", "-")
|
||||
return s
|
||||
}
|
||||
|
||||
func extOK(ext string) bool {
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg", ".png", ".gif", ".webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleProductDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// erwartetes Muster: /products/{id} oder Unterpfade /add-units
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/products/"), "/")
|
||||
if len(parts) == 0 || parts[0] == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
id := atoiDefault(parts[0], 0)
|
||||
if id == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if len(parts) == 2 && parts[1] == "add-units" {
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Ungültiges Formular", 400)
|
||||
return
|
||||
}
|
||||
qty := atoiDefault(r.FormValue("quantity"), 0)
|
||||
exp := strings.TrimSpace(r.FormValue("expiry_date")) // YYYY-MM-DD
|
||||
if qty <= 0 || len(exp) != 10 {
|
||||
http.Error(w, "Menge und Datum erforderlich", 400)
|
||||
return
|
||||
}
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
tx, err := a.DB.Begin()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
stmt, err := tx.Prepare(`INSERT INTO units(product_id, expiry_date, status, added_at) VALUES(?, ?, 'in', ?)`)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
for i := 0; i < qty; i++ {
|
||||
if _, err := stmt.Exec(id, exp, now); err != nil {
|
||||
tx.Rollback()
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
stmt.Close()
|
||||
if err := tx.Commit(); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/products/%d", id), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Details anzeigen, optional POST zur Produktbearbeitung (Min-Bestand etc.)
|
||||
if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err == nil {
|
||||
min := atoiDefault(r.FormValue("min_stock"), 0)
|
||||
pv := strings.TrimSpace(r.FormValue("preferred_vendor"))
|
||||
size := strings.TrimSpace(r.FormValue("size"))
|
||||
manufacturer := strings.TrimSpace(r.FormValue("manufacturer"))
|
||||
_, err := a.DB.Exec(`UPDATE products SET min_stock=?, preferred_vendor=?, size=?, manufacturer=? WHERE id=?`, min, pv, size, manufacturer, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/products/%d", id), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
p, err := a.loadProduct(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Produkt nicht gefunden", 404)
|
||||
return
|
||||
}
|
||||
|
||||
// Einheiten laden (nur "in")
|
||||
rows, err := a.DB.Query(`SELECT id, product_id, expiry_date, status, added_at FROM units WHERE product_id=? AND status='in' ORDER BY expiry_date ASC, id ASC`, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var units []Unit
|
||||
for rows.Next() {
|
||||
var u Unit
|
||||
if err := rows.Scan(&u.ID, &u.ProductID, &u.ExpiryDate, &u.Status, &u.AddedAt); err == nil {
|
||||
units = append(units, u)
|
||||
}
|
||||
}
|
||||
var next *Unit
|
||||
if len(units) > 0 {
|
||||
n := units[0]
|
||||
n.Product = p
|
||||
next = &n
|
||||
}
|
||||
data := map[string]any{
|
||||
"Product": p,
|
||||
"Units": units,
|
||||
"Next": next,
|
||||
}
|
||||
a.render(w, "product_detail.gohtml", data)
|
||||
}
|
||||
|
||||
func (a *App) handleUnitActions(w http.ResponseWriter, r *http.Request) {
|
||||
// /units/{id}/checkout
|
||||
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/units/"), "/")
|
||||
if len(parts) < 2 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
unitID := atoiDefault(parts[0], 0)
|
||||
action := parts[1]
|
||||
if unitID == 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if action == "checkout" && r.Method == http.MethodPost {
|
||||
// hole Produkt-ID für Redirect
|
||||
var productID int
|
||||
row := a.DB.QueryRow(`SELECT product_id FROM units WHERE id=?`, unitID)
|
||||
if err := row.Scan(&productID); err != nil {
|
||||
http.Error(w, "Einheit nicht gefunden", 404)
|
||||
return
|
||||
}
|
||||
_, err := a.DB.Exec(`UPDATE units SET status='out' WHERE id=?`, unitID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
// Entscheide Redirect: falls "from" übergeben, dorthin zurück
|
||||
from := r.URL.Query().Get("from")
|
||||
if from != "" {
|
||||
http.Redirect(w, r, from, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, fmt.Sprintf("/products/%d", productID), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
func (a *App) handleAlerts(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.DB.Query(`SELECT id, name, manufacturer, size, image_path, preferred_vendor, min_stock FROM products ORDER BY name COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var below []Product
|
||||
for rows.Next() {
|
||||
var p Product
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock); err == nil {
|
||||
p.CurrentStock, _ = a.productStock(p.ID)
|
||||
if p.MinStock > 0 && p.CurrentStock < p.MinStock {
|
||||
below = append(below, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
data := map[string]any{
|
||||
"Below": below,
|
||||
}
|
||||
a.render(w, "alerts.gohtml", data)
|
||||
}
|
||||
|
||||
func (a *App) handleShoppingList(w http.ResponseWriter, r *http.Request) {
|
||||
// Lade alle Produkte mit aktuellem Bestand, gruppiere in Go nach Händler
|
||||
rows, err := a.DB.Query(`
|
||||
SELECT p.id, p.name, p.manufacturer, p.size, p.image_path, p.preferred_vendor, p.min_stock,
|
||||
COALESCE(SUM(CASE WHEN u.status='in' THEN 1 ELSE 0 END), 0) AS stock
|
||||
FROM products p
|
||||
LEFT JOIN units u ON u.product_id = p.id
|
||||
GROUP BY p.id
|
||||
ORDER BY p.preferred_vendor, p.name COLLATE NOCASE
|
||||
`)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
type Item struct {
|
||||
Product
|
||||
Needed int
|
||||
}
|
||||
group := map[string][]Item{}
|
||||
for rows.Next() {
|
||||
var p Product
|
||||
var stock int
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Manufacturer, &p.Size, &p.ImagePath, &p.PreferredVendor, &p.MinStock, &stock); err == nil {
|
||||
p.CurrentStock = stock
|
||||
need := p.MinStock - stock
|
||||
if p.MinStock > 0 && need > 0 {
|
||||
key := p.PreferredVendor
|
||||
if strings.TrimSpace(key) == "" {
|
||||
key = "(Kein Händler)"
|
||||
}
|
||||
group[key] = append(group[key], Item{Product: p, Needed: need})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Für stabile Ausgabe auch Liste der Keys bauen
|
||||
var vendors []string
|
||||
for v := range group {
|
||||
vendors = append(vendors, v)
|
||||
}
|
||||
// einfache Sortierung der Vendor-Namen
|
||||
for i := 0; i < len(vendors); i++ {
|
||||
for j := i + 1; j < len(vendors); j++ {
|
||||
if strings.ToLower(vendors[j]) < strings.ToLower(vendors[i]) {
|
||||
vendors[i], vendors[j] = vendors[j], vendors[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
data := map[string]any{
|
||||
"Groups": group,
|
||||
"Vendors": vendors,
|
||||
}
|
||||
a.render(w, "shopping_list.gohtml", data)
|
||||
}
|
||||
Reference in New Issue
Block a user