Compare commits

...

2 Commits

Author SHA1 Message Date
Scarriffle
48d47f1d39 Web: Mindestbestand-Ort per Dropdown verschieben + OFF-Panel-Regression behoben
MinStock: die ORT-Spalte ist jetzt ein Dropdown (Gesamt/Lagerort). Auswahl
verschiebt den Bedarf (moveRow) statt ihn zu duplizieren - mit Rueckfrage, falls
der Zielort schon belegt ist. Produktwerte werden dabei umgerechnet, Gruppen nicht.

OFF-Panel: meine schmalere Formularspalte hatte den Vergleichs-Button abgeschnitten
und die OFF-Tabelle aus der Karte laufen lassen. Tabelle jetzt in .table-wrap
(scrollt/stapelt), Button-Text darf umbrechen, Formularspalte etwas breiter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 09:32:45 +02:00
Scarriffle
f78ae924de Backend: verschachtelte Ort-Mindestbestaende hierarchisch verrechnen
shopping_list_by_location zaehlte Ober- und Unterort unabhaengig, obwohl der
Ober-Subtree den Unterort schon enthaelt - Bedarf wurde doppelt gemeldet. Jetzt
werden Bedarfe je Produkt/Gruppe von unten nach oben verrechnet (_netted_topups):
was in einen Unterort gekauft wird, deckt den Oberort mit. Im Mehl-Beispiel (Lemgo
5, Kueche 2, je 1 fehlend) meldet der Server nur noch 1 (Kueche) statt 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 09:32:45 +02:00
5 changed files with 183 additions and 27 deletions

View File

