Files
data-entry-app/backend/app/api/editor.py
T
2026-06-18 15:15:46 +12:00

978 lines
36 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import case, func, or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession, get_auth_session
from app.db.session import get_db
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial
from app.models.change_event import EditorChangeEvent
from app.schemas.editor import (
EditorChangeEventRead,
EditorIngredientCreate,
EditorIngredientRow,
EditorIngredientUpdate,
EditorMixFormulaRead,
EditorMixCreate,
EditorMixFormulaReplace,
EditorMixIngredientCreate,
EditorMixIngredientUpdate,
EditorMixRow,
EditorMixUpdate,
EditorProductFormulaRead,
EditorProductIngredientCreate,
EditorProductIngredientUpdate,
EditorProductRow,
EditorProductUpdate,
EditorResolvedMixFormula,
)
from app.services.change_log import (
ENTITY_INGREDIENT,
ENTITY_MIX,
diff_fields,
list_changes,
record_change,
)
from app.services.client_access_service import has_access_level
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
from app.services.mix_calculator_service import resolve_editor_mix_formula, resolve_representative_product
router = APIRouter(prefix="/api/editor", tags=["editor"])
def _serialize_row(product: Product) -> dict:
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"item_id": product.item_id,
"name": product.name,
"mix_id": product.mix_id,
"mix_client_name": product.mix.client_name if product.mix else "",
"mix_name": product.mix.name if product.mix else "",
"sale_type": product.sale_type,
"unit_of_measure": product.unit_of_measure,
"visible": product.visible,
"product_notes": product.notes,
"mix_notes": product.mix.notes if product.mix else None,
}
def _serialize_product_formula(product: Product) -> dict:
ingredients = [
{
"id": ingredient.id,
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"sort_order": ingredient.sort_order,
"notes": ingredient.notes,
}
for ingredient in sorted(product.ingredients, key=lambda item: (item.sort_order, item.raw_material.name if item.raw_material else ""))
]
return {
"id": product.id,
"tenant_id": product.tenant_id,
"client_name": product.client_name,
"name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"ingredients": ingredients,
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
}
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
# Status is product-driven once a mix has products (Active = at least one
# visible product). A mix with no products yet has nothing to fan out to, so
# it falls back to its own `status` column — that's what lets a brand-new
# mix read as Active instead of being stuck Inactive and hidden.
visible = visible_count > 0 if product_count > 0 else mix.status == "active"
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"visible": visible,
"product_count": product_count,
"visible_product_count": visible_count,
"notes": mix.notes,
}
def _mix_product_counts(db: Session, tenant_id: str) -> dict[int, tuple[int, int]]:
"""Per-mix (total products, visible products) used to drive the Status column."""
rows = db.execute(
select(
Product.mix_id,
func.count(),
func.sum(case((Product.visible, 1), else_=0)),
)
.where(Product.tenant_id == tenant_id)
.group_by(Product.mix_id)
).all()
return {mix_id: (int(total), int(visible or 0)) for mix_id, total, visible in rows}
def _serialize_mix_formula(mix: Mix) -> dict:
ingredients = [
{
"id": ingredient.id,
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"notes": ingredient.notes,
}
for ingredient in sorted(
mix.ingredients,
key=lambda item: item.raw_material.name if item.raw_material else "",
)
]
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"ingredients": ingredients,
"total_kg": round(sum(ingredient["quantity_kg"] for ingredient in ingredients), 4),
}
def _serialize_change_event(event: EditorChangeEvent) -> dict:
return {
"id": event.id,
"entity_type": event.entity_type,
"entity_id": event.entity_id,
"action": event.action,
"actor_name": event.actor_name,
"actor_email": event.actor_email,
"actor_role": event.actor_role,
"summary": event.summary,
"changes": event.changes or [],
"created_at": event.created_at,
}
def _format_kg(value: float) -> str:
text = f"{value:.4f}".rstrip("0").rstrip(".")
return f"{text or '0'} kg"
def _formula_deltas(
before: list[dict],
after: list[dict],
) -> list[dict]:
"""Per-ingredient before/after deltas between two resolved formulas.
`resolve_editor_mix_formula` returns plain dicts (ingredients are dicts too),
so read the rows by key, not attribute.
"""
before_map = {row["raw_material_name"]: row["quantity_kg"] for row in before}
after_map = {row["raw_material_name"]: row["quantity_kg"] for row in after}
deltas: list[dict] = []
for name in sorted(set(before_map) | set(after_map)):
old = before_map.get(name)
new = after_map.get(name)
if old == new:
continue
deltas.append(
{
"field": name,
"label": name,
"before": _format_kg(old) if old is not None else None,
"after": _format_kg(new) if new is not None else None,
}
)
return deltas
def _load_editor_mix_formula(db: Session, *, mix_id: int, tenant_id: str) -> Mix | None:
return db.scalar(
select(Mix)
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
)
def _load_editor_product_formula(db: Session, *, product_id: int, tenant_id: str) -> Product | None:
return db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == tenant_id)
.options(
joinedload(Product.mix),
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
)
)
def _require_editor_session(
session: AuthSession = Depends(get_auth_session),
db: Session = Depends(get_db),
) -> AuthSession:
if session.role == "internal":
permissions = session.module_permissions or {}
if not has_access_level(permissions.get("client_access"), "manage"):
raise HTTPException(status_code=403, detail="Lean access is required")
if not has_access_level(permissions.get("products"), "edit"):
raise HTTPException(status_code=403, detail="products edit access is required")
if not has_access_level(permissions.get("mix_master"), "edit"):
raise HTTPException(status_code=403, detail="mix_master edit access is required")
if not session.tenant_id:
raise HTTPException(status_code=403, detail="Internal user context is missing")
return session
raise HTTPException(status_code=403, detail="Lean access is required")
@router.get("/products", response_model=list[EditorProductRow])
def list_editor_products(
q: str | None = Query(default=None, max_length=255),
client_name: str | None = Query(default=None, max_length=255),
limit: int = Query(default=500, ge=1, le=1000),
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
statement = (
select(Product)
.where(Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
.join(Product.mix)
.order_by(Product.client_name, Product.name, Product.id)
.limit(limit)
)
if client_name:
statement = statement.where(Product.client_name == client_name)
if q:
term = f"%{q.strip()}%"
statement = statement.where(
or_(
Product.client_name.ilike(term),
Product.name.ilike(term),
Product.item_id.ilike(term),
Product.unit_of_measure.ilike(term),
Mix.name.ilike(term),
)
)
return [_serialize_row(product) for product in db.scalars(statement).all()]
@router.patch("/products/{product_id}", response_model=EditorProductRow)
def update_editor_product(
product_id: int,
payload: EditorProductUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == session.tenant_id)
.options(joinedload(Product.mix))
)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if payload.mix_id is not None:
mix = db.scalar(select(Mix).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return _serialize_row(product)
@router.get("/mixes", response_model=list[EditorMixRow])
def list_editor_mixes(
q: str | None = Query(default=None, max_length=255),
client_name: str | None = Query(default=None, max_length=255),
limit: int = Query(default=500, ge=1, le=1000),
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
statement = select(Mix).where(Mix.tenant_id == session.tenant_id)
if client_name:
statement = statement.where(Mix.client_name == client_name)
if q:
term = f"%{q.strip()}%"
statement = statement.where(or_(Mix.client_name.ilike(term), Mix.name.ilike(term)))
statement = statement.order_by(Mix.client_name, Mix.name, Mix.id).limit(limit)
counts = _mix_product_counts(db, session.tenant_id or "")
mixes = db.scalars(statement).all()
return [
_serialize_mix_row(mix, visible_count=counts.get(mix.id, (0, 0))[1], product_count=counts.get(mix.id, (0, 0))[0])
for mix in mixes
]
@router.post("/mixes", response_model=EditorMixRow, status_code=201)
def create_editor_mix(
payload: EditorMixCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = Mix(
tenant_id=session.tenant_id or "",
client_name=payload.client_name.strip(),
name=payload.name.strip(),
notes=payload.notes,
# Active by default so a freshly created mix shows under the default
# "Active" filter rather than being hidden until it has a visible product.
status="active",
)
db.add(mix)
db.flush()
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix.id,
action="created",
summary=f"Created mix “{mix.name}” for {mix.client_name}",
)
db.commit()
db.refresh(mix)
# A brand-new mix has no products yet, so it reads as Inactive (no visible products).
return _serialize_mix_row(mix, visible_count=0, product_count=0)
@router.patch("/mixes/{mix_id}", response_model=EditorMixRow)
def update_editor_mix(
mix_id: int,
payload: EditorMixUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
updates = payload.model_dump(exclude_unset=True)
# `visible` is a virtual field: for a mix with products it fans out to the
# visibility of every product; for a product-less mix it maps to the mix's
# own `status` column so the toggle still persists.
visible = updates.pop("visible", None)
product_total = (
db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
)
or 0
)
before = {field: getattr(mix, field) for field in updates}
if visible is not None:
if product_total > 0:
visible_before = db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id, Product.visible)
)
before["visible"] = bool(visible_before)
else:
before["visible"] = mix.status == "active"
for field, value in updates.items():
setattr(mix, field, value)
if visible is not None:
if product_total > 0:
for product in db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
).all():
product.visible = visible
else:
mix.status = "active" if visible else "inactive"
after = dict(updates)
if visible is not None:
after["visible"] = visible
deltas = diff_fields(
before,
after,
{"name": "Mix name", "client_name": "Client", "notes": "Notes", "visible": "Status (active)"},
)
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix.id,
action="updated",
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
changes=deltas,
)
db.commit()
counts = _mix_product_counts(db, session.tenant_id or "")
total, visible_count = counts.get(mix_id, (0, 0))
return _serialize_mix_row(mix, visible_count=visible_count, product_count=total)
@router.delete("/mixes/{mix_id}", status_code=204)
def delete_editor_mix(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""Delete a mix that no product depends on.
A product must reference a mix (`products.mix_id` is NOT NULL), so a mix that
still drives products can't be removed without orphaning them — those should
be marked inactive instead. The mix's own ingredient rows cascade away with
it via the `delete-orphan` relationship.
"""
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id))
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
product_total = (
db.scalar(
select(func.count())
.select_from(Product)
.where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
)
or 0
)
if product_total > 0:
raise HTTPException(
status_code=409,
detail=(
f"This mix has {product_total} linked product"
f"{'s' if product_total != 1 else ''}. Mark it inactive or remove its products first."
),
)
db.delete(mix)
db.commit()
return None
@router.get("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead)
def get_editor_mix_ingredients(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
return _serialize_mix_formula(mix)
@router.post("/mixes/{mix_id}/ingredients", response_model=EditorMixFormulaRead, status_code=201)
def add_editor_mix_ingredient(
mix_id: int,
payload: EditorMixIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
raw_material = db.scalar(
select(RawMaterial).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)
)
if raw_material is None:
raise HTTPException(status_code=404, detail="Raw material not found")
db.add(
MixIngredient(
tenant_id=session.tenant_id or "",
mix_id=mix_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
notes=payload.notes,
)
)
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="ingredient_added",
summary=f"Added {raw_material.name} ({_format_kg(payload.quantity_kg)})",
changes=[{"field": raw_material.name, "label": raw_material.name, "before": None, "after": _format_kg(payload.quantity_kg)}],
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=400, detail="Raw material is already on this mix") from exc
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.patch("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def update_editor_mix_ingredient(
mix_id: int,
ingredient_id: int,
payload: EditorMixIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient)
.join(Mix)
.where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
Mix.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
updates = payload.model_dump(exclude_unset=True)
before = {field: getattr(ingredient, field) for field in updates}
for field, value in updates.items():
setattr(ingredient, field, value)
deltas = diff_fields(before, updates, {"quantity_kg": f"{raw_material_name} quantity", "notes": f"{raw_material_name} notes"})
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="ingredient_updated",
summary=f"Updated {raw_material_name}",
changes=deltas,
)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.delete("/mixes/{mix_id}/ingredients/{ingredient_id}", response_model=EditorMixFormulaRead)
def delete_editor_mix_ingredient(
mix_id: int,
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(MixIngredient)
.join(Mix)
.where(
MixIngredient.id == ingredient_id,
MixIngredient.mix_id == mix_id,
Mix.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
raw_material_name = ingredient.raw_material.name if ingredient.raw_material else f"Raw material {ingredient.raw_material_id}"
removed_kg = ingredient.quantity_kg
db.delete(ingredient)
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="ingredient_removed",
summary=f"Removed {raw_material_name}",
changes=[{"field": raw_material_name, "label": raw_material_name, "before": _format_kg(removed_kg), "after": None}],
)
db.commit()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
return _serialize_mix_formula(mix)
@router.get("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def get_editor_mix_resolved_formula(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""The mix formula as the Mix Calculator reads it (product-first resolution).
This is what the Mix Editor displays, so the two surfaces show identical
ingredients and quantities. See `resolve_editor_mix_formula`.
"""
tenant_id = session.tenant_id or ""
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
@router.put("/mixes/{mix_id}/formula", response_model=EditorResolvedMixFormula)
def replace_editor_mix_formula(
mix_id: int,
payload: EditorMixFormulaReplace,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
"""Replace a mix's whole formula in one save.
Writes back to the *same source* the Mix Calculator reads: the representative
product's own formula (`ProductIngredient`) when it has one, otherwise the
shared mix master (`MixIngredient`). Either way the calculator immediately
reflects the edit.
"""
tenant_id = session.tenant_id or ""
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
if mix is None:
raise HTTPException(status_code=404, detail="Mix not found")
# Snapshot the formula as it stands so we can diff it against the saved one.
before_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
raw_ids = [row.raw_material_id for row in payload.rows]
if len(set(raw_ids)) != len(raw_ids):
raise HTTPException(status_code=400, detail="Each raw material can only appear once in a mix")
existing_ids = set(
db.scalars(
select(RawMaterial.id).where(RawMaterial.tenant_id == tenant_id, RawMaterial.id.in_(raw_ids))
).all()
)
missing = [raw_id for raw_id in raw_ids if raw_id not in existing_ids]
if missing:
raise HTTPException(status_code=404, detail="Raw material not found")
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix_id)
if product is not None and product.ingredients:
# Replace the representative product's own formula.
for ingredient in list(product.ingredients):
db.delete(ingredient)
db.flush()
for sort_order, row in enumerate(payload.rows, start=1):
db.add(
ProductIngredient(
tenant_id=tenant_id,
product_id=product.id,
raw_material_id=row.raw_material_id,
quantity_kg=row.quantity_kg,
sort_order=sort_order,
notes=row.notes,
)
)
else:
# No product-specific formula in play: edit the shared mix master, which
# is what the calculator falls back to for this mix.
for ingredient in list(mix.ingredients):
db.delete(ingredient)
db.flush()
for row in payload.rows:
db.add(
MixIngredient(
tenant_id=tenant_id,
mix_id=mix.id,
raw_material_id=row.raw_material_id,
quantity_kg=row.quantity_kg,
notes=row.notes,
)
)
db.flush()
# Drop now-stale ORM state so the re-resolve reads the rows we just wrote
# rather than the formerly-loaded ingredient collections from the identity map.
db.expire_all()
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
deltas = _formula_deltas(before_formula["ingredients"], after_formula["ingredients"])
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_MIX,
entity_id=mix_id,
action="formula_updated",
summary=f"Updated formula ({len(deltas)} ingredient {'change' if len(deltas) == 1 else 'changes'})",
changes=deltas,
)
db.commit()
return after_formula
@router.get("/mixes/{mix_id}/history", response_model=list[EditorChangeEventRead])
def get_editor_mix_history(
mix_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == tenant_id)) is None:
raise HTTPException(status_code=404, detail="Mix not found")
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_MIX, entity_id=mix_id)
return [_serialize_change_event(event) for event in events]
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
def get_editor_product_ingredients(
product_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return _serialize_product_formula(product)
@router.post("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead, status_code=201)
def add_editor_product_ingredient(
product_id: int,
payload: EditorProductIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
raise HTTPException(status_code=404, detail="Raw material not found")
next_sort_order = (
db.scalar(
select(func.coalesce(func.max(ProductIngredient.sort_order), 0)).where(ProductIngredient.product_id == product_id)
)
or 0
) + 1
db.add(
ProductIngredient(
tenant_id=session.tenant_id or "",
product_id=product_id,
raw_material_id=payload.raw_material_id,
quantity_kg=payload.quantity_kg,
sort_order=next_sort_order,
notes=payload.notes,
)
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=400, detail="Raw material is already on this product") from exc
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
@router.patch("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def update_editor_product_ingredient(
product_id: int,
ingredient_id: int,
payload: EditorProductIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(ProductIngredient)
.join(Product)
.where(
ProductIngredient.id == ingredient_id,
ProductIngredient.product_id == product_id,
Product.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(ingredient, field, value)
db.commit()
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
@router.delete("/products/{product_id}/ingredients/{ingredient_id}", response_model=EditorProductFormulaRead)
def delete_editor_product_ingredient(
product_id: int,
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
ingredient = db.scalar(
select(ProductIngredient)
.join(Product)
.where(
ProductIngredient.id == ingredient_id,
ProductIngredient.product_id == product_id,
Product.tenant_id == session.tenant_id,
)
)
if ingredient is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
db.delete(ingredient)
db.commit()
product = _load_editor_product_formula(db, product_id=product_id, tenant_id=session.tenant_id or "")
return _serialize_product_formula(product)
# --- Ingredients (raw materials) catalogue -----------------------------------
#
# The mix editor consumes raw materials as the ingredients dropdown; this gives
# Lean admins a sibling editor to curate that catalogue — the ingredients that
# ultimately get used inside mixes. Same auth/tenant model as the mix editor.
def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
active_price = get_active_price(material)
cost_per_kg = (
calculate_raw_material_cost(material, active_price).cost_per_kg if active_price is not None else None
)
return {
"id": material.id,
"name": material.name,
"supplier": material.supplier,
"unit_of_measure": material.unit_of_measure,
"kg_per_unit": material.kg_per_unit,
"status": material.status,
"rounding_decimals": material.rounding_decimals,
"notes": material.notes,
"cost_per_kg": cost_per_kg,
"usage_count": usage_count,
"created_at": material.created_at,
}
def _ingredient_usage_counts(db: Session, tenant_id: str) -> dict[int, int]:
"""How many product/mix formula rows reference each raw material."""
rows = db.execute(
select(ProductIngredient.raw_material_id, func.count())
.where(ProductIngredient.tenant_id == tenant_id)
.group_by(ProductIngredient.raw_material_id)
).all()
return {raw_material_id: count for raw_material_id, count in rows}
@router.get("/ingredients", response_model=list[EditorIngredientRow])
def list_editor_ingredients(
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == tenant_id)
.options(selectinload(RawMaterial.price_versions))
.order_by(RawMaterial.name)
).all()
usage = _ingredient_usage_counts(db, tenant_id)
return [_serialize_ingredient(material, usage.get(material.id, 0)) for material in materials]
@router.post("/ingredients", response_model=EditorIngredientRow, status_code=201)
def create_editor_ingredient(
payload: EditorIngredientCreate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
material = RawMaterial(
tenant_id=session.tenant_id or "",
name=payload.name.strip(),
supplier=(payload.supplier or "").strip() or None,
unit_of_measure=payload.unit_of_measure.strip(),
kg_per_unit=payload.kg_per_unit,
status=payload.status.strip() or "active",
rounding_decimals=payload.rounding_decimals,
notes=payload.notes,
)
db.add(material)
try:
db.flush()
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=material.id,
action="created",
summary=f"Created ingredient “{material.name}”",
)
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
db.refresh(material)
return _serialize_ingredient(material, 0)
@router.patch("/ingredients/{ingredient_id}", response_model=EditorIngredientRow)
def update_editor_ingredient(
ingredient_id: int,
payload: EditorIngredientUpdate,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
material = db.scalar(
select(RawMaterial)
.where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)
.options(selectinload(RawMaterial.price_versions))
)
if material is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
updates = payload.model_dump(exclude_unset=True)
if "name" in updates and updates["name"] is not None:
updates["name"] = updates["name"].strip()
if "supplier" in updates:
updates["supplier"] = (updates["supplier"] or "").strip() or None
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
updates["unit_of_measure"] = updates["unit_of_measure"].strip()
before = {field: getattr(material, field) for field in updates}
for field, value in updates.items():
setattr(material, field, value)
deltas = diff_fields(
before,
updates,
{
"name": "Name",
"supplier": "Supplier",
"unit_of_measure": "Unit of measure",
"kg_per_unit": "Kg per unit",
"status": "Status",
"rounding_decimals": "Rounding (dp)",
"notes": "Notes",
},
)
if deltas:
record_change(
db,
session=session,
entity_type=ENTITY_INGREDIENT,
entity_id=material.id,
action="updated",
summary=f"Updated {', '.join(delta['label'] for delta in deltas)}",
changes=deltas,
)
try:
db.commit()
except IntegrityError as exc:
db.rollback()
raise HTTPException(status_code=409, detail="An ingredient with that name already exists") from exc
db.refresh(material)
usage = _ingredient_usage_counts(db, tenant_id)
return _serialize_ingredient(material, usage.get(material.id, 0))
@router.get("/ingredients/{ingredient_id}/history", response_model=list[EditorChangeEventRead])
def get_editor_ingredient_history(
ingredient_id: int,
session: AuthSession = Depends(_require_editor_session),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or ""
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == ingredient_id, RawMaterial.tenant_id == tenant_id)) is None:
raise HTTPException(status_code=404, detail="Ingredient not found")
events = list_changes(db, tenant_id=tenant_id, entity_type=ENTITY_INGREDIENT, entity_id=ingredient_id)
return [_serialize_change_event(event) for event in events]