diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0025d09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +dist/ +build/ +node_modules/ +.svelte-kit/ +backend/.venv/ +backend/.pytest_cache/ +frontend/node_modules/ +*.pyc +*.pyo +*.pyd +*.sqlite3 +*.db + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bc7989 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Data Entry App + +Initial MVP implementation of the costing platform described in `CLAUDE.MD`. + +## Structure + +```text +backend/ FastAPI API, SQLAlchemy models, costing engine, seed data, tests +frontend/ SvelteKit UI scaffold for dashboard and core modules +``` + +## Backend + +Create a virtual environment, install dependencies, then run: + +```bash +cd backend +pip install -e . +uvicorn app.main:app --reload +``` + +API docs will be available at `http://localhost:8000/docs`. + +Useful commands: + +```bash +python -m app.seed +pytest +``` + +The backend defaults to SQLite for the prototype and can be switched with the +`DATABASE_URL` environment variable. + +## Frontend + +Install dependencies and start the dev server: + +```bash +cd frontend +npm install +npm run dev +``` + +Set `PUBLIC_API_BASE_URL` if the backend is not running on `http://localhost:8000`. + +## Delivered in this MVP + +- Raw materials with versioned prices +- Mixes with ingredient rows and calculated cost per kg +- Products with transparent cost breakdowns +- Scenario runs with override support +- Power BI-style reporting endpoints +- SvelteKit dashboard and module pages aligned to the API contract diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..aca4201 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,28 @@ +from fastapi import APIRouter, HTTPException, status +from pydantic import BaseModel + +from app.core.config import settings + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + email: str + password: str + + +class LoginResponse(BaseModel): + name: str + email: str + role: str + + +@router.post("/login", response_model=LoginResponse) +def login(payload: LoginRequest): + if payload.email.strip().lower() != settings.operator_email.lower() or payload.password != settings.operator_password: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + return { + "name": settings.operator_name, + "email": settings.operator_email, + "role": "operator", + } diff --git a/backend/app/api/mixes.py b/backend/app/api/mixes.py new file mode 100644 index 0000000..aa1f32a --- /dev/null +++ b/backend/app/api/mixes.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.mix import Mix, MixIngredient +from app.models.raw_material import RawMaterial +from app.schemas.mix import MixCreate, MixIngredientCreate, MixIngredientUpdate, MixRead, MixUpdate +from app.services.costing_engine import calculate_mix_cost + +router = APIRouter(prefix="/api/mixes", tags=["mixes"]) + + +@router.get("", response_model=list[MixRead]) +def list_mixes(db: Session = Depends(get_db)): + mixes = db.scalars(select(Mix).order_by(Mix.name)).all() + return [calculate_mix_cost(db, mix.id) for mix in mixes] + + +@router.post("", response_model=MixRead, status_code=status.HTTP_201_CREATED) +def create_mix(payload: MixCreate, db: Session = Depends(get_db)): + mix = Mix( + client_name=payload.client_name, + name=payload.name, + status=payload.status, + version=payload.version, + notes=payload.notes, + ) + db.add(mix) + db.flush() + for ingredient in payload.ingredients: + if db.scalar(select(RawMaterial).where(RawMaterial.id == ingredient.raw_material_id)) is None: + raise HTTPException(status_code=404, detail=f"Raw material {ingredient.raw_material_id} not found") + db.add( + MixIngredient( + mix_id=mix.id, + raw_material_id=ingredient.raw_material_id, + quantity_kg=ingredient.quantity_kg, + notes=ingredient.notes, + ) + ) + db.commit() + return calculate_mix_cost(db, mix.id) + + +@router.get("/{mix_id}", response_model=MixRead) +def get_mix(mix_id: int, db: Session = Depends(get_db)): + if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None: + raise HTTPException(status_code=404, detail="Mix not found") + return calculate_mix_cost(db, mix_id) + + +@router.patch("/{mix_id}", response_model=MixRead) +def update_mix(mix_id: int, payload: MixUpdate, db: Session = Depends(get_db)): + mix = db.scalar(select(Mix).where(Mix.id == mix_id)) + if mix is None: + raise HTTPException(status_code=404, detail="Mix not found") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(mix, field, value) + db.commit() + return calculate_mix_cost(db, mix_id) + + +@router.post("/{mix_id}/ingredients", response_model=MixRead, status_code=status.HTTP_201_CREATED) +def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, db: Session = Depends(get_db)): + if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None: + raise HTTPException(status_code=404, detail="Mix not found") + if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id)) is None: + raise HTTPException(status_code=404, detail="Raw material not found") + db.add(MixIngredient(mix_id=mix_id, raw_material_id=payload.raw_material_id, quantity_kg=payload.quantity_kg, notes=payload.notes)) + db.commit() + return calculate_mix_cost(db, mix_id) + + +@router.patch("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead) +def update_mix_ingredient(mix_id: int, ingredient_id: int, payload: MixIngredientUpdate, db: Session = Depends(get_db)): + ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_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() + return calculate_mix_cost(db, mix_id) + + +@router.delete("/{mix_id}/ingredients/{ingredient_id}", response_model=MixRead) +def delete_mix_ingredient(mix_id: int, ingredient_id: int, db: Session = Depends(get_db)): + ingredient = db.scalar(select(MixIngredient).where(MixIngredient.id == ingredient_id, MixIngredient.mix_id == mix_id)) + if ingredient is None: + raise HTTPException(status_code=404, detail="Ingredient not found") + db.delete(ingredient) + db.commit() + return calculate_mix_cost(db, mix_id) + + +@router.get("/{mix_id}/cost-breakdown", response_model=MixRead) +def get_mix_cost_breakdown(mix_id: int, db: Session = Depends(get_db)): + if db.scalar(select(Mix.id).where(Mix.id == mix_id)) is None: + raise HTTPException(status_code=404, detail="Mix not found") + return calculate_mix_cost(db, mix_id) + diff --git a/backend/app/api/powerbi.py b/backend/app/api/powerbi.py new file mode 100644 index 0000000..07c30b7 --- /dev/null +++ b/backend/app/api/powerbi.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.mix import Mix +from app.models.product import Product +from app.models.raw_material import RawMaterial +from app.models.scenario import Scenario +from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, serialize_raw_material + +router = APIRouter(prefix="/api/powerbi", tags=["powerbi"]) + + +@router.get("/raw-material-costs") +def raw_material_costs(db: Session = Depends(get_db)): + materials = db.scalars(select(RawMaterial).order_by(RawMaterial.name)).all() + return [serialize_raw_material(material) for material in materials] + + +@router.get("/mix-costs") +def mix_costs(db: Session = Depends(get_db)): + mixes = db.scalars(select(Mix).order_by(Mix.name)).all() + return [calculate_mix_cost(db, mix.id) for mix in mixes] + + +@router.get("/product-costs") +def product_costs(db: Session = Depends(get_db)): + products = db.scalars(select(Product).order_by(Product.name)).all() + return [calculate_product_cost(db, product.id) for product in products] + + +@router.get("/scenario-results") +def scenario_results(db: Session = Depends(get_db)): + scenarios = db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all() + return [ + { + "scenario_id": scenario.id, + "scenario_name": scenario.name, + "status": scenario.status, + "description": scenario.description, + "overrides": scenario.overrides, + } + for scenario in scenarios + ] + + +@router.get("/data-quality-issues") +def data_quality_issues(db: Session = Depends(get_db)): + issues: list[dict] = [] + for mix in db.scalars(select(Mix)).all(): + result = calculate_mix_cost(db, mix.id) + for warning in result["warnings"]: + issues.append({"entity_type": "mix", "entity_id": mix.id, "entity_name": mix.name, "warning": warning}) + for product in db.scalars(select(Product)).all(): + result = calculate_product_cost(db, product.id) + for warning in result["warnings"]: + issues.append({"entity_type": "product", "entity_id": product.id, "entity_name": product.name, "warning": warning}) + for material in db.scalars(select(RawMaterial)).all(): + serialized = serialize_raw_material(material) + if serialized["current_price"] is None: + issues.append({"entity_type": "raw_material", "entity_id": material.id, "entity_name": material.name, "warning": "No active price"}) + return issues + diff --git a/backend/app/api/products.py b/backend/app/api/products.py new file mode 100644 index 0000000..e9e87bb --- /dev/null +++ b/backend/app/api/products.py @@ -0,0 +1,88 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.mix import Mix +from app.models.product import Product +from app.schemas.product import ProductCostBreakdown, ProductCreate, ProductRead, ProductUpdate +from app.services.costing_engine import calculate_product_cost + +router = APIRouter(prefix="/api/products", tags=["products"]) + + +def _serialize_product(product: Product) -> dict: + return { + "id": product.id, + "tenant_id": product.tenant_id, + "client_name": product.client_name, + "item_id": product.item_id, + "name": product.name, + "mix_id": product.mix_id, + "mix_name": product.mix.name if product.mix else "", + "sale_type": product.sale_type, + "own_bag": product.own_bag, + "unit_of_measure": product.unit_of_measure, + "items_per_pallet": product.items_per_pallet, + "bagging_process": product.bagging_process, + "distributor_margin": product.distributor_margin, + "wholesale_margin": product.wholesale_margin, + "notes": product.notes, + "created_at": product.created_at, + } + + +@router.get("", response_model=list[ProductRead]) +def list_products(db: Session = Depends(get_db)): + products = db.scalars(select(Product).order_by(Product.name)).all() + return [_serialize_product(product) for product in products] + + +@router.post("", response_model=ProductRead, status_code=status.HTTP_201_CREATED) +def create_product(payload: ProductCreate, db: Session = Depends(get_db)): + if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None: + raise HTTPException(status_code=404, detail="Mix not found") + product = Product(**payload.model_dump()) + db.add(product) + db.commit() + db.refresh(product) + return _serialize_product(product) + + +@router.get("/{product_id}", response_model=ProductRead) +def get_product(product_id: int, db: Session = Depends(get_db)): + product = db.scalar(select(Product).where(Product.id == product_id)) + if product is None: + raise HTTPException(status_code=404, detail="Product not found") + return _serialize_product(product) + + +@router.patch("/{product_id}", response_model=ProductRead) +def update_product(product_id: int, payload: ProductUpdate, db: Session = Depends(get_db)): + product = db.scalar(select(Product).where(Product.id == product_id)) + if product is None: + raise HTTPException(status_code=404, detail="Product not found") + if payload.mix_id is not None and db.scalar(select(Mix.id).where(Mix.id == payload.mix_id)) is None: + raise HTTPException(status_code=404, detail="Mix not found") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(product, field, value) + db.commit() + db.refresh(product) + return _serialize_product(product) + + +@router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown) +def get_product_cost_breakdown(product_id: int, db: Session = Depends(get_db)): + try: + return calculate_product_cost(db, product_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/{product_id}/price-output", response_model=ProductCostBreakdown) +def get_product_price_output(product_id: int, db: Session = Depends(get_db)): + try: + return calculate_product_cost(db, product_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + diff --git a/backend/app/api/raw_materials.py b/backend/app/api/raw_materials.py new file mode 100644 index 0000000..cf1514b --- /dev/null +++ b/backend/app/api/raw_materials.py @@ -0,0 +1,122 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.db.session import get_db +from app.models.raw_material import RawMaterial, RawMaterialPriceVersion +from app.schemas.raw_material import ( + RawMaterialCreate, + RawMaterialPriceVersionCreate, + RawMaterialPriceVersionRead, + RawMaterialRead, + RawMaterialUpdate, +) +from app.services.costing_engine import calculate_raw_material_cost, serialize_raw_material + +router = APIRouter(prefix="/api/raw-materials", tags=["raw-materials"]) + + +def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> dict: + price_comp = calculate_raw_material_cost(material, price) + return { + "id": price.id, + "market_value": price.market_value, + "waste_percentage": price.waste_percentage, + "effective_date": price.effective_date, + "status": price.status, + "notes": price.notes, + "created_at": price.created_at, + "loss_cost": price_comp.loss_cost, + "cost_per_unit": price_comp.cost_per_unit, + "cost_per_kg": price_comp.cost_per_kg, + } + + +@router.get("", response_model=list[RawMaterialRead]) +def list_raw_materials(db: Session = Depends(get_db)): + materials = db.scalars(select(RawMaterial).options(selectinload(RawMaterial.price_versions)).order_by(RawMaterial.name)).all() + return [serialize_raw_material(material) for material in materials] + + +@router.post("", response_model=RawMaterialRead, status_code=status.HTTP_201_CREATED) +def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db)): + material = RawMaterial( + name=payload.name, + supplier=payload.supplier, + unit_of_measure=payload.unit_of_measure, + kg_per_unit=payload.kg_per_unit, + status=payload.status, + notes=payload.notes, + ) + material.price_versions.append( + RawMaterialPriceVersion( + market_value=payload.initial_price.market_value, + waste_percentage=payload.initial_price.waste_percentage, + effective_date=payload.initial_price.effective_date, + status=payload.initial_price.status, + notes=payload.initial_price.notes, + ) + ) + db.add(material) + db.commit() + db.refresh(material) + return serialize_raw_material(material) + + +@router.get("/{raw_material_id}", response_model=RawMaterialRead) +def get_raw_material(raw_material_id: int, db: Session = Depends(get_db)): + material = db.scalar( + select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions)) + ) + if material is None: + raise HTTPException(status_code=404, detail="Raw material not found") + return serialize_raw_material(material) + + +@router.patch("/{raw_material_id}", response_model=RawMaterialRead) +def update_raw_material(raw_material_id: int, payload: RawMaterialUpdate, db: Session = Depends(get_db)): + material = db.scalar( + select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions)) + ) + if material is None: + raise HTTPException(status_code=404, detail="Raw material not found") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(material, field, value) + db.commit() + db.refresh(material) + return serialize_raw_material(material) + + +@router.post("/{raw_material_id}/prices", response_model=RawMaterialPriceVersionRead, status_code=status.HTTP_201_CREATED) +def add_price_version(raw_material_id: int, payload: RawMaterialPriceVersionCreate, db: Session = Depends(get_db)): + material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id)) + if material is None: + raise HTTPException(status_code=404, detail="Raw material not found") + price = RawMaterialPriceVersion( + raw_material_id=raw_material_id, + market_value=payload.market_value, + waste_percentage=payload.waste_percentage, + effective_date=payload.effective_date, + status=payload.status, + notes=payload.notes, + ) + db.add(price) + db.commit() + db.refresh(price) + return _serialize_price(material, price) + + +@router.get("/{raw_material_id}/price-history", response_model=list[RawMaterialPriceVersionRead]) +def get_price_history(raw_material_id: int, db: Session = Depends(get_db)): + material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id)) + if material is None: + raise HTTPException(status_code=404, detail="Raw material not found") + prices = db.scalars( + select(RawMaterialPriceVersion) + .where(RawMaterialPriceVersion.raw_material_id == raw_material_id) + .order_by(RawMaterialPriceVersion.effective_date.desc()) + ).all() + items = [] + for price in prices: + items.append(_serialize_price(material, price)) + return items diff --git a/backend/app/api/scenarios.py b/backend/app/api/scenarios.py new file mode 100644 index 0000000..4249eaa --- /dev/null +++ b/backend/app/api/scenarios.py @@ -0,0 +1,84 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db.session import get_db +from app.models.scenario import CostingResult, Scenario +from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse +from app.services.scenario_engine import run_scenario + +router = APIRouter(prefix="/api/scenarios", tags=["scenarios"]) + + +@router.get("", response_model=list[ScenarioRead]) +def list_scenarios(db: Session = Depends(get_db)): + return db.scalars(select(Scenario).order_by(Scenario.created_at.desc())).all() + + +@router.post("", response_model=ScenarioRead, status_code=status.HTTP_201_CREATED) +def create_scenario(payload: ScenarioCreate, db: Session = Depends(get_db)): + scenario = Scenario(name=payload.name, description=payload.description, overrides=payload.overrides) + db.add(scenario) + db.commit() + db.refresh(scenario) + return scenario + + +@router.get("/{scenario_id}", response_model=ScenarioRead) +def get_scenario(scenario_id: int, db: Session = Depends(get_db)): + scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) + if scenario is None: + raise HTTPException(status_code=404, detail="Scenario not found") + return scenario + + +@router.post("/{scenario_id}/run", response_model=ScenarioRunResponse) +def run_scenario_endpoint(scenario_id: int, db: Session = Depends(get_db)): + scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) + if scenario is None: + raise HTTPException(status_code=404, detail="Scenario not found") + results = run_scenario(db, scenario) + db.refresh(scenario) + return {"scenario": scenario, "results": results} + + +@router.get("/{scenario_id}/results") +def get_scenario_results(scenario_id: int, db: Session = Depends(get_db)): + scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) + if scenario is None: + raise HTTPException(status_code=404, detail="Scenario not found") + results = db.scalars(select(CostingResult).where(CostingResult.scenario_id == scenario_id)).all() + return [ + { + "product_id": result.product_id, + "finished_product_delivered": result.finished_product_delivered, + "distributor_price": result.distributor_price, + "wholesale_price": result.wholesale_price, + "warnings": result.warnings, + "details": result.details, + } + for result in results + ] + + +@router.post("/{scenario_id}/approve", response_model=ScenarioRead) +def approve_scenario(scenario_id: int, db: Session = Depends(get_db)): + scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) + if scenario is None: + raise HTTPException(status_code=404, detail="Scenario not found") + scenario.status = "approved" + db.commit() + db.refresh(scenario) + return scenario + + +@router.post("/{scenario_id}/reject", response_model=ScenarioRead) +def reject_scenario(scenario_id: int, db: Session = Depends(get_db)): + scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id)) + if scenario is None: + raise HTTPException(status_code=404, detail="Scenario not found") + scenario.status = "rejected" + db.commit() + db.refresh(scenario) + return scenario + diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..e38d5c0 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,24 @@ +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Settings: + app_name: str + database_url: str + operator_name: str + operator_email: str + operator_password: str + + @classmethod + def from_env(cls) -> "Settings": + return cls( + app_name=os.getenv("APP_NAME", "Data Entry App API"), + database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"), + operator_name=os.getenv("OPERATOR_NAME", "Operations Manager"), + operator_email=os.getenv("OPERATOR_EMAIL", "operator@example.com"), + operator_password=os.getenv("OPERATOR_PASSWORD", "changeme"), + ) + + +settings = Settings.from_env() diff --git a/backend/app/db/__init__.py b/backend/app/db/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/db/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/db/session.py b/backend/app/db/session.py new file mode 100644 index 0000000..8c632de --- /dev/null +++ b/backend/app/db/session.py @@ -0,0 +1,23 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from app.core.config import settings + + +class Base(DeclarativeBase): + pass + + +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} + +engine = create_engine(settings.database_url, connect_args=connect_args) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..1ee1b01 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,81 @@ +import os +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +if __package__ in {None, ""}: + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +import uvicorn + +from app.api.auth import router as auth_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 +from app.api.raw_materials import router as raw_materials_router +from app.api.scenarios import router as scenarios_router +from app.core.config import settings +from app.db.session import Base, engine +from app.seed import seed_if_empty + + +@asynccontextmanager +async def lifespan(_: FastAPI): + Base.metadata.create_all(bind=engine) + seed_if_empty() + yield + + +app = FastAPI(title=settings.app_name, lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(auth_router) +app.include_router(raw_materials_router) +app.include_router(mixes_router) +app.include_router(products_router) +app.include_router(scenarios_router) +app.include_router(powerbi_router) + + +@app.get("/") +def root(): + return { + "app": settings.app_name, + "message": "Use the operator frontend to sign in, manage raw materials, and review downstream mix and product costs.", + "workflow": [ + "Sign in as an operator", + "Update raw material prices or add new materials", + "Review mix master recalculations", + "Confirm finished product pricing outputs", + ], + "endpoints": { + "login": "/api/auth/login", + "raw_materials": "/api/raw-materials", + "mixes": "/api/mixes", + "products": "/api/products", + "scenarios": "/api/scenarios", + "docs": "/docs", + }, + } + + +@app.get("/health") +def healthcheck(): + return {"status": "ok"} + + +if __name__ == "__main__": + uvicorn.run( + app, + host=os.getenv("HOST", "0.0.0.0"), + port=int(os.getenv("PORT", "8000")), + ) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..03ac0a3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,19 @@ +from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule +from app.models.mix import Mix, MixIngredient +from app.models.product import Product +from app.models.raw_material import RawMaterial, RawMaterialPriceVersion +from app.models.scenario import CostingResult, Scenario + +__all__ = [ + "CostingResult", + "FreightCostRule", + "Mix", + "MixIngredient", + "PackagingCostRule", + "ProcessCostRule", + "Product", + "RawMaterial", + "RawMaterialPriceVersion", + "Scenario", +] + diff --git a/backend/app/models/assumption.py b/backend/app/models/assumption.py new file mode 100644 index 0000000..6ed54de --- /dev/null +++ b/backend/app/models/assumption.py @@ -0,0 +1,38 @@ +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Float, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.session import Base + + +class ProcessCostRule(Base): + __tablename__ = "process_cost_rules" + + id: Mapped[int] = mapped_column(primary_key=True) + process_name: Mapped[str] = mapped_column(String(64), unique=True) + grading_cost: Mapped[float] = mapped_column(Float, default=0.0) + bagging_cost: Mapped[float] = mapped_column(Float, default=0.0) + cracking_cost: Mapped[float] = mapped_column(Float, default=0.0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class PackagingCostRule(Base): + __tablename__ = "packaging_cost_rules" + + id: Mapped[int] = mapped_column(primary_key=True) + sale_type: Mapped[str] = mapped_column(String(64)) + unit_of_measure: Mapped[str] = mapped_column(String(64)) + own_bag: Mapped[bool] = mapped_column(Boolean, default=False) + bag_cost: Mapped[float] = mapped_column(Float, default=0.0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + +class FreightCostRule(Base): + __tablename__ = "freight_cost_rules" + + id: Mapped[int] = mapped_column(primary_key=True) + sale_type: Mapped[str] = mapped_column(String(64)) + unit_of_measure: Mapped[str] = mapped_column(String(64)) + cost_per_unit: Mapped[float] = mapped_column(Float, default=0.0) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) diff --git a/backend/app/models/mix.py b/backend/app/models/mix.py new file mode 100644 index 0000000..accb338 --- /dev/null +++ b/backend/app/models/mix.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base + + +class Mix(Base): + __tablename__ = "mixes" + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), default="default") + client_name: Mapped[str] = mapped_column(String(255)) + name: Mapped[str] = mapped_column(String(255), index=True) + status: Mapped[str] = mapped_column(String(32), default="draft") + version: Mapped[int] = mapped_column(default=1) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + ingredients: Mapped[list["MixIngredient"]] = relationship( + back_populates="mix", + cascade="all, delete-orphan", + ) + products: Mapped[list["Product"]] = relationship(back_populates="mix") + + +class MixIngredient(Base): + __tablename__ = "mix_ingredients" + __table_args__ = (UniqueConstraint("mix_id", "raw_material_id", name="uq_mix_ingredient"),) + + id: Mapped[int] = mapped_column(primary_key=True) + mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"), index=True) + raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True) + quantity_kg: Mapped[float] = mapped_column(Float) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + mix: Mapped[Mix] = relationship(back_populates="ingredients") + raw_material: Mapped["RawMaterial"] = relationship() + + +from app.models.product import Product # noqa: E402 +from app.models.raw_material import RawMaterial # noqa: E402 + diff --git a/backend/app/models/product.py b/backend/app/models/product.py new file mode 100644 index 0000000..2796a2d --- /dev/null +++ b/backend/app/models/product.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base + + +class Product(Base): + __tablename__ = "products" + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), default="default") + client_name: Mapped[str] = mapped_column(String(255)) + item_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + name: Mapped[str] = mapped_column(String(255), index=True) + mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id")) + sale_type: Mapped[str] = mapped_column(String(64), default="standard") + own_bag: Mapped[bool] = mapped_column(Boolean, default=False) + unit_of_measure: Mapped[str] = mapped_column(String(64), default="20kg bag") + items_per_pallet: Mapped[int] = mapped_column(Integer, default=50) + bagging_process: Mapped[str | None] = mapped_column(String(64), nullable=True) + distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True) + wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + mix: Mapped["Mix"] = relationship(back_populates="products") + + +from app.models.mix import Mix # noqa: E402 + diff --git a/backend/app/models/raw_material.py b/backend/app/models/raw_material.py new file mode 100644 index 0000000..6ea44b8 --- /dev/null +++ b/backend/app/models/raw_material.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, Float, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base + + +class RawMaterial(Base): + __tablename__ = "raw_materials" + + id: Mapped[int] = mapped_column(primary_key=True) + tenant_id: Mapped[str] = mapped_column(String(64), default="default") + name: Mapped[str] = mapped_column(String(255), unique=True, index=True) + supplier: Mapped[str | None] = mapped_column(String(255), nullable=True) + unit_of_measure: Mapped[str] = mapped_column(String(64)) + kg_per_unit: Mapped[float] = mapped_column(Float) + status: Mapped[str] = mapped_column(String(32), default="active") + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + price_versions: Mapped[list["RawMaterialPriceVersion"]] = relationship( + back_populates="raw_material", + cascade="all, delete-orphan", + order_by="desc(RawMaterialPriceVersion.effective_date)", + ) + + +class RawMaterialPriceVersion(Base): + __tablename__ = "raw_material_price_versions" + + id: Mapped[int] = mapped_column(primary_key=True) + raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True) + market_value: Mapped[float] = mapped_column(Float) + waste_percentage: Mapped[float] = mapped_column(Float, default=0.0) + effective_date: Mapped[date] = mapped_column(Date, default=date.today) + status: Mapped[str] = mapped_column(String(32), default="active") + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + raw_material: Mapped[RawMaterial] = relationship(back_populates="price_versions") + diff --git a/backend/app/models/scenario.py b/backend/app/models/scenario.py new file mode 100644 index 0000000..4b7c0cd --- /dev/null +++ b/backend/app/models/scenario.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Float, ForeignKey, JSON, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.session import Base + + +class Scenario(Base): + __tablename__ = "scenarios" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255), unique=True) + status: Mapped[str] = mapped_column(String(32), default="draft") + description: Mapped[str | None] = mapped_column(String(500), nullable=True) + overrides: Mapped[dict] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + results: Mapped[list["CostingResult"]] = relationship( + back_populates="scenario", + cascade="all, delete-orphan", + ) + + +class CostingResult(Base): + __tablename__ = "costing_results" + + id: Mapped[int] = mapped_column(primary_key=True) + scenario_id: Mapped[int] = mapped_column(ForeignKey("scenarios.id"), index=True) + product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True) + finished_product_delivered: Mapped[float] = mapped_column(Float) + distributor_price: Mapped[float | None] = mapped_column(Float, nullable=True) + wholesale_price: Mapped[float | None] = mapped_column(Float, nullable=True) + warnings: Mapped[list[str]] = mapped_column(JSON, default=list) + details: Mapped[dict] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + + scenario: Mapped[Scenario] = relationship(back_populates="results") + diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/schemas/mix.py b/backend/app/schemas/mix.py new file mode 100644 index 0000000..3476064 --- /dev/null +++ b/backend/app/schemas/mix.py @@ -0,0 +1,60 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class MixIngredientCreate(BaseModel): + raw_material_id: int + quantity_kg: float = Field(gt=0) + notes: str | None = None + + +class MixIngredientUpdate(BaseModel): + quantity_kg: float | None = Field(default=None, gt=0) + notes: str | None = None + + +class MixIngredientRead(BaseModel): + id: int + raw_material_id: int + raw_material_name: str + quantity_kg: float + cost_per_kg: float | None + line_cost: float | None + notes: str | None + model_config = ConfigDict(from_attributes=True) + + +class MixCreate(BaseModel): + client_name: str + name: str + status: str = "draft" + version: int = 1 + notes: str | None = None + ingredients: list[MixIngredientCreate] + + +class MixUpdate(BaseModel): + client_name: str | None = None + name: str | None = None + status: str | None = None + version: int | None = None + notes: str | None = None + + +class MixRead(BaseModel): + id: int + tenant_id: str + client_name: str + name: str + status: str + version: int + notes: str | None + created_at: datetime + ingredients: list[MixIngredientRead] + total_mix_kg: float + total_mix_cost: float + mix_cost_per_kg: float | None + warnings: list[str] + model_config = ConfigDict(from_attributes=True) + diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py new file mode 100644 index 0000000..645a461 --- /dev/null +++ b/backend/app/schemas/product.py @@ -0,0 +1,70 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class ProductCreate(BaseModel): + client_name: str + item_id: str | None = None + name: str + mix_id: int + sale_type: str = "standard" + own_bag: bool = False + unit_of_measure: str = "20kg bag" + items_per_pallet: int = Field(default=50, gt=0) + bagging_process: str | None = None + distributor_margin: float | None = Field(default=None, gt=0, lt=1) + wholesale_margin: float | None = Field(default=None, gt=0, lt=1) + notes: str | None = None + + +class ProductUpdate(BaseModel): + client_name: str | None = None + item_id: str | None = None + name: str | None = None + mix_id: int | None = None + sale_type: str | None = None + own_bag: bool | None = None + unit_of_measure: str | None = None + items_per_pallet: int | None = Field(default=None, gt=0) + bagging_process: str | None = None + distributor_margin: float | None = Field(default=None, gt=0, lt=1) + wholesale_margin: float | None = Field(default=None, gt=0, lt=1) + notes: str | None = None + + +class ProductRead(BaseModel): + id: int + tenant_id: str + client_name: str + item_id: str | None + name: str + mix_id: int + mix_name: str + sale_type: str + own_bag: bool + unit_of_measure: str + items_per_pallet: int + bagging_process: str | None + distributor_margin: float | None + wholesale_margin: float | None + notes: str | None + created_at: datetime + model_config = ConfigDict(from_attributes=True) + + +class ProductCostBreakdown(BaseModel): + product_id: int + product_name: str + cleaned_product_cost: float + grading_cost: float + bagging_cost: float + cracking_cost: float + bag_cost: float + freight_cost: float + finished_product_delivered: float + distributor_price: float | None + wholesale_price: float | None + warnings: list[str] + inputs: dict[str, object] + diff --git a/backend/app/schemas/raw_material.py b/backend/app/schemas/raw_material.py new file mode 100644 index 0000000..d05d7a0 --- /dev/null +++ b/backend/app/schemas/raw_material.py @@ -0,0 +1,53 @@ +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class RawMaterialPriceVersionCreate(BaseModel): + market_value: float = Field(gt=0) + waste_percentage: float = Field(ge=0, default=0.0) + effective_date: date + status: str = "active" + notes: str | None = None + + +class RawMaterialPriceVersionRead(RawMaterialPriceVersionCreate): + id: int + created_at: datetime + loss_cost: float + cost_per_unit: float + cost_per_kg: float + model_config = ConfigDict(from_attributes=True) + + +class RawMaterialCreate(BaseModel): + name: str + supplier: str | None = None + unit_of_measure: str + kg_per_unit: float = Field(gt=0) + status: str = "active" + notes: str | None = None + initial_price: RawMaterialPriceVersionCreate + + +class RawMaterialUpdate(BaseModel): + supplier: str | None = None + unit_of_measure: str | None = None + kg_per_unit: float | None = Field(default=None, gt=0) + status: str | None = None + notes: str | None = None + + +class RawMaterialRead(BaseModel): + id: int + tenant_id: str + name: str + supplier: str | None + unit_of_measure: str + kg_per_unit: float + status: str + notes: str | None + created_at: datetime + current_price: RawMaterialPriceVersionRead | None + model_config = ConfigDict(from_attributes=True) + diff --git a/backend/app/schemas/scenario.py b/backend/app/schemas/scenario.py new file mode 100644 index 0000000..bf758fa --- /dev/null +++ b/backend/app/schemas/scenario.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.product import ProductCostBreakdown + + +class ScenarioCreate(BaseModel): + name: str + description: str | None = None + overrides: dict = Field(default_factory=dict) + + +class ScenarioRead(BaseModel): + id: int + name: str + status: str + description: str | None + overrides: dict + created_at: datetime + model_config = ConfigDict(from_attributes=True) + + +class ScenarioRunResponse(BaseModel): + scenario: ScenarioRead + results: list[ProductCostBreakdown] diff --git a/backend/app/seed.py b/backend/app/seed.py new file mode 100644 index 0000000..40fbd3e --- /dev/null +++ b/backend/app/seed.py @@ -0,0 +1,76 @@ +from datetime import date + +from sqlalchemy import select + +from app.db.session import Base, SessionLocal, engine +from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule +from app.models.mix import Mix, MixIngredient +from app.models.product import Product +from app.models.raw_material import RawMaterial, RawMaterialPriceVersion + + +def seed_if_empty(): + Base.metadata.create_all(bind=engine) + with SessionLocal() as db: + existing = db.scalar(select(RawMaterial.id)) + if existing is not None: + return + + maize = RawMaterial(name="Maize", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active") + barley = RawMaterial(name="Barley", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active") + acid_buf = RawMaterial(name="Acid Buf", supplier="Example Supplier", unit_of_measure="bag", kg_per_unit=25, status="active") + + maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1))) + barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1))) + acid_buf.price_versions.append(RawMaterialPriceVersion(market_value=39, waste_percentage=0.0, effective_date=date(2026, 4, 1))) + + db.add_all([maize, barley, acid_buf]) + db.flush() + + db.add_all( + [ + ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0), + ProcessCostRule(process_name="bulk_loadout", grading_cost=0.03, bagging_cost=0.0, cracking_cost=0.0), + PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63), + PackagingCostRule(sale_type="bulka", unit_of_measure="550kg bulka", own_bag=False, bag_cost=7.5), + FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45), + FreightCostRule(sale_type="bulka", unit_of_measure="550kg bulka", cost_per_unit=18.0), + ] + ) + db.flush() + + mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1, notes="Seed recipe for MVP") + db.add(mix) + db.flush() + + db.add_all( + [ + MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180), + MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=95), + MixIngredient(mix_id=mix.id, raw_material_id=acid_buf.id, quantity_kg=5), + ] + ) + db.flush() + + db.add( + Product( + client_name="Specialty Feeds", + item_id="SKU-001", + 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", + distributor_margin=0.225, + wholesale_margin=0.18, + notes="Reference product for formula parity work", + ) + ) + db.commit() + + +if __name__ == "__main__": + seed_if_empty() + diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/app/services/costing_engine.py b/backend/app/services/costing_engine.py new file mode 100644 index 0000000..25778bd --- /dev/null +++ b/backend/app/services/costing_engine.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +import re + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule +from app.models.mix import Mix, MixIngredient +from app.models.product import Product +from app.models.raw_material import RawMaterial, RawMaterialPriceVersion + + +@dataclass +class PriceComputation: + loss_cost: float + cost_per_unit: float + cost_per_kg: float + + +def calculate_raw_material_cost(raw_material: RawMaterial, price: RawMaterialPriceVersion) -> PriceComputation: + loss_cost = price.market_value * price.waste_percentage + cost_per_unit = price.market_value + loss_cost + cost_per_kg = cost_per_unit / raw_material.kg_per_unit + return PriceComputation(loss_cost=round(loss_cost, 4), cost_per_unit=round(cost_per_unit, 4), cost_per_kg=round(cost_per_kg, 4)) + + +def get_active_price(raw_material: RawMaterial) -> RawMaterialPriceVersion | None: + active_prices = [price for price in raw_material.price_versions if price.status == "active"] + if not active_prices: + return None + active_prices.sort(key=lambda item: item.effective_date, reverse=True) + return active_prices[0] + + +def calculate_mix_cost(db: Session, mix_id: int, overrides: dict | None = None) -> dict: + overrides = overrides or {} + mix = db.scalar( + select(Mix) + .where(Mix.id == mix_id) + .options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material).selectinload(RawMaterial.price_versions)) + ) + if mix is None: + raise ValueError(f"Mix {mix_id} not found") + + total_mix_kg = 0.0 + total_mix_cost = 0.0 + warnings: list[str] = [] + lines: list[dict] = [] + + for ingredient in mix.ingredients: + raw_material = ingredient.raw_material + active_price = get_active_price(raw_material) + if active_price is None: + warnings.append(f"{raw_material.name} has no active price") + lines.append( + { + "id": ingredient.id, + "raw_material_id": raw_material.id, + "raw_material_name": raw_material.name, + "quantity_kg": ingredient.quantity_kg, + "cost_per_kg": None, + "line_cost": None, + "notes": ingredient.notes, + } + ) + total_mix_kg += ingredient.quantity_kg + continue + + market_value = overrides.get("raw_material_market_values", {}).get(str(raw_material.id), active_price.market_value) + waste_percentage = overrides.get("raw_material_waste_percentages", {}).get(str(raw_material.id), active_price.waste_percentage) + price_stub = RawMaterialPriceVersion( + raw_material_id=raw_material.id, + market_value=market_value, + waste_percentage=waste_percentage, + effective_date=active_price.effective_date, + status=active_price.status, + ) + price_comp = calculate_raw_material_cost(raw_material, price_stub) + line_cost = round(ingredient.quantity_kg * price_comp.cost_per_kg, 4) + total_mix_kg += ingredient.quantity_kg + total_mix_cost += line_cost + lines.append( + { + "id": ingredient.id, + "raw_material_id": raw_material.id, + "raw_material_name": raw_material.name, + "quantity_kg": ingredient.quantity_kg, + "cost_per_kg": price_comp.cost_per_kg, + "line_cost": line_cost, + "notes": ingredient.notes, + } + ) + + if total_mix_kg == 0: + warnings.append("Mix total kg is zero") + mix_cost_per_kg = None + else: + mix_cost_per_kg = round(total_mix_cost / total_mix_kg, 4) + + if not mix.ingredients: + warnings.append("Mix has no ingredients") + + return { + "id": mix.id, + "tenant_id": mix.tenant_id, + "client_name": mix.client_name, + "name": mix.name, + "status": mix.status, + "version": mix.version, + "notes": mix.notes, + "created_at": mix.created_at, + "ingredients": lines, + "total_mix_kg": round(total_mix_kg, 4), + "total_mix_cost": round(total_mix_cost, 4), + "mix_cost_per_kg": mix_cost_per_kg, + "warnings": warnings, + } + + +def _get_process_costs(db: Session, process_name: str | None, overrides: dict) -> tuple[float, float, float, list[str]]: + if not process_name: + return 0.0, 0.0, 0.0, ["Missing bagging process"] + + rule = db.scalar(select(ProcessCostRule).where(ProcessCostRule.process_name == process_name)) + if rule is None: + return 0.0, 0.0, 0.0, [f"Process rule not found for {process_name}"] + + override_costs = overrides.get("process_costs", {}).get(process_name, {}) + grading = override_costs.get("grading_cost", rule.grading_cost) + bagging = override_costs.get("bagging_cost", rule.bagging_cost) + cracking = override_costs.get("cracking_cost", rule.cracking_cost) + return grading, bagging, cracking, [] + + +def _get_packaging_cost(db: Session, product: Product, overrides: dict) -> tuple[float, list[str]]: + if product.own_bag: + return 0.0, [] + + rule = db.scalar( + select(PackagingCostRule).where( + PackagingCostRule.sale_type == product.sale_type, + PackagingCostRule.unit_of_measure == product.unit_of_measure, + PackagingCostRule.own_bag == product.own_bag, + ) + ) + if rule is None: + return 0.0, ["Packaging rule not found"] + + return overrides.get("packaging_costs", {}).get(str(rule.id), rule.bag_cost), [] + + +def _get_freight_cost(db: Session, product: Product, overrides: dict) -> tuple[float, list[str]]: + rule = db.scalar( + select(FreightCostRule).where( + FreightCostRule.sale_type == product.sale_type, + FreightCostRule.unit_of_measure == product.unit_of_measure, + ) + ) + if rule is None: + return 0.0, ["Freight rule not found"] + return overrides.get("freight_costs", {}).get(str(rule.id), rule.cost_per_unit), [] + + +def _apply_margin(cost: float, margin: float | None) -> float | None: + if margin is None: + return None + if margin >= 1: + raise ValueError("Margin must be lower than 1") + return round(cost / (1 - margin), 4) + + +def _extract_unit_quantity_kg(unit_of_measure: str) -> float: + normalized = unit_of_measure.strip().lower() + if normalized == "tonne": + return 1000.0 + if normalized == "kg": + return 1.0 + match = re.search(r"(\d+(?:\.\d+)?)\s*kg", normalized) + if match: + return float(match.group(1)) + return 1.0 + + +def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict: + overrides = overrides or {} + product = db.scalar(select(Product).where(Product.id == product_id).options(selectinload(Product.mix))) + if product is None: + raise ValueError(f"Product {product_id} not found") + + 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) + + 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) + grading_cost, bagging_cost, cracking_cost, process_warnings = _get_process_costs(db, product.bagging_process, overrides) + warnings.extend(process_warnings) + grading_cost = round(grading_cost * sale_unit_kg, 4) + bagging_cost = round(bagging_cost * sale_unit_kg, 4) + cracking_cost = round(cracking_cost * sale_unit_kg, 4) + bag_cost, packaging_warnings = _get_packaging_cost(db, product, overrides) + warnings.extend(packaging_warnings) + freight_cost, freight_warnings = _get_freight_cost(db, product, overrides) + warnings.extend(freight_warnings) + + finished_product_delivered = round( + cleaned_product_cost + grading_cost + bagging_cost + cracking_cost + bag_cost + freight_cost, + 4, + ) + + distributor_margin = overrides.get("product_margins", {}).get(str(product.id), {}).get("distributor_margin", product.distributor_margin) + wholesale_margin = overrides.get("product_margins", {}).get(str(product.id), {}).get("wholesale_margin", product.wholesale_margin) + + return { + "product_id": product.id, + "product_name": product.name, + "cleaned_product_cost": round(cleaned_product_cost, 4), + "grading_cost": round(grading_cost, 4), + "bagging_cost": round(bagging_cost, 4), + "cracking_cost": round(cracking_cost, 4), + "bag_cost": round(bag_cost, 4), + "freight_cost": round(freight_cost, 4), + "finished_product_delivered": finished_product_delivered, + "distributor_price": _apply_margin(finished_product_delivered, distributor_margin), + "wholesale_price": _apply_margin(finished_product_delivered, wholesale_margin), + "warnings": warnings, + "inputs": { + "mix": { + "mix_id": product.mix_id, + "mix_name": product.mix.name, + "total_mix_kg": mix_result["total_mix_kg"], + "total_mix_cost": mix_result["total_mix_cost"], + "mix_cost_per_kg": mix_result["mix_cost_per_kg"], + "sale_unit_kg": sale_unit_kg, + }, + "product": { + "sale_type": product.sale_type, + "own_bag": product.own_bag, + "unit_of_measure": product.unit_of_measure, + "items_per_pallet": product.items_per_pallet, + "bagging_process": product.bagging_process, + "distributor_margin": distributor_margin, + "wholesale_margin": wholesale_margin, + }, + }, + } + + +def serialize_raw_material(raw_material: RawMaterial) -> dict: + active_price = get_active_price(raw_material) + current_price = None + if active_price is not None: + price_comp = calculate_raw_material_cost(raw_material, active_price) + current_price = { + "id": active_price.id, + "market_value": active_price.market_value, + "waste_percentage": active_price.waste_percentage, + "effective_date": active_price.effective_date, + "status": active_price.status, + "notes": active_price.notes, + "created_at": active_price.created_at, + **asdict(price_comp), + } + return { + "id": raw_material.id, + "tenant_id": raw_material.tenant_id, + "name": raw_material.name, + "supplier": raw_material.supplier, + "unit_of_measure": raw_material.unit_of_measure, + "kg_per_unit": raw_material.kg_per_unit, + "status": raw_material.status, + "notes": raw_material.notes, + "created_at": raw_material.created_at, + "current_price": current_price, + } diff --git a/backend/app/services/scenario_engine.py b/backend/app/services/scenario_engine.py new file mode 100644 index 0000000..eff4476 --- /dev/null +++ b/backend/app/services/scenario_engine.py @@ -0,0 +1,32 @@ +from sqlalchemy import delete, select +from sqlalchemy.orm import Session + +from app.models.product import Product +from app.models.scenario import CostingResult, Scenario +from app.services.costing_engine import calculate_product_cost + + +def run_scenario(db: Session, scenario: Scenario) -> list[dict]: + db.execute(delete(CostingResult).where(CostingResult.scenario_id == scenario.id)) + products = db.scalars(select(Product).order_by(Product.name)).all() + results: list[dict] = [] + + for product in products: + breakdown = calculate_product_cost(db, product.id, overrides=scenario.overrides or {}) + db.add( + CostingResult( + scenario_id=scenario.id, + product_id=product.id, + finished_product_delivered=breakdown["finished_product_delivered"], + distributor_price=breakdown["distributor_price"], + wholesale_price=breakdown["wholesale_price"], + warnings=breakdown["warnings"], + details=breakdown, + ) + ) + results.append(breakdown) + + scenario.status = "reviewed" + db.commit() + return results + diff --git a/backend/data_entry_app_backend.egg-info/PKG-INFO b/backend/data_entry_app_backend.egg-info/PKG-INFO new file mode 100644 index 0000000..d795e5f --- /dev/null +++ b/backend/data_entry_app_backend.egg-info/PKG-INFO @@ -0,0 +1,10 @@ +Metadata-Version: 2.4 +Name: data-entry-app-backend +Version: 0.1.0 +Summary: Costing platform MVP backend +Requires-Python: >=3.11 +Requires-Dist: fastapi<1.0,>=0.115 +Requires-Dist: uvicorn[standard]<1.0,>=0.30 +Requires-Dist: sqlalchemy<3.0,>=2.0 +Requires-Dist: pydantic<3.0,>=2.8 +Requires-Dist: pytest<9.0,>=8.0 diff --git a/backend/data_entry_app_backend.egg-info/SOURCES.txt b/backend/data_entry_app_backend.egg-info/SOURCES.txt new file mode 100644 index 0000000..2082ccb --- /dev/null +++ b/backend/data_entry_app_backend.egg-info/SOURCES.txt @@ -0,0 +1,34 @@ +pyproject.toml +./app/__init__.py +./app/main.py +./app/seed.py +./app/api/__init__.py +./app/api/mixes.py +./app/api/powerbi.py +./app/api/products.py +./app/api/raw_materials.py +./app/api/scenarios.py +./app/core/__init__.py +./app/core/config.py +./app/db/__init__.py +./app/db/session.py +./app/models/__init__.py +./app/models/assumption.py +./app/models/mix.py +./app/models/product.py +./app/models/raw_material.py +./app/models/scenario.py +./app/schemas/__init__.py +./app/schemas/mix.py +./app/schemas/product.py +./app/schemas/raw_material.py +./app/schemas/scenario.py +./app/services/__init__.py +./app/services/costing_engine.py +./app/services/scenario_engine.py +data_entry_app_backend.egg-info/PKG-INFO +data_entry_app_backend.egg-info/SOURCES.txt +data_entry_app_backend.egg-info/dependency_links.txt +data_entry_app_backend.egg-info/requires.txt +data_entry_app_backend.egg-info/top_level.txt +tests/test_costing_engine.py \ No newline at end of file diff --git a/backend/data_entry_app_backend.egg-info/dependency_links.txt b/backend/data_entry_app_backend.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/data_entry_app_backend.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/backend/data_entry_app_backend.egg-info/requires.txt b/backend/data_entry_app_backend.egg-info/requires.txt new file mode 100644 index 0000000..a9fc968 --- /dev/null +++ b/backend/data_entry_app_backend.egg-info/requires.txt @@ -0,0 +1,5 @@ +fastapi<1.0,>=0.115 +uvicorn[standard]<1.0,>=0.30 +sqlalchemy<3.0,>=2.0 +pydantic<3.0,>=2.8 +pytest<9.0,>=8.0 diff --git a/backend/data_entry_app_backend.egg-info/top_level.txt b/backend/data_entry_app_backend.egg-info/top_level.txt new file mode 100644 index 0000000..b80f0bd --- /dev/null +++ b/backend/data_entry_app_backend.egg-info/top_level.txt @@ -0,0 +1 @@ +app diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..25b347e --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "data-entry-app-backend" +version = "0.1.0" +description = "Costing platform MVP backend" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115,<1.0", + "uvicorn[standard]>=0.30,<1.0", + "sqlalchemy>=2.0,<3.0", + "pydantic>=2.8,<3.0", + "pytest>=8.0,<9.0", +] + +[tool.setuptools] +package-dir = {"" = "."} + +[tool.setuptools.packages.find] +where = ["."] +include = ["app*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py new file mode 100644 index 0000000..74182d6 --- /dev/null +++ b/backend/tests/test_costing_engine.py @@ -0,0 +1,95 @@ +from datetime import date + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.core.config import settings +from app.db.session import Base +from app.main import app +from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule +from app.models.mix import Mix, MixIngredient +from app.models.product import Product +from app.models.raw_material import RawMaterial, RawMaterialPriceVersion +from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost + + +def build_session() -> Session: + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=engine) + session = sessionmaker(bind=engine, expire_on_commit=False)() + return session + + +def test_calculate_raw_material_cost(): + raw_material = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active") + price = RawMaterialPriceVersion(market_value=500, waste_percentage=0.02, effective_date=date(2026, 4, 1)) + + result = calculate_raw_material_cost(raw_material, price) + + assert result.loss_cost == 10.0 + assert result.cost_per_unit == 510.0 + assert result.cost_per_kg == 0.51 + + +def test_mix_and_product_cost_breakdown(): + db = build_session() + + maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active") + maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1))) + barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active") + barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1))) + db.add_all([maize, barley]) + db.flush() + + mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1) + db.add(mix) + db.flush() + db.add_all( + [ + MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180), + MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100), + ] + ) + db.add(ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0)) + db.add(PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63)) + db.add(FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45)) + db.flush() + product = Product( + 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", + distributor_margin=0.225, + wholesale_margin=0.18, + ) + db.add(product) + db.commit() + + mix_result = calculate_mix_cost(db, mix.id) + product_result = calculate_product_cost(db, product.id) + + assert mix_result["total_mix_kg"] == 280 + assert mix_result["mix_cost_per_kg"] == 0.5114 + assert product_result["finished_product_delivered"] == 14.208 + assert product_result["distributor_price"] == 18.3329 + assert product_result["wholesale_price"] == 17.3268 + + +def test_root_and_login_endpoints(): + client = TestClient(app) + + root_response = client.get("/") + assert root_response.status_code == 200 + assert root_response.json()["endpoints"]["login"] == "/api/auth/login" + + login_response = client.post( + "/api/auth/login", + json={"email": settings.operator_email, "password": settings.operator_password}, + ) + assert login_response.status_code == 200 + assert login_response.json()["email"] == settings.operator_email diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5e79043 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1315 @@ +{ + "name": "data-entry-app-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "data-entry-app-frontend", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/adapter-auto": "^3.2.0", + "@sveltejs/kit": "^2.7.1", + "svelte": "^5.0.0", + "typescript": "^5.5.4", + "vite": "^8.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.3.1.tgz", + "integrity": "sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-meta-resolve": "^4.1.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.58.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.58.0.tgz", + "integrity": "sha512-kT9GCN8yJTkCK1W+Gi/bvGooWAM7y7WXP+yd+rf6QOIjyoK1ERPrMwSufXJUNu2pMWIqruhFvmz+LbOqsEmKmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", + "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", + "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "peer": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.55.5", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.5.tgz", + "integrity": "sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "peer": true, + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..da446f7 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "data-entry-app-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.2.0", + "@sveltejs/kit": "^2.7.1", + "svelte": "^5.0.0", + "typescript": "^5.5.4", + "vite": "^8.0.0" + } +} diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..fa74e64 --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,12 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + + diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..ee8a614 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,74 @@ +import { env } from '$env/dynamic/public'; +import { mockCosts, mockMixes, mockProducts, mockRawMaterials, mockScenarios } from '$lib/mock'; +import type { + LoginResponse, + Product, + ProductCostBreakdown, + RawMaterial, + RawMaterialCreateInput, + RawMaterialPriceCreateInput, + Scenario +} from '$lib/types'; + +const API_BASE_URL = env.PUBLIC_API_BASE_URL || 'http://localhost:8000'; + +async function fetchJson(path: string, fallback: T): Promise { + try { + const response = await fetch(`${API_BASE_URL}${path}`); + if (!response.ok) { + return fallback; + } + return (await response.json()) as T; + } catch { + return fallback; + } +} + +async function request(path: string, options: RequestInit): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + headers: { + 'Content-Type': 'application/json', + ...(options.headers ?? {}) + }, + ...options + }); + + if (!response.ok) { + let message = 'Request failed'; + + try { + const body = (await response.json()) as { detail?: string }; + message = body.detail ?? message; + } catch { + message = response.statusText || message; + } + + throw new Error(message); + } + + return (await response.json()) as T; +} + +export const api = { + rawMaterials: () => fetchJson('/api/raw-materials', mockRawMaterials), + mixes: () => fetchJson('/api/mixes', mockMixes), + products: () => fetchJson('/api/products', mockProducts), + productCosts: () => fetchJson('/api/powerbi/product-costs', mockCosts), + scenarios: () => fetchJson('/api/scenarios', mockScenarios), + dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', []), + login: (email: string, password: string) => + request('/api/auth/login', { + method: 'POST', + body: JSON.stringify({ email, password }) + }), + createRawMaterial: (payload: RawMaterialCreateInput) => + request('/api/raw-materials', { + method: 'POST', + body: JSON.stringify(payload) + }), + addRawMaterialPrice: (rawMaterialId: number, payload: RawMaterialPriceCreateInput) => + request(`/api/raw-materials/${rawMaterialId}/prices`, { + method: 'POST', + body: JSON.stringify(payload) + }) +}; diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts new file mode 100644 index 0000000..850363c --- /dev/null +++ b/frontend/src/lib/mock.ts @@ -0,0 +1,96 @@ +import type { Mix, Product, ProductCostBreakdown, RawMaterial, Scenario } from '$lib/types'; + +export const mockRawMaterials: RawMaterial[] = [ + { + id: 1, + name: 'Maize', + unit_of_measure: 'tonne', + kg_per_unit: 1000, + status: 'active', + current_price: { + market_value: 520, + waste_percentage: 0.02, + cost_per_kg: 0.5304, + effective_date: '2026-04-01' + } + }, + { + id: 2, + name: 'Barley', + unit_of_measure: 'tonne', + kg_per_unit: 1000, + status: 'active', + current_price: { + market_value: 470, + waste_percentage: 0.015, + cost_per_kg: 0.4771, + effective_date: '2026-04-01' + } + } +]; + +export const mockMixes: Mix[] = [ + { + id: 1, + client_name: 'Specialty Feeds', + name: 'Pigeon Mix', + status: 'active', + ingredients: [ + { + id: 1, + raw_material_id: 1, + raw_material_name: 'Maize', + quantity_kg: 180, + cost_per_kg: 0.5304, + line_cost: 95.472 + }, + { + id: 2, + raw_material_id: 2, + raw_material_name: 'Barley', + quantity_kg: 100, + cost_per_kg: 0.4771, + line_cost: 47.71 + } + ], + total_mix_kg: 280, + total_mix_cost: 143.18, + mix_cost_per_kg: 0.5114, + warnings: [] + } +]; + +export const mockProducts: Product[] = [ + { + id: 1, + name: 'Specialty Pigeon Breeder 20kg', + client_name: 'Specialty Feeds', + mix_id: 1, + mix_name: 'Pigeon Mix', + sale_type: 'standard', + unit_of_measure: '20kg bag', + distributor_margin: 0.225, + wholesale_margin: 0.18 + } +]; + +export const mockCosts: ProductCostBreakdown[] = [ + { + product_id: 1, + product_name: 'Specialty Pigeon Breeder 20kg', + finished_product_delivered: 14.208, + distributor_price: 18.3329, + wholesale_price: 17.3268, + warnings: [] + } +]; + +export const mockScenarios: Scenario[] = [ + { + id: 1, + name: 'Current Standard', + status: 'approved', + description: 'Baseline approved pricing', + overrides: {} + } +]; diff --git a/frontend/src/lib/session.ts b/frontend/src/lib/session.ts new file mode 100644 index 0000000..f699573 --- /dev/null +++ b/frontend/src/lib/session.ts @@ -0,0 +1,58 @@ +import { browser } from '$app/environment'; +import { writable } from 'svelte/store'; + +export type OperatorSession = { + name: string; + email: string; + role: string; +}; + +const STORAGE_KEY = 'data-entry-app-operator-session'; + +function readSession(): OperatorSession | null { + if (!browser) { + return null; + } + + const value = localStorage.getItem(STORAGE_KEY); + if (!value) { + return null; + } + + try { + return JSON.parse(value) as OperatorSession; + } catch { + localStorage.removeItem(STORAGE_KEY); + return null; + } +} + +function createOperatorSessionStore() { + const store = writable(readSession()); + + if (browser) { + window.addEventListener('storage', (event) => { + if (event.key === STORAGE_KEY) { + store.set(readSession()); + } + }); + } + + return { + subscribe: store.subscribe, + set(session: OperatorSession) { + if (browser) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); + } + store.set(session); + }, + clear() { + if (browser) { + localStorage.removeItem(STORAGE_KEY); + } + store.set(null); + } + }; +} + +export const operatorSession = createOperatorSessionStore(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..ae2c813 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,121 @@ +export type RawMaterial = { + id: number; + tenant_id?: string; + name: string; + supplier?: string | null; + unit_of_measure: string; + kg_per_unit: number; + status: string; + notes?: string | null; + created_at?: string; + current_price?: { + id?: number; + market_value: number; + waste_percentage: number; + cost_per_kg: number; + effective_date: string; + status?: string; + notes?: string | null; + created_at?: string; + loss_cost?: number; + cost_per_unit?: number; + } | null; +}; + +export type MixIngredient = { + id: number; + raw_material_id: number; + raw_material_name: string; + quantity_kg: number; + cost_per_kg: number | null; + line_cost: number | null; + notes?: string | null; +}; + +export type Mix = { + id: number; + tenant_id?: string; + client_name: string; + name: string; + status: string; + version?: number; + notes?: string | null; + created_at?: string; + ingredients: MixIngredient[]; + total_mix_kg: number; + total_mix_cost: number; + mix_cost_per_kg: number | null; + warnings: string[]; +}; + +export type Product = { + id: number; + tenant_id?: string; + name: string; + client_name: string; + mix_id?: number; + mix_name: string; + sale_type: string; + own_bag?: boolean; + unit_of_measure: string; + items_per_pallet?: number; + bagging_process?: string | null; + distributor_margin: number | null; + wholesale_margin: number | null; + notes?: string | null; + created_at?: string; +}; + +export type ProductCostBreakdown = { + product_id: number; + product_name: string; + cleaned_product_cost?: number; + grading_cost?: number; + bagging_cost?: number; + cracking_cost?: number; + bag_cost?: number; + freight_cost?: number; + finished_product_delivered: number; + distributor_price: number | null; + wholesale_price: number | null; + warnings: string[]; + inputs?: Record; +}; + +export type Scenario = { + id: number; + name: string; + status: string; + description?: string | null; + overrides: Record; +}; + +export type LoginResponse = { + name: string; + email: string; + role: string; +}; + +export type RawMaterialCreateInput = { + name: string; + supplier?: string | null; + unit_of_measure: string; + kg_per_unit: number; + status?: string; + notes?: string | null; + initial_price: { + market_value: number; + waste_percentage: number; + effective_date: string; + status?: string; + notes?: string | null; + }; +}; + +export type RawMaterialPriceCreateInput = { + market_value: number; + waste_percentage: number; + effective_date: string; + status?: string; + notes?: string | null; +}; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..5f611dd --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,194 @@ + + + + Data Entry App + + +
+
+
+ Data Entry App +