@@ -1,5 +1,6 @@
from collections import defaultdict
from datetime import date, timedelta
from typing import Callable
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -109,47 +110,87 @@ def group_shopping_list(
return items
def _netted_topups(
db: Session, locs_min: dict[str, float], stock_of: Callable[[str], float]
) -> dict[str, float]:
"""Bedarf je Ort mit verschachtelten Orten verrechnet: Was in einen Unterort
gekauft wird, liegt auch im Subtree des Oberorts und deckt dessen Bedarf mit.
``topup(ort)`` ist die je Ort ZUSÄTZLICH nötige Menge über die Käufe in den
Unterorten hinaus. So kostet „Lemgo braucht 5, Küche braucht 2" bei je 1 fehlend
nur 1 (in die Küche), nicht 2."""
locs = list(locs_min)
# Nachkommen-Bedarfsorte je Ort (im Lagerort-Baum), memoisiert von unten nach oben.
desc = {
loc: [d for d in locs if d != loc and d in descendant_location_ids(db, loc)]
for loc in locs
}
memo: dict[str, float] = {}
def topup(loc: str) -> float:
if loc not in memo:
committed = sum(topup(d) for d in desc[loc])
memo[loc] = max(0.0, locs_min[loc] - (stock_of(loc) + committed))
return memo[loc]
return {loc: topup(loc) for loc in locs}
@router.get("/shopping-list/by-location", response_model=list[LocationNeeds])
def shopping_list_by_location(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[LocationNeeds]:
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT
unter dem dort hinterlegten Mindestbestand liegt."""
prod_needs: dict[int, list[LocationNeedProduct]] = defaultdict(list)
group_needs: dict[int, list[LocationNeedGroup]] = defaultdict(list)
prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
# Je Produkt alle Ort-Mindestbestände sammeln und hierarchisch verrechnen.
prod_by_id: dict[int, list[ProductLocationMinStock]] = defaultdict(list)
for e in db.query(ProductLocationMinStock).all():
product = db.get(Product, e.product_id)
prod_by_id[e.product_id].append(e)
for product_id, entries in prod_by_id.items():
product = db.get(Product, product_id)
if product is None:
continue
faktor, label = article_unit(product)
stock = location_subtree_stock_base(db, product, e.location_id) / (faktor or 1.0)
if stock < e.min_stock:
prod_needs[e.location_id].append(LocationNeedProduct(
product_id=product.id, name=product.name, unit_label=label,
stock=round(stock, 3), min_stock=e.min_stock,
deficit=round(e.min_stock - stock, 3),
))
faktor = faktor or 1.0
locs_min = {e.location_id: e.min_stock for e in entries}
bestand = {loc: location_subtree_stock_base(db, product, loc) / faktor for loc in locs_min}
for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items():
if need > 1e-9:
prod_needs[loc].append(LocationNeedProduct(
product_id=product.id, name=product.name, unit_label=label,
stock=round(bestand[loc], 3), min_stock=locs_min[loc],
deficit=round(need, 3),
))
# Je Gruppe genauso Bestand je Ort ist die Summe der passenden Produkte im Subtree.
group_by_id: dict[int, list[GroupLocationMinStock]] = defaultdict(list)
for e in db.query(GroupLocationMinStock).all():
group = db.get(Group, e.group_id)
group_by_id[e.group_id].append(e)
for group_id, entries in group_by_id.items():
group = db.get(Group, group_id)
if group is None:
continue
unit = group.min_stock_unit
if unit is not None:
base = BASE_OF_KIND[unit.kind]
matching = [p for p in group.products if p.base_unit == base]
stock = sum(location_subtree_stock_base(db, p, e.location_id) for p in matching) / unit.factor
unit_name = unit.name
divisor, unit_name = unit.factor, unit.name
else:
stock = float(sum(location_subtree_stock_base(db, p, e.location_id) for p in group.products))
unit_name = ""
if stock < e.min_stock:
group_needs[e.location_id].append(LocationNeedGroup(
group_id=group.id, name=group.name, unit_name=unit_name,
stock=round(stock, 3), min_stock=e.min_stock,
deficit=round(e.min_stock - stock, 3),
))
matching, divisor, unit_name = list(group.products), 1.0, ""
locs_min = {e.location_id: e.min_stock for e in entries}
bestand = {
loc: sum(location_subtree_stock_base(db, p, loc) for p in matching) / divisor
for loc in locs_min
}
for loc, need in _netted_topups(db, locs_min, bestand.__getitem__).items():
if need > 1e-9:
group_needs[loc].append(LocationNeedGroup(
group_id=group.id, name=group.name, unit_name=unit_name,
stock=round(bestand[loc], 3), min_stock=locs_min[loc],
deficit=round(need, 3),
))
loc_ids = set(prod_needs) | set(group_needs)
namen = {

View File

@@ -96,3 +96,51 @@ def test_unterlagerort_reicht_nicht_zeigt_restbedarf(db, user):
needs = shopping_list_by_location(db=db, _=user)
assert needs[0].location_name == "Hedingen"
assert needs[0].products[0].deficit == 3 # 5 - 2
def test_verschachtelte_orte_werden_verrechnet(db, user):
"""Ober- UND Unterort haben einen Mindestbestand. Was in den Unterort gekauft
wird, liegt im Subtree des Oberorts und deckt ihn mit dann taucht der Oberort
nicht mehr auf (keine Doppelzählung)."""
p = Product(name="Mehl", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
lemgo = Location(name="Lemgo")
db.add(lemgo)
db.flush()
kueche = Location(name="Kueche", parent_id=lemgo.id)
db.add(kueche)
db.flush()
# 3 direkt in Lemgo, 1 in der Kueche -> Lemgo-Subtree = 4, Kueche = 1.
db.add(Lot(product_id=p.id, quantity=3, location_id=lemgo.id))
db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id))
db.add(ProductLocationMinStock(product_id=p.id, location_id=lemgo.id, min_stock=5))
db.add(ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2))
db.commit()
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
# 1 in die Kueche gekauft (2-1) hebt Lemgo auf 5 -> Lemgo verschwindet.
assert set(nach_ort) == {"Kueche"}
assert nach_ort["Kueche"].products[0].deficit == 1
def test_verschachtelte_orte_restbedarf_am_oberort(db, user):
"""Deckt der Unterort-Kauf den Oberort nicht ganz, bleibt der Restbedarf am
Oberort aber die Unterort-Menge wird nicht doppelt gezaehlt."""
p = Product(name="Mehl", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
lemgo = Location(name="Lemgo")
db.add(lemgo)
db.flush()
kueche = Location(name="Kueche", parent_id=lemgo.id)
db.add(kueche)
db.flush()
db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id)) # nur 1 in der Kueche
db.add(ProductLocationMinStock(product_id=p.id, location_id=lemgo.id, min_stock=5))
db.add(ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2))
db.commit()
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
assert nach_ort["Kueche"].products[0].deficit == 1 # 2 - 1
assert nach_ort["Lemgo"].products[0].deficit == 3 # 5 - (1 da + 1 aus Kueche-Kauf), nicht 4

View File

@@ -35,6 +35,7 @@ export default function OffVergleich({
{abweichend.length === 0 && !bildAbweichend ? (
<p className="muted small">Keine abweichenden Angaben deine Daten sind aktuell.</p>
) : (
<div className="table-wrap">
<table className="table off-tabelle">
<thead>
<tr>
@@ -93,6 +94,7 @@ export default function OffVergleich({
</tr>
</tbody>
</table>
</div>
)}
</section>
);

View File

@@ -6,7 +6,7 @@ import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
import { categoryInfoMap } from "../categoryPath";
import { locationPathById } from "../locationPath";
import { locationOptions, locationPathById } from "../locationPath";
import { fmt } from "../units";
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
@@ -163,6 +163,55 @@ export default function MinStock() {
} catch (err) { setError(err.message); }
}
// Einen bestehenden Mindestbestand auf einen anderen Ort verschieben: „(Gesamt)"
// (ziel = "") oder ein Lagerort. Der Wert bleibt, der bisherige Geltungsbereich
// wird geleert so entsteht keine Dublette. Produkt-Werte werden zwischen
// Anzeige-/Artikel-/Basiseinheit umgerechnet (wie in saveSoll), Gruppen nicht.
async function moveRow(row, ziel) {
const aktuell = row.scope === "loc" ? row.locId : "";
if (String(ziel) === String(aktuell)) return;
if (row.soll == null) return; // nichts zu verschieben
const ent = row.entity;
// Zielort schon mit einem Bedarf belegt? Dann würde er überschrieben.
if (ziel && (ent.location_min_stocks || []).some((e) => String(e.location_id) === String(ziel))) {
const ok = await confirm({
title: "Lagerort schon belegt",
message: `Für „${row.name}" gibt es an diesem Lagerort bereits einen Mindestbestand. Mit dem verschobenen Wert überschreiben?`,
confirmLabel: "Überschreiben", danger: true,
});
if (!ok) return;
}
setError(null);
try {
if (row.kind === "product") {
const p = ent;
const artikel = (row.soll * dispFactor(p)) / articleUnit(p);
let liste = p.location_min_stocks || [];
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null); // alten Ort raus
if (ziel) liste = mergeLoc(liste, ziel, artikel); // Zielort rein
// Gesamt-Wert setzen (Umzug nach Gesamt) oder leeren (Umzug weg von Gesamt).
if (row.scope === "global" || !ziel) {
await api.updateProduct(p.id, {
min_stock: ziel ? null : Math.round(row.soll * dispFactor(p)),
min_stock_in_packages: false,
min_stock_unit_id: p.display_unit_id ?? null,
});
}
if (row.scope === "loc" || ziel) await api.setProductLocationMinStock(p.id, liste);
} else {
const g = ent; // Gruppe: Gesamt und Ort teilen dieselbe Einheit keine Umrechnung
let liste = g.location_min_stocks || [];
if (row.scope === "loc") liste = mergeLoc(liste, row.locId, null);
if (ziel) liste = mergeLoc(liste, ziel, row.soll);
if (row.scope === "global" || !ziel) {
await api.updateGroup(g.id, { min_stock: ziel ? null : row.soll });
}
if (row.scope === "loc" || ziel) await api.setGroupLocationMinStock(g.id, liste);
}
await load();
} catch (err) { setError(err.message); }
}
// Ausgewähltes Ziel des Dialogs + dessen Einheit (Menge wird darin erfasst).
const draftTarget = draft.kind === "product"
? products.find((p) => String(p.id) === String(draft.targetId))
@@ -224,8 +273,22 @@ export default function MinStock() {
render: (r) => (r.catId != null
? <CategoryPathLabel parts={catInfo.get(r.catId)?.parts} fallback="" />
: <span className="muted"></span>) },
{ key: "ort", header: "Ort", width: 220, filterText: (r) => r.ort, sortValue: (r) => r.ort,
render: (r) => (r.scope === "global" ? <span className="muted">{r.ort}</span> : r.ort) },
{ key: "ort", header: "Ort", width: 240, filterText: (r) => r.ort, sortValue: (r) => r.ort,
render: (r) => {
// Editierbar, wenn es einen Wert zu verschieben gibt (leere Produktzeilen
// ohne Bedarf bleiben Text dort legt man über „+ Mindestbestand" an).
if (!isAdmin || r.soll == null) {
return r.scope === "global" ? <span className="muted">{r.ort}</span> : r.ort;
}
return (
<select value={r.scope === "loc" ? r.locId : ""} style={{ marginTop: 0, minWidth: 150 }}
title="Lagerort ändern der Mindestbestand wird verschoben"
onChange={(e) => moveRow(r, e.target.value)}>
<option value="">(Gesamt)</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
);
} },
{ key: "soll", header: "Mindestbestand", width: 190,
sortValue: (r) => r.soll ?? -1,
render: (r) => (isAdmin ? (

View File

@@ -193,7 +193,7 @@ h2 { font-size: 0.95rem; font-weight: 650; margin: 0 0 var(--sp-3); letter-spaci
halbe Seite -, rechts die Chargen-/Bestandstabelle, die den restlichen Platz
bekommt, damit Menge/MHD/Lagerort ohne Seitwaerts-Scrollen nebeneinander
passen. Spiegelbild von .wide-aside (dort steht die Tabelle links). */
.grid-2.form-plus-table { grid-template-columns: clamp(440px, 34%, 640px) minmax(0, 1fr); align-items: start; }
.grid-2.form-plus-table { grid-template-columns: clamp(480px, 38%, 720px) minmax(0, 1fr); align-items: start; }
@media (max-width: 1000px) {
.grid-2.form-plus-table { grid-template-columns: minmax(0, 1fr); }
}
@@ -263,8 +263,10 @@ input::placeholder { color: var(--muted); opacity: 0.7; }
/* Knoepfe neben dem Barcode-Feld: nebeneinander, mit Umbruch auf schmalen
Fenstern. Das Eingabefeld daneben fuellt die restliche Breite. */
.knopf-spalte { display: flex; flex-flow: row wrap; gap: var(--sp-2); flex: 0 0 auto; }
.knopf-spalte .btn { justify-content: center; white-space: nowrap; }
.knopf-spalte { display: flex; flex-flow: row wrap; gap: var(--sp-2); flex: 0 1 auto; min-width: 0; }
/* Kein nowrap: in einer schmalen Formularspalte darf ein langer Button-Text
("Mit Open Food Facts vergleichen") umbrechen statt abgeschnitten zu werden. */
.knopf-spalte .btn { justify-content: center; white-space: normal; min-width: 0; }
/* Gegenueberstellung mit Open Food Facts */
.off-vergleich {