Mindestbestand in jeder angelegten Einheit erfassbar (und gemerkt)
- Produktformular: Auswahl aller verwalteten Einheiten passender Art (plus Packungen), inkl. Umrechnung beim Wechsel. - Die gewaehlte Erfassungseinheit wird gespeichert (products.min_stock_unit_id / min_stock_in_packages) und exakt so wieder angezeigt - vorher wurde sie aus der Produkteinheit abgeleitet. - ProductOut liefert min_stock_display + min_stock_unit_label; Produkttabelle zeigt den Mindestbestand damit in der erfassten Einheit mit Einheit dahinter. - Migration: ADD COLUMN IF NOT EXISTS fuer die zwei neuen Spalten. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,18 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
|
|||||||
name, factor = display_unit_info(product)
|
name, factor = display_unit_info(product)
|
||||||
out.unit_name = name
|
out.unit_name = name
|
||||||
out.unit_factor = factor
|
out.unit_factor = factor
|
||||||
|
|
||||||
|
# Mindestbestand in der Einheit anzeigen, in der er erfasst wurde.
|
||||||
|
if product.min_stock is not None:
|
||||||
|
if product.min_stock_in_packages and product.package_size:
|
||||||
|
out.min_stock_display = product.min_stock / product.package_size
|
||||||
|
out.min_stock_unit_label = "Pkg"
|
||||||
|
elif product.min_stock_unit is not None:
|
||||||
|
out.min_stock_display = product.min_stock / product.min_stock_unit.factor
|
||||||
|
out.min_stock_unit_label = product.min_stock_unit.name
|
||||||
|
else:
|
||||||
|
out.min_stock_display = product.min_stock / factor
|
||||||
|
out.min_stock_unit_label = name
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ def _ensure_schema() -> None:
|
|||||||
"REFERENCES units(id) ON DELETE SET NULL",
|
"REFERENCES units(id) ON DELETE SET NULL",
|
||||||
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
||||||
"REFERENCES units(id) ON DELETE SET NULL",
|
"REFERENCES units(id) ON DELETE SET NULL",
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
|
||||||
|
"REFERENCES units(id) ON DELETE SET NULL",
|
||||||
|
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
|
||||||
|
"NOT NULL DEFAULT FALSE",
|
||||||
]
|
]
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
for stmt in stmts:
|
for stmt in stmts:
|
||||||
|
|||||||
@@ -124,13 +124,21 @@ class Product(Base):
|
|||||||
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||||
)
|
)
|
||||||
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units
|
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units
|
||||||
|
# In welcher Einheit der Mindestbestand erfasst wurde (nur für die Anzeige).
|
||||||
|
min_stock_unit_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
min_stock_in_packages: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, nullable=False, default=False
|
||||||
|
)
|
||||||
|
|
||||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
||||||
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
|
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||||
|
|
||||||
group: Mapped[Group | None] = relationship(back_populates="products")
|
group: Mapped[Group | None] = relationship(back_populates="products")
|
||||||
display_unit: Mapped[Unit | None] = relationship()
|
display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id])
|
||||||
|
min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id])
|
||||||
lots: Mapped[list[Lot]] = relationship(
|
lots: Mapped[list[Lot]] = relationship(
|
||||||
back_populates="product", cascade="all, delete-orphan"
|
back_populates="product", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ def create_product(
|
|||||||
package_size=payload.package_size,
|
package_size=payload.package_size,
|
||||||
group_id=payload.group_id,
|
group_id=payload.group_id,
|
||||||
min_stock=payload.min_stock,
|
min_stock=payload.min_stock,
|
||||||
|
min_stock_unit_id=payload.min_stock_unit_id,
|
||||||
|
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
||||||
source="manual",
|
source="manual",
|
||||||
)
|
)
|
||||||
db.add(product)
|
db.add(product)
|
||||||
@@ -125,6 +127,8 @@ def update_product(
|
|||||||
product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id)
|
product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id)
|
||||||
except ConversionError as exc:
|
except ConversionError as exc:
|
||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||||
|
if data.get("min_stock_in_packages") is None:
|
||||||
|
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
|
||||||
for field, value in data.items():
|
for field, value in data.items():
|
||||||
setattr(product, field, value)
|
setattr(product, field, value)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -100,7 +100,10 @@ class ProductBase(BaseModel):
|
|||||||
unit_id: int | None = None
|
unit_id: int | None = None
|
||||||
package_size: float | None = Field(default=None, gt=0)
|
package_size: float | None = Field(default=None, gt=0)
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
min_stock: float | None = Field(default=None, ge=0)
|
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
|
||||||
|
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
||||||
|
min_stock_unit_id: int | None = None
|
||||||
|
min_stock_in_packages: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
class ProductCreate(ProductBase):
|
||||||
@@ -117,6 +120,8 @@ class ProductUpdate(BaseModel):
|
|||||||
package_size: float | None = Field(default=None, gt=0)
|
package_size: float | None = Field(default=None, gt=0)
|
||||||
group_id: int | None = None
|
group_id: int | None = None
|
||||||
min_stock: float | None = Field(default=None, ge=0)
|
min_stock: float | None = Field(default=None, ge=0)
|
||||||
|
min_stock_unit_id: int | None = None
|
||||||
|
min_stock_in_packages: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ProductOut(BaseModel):
|
class ProductOut(BaseModel):
|
||||||
@@ -131,6 +136,8 @@ class ProductOut(BaseModel):
|
|||||||
package_size: float | None
|
package_size: float | None
|
||||||
group_id: int | None
|
group_id: int | None
|
||||||
min_stock: float | None
|
min_stock: float | None
|
||||||
|
min_stock_unit_id: int | None = None
|
||||||
|
min_stock_in_packages: bool = False
|
||||||
source: str
|
source: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
# angereichert:
|
# angereichert:
|
||||||
@@ -139,6 +146,9 @@ class ProductOut(BaseModel):
|
|||||||
kind: str = ""
|
kind: str = ""
|
||||||
unit_name: str = ""
|
unit_name: str = ""
|
||||||
unit_factor: float = 1.0
|
unit_factor: float = 1.0
|
||||||
|
# Mindestbestand in der erfassten Einheit (für die Anzeige):
|
||||||
|
min_stock_display: float | None = None
|
||||||
|
min_stock_unit_label: str = ""
|
||||||
|
|
||||||
|
|
||||||
class LookupResult(BaseModel):
|
class LookupResult(BaseModel):
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export default function ProductForm() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [info, setInfo] = useState(null);
|
const [info, setInfo] = useState(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
// Einheit für die Mindestbestand-Eingabe: "unit" (gewählte Einheit) oder "package".
|
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
|
||||||
const [minUnit, setMinUnit] = useState("unit");
|
const [minUnit, setMinUnit] = useState("");
|
||||||
const [warnDays, setWarnDays] = useState(7);
|
const [warnDays, setWarnDays] = useState(7);
|
||||||
|
|
||||||
const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null;
|
const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null;
|
||||||
@@ -48,19 +48,20 @@ export default function ProductForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Faktor, mit dem der angezeigte Mindestbestand in Basiseinheiten umgerechnet wird.
|
// Faktor, mit dem der angezeigte Mindestbestand in Basiseinheiten umgerechnet wird.
|
||||||
function minFactor(mode, pkgSize, uFactor) {
|
// sel ist "package" oder die ID einer verwalteten Einheit.
|
||||||
if (mode === "package") return Number(pkgSize) > 0 ? Number(pkgSize) : 1;
|
function minFactor(sel, pkgSize, unitList) {
|
||||||
return uFactor || 1;
|
if (sel === "package") return Number(pkgSize) > 0 ? Number(pkgSize) : 1;
|
||||||
|
const u = unitList.find((x) => String(x.id) === String(sel));
|
||||||
|
return u ? u.factor : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeMinUnit(newUnit) {
|
function changeMinUnit(newSel) {
|
||||||
if (form.min_stock !== "" && newUnit !== minUnit) {
|
if (form.min_stock !== "" && newSel !== minUnit) {
|
||||||
const oldF = minFactor(minUnit, form.package_size, unitFactor);
|
const oldF = minFactor(minUnit, form.package_size, units);
|
||||||
const newF = minFactor(newUnit, form.package_size, unitFactor);
|
const newF = minFactor(newSel, form.package_size, units);
|
||||||
const base = Number(form.min_stock) * oldF;
|
set("min_stock", String((Number(form.min_stock) * oldF) / newF));
|
||||||
set("min_stock", String(base / newF));
|
|
||||||
}
|
}
|
||||||
setMinUnit(newUnit);
|
setMinUnit(newSel);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySuggestion(s, groupsList, unitList) {
|
function applySuggestion(s, groupsList, unitList) {
|
||||||
@@ -120,9 +121,13 @@ export default function ProductForm() {
|
|||||||
const uid = p.display_unit_id
|
const uid = p.display_unit_id
|
||||||
? String(p.display_unit_id)
|
? String(p.display_unit_id)
|
||||||
: canonicalUnitId(us, p.base_unit);
|
: canonicalUnitId(us, p.base_unit);
|
||||||
const uf = us.find((u) => String(u.id) === uid)?.factor || 1;
|
// Erfassungseinheit des Mindestbestands wiederherstellen.
|
||||||
const mode = p.package_size && p.package_size > 0 ? "package" : "unit";
|
const mode = p.min_stock_in_packages
|
||||||
const f = mode === "package" ? p.package_size : uf;
|
? "package"
|
||||||
|
: p.min_stock_unit_id
|
||||||
|
? String(p.min_stock_unit_id)
|
||||||
|
: uid;
|
||||||
|
const f = minFactor(mode, p.package_size, us);
|
||||||
setMinUnit(mode);
|
setMinUnit(mode);
|
||||||
setForm({
|
setForm({
|
||||||
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
||||||
@@ -148,9 +153,10 @@ export default function ProductForm() {
|
|||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
function buildPayload() {
|
function buildPayload() {
|
||||||
|
const sel = minUnit || form.unit_id;
|
||||||
let minBase = null;
|
let minBase = null;
|
||||||
if (form.min_stock !== "") {
|
if (form.min_stock !== "") {
|
||||||
minBase = Number(form.min_stock) * minFactor(minUnit, form.package_size, unitFactor);
|
minBase = Number(form.min_stock) * minFactor(sel, form.package_size, units);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
barcode: form.barcode || null,
|
barcode: form.barcode || null,
|
||||||
@@ -160,6 +166,8 @@ export default function ProductForm() {
|
|||||||
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
unit_id: form.unit_id === "" ? null : Number(form.unit_id),
|
||||||
package_size: form.package_size === "" ? null : Number(form.package_size),
|
package_size: form.package_size === "" ? null : Number(form.package_size),
|
||||||
min_stock: minBase,
|
min_stock: minBase,
|
||||||
|
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
|
||||||
|
min_stock_in_packages: sel === "package",
|
||||||
group_id: form.group_id === "" ? null : Number(form.group_id),
|
group_id: form.group_id === "" ? null : Number(form.group_id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -257,9 +265,11 @@ export default function ProductForm() {
|
|||||||
<div className="field-inline">
|
<div className="field-inline">
|
||||||
<input type="number" step="any" value={form.min_stock}
|
<input type="number" step="any" value={form.min_stock}
|
||||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
||||||
<select value={minUnit} onChange={(e) => changeMinUnit(e.target.value)}
|
<select value={minUnit || form.unit_id} onChange={(e) => changeMinUnit(e.target.value)}
|
||||||
disabled={readOnly} style={{ maxWidth: 150, marginTop: 0 }}>
|
disabled={readOnly} style={{ maxWidth: 160, marginTop: 0 }}>
|
||||||
<option value="unit">{unitName || "Einheit"}</option>
|
{units
|
||||||
|
.filter((u) => !selectedUnit || u.kind === selectedUnit.kind)
|
||||||
|
.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
{form.package_size && <option value="package">Packung(en)</option>}
|
{form.package_size && <option value="package">Packung(en)</option>}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,13 +99,7 @@ export default function Products() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="num muted">
|
<td className="num muted">
|
||||||
{p.min_stock != null ? (
|
{p.min_stock != null ? (
|
||||||
<>
|
<>{fmt(p.min_stock_display)} {p.min_stock_unit_label}</>
|
||||||
{fmt(p.min_stock / (p.unit_factor || 1))}{" "}
|
|
||||||
{p.unit_name || unitShort(p.base_unit)}
|
|
||||||
{p.package_size ? (
|
|
||||||
<span className="small"> · {fmt(p.min_stock / p.package_size)} Pkg</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : "–"}
|
) : "–"}
|
||||||
</td>
|
</td>
|
||||||
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
|
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
|
||||||
|
|||||||
Reference in New Issue
Block a user