Compare commits
2 Commits
39803dfb82
...
48d47f1d39
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48d47f1d39 | ||
|
|
f78ae924de |
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user