From daa6e60a69083fde7ef9b1853d9860d8887c8a04 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Wed, 3 Jun 2026 15:09:21 +1200 Subject: [PATCH] v0.1.11b fixes --- backend/app/api/editor.py | 155 +++++++++++++++++- backend/app/schemas/editor.py | 35 ++++ backend/app/seed.py | 44 ++++- backend/app/services/mix_calculator_pdf.py | 4 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/lib/api.ts | 17 ++ .../MixCalculatorPrintDocument.svelte | 6 +- .../src/lib/navigation/client-navigation.ts | 4 +- frontend/src/lib/types.ts | 20 +++ frontend/src/routes/editor/+page.svelte | 56 +++---- 11 files changed, 304 insertions(+), 43 deletions(-) diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py index 0eb6d5c..9f03e2a 100644 --- a/backend/app/api/editor.py +++ b/backend/app/api/editor.py @@ -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) diff --git a/backend/app/schemas/editor.py b/backend/app/schemas/editor.py index 9fb9bd0..e855329 100644 --- a/backend/app/schemas/editor.py +++ b/backend/app/schemas/editor.py @@ -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 diff --git a/backend/app/seed.py b/backend/app/seed.py index bf190de..fc7a078 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -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() diff --git a/backend/app/services/mix_calculator_pdf.py b/backend/app/services/mix_calculator_pdf.py index 3facea9..4932dc0 100644 --- a/backend/app/services/mix_calculator_pdf.py +++ b/backend/app/services/mix_calculator_pdf.py @@ -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") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f50bc96..c40b550 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.11", + "version": "0.1.11b", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.11", + "version": "0.1.11b", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 1f501d4..490f1c5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.11", + "version": "0.1.11b", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index f1d1d16..f1cca33 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -20,6 +20,7 @@ import type { ClientUserUpdateInput, LoginResponse, EditorMixUpdateInput, + EditorProductFormula, EditorProductRow, EditorProductUpdateInput, MixCalculatorCreateInput, @@ -340,6 +341,22 @@ export const api = { method: 'PATCH', body: JSON.stringify(payload) }, 'client'), + editorProductFormula: (productId: number) => + request(`/api/editor/products/${productId}/ingredients`, {}, 'client'), + addEditorProductIngredient: (productId: number, payload: { raw_material_id: number; quantity_kg: number; notes?: string | null }) => + request(`/api/editor/products/${productId}/ingredients`, { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), + updateEditorProductIngredient: (productId: number, ingredientId: number, payload: MixIngredientUpdateInput) => + request(`/api/editor/products/${productId}/ingredients/${ingredientId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), + deleteEditorProductIngredient: (productId: number, ingredientId: number) => + request(`/api/editor/products/${productId}/ingredients/${ingredientId}`, { + method: 'DELETE' + }, 'client'), productCosts: (fetcher?: ApiFetch) => cachedFetchJson('/api/powerbi/product-costs', mockCosts, 'client', fetcher), scenarios: (fetcher?: ApiFetch) => cachedFetchJson('/api/scenarios', mockScenarios, 'client', fetcher), diff --git a/frontend/src/lib/components/MixCalculatorPrintDocument.svelte b/frontend/src/lib/components/MixCalculatorPrintDocument.svelte index 7c6e0fd..8e69e9f 100644 --- a/frontend/src/lib/components/MixCalculatorPrintDocument.svelte +++ b/frontend/src/lib/components/MixCalculatorPrintDocument.svelte @@ -285,16 +285,16 @@ th { background: #fff; - font-size: 0.6rem; + font-size: 0.7rem; } td { - font-size: 0.7rem; + font-size: 0.82rem; vertical-align: top; } td strong { - font-size: 0.72rem; + font-size: 0.84rem; font-weight: 700; color: #000; } diff --git a/frontend/src/lib/navigation/client-navigation.ts b/frontend/src/lib/navigation/client-navigation.ts index 57cf388..57ff8df 100644 --- a/frontend/src/lib/navigation/client-navigation.ts +++ b/frontend/src/lib/navigation/client-navigation.ts @@ -1,7 +1,6 @@ import { Calculator, ClipboardPenLine, - FlaskConical, Gauge, LayoutDashboard, ShieldCheck, @@ -86,7 +85,8 @@ export const throughputItem: NavItem = { }; export const workingDocumentItems: NavItem[] = [ - { href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' } + // Mix Master remains available through the existing route and access logic, + // but is temporarily hidden from the sidebar. ]; export const accessControlItem: NavItem = { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e19070e..78c5f05 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -241,6 +241,26 @@ export type EditorMixUpdateInput = { notes?: string | null; }; +export type EditorProductIngredient = { + id: number; + raw_material_id: number; + raw_material_name: string; + quantity_kg: number; + sort_order: number; + notes: string | null; +}; + +export type EditorProductFormula = { + id: number; + tenant_id: string; + client_name: string; + name: string; + mix_id: number; + mix_name: string; + ingredients: EditorProductIngredient[]; + total_kg: number; +}; + export type Scenario = { id: number; name: string; diff --git a/frontend/src/routes/editor/+page.svelte b/frontend/src/routes/editor/+page.svelte index 813a23d..c13ac78 100644 --- a/frontend/src/routes/editor/+page.svelte +++ b/frontend/src/routes/editor/+page.svelte @@ -1,7 +1,7 @@