2026-06-03 00:17:12 +12:00
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
2026-06-13 10:00:15 +12:00
|
|
|
from sqlalchemy import case, func, or_, select
|
2026-06-03 15:09:21 +12:00
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
from sqlalchemy.orm import Session, joinedload, selectinload
|
2026-06-03 00:17:12 +12:00
|
|
|
|
|
|
|
|
from app.api.deps import AuthSession, get_auth_session
|
|
|
|
|
from app.db.session import get_db
|
2026-06-13 10:00:15 +12:00
|
|
|
from app.models.mix import Mix, MixIngredient
|
2026-06-03 15:09:21 +12:00
|
|
|
from app.models.product import Product, ProductIngredient
|
|
|
|
|
from app.models.raw_material import RawMaterial
|
|
|
|
|
from app.schemas.editor import (
|
2026-06-13 10:00:15 +12:00
|
|
|
EditorIngredientCreate,
|
|
|
|
|
EditorIngredientRow,
|
|
|
|
|
EditorIngredientUpdate,
|
|
|
|
|
EditorMixFormulaRead,
|
|
|
|
|
EditorMixIngredientCreate,
|
|
|
|
|
EditorMixIngredientUpdate,
|
|
|
|
|
EditorMixRow,
|
2026-06-03 15:09:21 +12:00
|
|
|
EditorMixUpdate,
|
|
|
|
|
EditorProductFormulaRead,
|
|
|
|
|
EditorProductIngredientCreate,
|
|
|
|
|
EditorProductIngredientUpdate,
|
|
|
|
|
EditorProductRow,
|
|
|
|
|
EditorProductUpdate,
|
|
|
|
|
)
|
2026-06-03 00:17:12 +12:00
|
|
|
from app.services.client_access_service import has_access_level
|
2026-06-13 10:00:15 +12:00
|
|
|
from app.services.costing_engine import calculate_raw_material_cost, get_active_price
|
2026-06-03 00:17:12 +12:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 15:09:21 +12:00
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
def _serialize_mix_row(mix: Mix, *, visible_count: int, product_count: int) -> dict:
|
|
|
|
|
return {
|
|
|
|
|
"id": mix.id,
|
|
|
|
|
"tenant_id": mix.tenant_id,
|
|
|
|
|
"client_name": mix.client_name,
|
|
|
|
|
"name": mix.name,
|
|
|
|
|
"visible": visible_count > 0,
|
|
|
|
|
"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 _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))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 15:09:21 +12:00
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 00:17:12 +12:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
@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.patch("/mixes/{mix_id}", response_model=EditorMixRow)
|
2026-06-03 00:17:12 +12:00
|
|
|
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")
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
updates = payload.model_dump(exclude_unset=True)
|
|
|
|
|
# `visible` is a virtual field: it fans out to the visibility of every product
|
|
|
|
|
# under the mix rather than mapping to a mix column.
|
|
|
|
|
visible = updates.pop("visible", None)
|
|
|
|
|
for field, value in updates.items():
|
2026-06-03 00:17:12 +12:00
|
|
|
setattr(mix, field, value)
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
if visible is not None:
|
|
|
|
|
for product in db.scalars(
|
|
|
|
|
select(Product).where(Product.tenant_id == session.tenant_id, Product.mix_id == mix_id)
|
|
|
|
|
).all():
|
|
|
|
|
product.visible = visible
|
|
|
|
|
|
2026-06-03 00:17:12 +12:00
|
|
|
db.commit()
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
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.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")
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
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")
|
|
|
|
|
for field, value in payload.model_dump(exclude_unset=True).items():
|
|
|
|
|
setattr(ingredient, field, value)
|
|
|
|
|
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")
|
|
|
|
|
db.delete(ingredient)
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=session.tenant_id or "")
|
|
|
|
|
return _serialize_mix_formula(mix)
|
2026-06-03 15:09:21 +12:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
2026-06-13 10:00:15 +12:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- 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.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()
|
|
|
|
|
for field, value in updates.items():
|
|
|
|
|
setattr(material, field, value)
|
|
|
|
|
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))
|