v0.1.11b fixes
This commit is contained in:
+151
-4
@@ -1,12 +1,21 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import 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
|
||||
from app.models.product import Product
|
||||
from app.schemas.editor import EditorMixUpdate, EditorProductRow, EditorProductUpdate
|
||||
from app.models.product import Product, ProductIngredient
|
||||
from app.models.raw_material import RawMaterial
|
||||
from app.schemas.editor import (
|
||||
EditorMixUpdate,
|
||||
EditorProductFormulaRead,
|
||||
EditorProductIngredientCreate,
|
||||
EditorProductIngredientUpdate,
|
||||
EditorProductRow,
|
||||
EditorProductUpdate,
|
||||
)
|
||||
from app.services.client_access_service import has_access_level
|
||||
|
||||
router = APIRouter(prefix="/api/editor", tags=["editor"])
|
||||
@@ -30,6 +39,41 @@ def _serialize_row(product: Product) -> dict:
|
||||
}
|
||||
|
||||
|
||||
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 _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),
|
||||
@@ -135,3 +179,106 @@ def update_editor_mix(
|
||||
.order_by(Product.client_name, Product.name, Product.id)
|
||||
).all()
|
||||
return [_serialize_row(product) for product in products]
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@@ -36,3 +36,38 @@ class EditorMixUpdate(BaseModel):
|
||||
client_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class EditorProductIngredientCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
raw_material_id: int
|
||||
quantity_kg: float = Field(gt=0)
|
||||
notes: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class EditorProductIngredientUpdate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
quantity_kg: float | None = Field(default=None, gt=0)
|
||||
notes: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class EditorProductIngredientRead(BaseModel):
|
||||
id: int
|
||||
raw_material_id: int
|
||||
raw_material_name: str
|
||||
quantity_kg: float
|
||||
sort_order: int
|
||||
notes: str | None
|
||||
|
||||
|
||||
class EditorProductFormulaRead(BaseModel):
|
||||
id: int
|
||||
tenant_id: str
|
||||
client_name: str
|
||||
name: str
|
||||
mix_id: int
|
||||
mix_name: str
|
||||
ingredients: list[EditorProductIngredientRead]
|
||||
total_kg: float
|
||||
|
||||
+43
-1
@@ -8,7 +8,7 @@ from pathlib import Path
|
||||
import re
|
||||
|
||||
from openpyxl import load_workbook
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db.session import Base, SessionLocal, engine
|
||||
@@ -726,6 +726,45 @@ def _upsert_product_ingredients(
|
||||
db.delete(ingredient)
|
||||
|
||||
|
||||
def seed_product_ingredients_from_workbook(db) -> dict[str, int]:
|
||||
"""Backfill row-specific product formulas for databases seeded before this table existed."""
|
||||
try:
|
||||
formula_workbook = _load_workbook("mix_quantites_per_client_per_pr")
|
||||
except FileNotFoundError:
|
||||
logger.info("Skipping product ingredient backfill because formula workbook is missing")
|
||||
return {"formulas": 0, "products_with_formulas": 0, "backfilled": 0}
|
||||
product_ingredient_rows = _read_product_ingredient_rows(formula_workbook)
|
||||
if not product_ingredient_rows:
|
||||
return {"formulas": 0, "products_with_formulas": 0, "backfilled": 0}
|
||||
|
||||
raw_material_map = {
|
||||
material.name: material
|
||||
for material in db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == TENANT_ID)).all()
|
||||
}
|
||||
if not raw_material_map:
|
||||
return {"formulas": len(product_ingredient_rows), "products_with_formulas": 0, "backfilled": 0}
|
||||
|
||||
had_product_ingredients = (
|
||||
db.scalar(select(ProductIngredient.id).where(ProductIngredient.tenant_id == TENANT_ID).limit(1)) is not None
|
||||
)
|
||||
_upsert_product_ingredients(
|
||||
db,
|
||||
product_rows=[],
|
||||
product_ingredient_rows=product_ingredient_rows,
|
||||
raw_material_map=raw_material_map,
|
||||
)
|
||||
db.flush()
|
||||
|
||||
products_with_formulas = db.scalar(
|
||||
select(func.count(func.distinct(ProductIngredient.product_id))).where(ProductIngredient.tenant_id == TENANT_ID)
|
||||
)
|
||||
return {
|
||||
"formulas": len(product_ingredient_rows),
|
||||
"products_with_formulas": int(products_with_formulas or 0),
|
||||
"backfilled": 0 if had_product_ingredients else int(products_with_formulas or 0),
|
||||
}
|
||||
|
||||
|
||||
def _infer_throughput_bag_size(product: Product) -> float | None:
|
||||
if product.sale_type == "bulka":
|
||||
return None
|
||||
@@ -1027,6 +1066,9 @@ def seed_startup_basics():
|
||||
seed_client_access(db)
|
||||
seed_access(db)
|
||||
seed_throughput_workbook(db)
|
||||
report = seed_product_ingredients_from_workbook(db)
|
||||
if report["backfilled"]:
|
||||
logger.info("Product ingredients backfilled from workbook: %s", report)
|
||||
db.commit()
|
||||
|
||||
|
||||
|
||||
@@ -287,12 +287,12 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
|
||||
available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding
|
||||
row_count = max(len(session_record.lines), 1)
|
||||
row_height = clamp(available_table_height / row_count, 16, 32)
|
||||
table_font_size = clamp(row_height * 0.36, 7, 11)
|
||||
table_font_size = clamp(row_height * 0.44, 8.5, 12.5)
|
||||
table_height = table_header_height + (row_height * row_count)
|
||||
table_bottom = table_top - table_height
|
||||
|
||||
pdf.setFillColor(palette["muted"])
|
||||
pdf.setFont("Helvetica-Bold", 7.5)
|
||||
pdf.setFont("Helvetica-Bold", 8.5)
|
||||
pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL")
|
||||
pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user