v1.2 scaffold

This commit is contained in:
2026-04-25 20:43:37 +12:00
parent 658cda8c35
commit bc211ffcc8
58 changed files with 5104 additions and 0 deletions
+18
View File
@@ -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
+53
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+28
View File
@@ -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",
}
+101
View File
@@ -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)
+64
View File
@@ -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
+88
View File
@@ -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
+122
View File
@@ -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
+84
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
+24
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
+23
View File
@@ -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()
+81
View File
@@ -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")),
)
+19
View File
@@ -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",
]
+38
View File
@@ -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)
+46
View File
@@ -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
+34
View File
@@ -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
+44
View File
@@ -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")
+41
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
+60
View File
@@ -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)
+70
View File
@@ -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]
+53
View File
@@ -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)
+26
View File
@@ -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]
+76
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
+277
View File
@@ -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,
}
+32
View File
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
@@ -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
@@ -0,0 +1 @@
app
+26
View File
@@ -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"]
+95
View File
@@ -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
+1315
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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"
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+74
View File
@@ -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<T>(path: string, fallback: T): Promise<T> {
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<T>(path: string, options: RequestInit): Promise<T> {
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<RawMaterial[]>('/api/raw-materials', mockRawMaterials),
mixes: () => fetchJson('/api/mixes', mockMixes),
products: () => fetchJson<Product[]>('/api/products', mockProducts),
productCosts: () => fetchJson<ProductCostBreakdown[]>('/api/powerbi/product-costs', mockCosts),
scenarios: () => fetchJson<Scenario[]>('/api/scenarios', mockScenarios),
dataQuality: () => fetchJson('/api/powerbi/data-quality-issues', []),
login: (email: string, password: string) =>
request<LoginResponse>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
}),
createRawMaterial: (payload: RawMaterialCreateInput) =>
request<RawMaterial>('/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)
})
};
+96
View File
@@ -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: {}
}
];
+58
View File
@@ -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<OperatorSession | null>(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();
+121
View File
@@ -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<string, unknown>;
};
export type Scenario = {
id: number;
name: string;
status: string;
description?: string | null;
overrides: Record<string, unknown>;
};
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;
};
+194
View File
@@ -0,0 +1,194 @@
<script lang="ts">
import { page } from '$app/state';
import { operatorSession } from '$lib/session';
const links = [
{ href: '/', label: 'Home' },
{ href: '/raw-materials', label: 'Raw Materials' },
{ href: '/mixes', label: 'Mix Master' },
{ href: '/products', label: 'Products' },
{ href: '/scenarios', label: 'Scenarios' }
];
</script>
<svelte:head>
<title>Data Entry App</title>
</svelte:head>
<div class="shell">
<header class="topbar">
<div class="brand-block">
<a class="brand" href="/">Data Entry App</a>
<p>Operator costing workflow</p>
</div>
<nav class="topnav" aria-label="Primary navigation">
{#each links as link}
<a class:active={page.url.pathname === link.href} href={link.href}>{link.label}</a>
{/each}
</nav>
<div class="session-panel">
{#if $operatorSession}
<div>
<span>Signed in</span>
<strong>{$operatorSession.name}</strong>
</div>
<button type="button" onclick={() => operatorSession.clear()}>Sign out</button>
{:else}
<a class="login-link" href="/">Operator login</a>
{/if}
</div>
</header>
<main class="content">
<slot />
</main>
</div>
<style>
:global(:root) {
--canvas: #f5efe4;
--canvas-strong: #fffaf1;
--ink: #20170f;
--muted: #695746;
--line: rgba(74, 53, 31, 0.14);
--brand: #8f4f1f;
--brand-deep: #5a2d18;
--accent: #d9a441;
--shadow: 0 18px 50px rgba(56, 38, 19, 0.12);
}
:global(body) {
margin: 0;
color: var(--ink);
font-family: "Segoe UI", "Helvetica Neue", sans-serif;
background:
radial-gradient(circle at top right, rgba(217, 164, 65, 0.22), transparent 24rem),
radial-gradient(circle at left center, rgba(143, 79, 31, 0.1), transparent 28rem),
linear-gradient(180deg, #f7f1e7 0%, #efe5d3 100%);
}
:global(*) {
box-sizing: border-box;
}
.shell {
min-height: 100vh;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
align-items: center;
padding: 1rem 2rem;
backdrop-filter: blur(16px);
background: rgba(247, 241, 231, 0.88);
border-bottom: 1px solid var(--line);
}
.brand-block {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.brand {
color: var(--brand-deep);
text-decoration: none;
font-size: 1.25rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.brand-block p,
.session-panel span {
margin: 0;
color: var(--muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.topnav {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.65rem;
}
.topnav a,
.login-link,
button {
border: 1px solid transparent;
border-radius: 999px;
padding: 0.75rem 1rem;
color: var(--brand-deep);
text-decoration: none;
background: transparent;
font: inherit;
cursor: pointer;
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
}
.topnav a:hover,
.topnav a.active,
.login-link:hover,
button:hover {
background: rgba(143, 79, 31, 0.08);
border-color: rgba(143, 79, 31, 0.18);
transform: translateY(-1px);
}
.topnav a.active {
background: linear-gradient(135deg, rgba(143, 79, 31, 0.14), rgba(217, 164, 65, 0.18));
font-weight: 600;
}
.session-panel {
display: flex;
align-items: center;
gap: 0.9rem;
}
.session-panel strong {
display: block;
}
button {
background: var(--brand-deep);
color: #fff7ef;
box-shadow: var(--shadow);
}
button:hover {
background: #472213;
}
.content {
padding: 2rem;
}
@media (max-width: 980px) {
.topbar {
grid-template-columns: 1fr;
justify-items: start;
padding: 1rem 1rem 0.9rem;
}
.topnav {
justify-content: flex-start;
}
.content {
padding: 1rem;
}
}
</style>
+587
View File
@@ -0,0 +1,587 @@
<script lang="ts">
import { api } from '$lib/api';
import { operatorSession } from '$lib/session';
import type { Mix, ProductCostBreakdown, RawMaterial } from '$lib/types';
let { data } = $props();
let email = $state('operator@example.com');
let password = $state('changeme');
let isLoggingIn = $state(false);
let loginError = $state('');
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return `$${value.toFixed(digits)}`;
}
function findHighestProduct(products: ProductCostBreakdown[]) {
return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0];
}
function findMostExpensiveMix(mixes: Mix[]) {
return [...mixes].sort((left, right) => (right.mix_cost_per_kg ?? 0) - (left.mix_cost_per_kg ?? 0))[0];
}
function findLatestMaterial(materials: RawMaterial[]) {
return [...materials].sort((left, right) => {
const leftDate = left.current_price?.effective_date ?? '';
const rightDate = right.current_price?.effective_date ?? '';
return rightDate.localeCompare(leftDate);
})[0];
}
async function handleLogin(event: SubmitEvent) {
event.preventDefault();
loginError = '';
isLoggingIn = true;
try {
const session = await api.login(email, password);
operatorSession.set(session);
} catch (error) {
loginError = error instanceof Error ? error.message : 'Unable to sign in';
} finally {
isLoggingIn = false;
}
}
const featuredProduct = $derived(findHighestProduct(data.productCosts));
const featuredMix = $derived(findMostExpensiveMix(data.mixes));
const featuredMaterial = $derived(findLatestMaterial(data.rawMaterials));
</script>
{#if !$operatorSession}
<section class="hero-grid">
<article class="hero-panel intro">
<p class="eyebrow">Costing control room</p>
<h1>Sign in to manage raw materials and push updates through Mix Master automatically.</h1>
<p class="lede">
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.
</p>
<div class="workflow">
<article>
<span>01</span>
<h2>Log in</h2>
<p>Enter the operator account to unlock material maintenance and costing review.</p>
</article>
<article>
<span>02</span>
<h2>Update inputs</h2>
<p>Record a new market value or waste percentage for any raw material.</p>
</article>
<article>
<span>03</span>
<h2>Review impact</h2>
<p>Check Mix Master and downstream product pricing immediately after the save.</p>
</article>
</div>
</article>
<article class="hero-panel login-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Operator login</p>
<h2>Use the seeded prototype account</h2>
</div>
<span class="badge">Backend-backed</span>
</div>
<form class="login-form" onsubmit={handleLogin}>
<label>
Email
<input bind:value={email} type="email" autocomplete="username" />
</label>
<label>
Password
<input bind:value={password} type="password" autocomplete="current-password" />
</label>
{#if loginError}
<p class="form-error">{loginError}</p>
{/if}
<button type="submit" disabled={isLoggingIn}>
{isLoggingIn ? 'Signing in...' : 'Sign in'}
</button>
</form>
<div class="credentials">
<p>Default email: <strong>operator@example.com</strong></p>
<p>Default password: <strong>changeme</strong></p>
</div>
</article>
</section>
{:else}
<section class="signed-in-banner">
<div>
<p class="eyebrow">Active operator</p>
<h1>Welcome back, {$operatorSession.name}.</h1>
<p>Manage raw materials first, then review Mix Master and product pricing before publishing changes.</p>
</div>
<a class="primary-link" href="/raw-materials">Open raw material manager</a>
</section>
{/if}
<section class="metric-row">
{#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}
<article class="metric-card">
<p>{card.label}</p>
<strong>{card.value}</strong>
<span>{card.hint}</span>
</article>
{/each}
</section>
<section class="overview-grid">
<article class="surface-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Latest input</p>
<h2>{featuredMaterial?.name ?? 'No materials loaded'}</h2>
</div>
<a href="/raw-materials">Manage inputs</a>
</div>
{#if featuredMaterial?.current_price}
<dl class="split-stats">
<div>
<dt>Market value</dt>
<dd>{currency(featuredMaterial.current_price.market_value)}</dd>
</div>
<div>
<dt>Waste</dt>
<dd>{(featuredMaterial.current_price.waste_percentage * 100).toFixed(1)}%</dd>
</div>
<div>
<dt>Cost per kg</dt>
<dd>{currency(featuredMaterial.current_price.cost_per_kg, 4)}</dd>
</div>
</dl>
{:else}
<p class="empty">No active price version is available for this material.</p>
{/if}
</article>
<article class="surface-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Mix master</p>
<h2>{featuredMix?.name ?? 'No mixes loaded'}</h2>
</div>
<a href="/mixes">Review mixes</a>
</div>
{#if featuredMix}
<dl class="split-stats">
<div>
<dt>Client</dt>
<dd>{featuredMix.client_name}</dd>
</div>
<div>
<dt>Total mix cost</dt>
<dd>{currency(featuredMix.total_mix_cost)}</dd>
</div>
<div>
<dt>Cost per kg</dt>
<dd>{currency(featuredMix.mix_cost_per_kg, 4)}</dd>
</div>
</dl>
{/if}
</article>
<article class="surface-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Delivered output</p>
<h2>{featuredProduct?.product_name ?? 'No product costs loaded'}</h2>
</div>
<a href="/products">Review products</a>
</div>
{#if featuredProduct}
<dl class="split-stats">
<div>
<dt>Delivered</dt>
<dd>{currency(featuredProduct.finished_product_delivered)}</dd>
</div>
<div>
<dt>Distributor</dt>
<dd>{currency(featuredProduct.distributor_price)}</dd>
</div>
<div>
<dt>Wholesale</dt>
<dd>{currency(featuredProduct.wholesale_price)}</dd>
</div>
</dl>
{/if}
</article>
</section>
<section class="detail-grid">
<article class="surface-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Current cascade</p>
<h2>Raw material updates flow straight into Mix Master</h2>
</div>
<a href="/raw-materials">Edit materials</a>
</div>
<table>
<thead>
<tr>
<th>Mix</th>
<th>Ingredients</th>
<th>Total Cost</th>
<th>Cost/Kg</th>
</tr>
</thead>
<tbody>
{#each data.mixes as mix}
<tr>
<td>{mix.name}</td>
<td>{mix.ingredients.length}</td>
<td>{currency(mix.total_mix_cost)}</td>
<td>{currency(mix.mix_cost_per_kg, 4)}</td>
</tr>
{/each}
</tbody>
</table>
</article>
<article class="surface-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Output pricing</p>
<h2>Finished products</h2>
</div>
<a href="/products">Open products</a>
</div>
<table>
<thead>
<tr>
<th>Product</th>
<th>Delivered</th>
<th>Distributor</th>
<th>Wholesale</th>
</tr>
</thead>
<tbody>
{#each data.productCosts as row}
<tr>
<td>{row.product_name}</td>
<td>{currency(row.finished_product_delivered)}</td>
<td>{currency(row.distributor_price)}</td>
<td>{currency(row.wholesale_price)}</td>
</tr>
{/each}
</tbody>
</table>
</article>
</section>
<style>
h1,
h2,
p,
dl,
dd {
margin: 0;
}
a {
color: var(--brand);
text-decoration: none;
}
.eyebrow {
color: var(--muted);
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.hero-grid,
.overview-grid,
.detail-grid {
display: grid;
gap: 1rem;
}
.hero-grid {
grid-template-columns: minmax(0, 1.6fr) minmax(20rem, 0.9fr);
margin-bottom: 1rem;
}
.overview-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 1rem;
}
.detail-grid {
grid-template-columns: 1.3fr 1fr;
}
.hero-panel,
.surface-card,
.metric-card,
.signed-in-banner {
background: rgba(255, 250, 241, 0.82);
border: 1px solid var(--line);
border-radius: 1.5rem;
box-shadow: var(--shadow);
}
.intro,
.login-panel,
.surface-card,
.signed-in-banner {
padding: 1.5rem;
}
.intro {
display: flex;
flex-direction: column;
gap: 1.4rem;
background:
linear-gradient(135deg, rgba(90, 45, 24, 0.95), rgba(143, 79, 31, 0.88)),
linear-gradient(180deg, rgba(217, 164, 65, 0.18), transparent);
color: #fff7ef;
}
.intro .eyebrow,
.intro .lede,
.intro .workflow p {
color: rgba(255, 247, 239, 0.8);
}
.intro h1 {
font-size: clamp(2rem, 4vw, 3.6rem);
max-width: 11ch;
line-height: 0.95;
}
.lede {
max-width: 52rem;
line-height: 1.6;
}
.workflow {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
.workflow article {
padding: 1rem;
border-radius: 1rem;
background: rgba(255, 247, 239, 0.08);
border: 1px solid rgba(255, 247, 239, 0.12);
}
.workflow span {
display: inline-flex;
width: 2rem;
height: 2rem;
align-items: center;
justify-content: center;
border-radius: 999px;
background: rgba(217, 164, 65, 0.18);
margin-bottom: 0.8rem;
font-weight: 700;
}
.workflow h2 {
margin-bottom: 0.45rem;
font-size: 1.1rem;
}
.panel-heading,
.signed-in-banner {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
}
.login-panel {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.badge,
.primary-link {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0.7rem 1rem;
}
.badge {
background: rgba(143, 79, 31, 0.08);
color: var(--brand-deep);
font-size: 0.82rem;
font-weight: 600;
}
.primary-link {
background: var(--brand-deep);
color: #fff7ef;
box-shadow: var(--shadow);
}
.login-form {
display: grid;
gap: 0.9rem;
}
.login-form label {
display: grid;
gap: 0.35rem;
color: var(--muted);
font-weight: 600;
}
.login-form input {
width: 100%;
padding: 0.9rem 1rem;
border-radius: 1rem;
border: 1px solid rgba(90, 45, 24, 0.16);
background: rgba(255, 255, 255, 0.8);
font: inherit;
}
.login-form button {
padding: 0.95rem 1.1rem;
border: none;
border-radius: 1rem;
background: linear-gradient(135deg, var(--brand-deep), var(--brand));
color: #fff7ef;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.login-form button:disabled {
opacity: 0.7;
cursor: wait;
}
.form-error {
color: #a3301d;
font-weight: 600;
}
.credentials {
padding: 1rem;
border-radius: 1rem;
background: rgba(143, 79, 31, 0.06);
color: var(--muted);
}
.credentials strong {
color: var(--ink);
}
.signed-in-banner {
margin-bottom: 1rem;
}
.signed-in-banner p:last-child {
color: var(--muted);
margin-top: 0.45rem;
}
.metric-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.metric-card {
padding: 1.1rem 1.2rem;
}
.metric-card p,
.metric-card span,
.empty,
dt {
color: var(--muted);
}
.metric-card strong {
display: block;
margin: 0.35rem 0;
font-size: 2rem;
}
.split-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
margin-top: 1rem;
}
dt {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
dd {
margin-top: 0.2rem;
font-size: 1.05rem;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th,
td {
padding: 0.85rem 0.4rem;
text-align: left;
border-bottom: 1px solid var(--line);
}
@media (max-width: 1100px) {
.hero-grid,
.overview-grid,
.detail-grid,
.metric-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.workflow,
.split-stats {
grid-template-columns: 1fr;
}
.panel-heading,
.signed-in-banner {
flex-direction: column;
align-items: start;
}
}
</style>
+20
View File
@@ -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
};
}
+96
View File
@@ -0,0 +1,96 @@
<script lang="ts">
let { data } = $props();
</script>
<section class="panel">
<h2>Mix Master</h2>
<p>Recipes are structured as ingredient rows instead of spreadsheet columns.</p>
<div class="cards">
{#each data.mixes as mix}
<article class="card">
<div class="card-header">
<div>
<h3>{mix.name}</h3>
<p>{mix.client_name}</p>
</div>
<span>{mix.status}</span>
</div>
<dl>
<div>
<dt>Total Kg</dt>
<dd>{mix.total_mix_kg}</dd>
</div>
<div>
<dt>Total Cost</dt>
<dd>${mix.total_mix_cost.toFixed(2)}</dd>
</div>
<div>
<dt>Cost/Kg</dt>
<dd>{mix.mix_cost_per_kg ? `$${mix.mix_cost_per_kg.toFixed(4)}` : 'N/A'}</dd>
</div>
</dl>
{#if mix.warnings.length}
<ul>
{#each mix.warnings as warning}
<li>{warning}</li>
{/each}
</ul>
{/if}
</article>
{/each}
</div>
</section>
<style>
.panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
gap: 1rem;
}
.card {
background: rgba(255, 251, 244, 0.82);
border-radius: 1.2rem;
padding: 1.1rem;
border: 1px solid rgba(91, 69, 40, 0.12);
}
.card-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: start;
}
h2,
h3,
p,
dl,
dd {
margin: 0;
}
dl {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-top: 1rem;
}
dt {
color: #5f5245;
font-size: 0.85rem;
}
ul {
margin: 1rem 0 0;
padding-left: 1rem;
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { api } from '$lib/api';
export async function load() {
return {
mixes: await api.mixes()
};
}
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
let { data } = $props();
</script>
<section class="panel">
<h2>Products</h2>
<p>Transparent delivered cost and pricing outputs from backend calculations.</p>
<table>
<thead>
<tr>
<th>Product</th>
<th>Mix</th>
<th>Sale Type</th>
<th>Delivered Cost</th>
<th>Distributor</th>
<th>Wholesale</th>
</tr>
</thead>
<tbody>
{#each data.products as product}
{@const cost = data.productCosts.find((item) => item.product_id === product.id)}
<tr>
<td>{product.name}</td>
<td>{product.mix_name}</td>
<td>{product.sale_type}</td>
<td>{cost ? `$${cost.finished_product_delivered.toFixed(2)}` : 'N/A'}</td>
<td>{cost?.distributor_price ? `$${cost.distributor_price.toFixed(2)}` : 'N/A'}</td>
<td>{cost?.wholesale_price ? `$${cost.wholesale_price.toFixed(2)}` : 'N/A'}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<style>
.panel {
background: rgba(255, 251, 244, 0.82);
border-radius: 1.2rem;
padding: 1.2rem;
border: 1px solid rgba(91, 69, 40, 0.12);
}
h2,
p {
margin-top: 0;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 0.85rem 0.4rem;
border-bottom: 1px solid rgba(91, 69, 40, 0.1);
}
</style>
+10
View File
@@ -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
};
}
@@ -0,0 +1,633 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { api } from '$lib/api';
import { operatorSession } from '$lib/session';
import type { Mix, Product, ProductCostBreakdown } from '$lib/types';
let { data } = $props();
let isCreating = $state(false);
let pendingMaterialId = $state<number | null>(null);
let successMessage = $state('');
let errorMessage = $state('');
const today = new Date().toISOString().slice(0, 10);
function currency(value: number | null | undefined, digits = 2) {
if (value === null || value === undefined) {
return 'N/A';
}
return `$${value.toFixed(digits)}`;
}
function getImpactedMixes(materialId: number): Mix[] {
return data.mixes.filter((mix: Mix) => mix.ingredients.some((ingredient) => ingredient.raw_material_id === materialId));
}
function getImpactedProducts(materialId: number): Array<Product & { deliveredCost: ProductCostBreakdown | undefined }> {
const mixIds = new Set(getImpactedMixes(materialId).map((mix) => mix.id));
return data.products
.filter((product: Product) => mixIds.has(product.mix_id ?? -1))
.map((product: Product) => ({
...product,
deliveredCost: data.productCosts.find((row: ProductCostBreakdown) => row.product_id === product.id)
}));
}
async function handleCreateMaterial(event: SubmitEvent) {
event.preventDefault();
successMessage = '';
errorMessage = '';
isCreating = true;
const form = event.currentTarget as HTMLFormElement;
const formData = new FormData(form);
try {
await api.createRawMaterial({
name: String(formData.get('name') ?? '').trim(),
supplier: String(formData.get('supplier') ?? '').trim() || null,
unit_of_measure: String(formData.get('unit_of_measure') ?? '').trim(),
kg_per_unit: Number(formData.get('kg_per_unit')),
status: String(formData.get('status') ?? 'active'),
notes: String(formData.get('notes') ?? '').trim() || null,
initial_price: {
market_value: Number(formData.get('market_value')),
waste_percentage: Number(formData.get('waste_percentage')),
effective_date: String(formData.get('effective_date') ?? today),
status: 'active',
notes: String(formData.get('price_notes') ?? '').trim() || null
}
});
form.reset();
const effectiveDate = form.elements.namedItem('effective_date');
if (effectiveDate instanceof HTMLInputElement) {
effectiveDate.value = today;
}
successMessage = 'Raw material created and added to the costing model.';
await invalidateAll();
} catch (error) {
errorMessage = error instanceof Error ? error.message : 'Unable to create raw material';
} finally {
isCreating = false;
}
}
async function handleAddPrice(event: SubmitEvent, rawMaterialId: number) {
event.preventDefault();
successMessage = '';
errorMessage = '';
pendingMaterialId = rawMaterialId;
const form = event.currentTarget as HTMLFormElement;
const formData = new FormData(form);
try {
await api.addRawMaterialPrice(rawMaterialId, {
market_value: Number(formData.get('market_value')),
waste_percentage: Number(formData.get('waste_percentage')),
effective_date: String(formData.get('effective_date') ?? today),
status: 'active',
notes: String(formData.get('notes') ?? '').trim() || null
});
form.reset();
const effectiveDate = form.elements.namedItem('effective_date');
if (effectiveDate instanceof HTMLInputElement) {
effectiveDate.value = today;
}
successMessage = 'Price version saved. Mix and product costs have been refreshed.';
await invalidateAll();
} catch (error) {
errorMessage = error instanceof Error ? error.message : 'Unable to add price version';
} finally {
pendingMaterialId = null;
}
}
</script>
{#if !$operatorSession}
<section class="locked-panel">
<p class="eyebrow">Operator access required</p>
<h1>Sign in from the homepage before managing raw materials.</h1>
<p>This page is the input maintenance area for Mix Master and downstream product pricing.</p>
<a href="/">Return to login</a>
</section>
{:else}
<section class="page-header">
<div>
<p class="eyebrow">Raw material manager</p>
<h1>Maintain input costs and watch the costing model update downstream.</h1>
<p>
Every active price version feeds the existing mix calculation engine. Save a new price, then review the
impacted mixes and finished product outputs below.
</p>
</div>
<div class="header-status">
<span>{$operatorSession.email}</span>
<strong>{data.rawMaterials.length} materials under control</strong>
</div>
</section>
{#if successMessage}
<p class="feedback success">{successMessage}</p>
{/if}
{#if errorMessage}
<p class="feedback error">{errorMessage}</p>
{/if}
<section class="top-grid">
<article class="surface-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Add raw material</p>
<h2>Create a new tracked input</h2>
</div>
</div>
<form class="material-form" onsubmit={handleCreateMaterial}>
<div class="form-grid">
<label>
Name
<input name="name" required />
</label>
<label>
Supplier
<input name="supplier" />
</label>
<label>
Unit of measure
<input name="unit_of_measure" value="tonne" required />
</label>
<label>
Kg per unit
<input name="kg_per_unit" type="number" min="0.0001" step="0.0001" value="1000" required />
</label>
<label>
Market value
<input name="market_value" type="number" min="0.0001" step="0.0001" required />
</label>
<label>
Waste percentage
<input name="waste_percentage" type="number" min="0" max="1" step="0.0001" value="0" required />
</label>
<label>
Effective date
<input name="effective_date" type="date" value={today} required />
</label>
<label>
Status
<select name="status">
<option value="active">Active</option>
<option value="draft">Draft</option>
<option value="inactive">Inactive</option>
</select>
</label>
</div>
<label>
Material notes
<textarea name="notes" rows="3"></textarea>
</label>
<label>
Price notes
<textarea name="price_notes" rows="2"></textarea>
</label>
<button type="submit" disabled={isCreating}>
{isCreating ? 'Creating material...' : 'Create raw material'}
</button>
</form>
</article>
<article class="surface-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Downstream view</p>
<h2>Current mix and product snapshot</h2>
</div>
</div>
<div class="snapshot-list">
<article>
<h3>Mix Master</h3>
<ul>
{#each data.mixes as mix}
<li>
<strong>{mix.name}</strong>
<span>{currency(mix.mix_cost_per_kg, 4)} / kg</span>
</li>
{/each}
</ul>
</article>
<article>
<h3>Finished products</h3>
<ul>
{#each data.productCosts as row}
<li>
<strong>{row.product_name}</strong>
<span>{currency(row.finished_product_delivered)}</span>
</li>
{/each}
</ul>
</article>
</div>
</article>
</section>
<section class="material-list">
{#each data.rawMaterials as material}
{@const impactedMixes = getImpactedMixes(material.id)}
{@const impactedProducts = getImpactedProducts(material.id)}
<article class="surface-card material-card">
<div class="panel-heading">
<div>
<p class="eyebrow">Tracked input</p>
<h2>{material.name}</h2>
<p class="subtle">
{material.supplier || 'Supplier not set'} · {material.unit_of_measure} · {material.kg_per_unit} kg per
unit
</p>
</div>
<span class:inactive={material.status !== 'active'} class="status-pill">{material.status}</span>
</div>
<div class="material-grid">
<section class="detail-panel">
<div class="detail-row">
<span>Current market value</span>
<strong>{currency(material.current_price?.market_value)}</strong>
</div>
<div class="detail-row">
<span>Current waste</span>
<strong>
{material.current_price ? `${(material.current_price.waste_percentage * 100).toFixed(1)}%` : 'N/A'}
</strong>
</div>
<div class="detail-row">
<span>Cost per kg</span>
<strong>{currency(material.current_price?.cost_per_kg, 4)}</strong>
</div>
<div class="detail-row">
<span>Effective date</span>
<strong>{material.current_price?.effective_date ?? 'N/A'}</strong>
</div>
</section>
<form class="price-form" onsubmit={(event) => handleAddPrice(event, material.id)}>
<h3>Record a new price version</h3>
<div class="form-grid compact">
<label>
Market value
<input name="market_value" type="number" min="0.0001" step="0.0001" required />
</label>
<label>
Waste percentage
<input name="waste_percentage" type="number" min="0" max="1" step="0.0001" value="0" required />
</label>
<label>
Effective date
<input name="effective_date" type="date" value={today} required />
</label>
</div>
<label>
Notes
<textarea name="notes" rows="2"></textarea>
</label>
<button type="submit" disabled={pendingMaterialId === material.id}>
{pendingMaterialId === material.id ? 'Saving price...' : 'Save price version'}
</button>
</form>
</div>
<div class="impact-grid">
<section class="impact-panel">
<h3>Impacted mixes</h3>
{#if impactedMixes.length}
<ul>
{#each impactedMixes as mix}
<li>
<strong>{mix.name}</strong>
<span>{currency(mix.mix_cost_per_kg, 4)} / kg</span>
</li>
{/each}
</ul>
{:else}
<p class="empty">No mix currently references this material.</p>
{/if}
</section>
<section class="impact-panel">
<h3>Impacted products</h3>
{#if impactedProducts.length}
<ul>
{#each impactedProducts as product}
<li>
<strong>{product.name}</strong>
<span>{currency(product.deliveredCost?.finished_product_delivered)}</span>
</li>
{/each}
</ul>
{:else}
<p class="empty">No product currently depends on this material.</p>
{/if}
</section>
</div>
</article>
{/each}
</section>
{/if}
<style>
h1,
h2,
h3,
p {
margin: 0;
}
a {
color: var(--brand);
text-decoration: none;
}
.eyebrow {
color: var(--muted);
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.locked-panel,
.page-header,
.surface-card,
.feedback {
background: rgba(255, 250, 241, 0.82);
border: 1px solid var(--line);
border-radius: 1.5rem;
box-shadow: var(--shadow);
}
.locked-panel,
.page-header,
.surface-card {
padding: 1.5rem;
}
.locked-panel {
display: grid;
gap: 0.75rem;
max-width: 42rem;
}
.page-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.page-header p:last-child,
.subtle,
.empty,
.detail-row span,
.snapshot-list li span {
color: var(--muted);
}
.page-header h1 {
margin: 0.35rem 0 0.55rem;
font-size: clamp(1.8rem, 4vw, 3rem);
max-width: 16ch;
line-height: 1;
}
.header-status {
text-align: right;
}
.header-status span {
display: block;
color: var(--muted);
margin-bottom: 0.35rem;
}
.feedback {
padding: 0.95rem 1.1rem;
margin: 0 0 1rem;
font-weight: 600;
}
.feedback.success {
border-color: rgba(44, 106, 66, 0.2);
color: #245838;
}
.feedback.error {
border-color: rgba(163, 48, 29, 0.22);
color: #8d2b1f;
}
.top-grid,
.material-grid,
.impact-grid {
display: grid;
gap: 1rem;
}
.top-grid {
grid-template-columns: 1.2fr 0.8fr;
margin-bottom: 1rem;
}
.panel-heading {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.material-form,
.price-form {
display: grid;
gap: 1rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.form-grid.compact {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
label {
display: grid;
gap: 0.35rem;
font-weight: 600;
color: var(--muted);
}
input,
textarea,
select,
button {
font: inherit;
}
input,
textarea,
select {
width: 100%;
padding: 0.85rem 0.95rem;
border-radius: 1rem;
border: 1px solid rgba(90, 45, 24, 0.16);
background: rgba(255, 255, 255, 0.85);
}
button {
padding: 0.95rem 1.1rem;
border: none;
border-radius: 1rem;
background: linear-gradient(135deg, var(--brand-deep), var(--brand));
color: #fff7ef;
font-weight: 700;
cursor: pointer;
justify-self: start;
}
button:disabled {
opacity: 0.7;
cursor: wait;
}
.snapshot-list {
display: grid;
gap: 1rem;
}
.snapshot-list article {
padding: 1rem;
border-radius: 1rem;
background: rgba(143, 79, 31, 0.06);
}
.snapshot-list ul,
.impact-panel ul {
list-style: none;
padding: 0;
margin: 0.9rem 0 0;
display: grid;
gap: 0.8rem;
}
.snapshot-list li,
.impact-panel li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.material-list {
display: grid;
gap: 1rem;
}
.material-card {
display: grid;
gap: 1rem;
}
.status-pill {
padding: 0.45rem 0.8rem;
border-radius: 999px;
background: rgba(44, 106, 66, 0.12);
color: #245838;
font-weight: 700;
text-transform: capitalize;
}
.status-pill.inactive {
background: rgba(143, 79, 31, 0.1);
color: var(--brand-deep);
}
.material-grid {
grid-template-columns: 0.75fr 1.25fr;
}
.detail-panel,
.impact-panel {
padding: 1rem;
border-radius: 1rem;
background: rgba(143, 79, 31, 0.06);
}
.detail-panel {
display: grid;
gap: 0.85rem;
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.impact-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 1100px) {
.top-grid,
.material-grid,
.impact-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.page-header,
.panel-heading,
.snapshot-list li,
.impact-panel li,
.detail-row {
flex-direction: column;
align-items: start;
}
.form-grid,
.form-grid.compact {
grid-template-columns: 1fr;
}
.header-status {
text-align: left;
}
}
</style>
@@ -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
};
}
@@ -0,0 +1,65 @@
<script lang="ts">
let { data } = $props();
</script>
<section class="panel">
<h2>Scenarios</h2>
<p>Simulation workspaces for raw cost, freight, process, and margin changes.</p>
<div class="scenario-list">
{#each data.scenarios as scenario}
<article>
<header>
<div>
<h3>{scenario.name}</h3>
<p>{scenario.description ?? 'No description'}</p>
</div>
<span>{scenario.status}</span>
</header>
<pre>{JSON.stringify(scenario.overrides, null, 2)}</pre>
</article>
{/each}
</div>
</section>
<style>
.panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.scenario-list {
display: grid;
gap: 1rem;
}
article {
background: rgba(255, 251, 244, 0.82);
border-radius: 1.2rem;
padding: 1.1rem;
border: 1px solid rgba(91, 69, 40, 0.12);
}
header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: start;
}
h2,
h3,
p,
pre {
margin: 0;
}
pre {
margin-top: 0.75rem;
padding: 0.85rem;
border-radius: 0.9rem;
background: rgba(53, 42, 29, 0.08);
overflow: auto;
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { api } from '$lib/api';
export async function load() {
return {
scenarios: await api.scenarios()
};
}
+11
View File
@@ -0,0 +1,11 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;
+15
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});