v1.2 scaffold
This commit is contained in:
+18
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Generated
+1315
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
};
|
||||
@@ -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: {}
|
||||
}
|
||||
];
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load() {
|
||||
return {
|
||||
mixes: await api.mixes()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export async function load() {
|
||||
return {
|
||||
scenarios: await api.scenarios()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user