From 5cb95266d8414b401c6b57ffd4e12870619d057d Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Wed, 29 Apr 2026 23:05:27 +1200 Subject: [PATCH] Mix calculator --- MIX-CALCULATOR.MD | 143 ++++ backend/app/api/mix_calculator.py | 95 +++ backend/app/db/migrations.py | 33 + backend/app/main.py | 3 + backend/app/models/__init__.py | 3 + backend/app/models/mix_calculator.py | 57 ++ backend/app/schemas/mix_calculator.py | 103 +++ backend/app/seed.py | 4 +- backend/app/services/client_access_service.py | 61 +- backend/app/services/costing_engine.py | 4 +- .../app/services/mix_calculator_service.py | 311 +++++++ backend/tests/test_costing_engine.py | 154 +++- frontend/src/lib/api.ts | 28 + .../src/lib/components/ClientShell.svelte | 69 +- .../components/MixCalculatorPrintSheet.svelte | 321 ++++++++ .../components/MixCalculatorWorkspace.svelte | 763 ++++++++++++++++++ frontend/src/lib/mock.ts | 110 ++- frontend/src/lib/types.ts | 95 +++ frontend/src/routes/+layout.svelte | 5 +- frontend/src/routes/load-fetch.test.ts | 35 + .../src/routes/mix-calculator/+page.svelte | 351 ++++++++ frontend/src/routes/mix-calculator/+page.ts | 22 + .../routes/mix-calculator/[id]/+page.svelte | 61 ++ .../src/routes/mix-calculator/[id]/+page.ts | 39 + .../mix-calculator/[id]/print/+page.svelte | 61 ++ .../routes/mix-calculator/[id]/print/+page.ts | 28 + .../routes/mix-calculator/new/+page.svelte | 6 + .../src/routes/mix-calculator/new/+page.ts | 24 + 28 files changed, 2943 insertions(+), 46 deletions(-) create mode 100644 MIX-CALCULATOR.MD create mode 100644 backend/app/api/mix_calculator.py create mode 100644 backend/app/models/mix_calculator.py create mode 100644 backend/app/schemas/mix_calculator.py create mode 100644 backend/app/services/mix_calculator_service.py create mode 100644 frontend/src/lib/components/MixCalculatorPrintSheet.svelte create mode 100644 frontend/src/lib/components/MixCalculatorWorkspace.svelte create mode 100644 frontend/src/routes/mix-calculator/+page.svelte create mode 100644 frontend/src/routes/mix-calculator/+page.ts create mode 100644 frontend/src/routes/mix-calculator/[id]/+page.svelte create mode 100644 frontend/src/routes/mix-calculator/[id]/+page.ts create mode 100644 frontend/src/routes/mix-calculator/[id]/print/+page.svelte create mode 100644 frontend/src/routes/mix-calculator/[id]/print/+page.ts create mode 100644 frontend/src/routes/mix-calculator/new/+page.svelte create mode 100644 frontend/src/routes/mix-calculator/new/+page.ts diff --git a/MIX-CALCULATOR.MD b/MIX-CALCULATOR.MD new file mode 100644 index 0000000..d515ed6 --- /dev/null +++ b/MIX-CALCULATOR.MD @@ -0,0 +1,143 @@ +### Mix Calculator — Clean Feature Spec + +# Purpose +The Mix Calculator allows authorised users to calculate the raw materials required for a client-specific product mix, based on a selected client, product, date, and batch size. + +It should also keep a history of each calculation session so users can review previous mixes without storing generated PDFs in the database. + +# Core Workflow + +- User opens Mix Calculator from the sidebar. +User enters/selects: +- Mix date (Defaults to today's date) +- Client +- Product - These should be populated from our existing products for that specific client +- Specify Batch size in kilograms +Total number of bags +Staff name / prepared by +App filters available products based on the selected client. +User selects a product. +App calculates: +Required raw materials +Required mix quantities +Total kilograms +Bag quantity details +User can save the session. +User can generate a polished PDF on demand. +Sidebar + +# Add a dedicated sidebar item: + +Mix Calculator + +This should be separate from costing, product setup, or admin areas. + +# Permissions +Create a dedicated permission area: + +mix_calculator:view +mix_calculator:create +mix_calculator:edit +mix_calculator:delete +mix_calculator:generate_pdf +mix_calculator:view_all_sessions + +# Suggested roles: + +Role Access +Admin Full access +Manager Create, view all, generate PDFs +Staff Create, view own sessions, generate PDFs +Viewer View only +Database Naming + +Avoid the table name mix-calculator because hyphens are awkward in SQL and code. + +Use: + +mix_calculator_sessions + +Optional supporting tables: + +clients +products +product_mixes +raw_materials +mix_calculator_session_lines +Suggested Tables +mix_calculator_sessions + +Stores each calculator run. + +id +session_number +client_id +product_id +mix_date +batch_size_kg +total_bags +total_kg +prepared_by_user_id +prepared_by_name +created_at +updated_at +created_by +status +notes +mix_calculator_session_lines + +Stores calculated raw material outputs for each session. + +id +session_id +raw_material_id +raw_material_name +required_kg +mix_percentage +unit +sort_order + +This allows historical sessions to remain accurate even if the product recipe changes later. + +PDF Behaviour + +PDFs should not be stored in the database. + +Instead: + +Generate PDF on demand from the saved session. +Use the saved session and session lines as the source. +Allow download as: +MixCalculator_{Client}_{Product}_{Date}_{SessionNumber}.pdf + +PDF should include: + +Date +Client +Product +Batch size +Total kilograms +Total bags +Prepared by / staff name +Raw material table +Required mix table +Session number +Generated timestamp +Feature Name Options + +Recommended: + +Mix Calculator + +Other options: + +Batch Mix Calculator +Production Mix Calculator +Client Mix Calculator +Mix Session Calculator + +Best fit: Mix Calculator. + +Clean Requirement Summary + +Build a dedicated Mix Calculator module that allows authorised users to create client-specific mix calculation sessions. Users select a date, client, product, batch size, total bags, and staff name. The system calculates required raw materials and mix quantities, saves the session history, and allows users to generate a polished PDF on demand. PDFs should not be stored in the database; they should be generated dynamically from the saved session data. \ No newline at end of file diff --git a/backend/app/api/mix_calculator.py b/backend/app/api/mix_calculator.py new file mode 100644 index 0000000..38183e6 --- /dev/null +++ b/backend/app/api/mix_calculator.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import AuthSession, require_client_module_access +from app.db.session import get_db +from app.schemas.mix_calculator import ( + MixCalculatorOptionsRead, + MixCalculatorPreviewRead, + MixCalculatorSessionCreate, + MixCalculatorSessionRead, + MixCalculatorSessionSummaryRead, + MixCalculatorSessionUpdate, +) +from app.services.mix_calculator_service import ( + build_mix_calculator_options, + calculate_mix_calculator_preview, + serialize_mix_calculator_session, + create_mix_calculator_session, + get_mix_calculator_session, + update_mix_calculator_session, + list_mix_calculator_sessions, + can_view_all_mix_calculator_sessions, +) + +router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"]) + + +@router.get("/options", response_model=MixCalculatorOptionsRead) +def mix_calculator_options( + session: AuthSession = Depends(require_client_module_access("mix_calculator")), + db: Session = Depends(get_db), +): + return build_mix_calculator_options(db, tenant_id=session.tenant_id or "") + + +@router.get("", response_model=list[MixCalculatorSessionSummaryRead]) +def mix_calculator_sessions( + session: AuthSession = Depends(require_client_module_access("mix_calculator")), + db: Session = Depends(get_db), +): + return list_mix_calculator_sessions(db, auth_session=session) + + +@router.post("/preview", response_model=MixCalculatorPreviewRead) +def preview_mix_calculator_session( + payload: MixCalculatorSessionCreate, + session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")), + db: Session = Depends(get_db), +): + try: + return calculate_mix_calculator_preview(db, tenant_id=session.tenant_id or "", payload=payload) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.post("", response_model=MixCalculatorSessionRead, status_code=status.HTTP_201_CREATED) +def create_saved_mix_calculator_session( + payload: MixCalculatorSessionCreate, + session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")), + db: Session = Depends(get_db), +): + try: + return create_mix_calculator_session(db, auth_session=session, payload=payload) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + +@router.get("/{session_id}", response_model=MixCalculatorSessionRead) +def read_mix_calculator_session( + session_id: int, + session: AuthSession = Depends(require_client_module_access("mix_calculator")), + db: Session = Depends(get_db), +): + session_record = get_mix_calculator_session(db, auth_session=session, session_id=session_id) + if session_record is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mix calculator session not found") + return serialize_mix_calculator_session(session_record, session) + + +@router.patch("/{session_id}", response_model=MixCalculatorSessionRead) +def patch_mix_calculator_session( + session_id: int, + payload: MixCalculatorSessionUpdate, + session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")), + db: Session = Depends(get_db), +): + session_record = get_mix_calculator_session(db, auth_session=session, session_id=session_id) + if session_record is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mix calculator session not found") + if not can_view_all_mix_calculator_sessions(session) and session_record.prepared_by_user_id != session.user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own mix calculator sessions") + try: + return update_mix_calculator_session(db, auth_session=session, session_record=session_record, payload=payload) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index 97a3e7d..a814ef7 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -15,6 +15,8 @@ TENANT_TABLES = { "raw_material_price_versions": None, "mixes": None, "mix_ingredients": None, + "mix_calculator_sessions": None, + "mix_calculator_session_lines": None, "products": None, "scenarios": None, "costing_results": None, @@ -230,6 +232,37 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]: """ ), ), + ( + "mix_calculator_sessions", + text( + """ + UPDATE mix_calculator_sessions + SET tenant_id = COALESCE( + ( + SELECT products.tenant_id + FROM products + WHERE products.id = mix_calculator_sessions.product_id + ), + :default_tenant + ) + WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' + """ + ), + ), + ( + "mix_calculator_session_lines", + text( + """ + UPDATE mix_calculator_session_lines + SET tenant_id = ( + SELECT mix_calculator_sessions.tenant_id + FROM mix_calculator_sessions + WHERE mix_calculator_sessions.id = mix_calculator_session_lines.session_id + ) + WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default' + """ + ), + ), ( "scenarios", text( diff --git a/backend/app/main.py b/backend/app/main.py index 339166d..0c75f1f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ import uvicorn from app.api.auth import router as auth_router from app.api.client_access import router as client_access_router +from app.api.mix_calculator import router as mix_calculator_router from app.api.mixes import router as mixes_router from app.api.powerbi import router as powerbi_router from app.api.products import router as products_router @@ -74,6 +75,7 @@ app.include_router(auth_router) app.include_router(client_access_router) app.include_router(raw_materials_router) app.include_router(mixes_router) +app.include_router(mix_calculator_router) app.include_router(products_router) app.include_router(scenarios_router) app.include_router(powerbi_router) @@ -95,6 +97,7 @@ def root(): "admin_login": "/api/auth/admin/login", "raw_materials": "/api/raw-materials", "mixes": "/api/mixes", + "mix_calculator": "/api/mix-calculator", "products": "/api/products", "scenarios": "/api/scenarios", "client_access": "/api/client-access", diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 6c8d677..2a4fc7d 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission +from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine from app.models.mix import Mix, MixIngredient from app.models.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion @@ -14,6 +15,8 @@ __all__ = [ "CostingResult", "FreightCostRule", "Mix", + "MixCalculatorSession", + "MixCalculatorSessionLine", "MixIngredient", "PackagingCostRule", "ProcessCostRule", diff --git a/backend/app/models/mix_calculator.py b/backend/app/models/mix_calculator.py new file mode 100644 index 0000000..23a7b9a --- /dev/null +++ b/backend/app/models/mix_calculator.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base + + +class MixCalculatorSession(Base): + __tablename__ = "mix_calculator_sessions" + __table_args__ = (UniqueConstraint("tenant_id", "session_number", name="uq_mix_calculator_session_number"),) + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) + session_number: Mapped[str] = mapped_column(String(32), index=True) + client_name: Mapped[str] = mapped_column(String(255), index=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True) + product_name: Mapped[str] = mapped_column(String(255)) + mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"), index=True) + mix_name: Mapped[str] = mapped_column(String(255)) + mix_date: Mapped[date] = mapped_column(Date, index=True) + batch_size_kg: Mapped[float] = mapped_column(Float) + total_bags: Mapped[float] = mapped_column(Float) + total_kg: Mapped[float] = mapped_column(Float) + product_unit_of_measure: Mapped[str] = mapped_column(String(64)) + product_unit_size_kg: Mapped[float] = mapped_column(Float) + prepared_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True, index=True) + prepared_by_name: Mapped[str] = mapped_column(String(255)) + created_by: Mapped[str] = mapped_column(String(255)) + status: Mapped[str] = mapped_column(String(32), default="saved") + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + lines: Mapped[list["MixCalculatorSessionLine"]] = relationship( + back_populates="session", + cascade="all, delete-orphan", + order_by="MixCalculatorSessionLine.sort_order", + ) + + +class MixCalculatorSessionLine(Base): + __tablename__ = "mix_calculator_session_lines" + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True) + session_id: Mapped[int] = mapped_column(ForeignKey("mix_calculator_sessions.id"), index=True) + raw_material_id: Mapped[int | None] = mapped_column(nullable=True) + raw_material_name: Mapped[str] = mapped_column(String(255)) + required_kg: Mapped[float] = mapped_column(Float) + mix_percentage: Mapped[float] = mapped_column(Float) + unit: Mapped[str] = mapped_column(String(64)) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + + session: Mapped[MixCalculatorSession] = relationship(back_populates="lines") diff --git a/backend/app/schemas/mix_calculator.py b/backend/app/schemas/mix_calculator.py new file mode 100644 index 0000000..7817595 --- /dev/null +++ b/backend/app/schemas/mix_calculator.py @@ -0,0 +1,103 @@ +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class MixCalculatorProductOptionRead(BaseModel): + product_id: int + client_name: str + product_name: str + mix_id: int + mix_name: str + unit_of_measure: str + unit_size_kg: float + mix_total_kg: float + + +class MixCalculatorOptionsRead(BaseModel): + clients: list[str] + products: list[MixCalculatorProductOptionRead] + + +class MixCalculatorSessionLineRead(BaseModel): + id: int | None = None + raw_material_id: int | None + raw_material_name: str + required_kg: float + mix_percentage: float + unit: str + sort_order: int + + +class MixCalculatorSessionBase(BaseModel): + mix_date: date + client_name: str + product_id: int + batch_size_kg: float = Field(gt=0) + prepared_by_name: str = Field(min_length=1, max_length=255) + status: str = "saved" + notes: str | None = None + + +class MixCalculatorSessionCreate(MixCalculatorSessionBase): + pass + + +class MixCalculatorSessionUpdate(BaseModel): + mix_date: date | None = None + client_name: str | None = None + product_id: int | None = None + batch_size_kg: float | None = Field(default=None, gt=0) + prepared_by_name: str | None = Field(default=None, min_length=1, max_length=255) + status: str | None = None + notes: str | None = None + + +class MixCalculatorPreviewRead(BaseModel): + client_name: str + product_id: int + product_name: str + mix_id: int + mix_name: str + mix_date: date + batch_size_kg: float + total_bags: float + total_kg: float + product_unit_of_measure: str + product_unit_size_kg: float + prepared_by_name: str + status: str + notes: str | None + warnings: list[str] + lines: list[MixCalculatorSessionLineRead] + + +class MixCalculatorSessionSummaryRead(BaseModel): + id: int + tenant_id: str + session_number: str + client_name: str + product_id: int + product_name: str + mix_id: int + mix_name: str + mix_date: date + batch_size_kg: float + total_bags: float + total_kg: float + product_unit_of_measure: str + product_unit_size_kg: float + prepared_by_user_id: int | None + prepared_by_name: str + created_by: str + status: str + notes: str | None + created_at: datetime + updated_at: datetime + warnings: list[str] + is_owner: bool + model_config = ConfigDict(from_attributes=True) + + +class MixCalculatorSessionRead(MixCalculatorSessionSummaryRead): + lines: list[MixCalculatorSessionLineRead] diff --git a/backend/app/seed.py b/backend/app/seed.py index 9ca7d0c..c88c227 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -72,8 +72,8 @@ def seed_client_access(db): ) enabled_feature_map = { - "hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export", "client_access"}, - "loft-grains": {"dashboard", "products", "powerbi_export"}, + "hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"}, + "loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"}, } for client in (specialty, loft): diff --git a/backend/app/services/client_access_service.py b/backend/app/services/client_access_service.py index 66ab26f..80b8ca2 100644 --- a/backend/app/services/client_access_service.py +++ b/backend/app/services/client_access_service.py @@ -17,6 +17,7 @@ MODULE_CATALOG = ( ("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"), ("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"), ("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"), + ("mix_calculator", "Mix Calculator", "production", "Create and review client-specific mix calculation sessions"), ("products", "Products", "pricing", "Review finished product pricing"), ("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"), ("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"), @@ -43,6 +44,7 @@ def client_access_query() -> Select[tuple[ClientAccount]]: def list_client_accounts(db: Session) -> list[ClientAccount]: ensure_client_user_module_permissions(db) + ensure_client_feature_access(db) return db.scalars(client_access_query()).all() @@ -50,9 +52,19 @@ def get_client_user_by_email(db: Session, *, email: str, tenant_id: str | None = statement = select(ClientUser).where(ClientUser.email == email) if tenant_id: statement = statement.where(ClientUser.tenant_id == tenant_id) - return db.scalar( + user = db.scalar( statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc()) ) + if user is None: + return None + + if ensure_user_module_permissions(db, user): + db.commit() + return db.scalar( + statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc()) + ) + + return user def module_access_map(user: ClientUser) -> dict[str, str]: @@ -66,13 +78,15 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool: def default_access_level_for_role(role: str, module_key: str) -> str: normalized = role.strip().lower() if normalized == "superadmin": - return "manage" if module_key == "client_access" else "edit" + return "manage" if module_key in {"client_access", "mix_calculator"} else "edit" if normalized == "admin": + if module_key == "mix_calculator": + return "manage" return "edit" if module_key != "client_access" else "none" if normalized == "operator": - return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "products", "scenarios"} else "none" + return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios"} else "none" if normalized == "viewer": - return "view" if module_key in {"dashboard", "products", "powerbi_export"} else "none" + return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export"} else "none" return "none" @@ -106,6 +120,45 @@ def ensure_client_user_module_permissions(db: Session) -> None: db.commit() +def ensure_client_feature_access(db: Session) -> None: + clients = db.scalars( + select(ClientAccount).options( + selectinload(ClientAccount.users).selectinload(ClientUser.module_permissions), + selectinload(ClientAccount.features), + ) + ).all() + changed = False + + for client in clients: + existing_feature_keys = {feature.feature_key for feature in client.features} + permission_levels: dict[str, str] = {} + + for user in client.users: + for permission in user.module_permissions: + current_level = permission_levels.get(permission.module_key, "none") + if ACCESS_LEVEL_ORDER.get(permission.access_level, 0) > ACCESS_LEVEL_ORDER.get(current_level, 0): + permission_levels[permission.module_key] = permission.access_level + + for feature_key, feature_name, feature_group, description in MODULE_CATALOG: + if feature_key in existing_feature_keys: + continue + db.add( + ClientFeatureAccess( + tenant_id=client.tenant_id, + client_account_id=client.id, + feature_key=feature_key, + feature_name=feature_name, + feature_group=feature_group, + description=description, + enabled=has_access_level(permission_levels.get(feature_key), "view"), + ) + ) + changed = True + + if changed: + db.commit() + + def serialize_client_user(user: ClientUser) -> dict: return { "id": user.id, diff --git a/backend/app/services/costing_engine.py b/backend/app/services/costing_engine.py index 3328483..61f7e56 100644 --- a/backend/app/services/costing_engine.py +++ b/backend/app/services/costing_engine.py @@ -177,7 +177,7 @@ def _apply_margin(cost: float, margin: float | None) -> float | None: return round(cost / (1 - margin), 4) -def _extract_unit_quantity_kg(unit_of_measure: str) -> float: +def extract_unit_quantity_kg(unit_of_measure: str) -> float: normalized = unit_of_measure.strip().lower() if normalized == "tonne": return 1000.0 @@ -199,7 +199,7 @@ def calculate_product_cost(db: Session, product_id: int, overrides: dict | None mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides) warnings = list(mix_result["warnings"]) - sale_unit_kg = _extract_unit_quantity_kg(product.unit_of_measure) + sale_unit_kg = extract_unit_quantity_kg(product.unit_of_measure) mix_cost_per_kg = mix_result["mix_cost_per_kg"] or 0.0 cleaned_product_cost = round(mix_cost_per_kg * sale_unit_kg, 4) diff --git a/backend/app/services/mix_calculator_service.py b/backend/app/services/mix_calculator_service.py new file mode 100644 index 0000000..a083ad6 --- /dev/null +++ b/backend/app/services/mix_calculator_service.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +from datetime import date + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.api.deps import AuthSession +from app.models.mix import Mix, MixIngredient +from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine +from app.models.product import Product +from app.schemas.mix_calculator import MixCalculatorSessionCreate, MixCalculatorSessionUpdate +from app.services.costing_engine import extract_unit_quantity_kg + + +def can_view_all_mix_calculator_sessions(session: AuthSession) -> bool: + return session.client_role in {"superadmin", "admin"} + + +def _build_session_access_query(session: AuthSession): + query = select(MixCalculatorSession).where(MixCalculatorSession.tenant_id == session.tenant_id) + if can_view_all_mix_calculator_sessions(session): + return query + return query.where(MixCalculatorSession.prepared_by_user_id == session.user_id) + + +def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None: + return db.scalar( + select(Product) + .where(Product.id == product_id, Product.tenant_id == tenant_id) + .options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material)) + ) + + +def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_measure: str) -> str | None: + rounded_bags = round(total_bags) + if abs(total_bags - rounded_bags) < 1e-9: + return None + return ( + f"Batch size {batch_size_kg:g}kg produces {total_bags:.2f} bags for {unit_of_measure}. " + "This is not a whole-bag quantity." + ) + + +def calculate_mix_calculator_preview( + db: Session, + *, + tenant_id: str, + payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict, +): + values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False) + product = _load_product_for_calculation(db, tenant_id, int(values["product_id"])) + if product is None: + raise ValueError("Product not found") + if product.client_name != values["client_name"]: + raise ValueError("Selected product does not belong to the chosen client") + if product.mix is None: + raise ValueError("Product mix is not configured") + + source_total_kg = round(sum(ingredient.quantity_kg for ingredient in product.mix.ingredients), 4) + if source_total_kg <= 0: + raise ValueError("Product mix has no source kilograms to scale") + + batch_size_kg = float(values["batch_size_kg"]) + scale_factor = batch_size_kg / source_total_kg + unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure) + total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0 + + warnings: list[str] = [] + bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure) + if bag_warning: + warnings.append(bag_warning) + + lines = [] + for index, ingredient in enumerate(product.mix.ingredients, start=1): + mix_percentage = round((ingredient.quantity_kg / source_total_kg) * 100, 4) + required_kg = round(ingredient.quantity_kg * scale_factor, 4) + raw_material = ingredient.raw_material + lines.append( + { + "raw_material_id": raw_material.id if raw_material is not None else ingredient.raw_material_id, + "raw_material_name": raw_material.name if raw_material is not None else f"Raw material {ingredient.raw_material_id}", + "required_kg": required_kg, + "mix_percentage": mix_percentage, + "unit": raw_material.unit_of_measure if raw_material is not None else "kg", + "sort_order": index, + } + ) + + return { + "client_name": product.client_name, + "product_id": product.id, + "product_name": product.name, + "mix_id": product.mix_id, + "mix_name": product.mix.name, + "mix_date": values["mix_date"], + "batch_size_kg": round(batch_size_kg, 4), + "total_bags": total_bags, + "total_kg": round(batch_size_kg, 4), + "product_unit_of_measure": product.unit_of_measure, + "product_unit_size_kg": round(unit_size_kg, 4), + "prepared_by_name": values["prepared_by_name"], + "status": values.get("status") or "saved", + "notes": values.get("notes"), + "warnings": warnings, + "lines": lines, + } + + +def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: + products = db.scalars( + select(Product) + .where(Product.tenant_id == tenant_id) + .options(selectinload(Product.mix).selectinload(Mix.ingredients)) + .order_by(Product.client_name, Product.name) + ).all() + + product_rows = [] + clients = sorted({product.client_name for product in products}) + for product in products: + mix_total_kg = round(sum(ingredient.quantity_kg for ingredient in (product.mix.ingredients if product.mix else [])), 4) + product_rows.append( + { + "product_id": product.id, + "client_name": product.client_name, + "product_name": product.name, + "mix_id": product.mix_id, + "mix_name": product.mix.name if product.mix else "", + "unit_of_measure": product.unit_of_measure, + "unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4), + "mix_total_kg": mix_total_kg, + } + ) + + return {"clients": clients, "products": product_rows} + + +def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_session: AuthSession) -> dict: + total_bags = round(session_record.total_bags, 4) + warnings: list[str] = [] + bag_warning = _fractional_bag_warning(session_record.batch_size_kg, total_bags, session_record.product_unit_of_measure) + if bag_warning: + warnings.append(bag_warning) + + return { + "id": session_record.id, + "tenant_id": session_record.tenant_id, + "session_number": session_record.session_number, + "client_name": session_record.client_name, + "product_id": session_record.product_id, + "product_name": session_record.product_name, + "mix_id": session_record.mix_id, + "mix_name": session_record.mix_name, + "mix_date": session_record.mix_date, + "batch_size_kg": round(session_record.batch_size_kg, 4), + "total_bags": total_bags, + "total_kg": round(session_record.total_kg, 4), + "product_unit_of_measure": session_record.product_unit_of_measure, + "product_unit_size_kg": round(session_record.product_unit_size_kg, 4), + "prepared_by_user_id": session_record.prepared_by_user_id, + "prepared_by_name": session_record.prepared_by_name, + "created_by": session_record.created_by, + "status": session_record.status, + "notes": session_record.notes, + "created_at": session_record.created_at, + "updated_at": session_record.updated_at, + "warnings": warnings, + "is_owner": session_record.prepared_by_user_id == auth_session.user_id, + "lines": [ + { + "id": line.id, + "raw_material_id": line.raw_material_id, + "raw_material_name": line.raw_material_name, + "required_kg": round(line.required_kg, 4), + "mix_percentage": round(line.mix_percentage, 4), + "unit": line.unit, + "sort_order": line.sort_order, + } + for line in session_record.lines + ], + } + + +def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession) -> list[dict]: + sessions = db.scalars( + _build_session_access_query(auth_session) + .options(selectinload(MixCalculatorSession.lines)) + .order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc()) + ).all() + return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions] + + +def get_mix_calculator_session(db: Session, *, auth_session: AuthSession, session_id: int) -> MixCalculatorSession | None: + return db.scalar( + _build_session_access_query(auth_session) + .where(MixCalculatorSession.id == session_id) + .options(selectinload(MixCalculatorSession.lines)) + ) + + +def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str: + prefix = f"HPP-{mix_date.strftime('%Y%m%d')}-" + existing = db.scalars( + select(MixCalculatorSession.session_number) + .where( + MixCalculatorSession.tenant_id == tenant_id, + MixCalculatorSession.mix_date == mix_date, + MixCalculatorSession.session_number.like(f"{prefix}%"), + ) + ).all() + sequence = 1 + if existing: + sequence = max(int(value.rsplit("-", 1)[-1]) for value in existing) + 1 + return f"{prefix}{sequence:04d}" + + +def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict: + preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload) + session_record = MixCalculatorSession( + tenant_id=auth_session.tenant_id or "default", + session_number=_next_session_number(db, tenant_id=auth_session.tenant_id or "default", mix_date=payload.mix_date), + client_name=preview["client_name"], + product_id=preview["product_id"], + product_name=preview["product_name"], + mix_id=preview["mix_id"], + mix_name=preview["mix_name"], + mix_date=preview["mix_date"], + batch_size_kg=preview["batch_size_kg"], + total_bags=preview["total_bags"], + total_kg=preview["total_kg"], + product_unit_of_measure=preview["product_unit_of_measure"], + product_unit_size_kg=preview["product_unit_size_kg"], + prepared_by_user_id=auth_session.user_id, + prepared_by_name=preview["prepared_by_name"], + created_by=auth_session.email, + status=preview["status"], + notes=preview["notes"], + ) + session_record.lines = [ + MixCalculatorSessionLine( + tenant_id=auth_session.tenant_id or "default", + raw_material_id=line["raw_material_id"], + raw_material_name=line["raw_material_name"], + required_kg=line["required_kg"], + mix_percentage=line["mix_percentage"], + unit=line["unit"], + sort_order=line["sort_order"], + ) + for line in preview["lines"] + ] + db.add(session_record) + db.commit() + db.refresh(session_record) + db.refresh(session_record, attribute_names=["lines"]) + return serialize_mix_calculator_session(session_record, auth_session) + + +def update_mix_calculator_session( + db: Session, + *, + auth_session: AuthSession, + session_record: MixCalculatorSession, + payload: MixCalculatorSessionUpdate, +) -> dict: + merged_values = { + "mix_date": session_record.mix_date, + "client_name": session_record.client_name, + "product_id": session_record.product_id, + "batch_size_kg": session_record.batch_size_kg, + "prepared_by_name": session_record.prepared_by_name, + "status": session_record.status, + "notes": session_record.notes, + } + merged_values.update(payload.model_dump(exclude_unset=True)) + preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=merged_values) + + session_record.client_name = preview["client_name"] + session_record.product_id = preview["product_id"] + session_record.product_name = preview["product_name"] + session_record.mix_id = preview["mix_id"] + session_record.mix_name = preview["mix_name"] + session_record.mix_date = preview["mix_date"] + session_record.batch_size_kg = preview["batch_size_kg"] + session_record.total_bags = preview["total_bags"] + session_record.total_kg = preview["total_kg"] + session_record.product_unit_of_measure = preview["product_unit_of_measure"] + session_record.product_unit_size_kg = preview["product_unit_size_kg"] + session_record.prepared_by_name = preview["prepared_by_name"] + session_record.status = preview["status"] + session_record.notes = preview["notes"] + + session_record.lines.clear() + session_record.lines.extend( + [ + MixCalculatorSessionLine( + tenant_id=auth_session.tenant_id or "default", + raw_material_id=line["raw_material_id"], + raw_material_name=line["raw_material_name"], + required_kg=line["required_kg"], + mix_percentage=line["mix_percentage"], + unit=line["unit"], + sort_order=line["sort_order"], + ) + for line in preview["lines"] + ] + ) + + db.commit() + db.refresh(session_record) + db.refresh(session_record, attribute_names=["lines"]) + return serialize_mix_calculator_session(session_record, auth_session) diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py index e2c1806..1553301 100644 --- a/backend/tests/test_costing_engine.py +++ b/backend/tests/test_costing_engine.py @@ -11,11 +11,13 @@ from app.db.session import Base from app.main import app from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser +from app.schemas.mix_calculator import MixCalculatorSessionCreate from app.models.mix import Mix, MixIngredient from app.models.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost +from app.services.mix_calculator_service import calculate_mix_calculator_preview def build_session() -> Session: @@ -86,30 +88,83 @@ def test_mix_and_product_cost_breakdown(): assert product_result["wholesale_price"] == 17.3268 +def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags(): + db = build_session() + + maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active") + barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active") + db.add_all([maize, barley]) + db.flush() + + mix = Mix(tenant_id="specialty-feeds", client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1) + db.add(mix) + db.flush() + db.add_all( + [ + MixIngredient(tenant_id="specialty-feeds", mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180), + MixIngredient(tenant_id="specialty-feeds", mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100), + ] + ) + db.flush() + + product = Product( + tenant_id="specialty-feeds", + client_name="Specialty Feeds", + name="Specialty Pigeon Breeder 20kg", + mix_id=mix.id, + sale_type="standard", + own_bag=False, + unit_of_measure="20kg bag", + items_per_pallet=50, + bagging_process="standard_bagging", + ) + db.add(product) + db.commit() + + preview = calculate_mix_calculator_preview( + db, + tenant_id="specialty-feeds", + payload=MixCalculatorSessionCreate( + mix_date=date(2026, 4, 29), + client_name="Specialty Feeds", + product_id=product.id, + batch_size_kg=550, + prepared_by_name="Shift A", + notes="Mid-morning run", + ), + ) + + assert preview["batch_size_kg"] == 550 + assert preview["total_bags"] == 27.5 + assert preview["lines"][0]["required_kg"] == 353.5714 + assert preview["lines"][1]["required_kg"] == 196.4286 + assert len(preview["warnings"]) == 1 + assert "not a whole-bag quantity" in preview["warnings"][0] + + def test_root_and_login_endpoints(): - client = TestClient(app) + with TestClient(app) as client: + root_response = client.get("/") + assert root_response.status_code == 200 + assert root_response.json()["endpoints"]["client_login"] == "/api/auth/client/login" + assert root_response.json()["endpoints"]["admin_login"] == "/api/auth/admin/login" - root_response = client.get("/") - assert root_response.status_code == 200 - assert root_response.json()["endpoints"]["client_login"] == "/api/auth/client/login" - assert root_response.json()["endpoints"]["admin_login"] == "/api/auth/admin/login" + client_login_response = client.post( + "/api/auth/client/login", + json={"email": settings.client_email, "password": settings.client_password}, + ) + assert client_login_response.status_code == 200 + assert client_login_response.json()["email"] == settings.client_email + assert client_login_response.json()["tenant_id"] == settings.client_tenant_id + assert client_login_response.json()["client_role"] == "superadmin" + assert client_login_response.json()["module_permissions"]["client_access"] == "manage" - client_login_response = client.post( - "/api/auth/client/login", - json={"email": settings.client_email, "password": settings.client_password}, - ) - assert client_login_response.status_code == 200 - assert client_login_response.json()["email"] == settings.client_email - assert client_login_response.json()["tenant_id"] == settings.client_tenant_id - assert client_login_response.json()["client_role"] == "superadmin" - assert client_login_response.json()["module_permissions"]["client_access"] == "manage" - - admin_login_response = client.post( - "/api/auth/admin/login", - json={"email": settings.admin_email, "password": settings.admin_password}, - ) - assert admin_login_response.status_code == 200 - assert admin_login_response.json()["email"] == settings.admin_email + admin_login_response = client.post( + "/api/auth/admin/login", + json={"email": settings.admin_email, "password": settings.admin_password}, + ) + assert admin_login_response.status_code == 200 + assert admin_login_response.json()["email"] == settings.admin_email def test_client_access_export_helpers(): @@ -220,6 +275,61 @@ def test_client_access_endpoints(): assert len(superadmin_access_response.json()) == 1 +def test_mix_calculator_endpoints_respect_owner_visibility(): + with TestClient(app) as client: + superadmin_login = client.post( + "/api/auth/client/login", + json={"email": settings.client_email, "password": settings.client_password}, + ) + assert superadmin_login.status_code == 200 + superadmin_headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"} + + options_response = client.get("/api/mix-calculator/options", headers=superadmin_headers) + assert options_response.status_code == 200 + assert options_response.json()["products"][0]["product_name"] == "Hunter Orchard Blend 20kg" + + create_response = client.post( + "/api/mix-calculator", + json={ + "mix_date": "2026-04-29", + "client_name": "Hunter Premium Produce", + "product_id": 1, + "batch_size_kg": 560, + "prepared_by_name": "Amelia Hart", + "notes": "Morning production run", + }, + headers=superadmin_headers, + ) + assert create_response.status_code == 201 + created = create_response.json() + assert created["session_number"].startswith("HPP-20260429-") + assert created["total_bags"] == 28 + assert created["lines"][0]["required_kg"] == 360 + + patch_response = client.patch( + f"/api/mix-calculator/{created['id']}", + json={"batch_size_kg": 550}, + headers=superadmin_headers, + ) + assert patch_response.status_code == 200 + assert patch_response.json()["total_bags"] == 27.5 + assert len(patch_response.json()["warnings"]) == 1 + + operator_login = client.post( + "/api/auth/client/login", + json={"email": "ethan.cole@hunterpremiumproduce.example", "password": settings.client_password}, + ) + assert operator_login.status_code == 200 + operator_headers = {"Authorization": f"Bearer {operator_login.json()['token']}"} + + operator_list_response = client.get("/api/mix-calculator", headers=operator_headers) + assert operator_list_response.status_code == 200 + assert operator_list_response.json() == [] + + operator_detail_response = client.get(f"/api/mix-calculator/{created['id']}", headers=operator_headers) + assert operator_detail_response.status_code == 404 + + def test_module_permission_blocks_client_module_access(): with TestClient(app) as client: admin_login_response = client.post( @@ -229,7 +339,7 @@ def test_module_permission_blocks_client_module_access(): admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"} access_response = client.get("/api/client-access", headers=admin_headers) first_client = access_response.json()[0] - first_user = first_client["users"][0] + first_user = next(user for user in first_client["users"] if user["email"] == settings.client_email) permission = next( permission for permission in first_user["module_permissions"] if permission["module_key"] == "raw_materials" diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a674dc2..0b0ce41 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,8 @@ import { mockClientAccess, mockClientAccessExport, mockCosts, + mockMixCalculatorOptions, + mockMixCalculatorSessions, mockMixes, mockProducts, mockRawMaterials, @@ -16,6 +18,11 @@ import type { ClientUserModulePermission, ClientUserUpdateInput, LoginResponse, + MixCalculatorCreateInput, + MixCalculatorOptions, + MixCalculatorPreview, + MixCalculatorSession, + MixCalculatorUpdateInput, Mix, MixCreateInput, MixIngredientUpdateInput, @@ -128,6 +135,27 @@ export const api = { rawMaterials: (fetcher?: ApiFetch) => fetchJson('/api/raw-materials', mockRawMaterials, 'client', fetcher), mixes: (fetcher?: ApiFetch) => fetchJson('/api/mixes', mockMixes, 'client', fetcher), mix: (mixId: number, fetcher?: ApiFetch) => request(`/api/mixes/${mixId}`, { method: 'GET' }, 'client', fetcher), + mixCalculatorOptions: (fetcher?: ApiFetch) => + fetchJson('/api/mix-calculator/options', mockMixCalculatorOptions, 'client', fetcher), + mixCalculatorSessions: (fetcher?: ApiFetch) => + fetchJson('/api/mix-calculator', mockMixCalculatorSessions, 'client', fetcher), + mixCalculatorSession: (sessionId: number, fetcher?: ApiFetch) => + request(`/api/mix-calculator/${sessionId}`, { method: 'GET' }, 'client', fetcher), + previewMixCalculatorSession: (payload: MixCalculatorCreateInput) => + request('/api/mix-calculator/preview', { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), + createMixCalculatorSession: (payload: MixCalculatorCreateInput) => + request('/api/mix-calculator', { + method: 'POST', + body: JSON.stringify(payload) + }, 'client'), + updateMixCalculatorSession: (sessionId: number, payload: MixCalculatorUpdateInput) => + request(`/api/mix-calculator/${sessionId}`, { + method: 'PATCH', + body: JSON.stringify(payload) + }, 'client'), products: (fetcher?: ApiFetch) => fetchJson('/api/products', mockProducts, 'client', fetcher), productCosts: (fetcher?: ApiFetch) => fetchJson('/api/powerbi/product-costs', mockCosts, 'client', fetcher), diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index 8157022..809ea4f 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -1,4 +1,5 @@ + + + {printableTitle} + + + + + diff --git a/frontend/src/lib/components/MixCalculatorWorkspace.svelte b/frontend/src/lib/components/MixCalculatorWorkspace.svelte new file mode 100644 index 0000000..138716d --- /dev/null +++ b/frontend/src/lib/components/MixCalculatorWorkspace.svelte @@ -0,0 +1,763 @@ + + +{#if !canEdit && !initialSession} +
+