Operator costing workflow

+
+ + + +
+ {#if $operatorSession} +
+ Signed in + {$operatorSession.name} +
+ + {:else} + + {/if} +
+
+ +
+ +
+
+ + diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..2a251ca --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,587 @@ + + +{#if !$operatorSession} +
+
+

Costing control room

+

Sign in to manage raw materials and push updates through Mix Master automatically.

+

+ This workflow is for operators maintaining input costs. Update a raw material once, then review the refreshed + mix cost per kg and finished product pricing in the same session. +

+ +
+
+ 01 +

Log in

+

Enter the operator account to unlock material maintenance and costing review.

+
+
+ 02 +

Update inputs

+

Record a new market value or waste percentage for any raw material.

+
+
+ 03 +

Review impact

+

Check Mix Master and downstream product pricing immediately after the save.

+
+
+
+ + +
+{:else} +
+
+

Active operator

+

Welcome back, {$operatorSession.name}.

+

Manage raw materials first, then review Mix Master and product pricing before publishing changes.

+
+ Open raw material manager +
+{/if} + +
+ {#each [ + { label: 'Raw materials', value: data.rawMaterials.length, hint: 'Maintain live input costs' }, + { label: 'Mixes', value: data.mixes.length, hint: 'Recipes recalculated from inputs' }, + { label: 'Products', value: data.productCosts.length, hint: 'Delivered pricing outputs' }, + { label: 'Warnings', value: data.dataQuality.length, hint: 'Items needing review' } + ] as card} +
+

{card.label}

+ {card.value} + {card.hint} +
+ {/each} +
+ +
+
+
+
+

Latest input

+

{featuredMaterial?.name ?? 'No materials loaded'}

+
+ Manage inputs +
+ + {#if featuredMaterial?.current_price} +
+
+
Market value
+
{currency(featuredMaterial.current_price.market_value)}
+
+
+
Waste
+
{(featuredMaterial.current_price.waste_percentage * 100).toFixed(1)}%
+
+
+
Cost per kg
+
{currency(featuredMaterial.current_price.cost_per_kg, 4)}
+
+
+ {:else} +

No active price version is available for this material.

+ {/if} +
+ +
+
+
+

Mix master

+

{featuredMix?.name ?? 'No mixes loaded'}

+
+ Review mixes +
+ + {#if featuredMix} +
+
+
Client
+
{featuredMix.client_name}
+
+
+
Total mix cost
+
{currency(featuredMix.total_mix_cost)}
+
+
+
Cost per kg
+
{currency(featuredMix.mix_cost_per_kg, 4)}
+
+
+ {/if} +
+ +
+
+
+

Delivered output

+

{featuredProduct?.product_name ?? 'No product costs loaded'}

+
+ Review products +
+ + {#if featuredProduct} +
+
+
Delivered
+
{currency(featuredProduct.finished_product_delivered)}
+
+
+
Distributor
+
{currency(featuredProduct.distributor_price)}
+
+
+
Wholesale
+
{currency(featuredProduct.wholesale_price)}
+
+
+ {/if} +
+
+ +
+
+
+
+

Current cascade

+

Raw material updates flow straight into Mix Master

+
+ Edit materials +
+ + + + + + + + + + + + {#each data.mixes as mix} + + + + + + + {/each} + +
MixIngredientsTotal CostCost/Kg
{mix.name}{mix.ingredients.length}{currency(mix.total_mix_cost)}{currency(mix.mix_cost_per_kg, 4)}
+
+ +
+
+
+

Output pricing

+

Finished products

+
+ Open products +
+ + + + + + + + + + + + {#each data.productCosts as row} + + + + + + + {/each} + +
ProductDeliveredDistributorWholesale
{row.product_name}{currency(row.finished_product_delivered)}{currency(row.distributor_price)}{currency(row.wholesale_price)}
+
+
+ + diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts new file mode 100644 index 0000000..ccf585d --- /dev/null +++ b/frontend/src/routes/+page.ts @@ -0,0 +1,20 @@ +import { api } from '$lib/api'; + +export async function load() { + const [rawMaterials, mixes, productCosts, scenarios, dataQuality] = await Promise.all([ + api.rawMaterials(), + api.mixes(), + api.productCosts(), + api.scenarios(), + api.dataQuality() + ]); + + return { + rawMaterials, + mixes, + productCosts, + scenarios, + dataQuality + }; +} + diff --git a/frontend/src/routes/mixes/+page.svelte b/frontend/src/routes/mixes/+page.svelte new file mode 100644 index 0000000..0bc42db --- /dev/null +++ b/frontend/src/routes/mixes/+page.svelte @@ -0,0 +1,96 @@ + + +
+

Mix Master

+

Recipes are structured as ingredient rows instead of spreadsheet columns.

+ +
+ {#each data.mixes as mix} +
+
+
+

{mix.name}

+

{mix.client_name}

+
+ {mix.status} +
+
+
+
Total Kg
+
{mix.total_mix_kg}
+
+
+
Total Cost
+
${mix.total_mix_cost.toFixed(2)}
+
+
+
Cost/Kg
+
{mix.mix_cost_per_kg ? `$${mix.mix_cost_per_kg.toFixed(4)}` : 'N/A'}
+
+
+ {#if mix.warnings.length} +
    + {#each mix.warnings as warning} +
  • {warning}
  • + {/each} +
+ {/if} +
+ {/each} +
+
+ + diff --git a/frontend/src/routes/mixes/+page.ts b/frontend/src/routes/mixes/+page.ts new file mode 100644 index 0000000..b8cdd5e --- /dev/null +++ b/frontend/src/routes/mixes/+page.ts @@ -0,0 +1,8 @@ +import { api } from '$lib/api'; + +export async function load() { + return { + mixes: await api.mixes() + }; +} + diff --git a/frontend/src/routes/products/+page.svelte b/frontend/src/routes/products/+page.svelte new file mode 100644 index 0000000..c53f928 --- /dev/null +++ b/frontend/src/routes/products/+page.svelte @@ -0,0 +1,59 @@ + + +
+

Products

+

Transparent delivered cost and pricing outputs from backend calculations.

+ + + + + + + + + + + + + {#each data.products as product} + {@const cost = data.productCosts.find((item) => item.product_id === product.id)} + + + + + + + + + {/each} + +
ProductMixSale TypeDelivered CostDistributorWholesale
{product.name}{product.mix_name}{product.sale_type}{cost ? `$${cost.finished_product_delivered.toFixed(2)}` : 'N/A'}{cost?.distributor_price ? `$${cost.distributor_price.toFixed(2)}` : 'N/A'}{cost?.wholesale_price ? `$${cost.wholesale_price.toFixed(2)}` : 'N/A'}
+
+ + diff --git a/frontend/src/routes/products/+page.ts b/frontend/src/routes/products/+page.ts new file mode 100644 index 0000000..e1892fe --- /dev/null +++ b/frontend/src/routes/products/+page.ts @@ -0,0 +1,10 @@ +import { api } from '$lib/api'; + +export async function load() { + const [products, productCosts] = await Promise.all([api.products(), api.productCosts()]); + return { + products, + productCosts + }; +} + diff --git a/frontend/src/routes/raw-materials/+page.svelte b/frontend/src/routes/raw-materials/+page.svelte new file mode 100644 index 0000000..d424452 --- /dev/null +++ b/frontend/src/routes/raw-materials/+page.svelte @@ -0,0 +1,633 @@ + + +{#if !$operatorSession} +
+

Operator access required

+

Sign in from the homepage before managing raw materials.

+

This page is the input maintenance area for Mix Master and downstream product pricing.

+ Return to login +
+{:else} + + + {#if successMessage} + + {/if} + + {#if errorMessage} + + {/if} + +
+
+
+
+

Add raw material

+

Create a new tracked input

+
+
+ +
+
+ + + + + + + + + + + + + + + +
+ + + + + + +
+
+ +
+
+
+

Downstream view

+

Current mix and product snapshot

+
+
+ +
+
+

Mix Master

+
    + {#each data.mixes as mix} +
  • + {mix.name} + {currency(mix.mix_cost_per_kg, 4)} / kg +
  • + {/each} +
+
+ +
+

Finished products

+
    + {#each data.productCosts as row} +
  • + {row.product_name} + {currency(row.finished_product_delivered)} +
  • + {/each} +
+
+
+
+
+ +
+ {#each data.rawMaterials as material} + {@const impactedMixes = getImpactedMixes(material.id)} + {@const impactedProducts = getImpactedProducts(material.id)} + +
+
+
+

Tracked input

+

{material.name}

+

+ {material.supplier || 'Supplier not set'} · {material.unit_of_measure} · {material.kg_per_unit} kg per + unit +

+
+ {material.status} +
+ +
+
+
+ Current market value + {currency(material.current_price?.market_value)} +
+
+ Current waste + + {material.current_price ? `${(material.current_price.waste_percentage * 100).toFixed(1)}%` : 'N/A'} + +
+
+ Cost per kg + {currency(material.current_price?.cost_per_kg, 4)} +
+
+ Effective date + {material.current_price?.effective_date ?? 'N/A'} +
+
+ +
handleAddPrice(event, material.id)}> +

Record a new price version

+ +
+ + + + + +
+ + + + +
+
+ +
+
+

Impacted mixes

+ {#if impactedMixes.length} +
    + {#each impactedMixes as mix} +
  • + {mix.name} + {currency(mix.mix_cost_per_kg, 4)} / kg +
  • + {/each} +
+ {:else} +

No mix currently references this material.

+ {/if} +
+ +
+

Impacted products

+ {#if impactedProducts.length} +
    + {#each impactedProducts as product} +
  • + {product.name} + {currency(product.deliveredCost?.finished_product_delivered)} +
  • + {/each} +
+ {:else} +

No product currently depends on this material.

+ {/if} +
+
+
+ {/each} +
+{/if} + + diff --git a/frontend/src/routes/raw-materials/+page.ts b/frontend/src/routes/raw-materials/+page.ts new file mode 100644 index 0000000..8b4496d --- /dev/null +++ b/frontend/src/routes/raw-materials/+page.ts @@ -0,0 +1,17 @@ +import { api } from '$lib/api'; + +export async function load() { + const [rawMaterials, mixes, products, productCosts] = await Promise.all([ + api.rawMaterials(), + api.mixes(), + api.products(), + api.productCosts() + ]); + + return { + rawMaterials, + mixes, + products, + productCosts + }; +} diff --git a/frontend/src/routes/scenarios/+page.svelte b/frontend/src/routes/scenarios/+page.svelte new file mode 100644 index 0000000..7c64ebb --- /dev/null +++ b/frontend/src/routes/scenarios/+page.svelte @@ -0,0 +1,65 @@ + + +
+

Scenarios

+

Simulation workspaces for raw cost, freight, process, and margin changes.

+ +
+ {#each data.scenarios as scenario} +
+
+
+

{scenario.name}

+

{scenario.description ?? 'No description'}

+
+ {scenario.status} +
+
{JSON.stringify(scenario.overrides, null, 2)}
+
+ {/each} +
+
+ + diff --git a/frontend/src/routes/scenarios/+page.ts b/frontend/src/routes/scenarios/+page.ts new file mode 100644 index 0000000..27e24b6 --- /dev/null +++ b/frontend/src/routes/scenarios/+page.ts @@ -0,0 +1,8 @@ +import { api } from '$lib/api'; + +export async function load() { + return { + scenarios: await api.scenarios() + }; +} + diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..69c2ea9 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,11 @@ +import adapter from '@sveltejs/adapter-auto'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + kit: { + adapter: adapter() + } +}; + +export default config; + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..ecf0289 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..c010b71 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +}); +