v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live. Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator). Fix: Security headers on all API responses (hardening) Add: New mix button available on the Mix Editor. Add: New ingredient button available on the Ingredient Editor
This commit is contained in:
@@ -13,6 +13,8 @@ from app.schemas.editor import (
|
||||
EditorIngredientRow,
|
||||
EditorIngredientUpdate,
|
||||
EditorMixFormulaRead,
|
||||
EditorMixCreate,
|
||||
EditorMixFormulaReplace,
|
||||
EditorMixIngredientCreate,
|
||||
EditorMixIngredientUpdate,
|
||||
EditorMixRow,
|
||||
@@ -22,9 +24,11 @@ from app.schemas.editor import (
|
||||
EditorProductIngredientUpdate,
|
||||
EditorProductRow,
|
||||
EditorProductUpdate,
|
||||
EditorResolvedMixFormula,
|
||||
)
|
||||
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"])
|
||||
|
||||
@@ -250,6 +254,25 @@ def list_editor_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,
|
||||
)
|
||||
db.add(mix)
|
||||
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,
|
||||
@@ -377,6 +400,94 @@ def delete_editor_mix_ingredient(
|
||||
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")
|
||||
|
||||
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.commit()
|
||||
mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id)
|
||||
return resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix)
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/ingredients", response_model=EditorProductFormulaRead)
|
||||
def get_editor_product_ingredients(
|
||||
product_id: int,
|
||||
|
||||
Reference in New Issue
Block a user