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 collections import defaultdict
from datetime import date, timedelta from datetime import date, timedelta
from typing import Callable
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -109,46 +110,86 @@ def group_shopping_list(
return items 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]) @router.get("/shopping-list/by-location", response_model=list[LocationNeeds])
def shopping_list_by_location( def shopping_list_by_location(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[LocationNeeds]: ) -> list[LocationNeeds]:
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT """Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT
unter dem dort hinterlegten Mindestbestand liegt.""" unter dem dort hinterlegten Mindestbestand liegt."""
prod_needs: dict[int, list[LocationNeedProduct]] = defaultdict(list) prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
group_needs: dict[int, list[LocationNeedGroup]] = 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(): 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: if product is None:
continue continue
faktor, label = article_unit(product) faktor, label = article_unit(product)
stock = location_subtree_stock_base(db, product, e.location_id) / (faktor or 1.0) faktor = faktor or 1.0
if stock < e.min_stock: locs_min = {e.location_id: e.min_stock for e in entries}
prod_needs[e.location_id].append(LocationNeedProduct( 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, product_id=product.id, name=product.name, unit_label=label,
stock=round(stock, 3), min_stock=e.min_stock, stock=round(bestand[loc], 3), min_stock=locs_min[loc],
deficit=round(e.min_stock - stock, 3), 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(): 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: if group is None:
continue continue
unit = group.min_stock_unit unit = group.min_stock_unit
if unit is not None: if unit is not None:
base = BASE_OF_KIND[unit.kind] base = BASE_OF_KIND[unit.kind]
matching = [p for p in group.products if p.base_unit == base] 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 divisor, unit_name = unit.factor, unit.name
unit_name = unit.name
else: else:
stock = float(sum(location_subtree_stock_base(db, p, e.location_id) for p in group.products)) matching, divisor, unit_name = list(group.products), 1.0, ""
unit_name = "" locs_min = {e.location_id: e.min_stock for e in entries}
if stock < e.min_stock: bestand = {
group_needs[e.location_id].append(LocationNeedGroup( 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, group_id=group.id, name=group.name, unit_name=unit_name,
stock=round(stock, 3), min_stock=e.min_stock, stock=round(bestand[loc], 3), min_stock=locs_min[loc],
deficit=round(e.min_stock - stock, 3), deficit=round(need, 3),
)) ))
loc_ids = set(prod_needs) | set(group_needs) loc_ids = set(prod_needs) | set(group_needs)

View File

@@ -96,3 +96,51 @@ def test_unterlagerort_reicht_nicht_zeigt_restbedarf(db, user):
needs = shopping_list_by_location(db=db, _=user) needs = shopping_list_by_location(db=db, _=user)
assert needs[0].location_name == "Hedingen" assert needs[0].location_name == "Hedingen"
assert needs[0].products[0].deficit == 3 # 5 - 2 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 ? ( {abweichend.length === 0 && !bildAbweichend ? (
<p className="muted small">Keine abweichenden Angaben deine Daten sind aktuell.</p> <p className="muted small">Keine abweichenden Angaben deine Daten sind aktuell.</p>
) : ( ) : (
<div className="table-wrap">
<table className="table off-tabelle"> <table className="table off-tabelle">
<thead> <thead>
<tr> <tr>
@@ -93,6 +94,7 @@ export default function OffVergleich({
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
)} )}
</section> </section>
); );

View File

@@ -6,7 +6,7 @@ import Icon from "../components/Icon";
import DataTable from "../components/DataTable"; import DataTable from "../components/DataTable";
import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel"; import CategoryPathLabel, { pathParts } from "../components/CategoryPathLabel";
import { categoryInfoMap } from "../categoryPath"; import { categoryInfoMap } from "../categoryPath";
import { locationPathById } from "../locationPath"; import { locationOptions, locationPathById } from "../locationPath";
import { fmt } from "../units"; import { fmt } from "../units";
// Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm, // Anzeigeeinheit eines Produkts: die im Formular gewählte Einheit (Gramm,
@@ -163,6 +163,55 @@ export default function MinStock() {
} catch (err) { setError(err.message); } } 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). // Ausgewähltes Ziel des Dialogs + dessen Einheit (Menge wird darin erfasst).
const draftTarget = draft.kind === "product" const draftTarget = draft.kind === "product"
? products.find((p) => String(p.id) === String(draft.targetId)) ? products.find((p) => String(p.id) === String(draft.targetId))
@@ -224,8 +273,22 @@ export default function MinStock() {
render: (r) => (r.catId != null render: (r) => (r.catId != null
? <CategoryPathLabel parts={catInfo.get(r.catId)?.parts} fallback="" /> ? <CategoryPathLabel parts={catInfo.get(r.catId)?.parts} fallback="" />
: <span className="muted"></span>) }, : <span className="muted"></span>) },
{ key: "ort", header: "Ort", width: 220, filterText: (r) => r.ort, sortValue: (r) => r.ort, { key: "ort", header: "Ort", width: 240, filterText: (r) => r.ort, sortValue: (r) => r.ort,
render: (r) => (r.scope === "global" ? <span className="muted">{r.ort}</span> : 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, { key: "soll", header: "Mindestbestand", width: 190,
sortValue: (r) => r.soll ?? -1, sortValue: (r) => r.soll ?? -1,
render: (r) => (isAdmin ? ( 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 halbe Seite -, rechts die Chargen-/Bestandstabelle, die den restlichen Platz
bekommt, damit Menge/MHD/Lagerort ohne Seitwaerts-Scrollen nebeneinander bekommt, damit Menge/MHD/Lagerort ohne Seitwaerts-Scrollen nebeneinander
passen. Spiegelbild von .wide-aside (dort steht die Tabelle links). */ 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) { @media (max-width: 1000px) {
.grid-2.form-plus-table { grid-template-columns: minmax(0, 1fr); } .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 /* Knoepfe neben dem Barcode-Feld: nebeneinander, mit Umbruch auf schmalen
Fenstern. Das Eingabefeld daneben fuellt die restliche Breite. */ 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 { display: flex; flex-flow: row wrap; gap: var(--sp-2); flex: 0 1 auto; min-width: 0; }
.knopf-spalte .btn { justify-content: center; white-space: nowrap; } /* 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 */ /* Gegenueberstellung mit Open Food Facts */
.off-vergleich { .off-vergleich {