Migrationen 003–009, Kundaufträge mit Lieferschein, Abräumen mit Urteil, ntfy/Backup/Zeitfenster, Live-Stream und Mobile-Mehr-Menü.
461 lines
13 KiB
Python
461 lines
13 KiB
Python
"""Aufträge, Kunden und Positionen."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
from psycopg.types.json import Json
|
|
|
|
from grotti import store
|
|
|
|
ORDER_STATUSES = frozenset({
|
|
"idee",
|
|
"angebot",
|
|
"bestaetigt",
|
|
"druckt",
|
|
"fertig",
|
|
"geliefert",
|
|
"bezahlt",
|
|
})
|
|
|
|
PROJECT_TO_ORDER = {
|
|
"idee": "idee",
|
|
"bereit": "angebot",
|
|
"druckt": "druckt",
|
|
"fertig": "fertig",
|
|
}
|
|
|
|
IDEA_TO_ORDER = {
|
|
"offen": "idee",
|
|
"spaeter": "idee",
|
|
"erledigt": "fertig",
|
|
}
|
|
|
|
|
|
def normalize_status(value):
|
|
s = (value or "idee").strip().lower()
|
|
if s not in ORDER_STATUSES:
|
|
raise ValueError("Unbekannter Auftragsstatus.")
|
|
return s
|
|
|
|
|
|
def customer_out(row):
|
|
return {
|
|
"id": row["id"],
|
|
"name": row["name"],
|
|
"email": row.get("email") or "",
|
|
"notes": row.get("notes") or "",
|
|
"created_at": store.iso(row["created_at"]),
|
|
}
|
|
|
|
|
|
def order_item_out(row, file_name=None):
|
|
return {
|
|
"id": row["id"],
|
|
"order_id": row["order_id"],
|
|
"file_id": row.get("file_id"),
|
|
"file_name": file_name or "",
|
|
"qty": int(row.get("qty") or 1),
|
|
"unit_price": float(row.get("unit_price") or 0),
|
|
"material_type": row.get("material_type") or "",
|
|
"color": row.get("color") or "",
|
|
"done_ok": int(row.get("done_ok") or 0),
|
|
"done_bad": int(row.get("done_bad") or 0),
|
|
"note": row.get("note") or "",
|
|
}
|
|
|
|
|
|
def order_out(row, customer=None, items=None, progress=None):
|
|
data = {
|
|
"id": row["id"],
|
|
"customer_id": row.get("customer_id"),
|
|
"title": row["title"],
|
|
"status": row["status"],
|
|
"due_at": row["due_at"].isoformat() if row.get("due_at") else None,
|
|
"notes": row.get("notes") or "",
|
|
"quote": row.get("quote") or {},
|
|
"created_at": store.iso(row["created_at"]),
|
|
"updated_at": store.iso(row.get("updated_at") or row["created_at"]),
|
|
}
|
|
if customer is not None:
|
|
data["customer"] = customer
|
|
if items is not None:
|
|
data["items"] = items
|
|
if progress is not None:
|
|
data["progress"] = progress
|
|
return data
|
|
|
|
|
|
def order_progress(conn, order_id):
|
|
row = conn.execute(
|
|
"""
|
|
SELECT
|
|
COALESCE(SUM(qty), 0) AS target,
|
|
COALESCE(SUM(LEAST(done_ok, qty)), 0) AS done_ok,
|
|
COALESCE(SUM(done_bad), 0) AS done_bad
|
|
FROM order_items
|
|
WHERE order_id = %s
|
|
""",
|
|
(order_id,),
|
|
).fetchone()
|
|
target = int(row["target"] or 0)
|
|
done = int(row["done_ok"] or 0)
|
|
return {
|
|
"target": target,
|
|
"done": done,
|
|
"done_bad": int(row["done_bad"] or 0),
|
|
"label": "%d von %d" % (done, target) if target else "0 von 0",
|
|
}
|
|
|
|
|
|
def list_customers(conn):
|
|
rows = conn.execute(
|
|
"SELECT * FROM customers ORDER BY name, id"
|
|
).fetchall()
|
|
return [customer_out(r) for r in rows]
|
|
|
|
|
|
def get_customer(conn, customer_id):
|
|
row = conn.execute("SELECT * FROM customers WHERE id = %s", (customer_id,)).fetchone()
|
|
return customer_out(row) if row else None
|
|
|
|
|
|
def create_customer(conn, name, email="", notes=""):
|
|
row = conn.execute(
|
|
"""
|
|
INSERT INTO customers (name, email, notes)
|
|
VALUES (%s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(name.strip(), (email or "").strip(), (notes or "").strip()),
|
|
).fetchone()
|
|
return customer_out(row)
|
|
|
|
|
|
def update_customer(conn, customer_id, fields):
|
|
sets = []
|
|
values = []
|
|
for key in ("name", "email", "notes"):
|
|
if key in fields:
|
|
sets.append("%s = %%s" % key)
|
|
values.append(fields[key])
|
|
if not sets:
|
|
return get_customer(conn, customer_id)
|
|
values.append(customer_id)
|
|
row = conn.execute(
|
|
"UPDATE customers SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
|
values,
|
|
).fetchone()
|
|
return customer_out(row) if row else None
|
|
|
|
|
|
def delete_customer(conn, customer_id):
|
|
row = conn.execute(
|
|
"DELETE FROM customers WHERE id = %s RETURNING id",
|
|
(customer_id,),
|
|
).fetchone()
|
|
return bool(row)
|
|
|
|
|
|
def list_orders(conn, status=None):
|
|
where = ""
|
|
params = []
|
|
if status:
|
|
where = "WHERE o.status = %s"
|
|
params.append(status)
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT o.*, c.name AS customer_name
|
|
FROM orders o
|
|
LEFT JOIN customers c ON c.id = o.customer_id
|
|
%s
|
|
ORDER BY o.updated_at DESC, o.id DESC
|
|
""" % where,
|
|
params,
|
|
).fetchall()
|
|
out = []
|
|
for row in rows:
|
|
prog = order_progress(conn, row["id"])
|
|
customer = None
|
|
if row.get("customer_id"):
|
|
customer = {"id": row["customer_id"], "name": row.get("customer_name") or ""}
|
|
out.append(order_out(row, customer=customer, progress=prog))
|
|
return out
|
|
|
|
|
|
def get_order(conn, order_id):
|
|
row = conn.execute(
|
|
"""
|
|
SELECT o.*, c.name AS customer_name, c.email AS customer_email
|
|
FROM orders o
|
|
LEFT JOIN customers c ON c.id = o.customer_id
|
|
WHERE o.id = %s
|
|
""",
|
|
(order_id,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
items_rows = conn.execute(
|
|
"""
|
|
SELECT oi.*, f.name AS file_name
|
|
FROM order_items oi
|
|
LEFT JOIN files f ON f.id = oi.file_id
|
|
WHERE oi.order_id = %s
|
|
ORDER BY oi.id
|
|
""",
|
|
(order_id,),
|
|
).fetchall()
|
|
items = [order_item_out(r, r.get("file_name")) for r in items_rows]
|
|
customer = None
|
|
if row.get("customer_id"):
|
|
customer = {
|
|
"id": row["customer_id"],
|
|
"name": row.get("customer_name") or "",
|
|
"email": row.get("customer_email") or "",
|
|
}
|
|
return order_out(
|
|
row,
|
|
customer=customer,
|
|
items=items,
|
|
progress=order_progress(conn, order_id),
|
|
)
|
|
|
|
|
|
def create_order(conn, title, status="idee", customer_id=None, due_at=None, notes="", quote=None):
|
|
status = normalize_status(status)
|
|
due = None
|
|
if due_at:
|
|
if isinstance(due_at, date):
|
|
due = due_at
|
|
else:
|
|
due = date.fromisoformat(str(due_at)[:10])
|
|
row = conn.execute(
|
|
"""
|
|
INSERT INTO orders (customer_id, title, status, due_at, notes, quote)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
customer_id,
|
|
title.strip(),
|
|
status,
|
|
due,
|
|
(notes or "").strip(),
|
|
Json(quote if isinstance(quote, dict) else {}),
|
|
),
|
|
).fetchone()
|
|
return get_order(conn, row["id"])
|
|
|
|
|
|
def update_order(conn, order_id, fields):
|
|
sets = ["updated_at = now()"]
|
|
values = []
|
|
if "title" in fields:
|
|
sets.append("title = %s")
|
|
values.append(fields["title"])
|
|
if "status" in fields:
|
|
sets.append("status = %s")
|
|
values.append(normalize_status(fields["status"]))
|
|
if "customer_id" in fields:
|
|
sets.append("customer_id = %s")
|
|
values.append(fields["customer_id"])
|
|
if "due_at" in fields:
|
|
sets.append("due_at = %s")
|
|
due = fields["due_at"]
|
|
if due in ("", None):
|
|
values.append(None)
|
|
elif isinstance(due, date):
|
|
values.append(due)
|
|
else:
|
|
values.append(date.fromisoformat(str(due)[:10]))
|
|
if "notes" in fields:
|
|
sets.append("notes = %s")
|
|
values.append(fields["notes"] or "")
|
|
if "quote" in fields:
|
|
sets.append("quote = %s")
|
|
values.append(Json(fields["quote"] if isinstance(fields["quote"], dict) else {}))
|
|
if len(sets) <= 1:
|
|
return get_order(conn, order_id)
|
|
values.append(order_id)
|
|
row = conn.execute(
|
|
"UPDATE orders SET %s WHERE id = %%s RETURNING id" % ", ".join(sets),
|
|
values,
|
|
).fetchone()
|
|
return get_order(conn, row["id"]) if row else None
|
|
|
|
|
|
def delete_order(conn, order_id):
|
|
row = conn.execute("DELETE FROM orders WHERE id = %s RETURNING id", (order_id,)).fetchone()
|
|
return bool(row)
|
|
|
|
|
|
def create_order_item(conn, order_id, file_id=None, qty=1, unit_price=0, material_type="", color="", note=""):
|
|
if not conn.execute("SELECT 1 FROM orders WHERE id = %s", (order_id,)).fetchone():
|
|
return None
|
|
try:
|
|
qty_n = max(1, int(qty))
|
|
except (TypeError, ValueError):
|
|
qty_n = 1
|
|
row = conn.execute(
|
|
"""
|
|
INSERT INTO order_items (
|
|
order_id, file_id, qty, unit_price, material_type, color, note
|
|
)
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
RETURNING *
|
|
""",
|
|
(
|
|
order_id,
|
|
file_id,
|
|
qty_n,
|
|
float(unit_price or 0),
|
|
(material_type or "").strip(),
|
|
(color or "").strip(),
|
|
(note or "").strip(),
|
|
),
|
|
).fetchone()
|
|
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (order_id,))
|
|
file_name = ""
|
|
if file_id:
|
|
f = store.get_file_row(conn, file_id)
|
|
file_name = (f or {}).get("name") or ""
|
|
return order_item_out(row, file_name)
|
|
|
|
|
|
def get_order_item(conn, item_id):
|
|
row = conn.execute(
|
|
"""
|
|
SELECT oi.*, f.name AS file_name
|
|
FROM order_items oi
|
|
LEFT JOIN files f ON f.id = oi.file_id
|
|
WHERE oi.id = %s
|
|
""",
|
|
(item_id,),
|
|
).fetchone()
|
|
return order_item_out(row, row.get("file_name")) if row else None
|
|
|
|
|
|
def update_order_item(conn, item_id, fields):
|
|
sets = []
|
|
values = []
|
|
for key in ("file_id", "material_type", "color", "note"):
|
|
if key in fields:
|
|
sets.append("%s = %%s" % key)
|
|
values.append(fields[key])
|
|
if "qty" in fields:
|
|
try:
|
|
qty_n = max(1, int(fields["qty"]))
|
|
except (TypeError, ValueError):
|
|
qty_n = 1
|
|
sets.append("qty = %s")
|
|
values.append(qty_n)
|
|
if "unit_price" in fields:
|
|
sets.append("unit_price = %s")
|
|
values.append(float(fields["unit_price"] or 0))
|
|
if not sets:
|
|
return get_order_item(conn, item_id)
|
|
values.append(item_id)
|
|
row = conn.execute(
|
|
"""
|
|
UPDATE order_items SET %s
|
|
WHERE id = %%s
|
|
RETURNING order_id
|
|
""" % ", ".join(sets),
|
|
values,
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
|
|
return get_order_item(conn, item_id)
|
|
|
|
|
|
def delete_order_item(conn, item_id):
|
|
row = conn.execute(
|
|
"DELETE FROM order_items WHERE id = %s RETURNING order_id",
|
|
(item_id,),
|
|
).fetchone()
|
|
if row:
|
|
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (row["order_id"],))
|
|
return bool(row)
|
|
|
|
|
|
def apply_verdict_delta(conn, order_item_id, old_ok, old_bad, new_ok, new_bad):
|
|
"""Erhöht done_ok/done_bad an der Position — nur Delta zum vorherigen Lauf-Stand."""
|
|
if not order_item_id:
|
|
return
|
|
d_ok = int(new_ok or 0) - int(old_ok or 0)
|
|
d_bad = int(new_bad or 0) - int(old_bad or 0)
|
|
if d_ok == 0 and d_bad == 0:
|
|
return
|
|
conn.execute(
|
|
"""
|
|
UPDATE order_items
|
|
SET done_ok = done_ok + %s, done_bad = done_bad + %s
|
|
WHERE id = %s
|
|
""",
|
|
(d_ok, d_bad, order_item_id),
|
|
)
|
|
item = conn.execute(
|
|
"SELECT order_id FROM order_items WHERE id = %s",
|
|
(order_item_id,),
|
|
).fetchone()
|
|
if item:
|
|
conn.execute("UPDATE orders SET updated_at = now() WHERE id = %s", (item["order_id"],))
|
|
|
|
|
|
def enqueue_order_item(
|
|
conn,
|
|
order_id,
|
|
item_id,
|
|
host="",
|
|
family="",
|
|
priority=100,
|
|
spool_id=None,
|
|
ace_map=None,
|
|
bed_leveling=False,
|
|
override=False,
|
|
):
|
|
item = conn.execute(
|
|
"SELECT * FROM order_items WHERE id = %s AND order_id = %s",
|
|
(item_id, order_id),
|
|
).fetchone()
|
|
if not item:
|
|
raise ValueError("Position fehlt.")
|
|
file_id = item.get("file_id")
|
|
if not file_id:
|
|
raise ValueError("Position ohne Datei.")
|
|
if not store.get_file_row(conn, file_id):
|
|
raise ValueError("Datei fehlt.")
|
|
row = store.enqueue_print(
|
|
conn,
|
|
file_id,
|
|
host or "",
|
|
family or "",
|
|
priority,
|
|
spool_id,
|
|
ace_map if isinstance(ace_map, list) else [],
|
|
bool(bed_leveling),
|
|
bool(override),
|
|
order_item_id=item["id"],
|
|
)
|
|
conn.execute("UPDATE orders SET updated_at = now(), status = CASE WHEN status IN ('idee', 'angebot', 'bestaetigt') THEN 'druckt' ELSE status END WHERE id = %s", (order_id,))
|
|
return row
|
|
|
|
|
|
def attach_order_item_to_live_run(conn, host, filename, order_item_id):
|
|
"""Verknüpft gestarteten Lauf mit Auftragsposition."""
|
|
if not order_item_id or not host:
|
|
return
|
|
base = (filename or "").replace("\\", "/").rsplit("/", 1)[-1]
|
|
conn.execute(
|
|
"""
|
|
UPDATE runs
|
|
SET order_item_id = COALESCE(order_item_id, %s)
|
|
WHERE printer_host = %s
|
|
AND (filename = %s OR filename LIKE %s)
|
|
AND state IN ('printing', 'paused', 'in_progress', 'completed')
|
|
AND ended_at IS NULL
|
|
""",
|
|
(order_item_id, host, base, "%/" + base),
|
|
)
|