Mix Calculator

+

Edit access is required to create a new session.

+

View-only users can open saved sessions from history, but cannot create or update production calculations.

+ Back to session history +
+{:else} +
+
+

Mix Calculator

+

{isExistingSession ? 'Edit saved mix session' : 'New mix calculation session'}

+

Scale a saved product mix by batch size, review required raw materials, then save the session for history and printing.

+
+
+ Session history + {#if initialSession} + Printable view + {/if} +
+
+ +
+
+
+
+

Session Inputs

+

Batch size drives the scale factor. Total bags are derived from the selected product unit size.

+
+ {#if selectedProduct} +
+ {selectedProduct.unit_size_kg}kg + {selectedProduct.unit_of_measure} +
+ {/if} +
+ + {#if formError} +

{formError}

+ {/if} + {#if formSuccess} +

{formSuccess}

+ {/if} + +
+ + + + + + + + + + + +
+ + {#if canEdit && selectedProduct} +
+ Source mix + {selectedProduct.mix_name} totals {formatNumber(selectedProduct.mix_total_kg, 2)}kg. Scale factor = batch size / source mix total. +
+ {/if} + + {#if canEdit} +
+ + + + + {#if initialSession} + + {/if} +
+ {/if} +
+ +
+
+
+

Calculated Output

+

{preview ? 'Snapshot of the scaled raw material requirements.' : 'Run the calculation to preview the session output.'}

+
+ {#if initialSession} +
+ Session + {initialSession.session_number} +
+ {/if} +
+ + {#if preview} +
+
+ Total kg + {formatNumber(preview.total_kg, 2)} +

Scaled batch size

+
+
+ Total bags + {formatNumber(preview.total_bags, 2)} +

{preview.product_unit_of_measure}

+
+
+ Prepared by + {preview.prepared_by_name} +

{formatDate(preview.mix_date)}

+
+
+ + {#if preview.warnings.length} +
+ {#each preview.warnings as warning} +

{warning}

+ {/each} +
+ {/if} + +
+
+ Client + {preview.client_name} +
+
+ Product + {preview.product_name} +
+
+ Mix source + {preview.mix_name} +
+
+ Unit size + {formatNumber(preview.product_unit_size_kg, 2)}kg +
+
+ +
+ + + + + + + + + + + {#each preview.lines as line} + + + + + + + {/each} + +
Raw materialMix %Required kgUnit
+ {line.raw_material_name} + {formatNumber(line.mix_percentage, 2)}%{formatNumber(line.required_kg, 2)}kg{line.unit}
+
+ {:else} +
+ No calculation yet + Choose a client, product, date, and batch size, then run the calculator. +
+ {/if} +
+
+{/if} + + diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts index dda6ba6..972e55b 100644 --- a/frontend/src/lib/mock.ts +++ b/frontend/src/lib/mock.ts @@ -1,6 +1,8 @@ import type { ClientAccessAccount, ClientAccessPowerBiExport, + MixCalculatorOptions, + MixCalculatorSession, Mix, Product, ProductCostBreakdown, @@ -13,6 +15,7 @@ const MODULE_PERMISSIONS = { dashboard: 'edit', raw_materials: 'edit', mix_master: 'edit', + mix_calculator: 'manage', products: 'edit', scenarios: 'edit', powerbi_export: 'edit', @@ -22,6 +25,7 @@ const MODULE_PERMISSIONS = { dashboard: 'edit', raw_materials: 'edit', mix_master: 'edit', + mix_calculator: 'edit', products: 'edit', scenarios: 'edit', powerbi_export: 'none', @@ -31,6 +35,7 @@ const MODULE_PERMISSIONS = { dashboard: 'view', raw_materials: 'none', mix_master: 'none', + mix_calculator: 'view', products: 'view', scenarios: 'none', powerbi_export: 'view', @@ -42,6 +47,7 @@ const MODULE_DETAILS = [ ['dashboard', 'Dashboard', 'workspace', 'Top-level operational dashboard'], ['raw_materials', 'Raw Materials', 'costing', 'Maintain live material costs and versions'], ['mix_master', 'Mix Master', 'costing', 'Create and maintain mix worksheets'], + ['mix_calculator', 'Mix Calculator', 'production', 'Create and review client-specific mix calculation sessions'], ['products', 'Products', 'pricing', 'Review finished product pricing'], ['scenarios', 'Scenarios', 'planning', 'Run scenario overrides and comparisons'], ['powerbi_export', 'Power BI Export', 'reporting', 'Expose client access data to BI consumers'], @@ -122,6 +128,70 @@ export const mockProducts: Product[] = [ } ]; +export const mockMixCalculatorOptions: MixCalculatorOptions = { + clients: ['Hunter Premium Produce'], + products: [ + { + product_id: 1, + client_name: 'Hunter Premium Produce', + product_name: 'Hunter Orchard Blend 20kg', + mix_id: 1, + mix_name: 'Hunter Orchard Blend', + unit_of_measure: '20kg bag', + unit_size_kg: 20, + mix_total_kg: 280 + } + ] +}; + +export const mockMixCalculatorSessions: MixCalculatorSession[] = [ + { + id: 1, + tenant_id: 'hunter-premium-produce', + session_number: 'HPP-20260429-0001', + client_name: 'Hunter Premium Produce', + product_id: 1, + product_name: 'Hunter Orchard Blend 20kg', + mix_id: 1, + mix_name: 'Hunter Orchard Blend', + mix_date: '2026-04-29', + batch_size_kg: 560, + total_bags: 28, + total_kg: 560, + product_unit_of_measure: '20kg bag', + product_unit_size_kg: 20, + prepared_by_user_id: 1, + prepared_by_name: 'Amelia Hart', + created_by: 'operator@example.com', + status: 'saved', + notes: 'Morning production run', + created_at: '2026-04-29T08:10:00', + updated_at: '2026-04-29T08:12:00', + warnings: [], + is_owner: true, + lines: [ + { + id: 1, + raw_material_id: 1, + raw_material_name: 'Maize', + required_kg: 360, + mix_percentage: 64.2857, + unit: 'tonne', + sort_order: 1 + }, + { + id: 2, + raw_material_id: 2, + raw_material_name: 'Barley', + required_kg: 200, + mix_percentage: 35.7143, + unit: 'tonne', + sort_order: 2 + } + ] + } +]; + export const mockCosts: ProductCostBreakdown[] = [ { product_id: 1, @@ -157,8 +227,8 @@ export const mockClientAccess: ClientAccessAccount[] = [ created_at: '2026-04-20T09:00:00', active_user_count: 1, new_user_count: 1, - enabled_feature_count: 7, - total_feature_count: 7, + enabled_feature_count: 8, + total_feature_count: 8, users: [ { id: 1, @@ -244,6 +314,17 @@ export const mockClientAccess: ClientAccessAccount[] = [ { id: 4, client_account_id: 1, + feature_key: 'mix_calculator', + feature_name: 'Mix Calculator', + feature_group: 'production', + description: 'Create and review client-specific mix calculation sessions', + enabled: true, + updated_at: '2026-04-24T15:00:00', + created_at: '2026-04-20T09:00:00' + }, + { + id: 5, + client_account_id: 1, feature_key: 'products', feature_name: 'Products', feature_group: 'pricing', @@ -253,7 +334,7 @@ export const mockClientAccess: ClientAccessAccount[] = [ created_at: '2026-04-20T09:00:00' }, { - id: 5, + id: 6, client_account_id: 1, feature_key: 'scenarios', feature_name: 'Scenarios', @@ -264,7 +345,7 @@ export const mockClientAccess: ClientAccessAccount[] = [ created_at: '2026-04-20T09:00:00' }, { - id: 6, + id: 7, client_account_id: 1, feature_key: 'powerbi_export', feature_name: 'Power BI Export', @@ -275,7 +356,7 @@ export const mockClientAccess: ClientAccessAccount[] = [ created_at: '2026-04-20T09:00:00' }, { - id: 13, + id: 8, client_account_id: 1, feature_key: 'client_access', feature_name: 'Client Access', @@ -314,8 +395,8 @@ export const mockClientAccess: ClientAccessAccount[] = [ created_at: '2026-04-21T10:00:00', active_user_count: 1, new_user_count: 0, - enabled_feature_count: 3, - total_feature_count: 7, + enabled_feature_count: 4, + total_feature_count: 8, users: [ { id: 3, @@ -378,6 +459,17 @@ export const mockClientAccess: ClientAccessAccount[] = [ { id: 10, client_account_id: 2, + feature_key: 'mix_calculator', + feature_name: 'Mix Calculator', + feature_group: 'production', + description: 'Create and review client-specific mix calculation sessions', + enabled: true, + updated_at: '2026-04-22T09:10:00', + created_at: '2026-04-21T10:00:00' + }, + { + id: 11, + client_account_id: 2, feature_key: 'products', feature_name: 'Products', feature_group: 'pricing', @@ -387,7 +479,7 @@ export const mockClientAccess: ClientAccessAccount[] = [ created_at: '2026-04-21T10:00:00' }, { - id: 11, + id: 12, client_account_id: 2, feature_key: 'scenarios', feature_name: 'Scenarios', @@ -398,7 +490,7 @@ export const mockClientAccess: ClientAccessAccount[] = [ created_at: '2026-04-21T10:00:00' }, { - id: 12, + id: 13, client_account_id: 2, feature_key: 'powerbi_export', feature_name: 'Power BI Export', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 453ee3b..81bef8d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -76,6 +76,101 @@ export type MixIngredientUpdateInput = { notes?: string | null; }; +export type MixCalculatorProductOption = { + product_id: number; + client_name: string; + product_name: string; + mix_id: number; + mix_name: string; + unit_of_measure: string; + unit_size_kg: number; + mix_total_kg: number; +}; + +export type MixCalculatorOptions = { + clients: string[]; + products: MixCalculatorProductOption[]; +}; + +export type MixCalculatorLine = { + id?: number | null; + raw_material_id?: number | null; + raw_material_name: string; + required_kg: number; + mix_percentage: number; + unit: string; + sort_order: number; +}; + +export type MixCalculatorPreview = { + client_name: string; + product_id: number; + product_name: string; + mix_id: number; + mix_name: string; + mix_date: string; + batch_size_kg: number; + total_bags: number; + total_kg: number; + product_unit_of_measure: string; + product_unit_size_kg: number; + prepared_by_name: string; + status: string; + notes?: string | null; + warnings: string[]; + lines: MixCalculatorLine[]; +}; + +export type MixCalculatorSessionSummary = { + id: number; + tenant_id: string; + session_number: string; + client_name: string; + product_id: number; + product_name: string; + mix_id: number; + mix_name: string; + mix_date: string; + batch_size_kg: number; + total_bags: number; + total_kg: number; + product_unit_of_measure: string; + product_unit_size_kg: number; + prepared_by_user_id?: number | null; + prepared_by_name: string; + created_by: string; + status: string; + notes?: string | null; + created_at: string; + updated_at: string; + warnings: string[]; + is_owner: boolean; +}; + +export type MixCalculatorSession = MixCalculatorSessionSummary & { + lines: MixCalculatorLine[]; +}; + +export type MixCalculatorCreateInput = { + mix_date: string; + client_name: string; + product_id: number; + batch_size_kg: number; + prepared_by_name: string; + status?: string; + notes?: string | null; +}; + +export type MixCalculatorUpdateInput = { + mix_date?: string; + client_name?: string; + product_id?: number; + batch_size_kg?: number; + prepared_by_name?: string; + status?: string; + notes?: string | null; +}; + export type Product = { id: number; tenant_id?: string; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index ccfaa37..f0147f2 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -6,9 +6,12 @@ let { children } = $props(); const isAdminRoute = $derived(page.url.pathname === '/admin' || page.url.pathname.startsWith('/admin/')); + const isPrintableRoute = $derived(page.url.pathname.startsWith('/mix-calculator/') && page.url.pathname.endsWith('/print')); -{#if isAdminRoute} +{#if isPrintableRoute} + {@render children()} +{:else if isAdminRoute} {@render children()} diff --git a/frontend/src/routes/load-fetch.test.ts b/frontend/src/routes/load-fetch.test.ts index 45501ce..8bdd00f 100644 --- a/frontend/src/routes/load-fetch.test.ts +++ b/frontend/src/routes/load-fetch.test.ts @@ -4,6 +4,9 @@ const apiMocks = vi.hoisted(() => ({ rawMaterials: vi.fn(), mixes: vi.fn(), mix: vi.fn(), + mixCalculatorOptions: vi.fn(), + mixCalculatorSessions: vi.fn(), + mixCalculatorSession: vi.fn(), products: vi.fn(), productCosts: vi.fn(), scenarios: vi.fn(), @@ -30,6 +33,10 @@ import { load as adminLoad } from './admin/+page'; import { load as mixesLoad } from './mixes/+page'; import { load as mixNewLoad } from './mixes/new/+page'; import { load as mixDetailLoad } from './mixes/[id]/+page'; +import { load as mixCalculatorLoad } from './mix-calculator/+page'; +import { load as mixCalculatorNewLoad } from './mix-calculator/new/+page'; +import { load as mixCalculatorDetailLoad } from './mix-calculator/[id]/+page'; +import { load as mixCalculatorPrintLoad } from './mix-calculator/[id]/print/+page'; import { load as productsLoad } from './products/+page'; import { load as rawMaterialsLoad } from './raw-materials/+page'; import { load as scenariosLoad } from './scenarios/+page'; @@ -47,6 +54,9 @@ describe('route loaders use the SvelteKit fetch argument', () => { apiMocks.rawMaterials.mockResolvedValue([{ id: 1 }]); apiMocks.mixes.mockResolvedValue([{ id: 2 }]); apiMocks.mix.mockResolvedValue({ id: 42 }); + apiMocks.mixCalculatorOptions.mockResolvedValue({ clients: ['Hunter Premium Produce'], products: [{ product_id: 1 }] }); + apiMocks.mixCalculatorSessions.mockResolvedValue([{ id: 11 }]); + apiMocks.mixCalculatorSession.mockResolvedValue({ id: 12 }); apiMocks.products.mockResolvedValue([{ id: 3 }]); apiMocks.productCosts.mockResolvedValue([{ id: 4 }]); apiMocks.scenarios.mockResolvedValue([{ id: 5 }]); @@ -108,6 +118,31 @@ describe('route loaders use the SvelteKit fetch argument', () => { expect(apiMocks.productCosts).toHaveBeenCalledWith(fetcher); }); + it('passes fetch through the mix calculator history loader', async () => { + await mixCalculatorLoad({ fetch: fetcher } as never); + + expect(apiMocks.mixCalculatorSessions).toHaveBeenCalledWith(fetcher); + }); + + it('passes fetch through the new mix calculator loader', async () => { + await mixCalculatorNewLoad({ fetch: fetcher } as never); + + expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher); + }); + + it('passes fetch through the mix calculator detail loader', async () => { + await mixCalculatorDetailLoad({ params: { id: '12' }, fetch: fetcher } as never); + + expect(apiMocks.mixCalculatorSession).toHaveBeenCalledWith(12, fetcher); + expect(apiMocks.mixCalculatorOptions).toHaveBeenCalledWith(fetcher); + }); + + it('passes fetch through the mix calculator print loader', async () => { + await mixCalculatorPrintLoad({ params: { id: '12' }, fetch: fetcher } as never); + + expect(apiMocks.mixCalculatorSession).toHaveBeenCalledWith(12, fetcher); + }); + it('passes fetch through the scenarios loader', async () => { await scenariosLoad({ fetch: fetcher } as never); diff --git a/frontend/src/routes/mix-calculator/+page.svelte b/frontend/src/routes/mix-calculator/+page.svelte new file mode 100644 index 0000000..4bb1041 --- /dev/null +++ b/frontend/src/routes/mix-calculator/+page.svelte @@ -0,0 +1,351 @@ + + +
+
+

Mix Calculator

+

Saved production sessions

+

Each session preserves the scaled raw material output so it can be reopened or printed later without relying on live recipe changes.

+
+ {#if canEdit} + New mix session + {/if} +
+ +
+
+ Saved Sessions + {data.sessions.length} +

Visible under your access scope

+
+
+ Total Planned Kg + {formatNumber(data.sessions.reduce((sum, session) => sum + session.total_kg, 0), 2)} +

Across the visible history

+
+
+ Sessions With Warnings + {data.sessions.filter((session) => session.warnings.length).length} +

Fractional bag outputs need review

+
+
+ +
+
+
+

Session history

+

Operators see their own sessions. Superadmins and admins see the full client history.

+
+
+ + {#if data.sessions.length} +
+ + + + + + + + + + + + + + {#each data.sessions as session} + + + + + + + + + + {/each} + +
SessionClient / ProductBatchBagsPrepared byUpdated
+ {session.session_number} + {session.mix_name} + + {session.product_name} + {session.client_name} + {formatNumber(session.batch_size_kg, 2)}kg + {formatNumber(session.total_bags, 2)} + {#if session.warnings.length} + Warn + {/if} + {session.prepared_by_name}{formatDate(session.updated_at)} +
+ Open + Print +
+
+
+ {:else} +
+ No saved sessions yet + {canEdit ? 'Run a new calculation and save it to start your session history.' : 'No sessions are visible under your access scope yet.'} +
+ {/if} +
+ + diff --git a/frontend/src/routes/mix-calculator/+page.ts b/frontend/src/routes/mix-calculator/+page.ts new file mode 100644 index 0000000..73b59e4 --- /dev/null +++ b/frontend/src/routes/mix-calculator/+page.ts @@ -0,0 +1,22 @@ +import { api } from '$lib/api'; +import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session'; + +export async function load({ fetch }) { + if (!hasStoredClientSession()) { + return { + sessions: [] + }; + } + + const session = getStoredClientSession(); + + try { + return { + sessions: hasModuleAccess(session, 'mix_calculator') ? await api.mixCalculatorSessions(fetch) : [] + }; + } catch { + return { + sessions: [] + }; + } +} diff --git a/frontend/src/routes/mix-calculator/[id]/+page.svelte b/frontend/src/routes/mix-calculator/[id]/+page.svelte new file mode 100644 index 0000000..d154521 --- /dev/null +++ b/frontend/src/routes/mix-calculator/[id]/+page.svelte @@ -0,0 +1,61 @@ + + +{#if data.session} + +{:else} +
+

Mix Calculator

+

Session unavailable.

+

The requested mix calculator session could not be loaded with the current access scope.

+ Back to session history +
+{/if} + + diff --git a/frontend/src/routes/mix-calculator/[id]/+page.ts b/frontend/src/routes/mix-calculator/[id]/+page.ts new file mode 100644 index 0000000..84bc221 --- /dev/null +++ b/frontend/src/routes/mix-calculator/[id]/+page.ts @@ -0,0 +1,39 @@ +import { api } from '$lib/api'; +import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session'; + +export async function load({ params, fetch }) { + if (!hasStoredClientSession()) { + return { + session: null, + options: { clients: [], products: [] } + }; + } + + const session = getStoredClientSession(); + const canView = hasModuleAccess(session, 'mix_calculator'); + const canEdit = hasModuleAccess(session, 'mix_calculator', 'edit'); + + if (!canView) { + return { + session: null, + options: { clients: [], products: [] } + }; + } + + try { + const [savedSession, options] = await Promise.all([ + api.mixCalculatorSession(Number(params.id), fetch), + canEdit ? api.mixCalculatorOptions(fetch) : Promise.resolve({ clients: [], products: [] }) + ]); + + return { + session: savedSession, + options + }; + } catch { + return { + session: null, + options: { clients: [], products: [] } + }; + } +} diff --git a/frontend/src/routes/mix-calculator/[id]/print/+page.svelte b/frontend/src/routes/mix-calculator/[id]/print/+page.svelte new file mode 100644 index 0000000..ab765ba --- /dev/null +++ b/frontend/src/routes/mix-calculator/[id]/print/+page.svelte @@ -0,0 +1,61 @@ + + +{#if data.session} + +{:else} +
+

Mix Calculator

+

Printable session unavailable.

+

The saved session could not be loaded for printing.

+ Back to session history +
+{/if} + + diff --git a/frontend/src/routes/mix-calculator/[id]/print/+page.ts b/frontend/src/routes/mix-calculator/[id]/print/+page.ts new file mode 100644 index 0000000..0287f75 --- /dev/null +++ b/frontend/src/routes/mix-calculator/[id]/print/+page.ts @@ -0,0 +1,28 @@ +import { api } from '$lib/api'; +import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session'; + +export async function load({ params, fetch }) { + if (!hasStoredClientSession()) { + return { + session: null + }; + } + + const session = getStoredClientSession(); + + if (!hasModuleAccess(session, 'mix_calculator')) { + return { + session: null + }; + } + + try { + return { + session: await api.mixCalculatorSession(Number(params.id), fetch) + }; + } catch { + return { + session: null + }; + } +} diff --git a/frontend/src/routes/mix-calculator/new/+page.svelte b/frontend/src/routes/mix-calculator/new/+page.svelte new file mode 100644 index 0000000..58353e8 --- /dev/null +++ b/frontend/src/routes/mix-calculator/new/+page.svelte @@ -0,0 +1,6 @@ + + + diff --git a/frontend/src/routes/mix-calculator/new/+page.ts b/frontend/src/routes/mix-calculator/new/+page.ts new file mode 100644 index 0000000..c08b2be --- /dev/null +++ b/frontend/src/routes/mix-calculator/new/+page.ts @@ -0,0 +1,24 @@ +import { api } from '$lib/api'; +import { getStoredClientSession, hasModuleAccess, hasStoredClientSession } from '$lib/session'; + +export async function load({ fetch }) { + if (!hasStoredClientSession()) { + return { + options: { clients: [], products: [] } + }; + } + + const session = getStoredClientSession(); + + try { + return { + options: hasModuleAccess(session, 'mix_calculator', 'edit') + ? await api.mixCalculatorOptions(fetch) + : { clients: [], products: [] } + }; + } catch { + return { + options: { clients: [], products: [] } + }; + } +}