v1.3 - client and admin scaffolding
This commit is contained in:
+48
-11
@@ -1,7 +1,13 @@
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession, require_admin_session, require_client_session
|
||||
from app.core.config import settings
|
||||
from app.core.security import issue_token
|
||||
from app.db.session import get_db
|
||||
from app.models.client_access import ClientAccount
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
@@ -11,18 +17,49 @@ class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
class SessionResponse(BaseModel):
|
||||
name: str
|
||||
email: str
|
||||
role: str
|
||||
tenant_id: str | None = None
|
||||
token: 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",
|
||||
}
|
||||
def _build_session_response(*, name: str, email: str, role: str, tenant_id: str | None = None) -> SessionResponse:
|
||||
token = issue_token({"name": name, "email": email, "role": role, "tenant_id": tenant_id})
|
||||
return SessionResponse(name=name, email=email, role=role, tenant_id=tenant_id, token=token)
|
||||
|
||||
|
||||
@router.post("/client/login", response_model=SessionResponse)
|
||||
def client_login(payload: LoginRequest, db: Session = Depends(get_db)):
|
||||
if payload.email.strip().lower() != settings.client_email.lower() or payload.password != settings.client_password:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid client email or password")
|
||||
|
||||
client_account = db.scalar(select(ClientAccount).where(ClientAccount.tenant_id == settings.client_tenant_id))
|
||||
if client_account is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client account is not configured")
|
||||
|
||||
return _build_session_response(
|
||||
name=settings.client_name,
|
||||
email=settings.client_email,
|
||||
role="client",
|
||||
tenant_id=client_account.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/login", response_model=SessionResponse)
|
||||
def admin_login(payload: LoginRequest):
|
||||
if payload.email.strip().lower() != settings.admin_email.lower() or payload.password != settings.admin_password:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin email or password")
|
||||
|
||||
return _build_session_response(name=settings.admin_name, email=settings.admin_email, role="admin")
|
||||
|
||||
|
||||
@router.get("/client/session", response_model=SessionResponse)
|
||||
def read_client_session(session: AuthSession = Depends(require_client_session)):
|
||||
return _build_session_response(name=session.name, email=session.email, role=session.role, tenant_id=session.tenant_id)
|
||||
|
||||
|
||||
@router.get("/admin/session", response_model=SessionResponse)
|
||||
def read_admin_session(session: AuthSession = Depends(require_admin_session)):
|
||||
return _build_session_response(name=session.name, email=session.email, role=session.role)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import require_admin_session
|
||||
from app.db.session import get_db
|
||||
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
|
||||
from app.schemas.client_access import ClientAccessRead, ClientFeatureUpdate, ClientUserCreate, ClientUserUpdate
|
||||
from app.services.client_access_service import list_client_accounts, serialize_client_account
|
||||
|
||||
router = APIRouter(prefix="/api/client-access", tags=["client-access"])
|
||||
|
||||
|
||||
def _get_client_or_404(db: Session, client_id: int) -> ClientAccount:
|
||||
client = db.scalar(select(ClientAccount).where(ClientAccount.id == client_id))
|
||||
if client is None:
|
||||
raise HTTPException(status_code=404, detail="Client account not found")
|
||||
return client
|
||||
|
||||
|
||||
def _read_client_account(db: Session, client_id: int) -> dict:
|
||||
client = next((item for item in list_client_accounts(db) if item.id == client_id), None)
|
||||
if client is None:
|
||||
raise HTTPException(status_code=404, detail="Client account not found")
|
||||
return serialize_client_account(client)
|
||||
|
||||
|
||||
@router.get("", response_model=list[ClientAccessRead])
|
||||
def get_client_access(db: Session = Depends(get_db), _: object = Depends(require_admin_session)):
|
||||
return [serialize_client_account(client) for client in list_client_accounts(db)]
|
||||
|
||||
|
||||
@router.post("/users", response_model=ClientAccessRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_client_user(
|
||||
payload: ClientUserCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: object = Depends(require_admin_session),
|
||||
):
|
||||
client = _get_client_or_404(db, payload.client_account_id)
|
||||
user = ClientUser(tenant_id=client.tenant_id, **payload.model_dump())
|
||||
db.add(user)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc
|
||||
|
||||
return _read_client_account(db, payload.client_account_id)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=ClientAccessRead)
|
||||
def update_client_user(user_id: int, payload: ClientUserUpdate, db: Session = Depends(get_db), _: object = Depends(require_admin_session)):
|
||||
user = db.scalar(select(ClientUser).where(ClientUser.id == user_id))
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="Client user not found")
|
||||
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(user, field, value)
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="A user with that email already exists for this client") from exc
|
||||
|
||||
return _read_client_account(db, user.client_account_id)
|
||||
|
||||
|
||||
@router.patch("/features/{feature_id}", response_model=ClientAccessRead)
|
||||
def update_client_feature(
|
||||
feature_id: int,
|
||||
payload: ClientFeatureUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: object = Depends(require_admin_session),
|
||||
):
|
||||
feature = db.scalar(select(ClientFeatureAccess).where(ClientFeatureAccess.id == feature_id))
|
||||
if feature is None:
|
||||
raise HTTPException(status_code=404, detail="Client feature not found")
|
||||
|
||||
feature.enabled = payload.enabled
|
||||
db.commit()
|
||||
return _read_client_account(db, feature.client_account_id)
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.security import verify_token
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthSession:
|
||||
role: str
|
||||
email: str
|
||||
name: str
|
||||
tenant_id: str | None = None
|
||||
|
||||
|
||||
def get_auth_session(credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme)) -> AuthSession:
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
|
||||
|
||||
payload = verify_token(credentials.credentials)
|
||||
return AuthSession(
|
||||
role=str(payload.get("role", "")),
|
||||
email=str(payload.get("email", "")),
|
||||
name=str(payload.get("name", "")),
|
||||
tenant_id=payload.get("tenant_id"),
|
||||
)
|
||||
|
||||
|
||||
def require_client_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
|
||||
if session.role != "client":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client access required")
|
||||
if not session.tenant_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Client tenant is missing")
|
||||
return session
|
||||
|
||||
|
||||
def require_admin_session(session: AuthSession = Depends(get_auth_session)) -> AuthSession:
|
||||
if session.role != "admin":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||
return session
|
||||
+52
-19
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession, require_client_session
|
||||
from app.db.session import get_db
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.raw_material import RawMaterial
|
||||
@@ -12,14 +13,15 @@ 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()
|
||||
def list_mixes(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).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)):
|
||||
def create_mix(payload: MixCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
mix = Mix(
|
||||
tenant_id=session.tenant_id,
|
||||
client_name=payload.client_name,
|
||||
name=payload.name,
|
||||
status=payload.status,
|
||||
@@ -29,10 +31,16 @@ def create_mix(payload: MixCreate, db: Session = Depends(get_db)):
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
for ingredient in payload.ingredients:
|
||||
if db.scalar(select(RawMaterial).where(RawMaterial.id == ingredient.raw_material_id)) is None:
|
||||
if db.scalar(
|
||||
select(RawMaterial).where(
|
||||
RawMaterial.id == ingredient.raw_material_id,
|
||||
RawMaterial.tenant_id == session.tenant_id,
|
||||
)
|
||||
) is None:
|
||||
raise HTTPException(status_code=404, detail=f"Raw material {ingredient.raw_material_id} not found")
|
||||
db.add(
|
||||
MixIngredient(
|
||||
tenant_id=session.tenant_id,
|
||||
mix_id=mix.id,
|
||||
raw_material_id=ingredient.raw_material_id,
|
||||
quantity_kg=ingredient.quantity_kg,
|
||||
@@ -44,15 +52,15 @@ def create_mix(payload: MixCreate, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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:
|
||||
def get_mix(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_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))
|
||||
def update_mix(mix_id: int, payload: MixUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
mix = db.scalar(select(Mix).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_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():
|
||||
@@ -62,19 +70,39 @@ def update_mix(mix_id: int, payload: MixUpdate, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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:
|
||||
def add_mix_ingredient(mix_id: int, payload: MixIngredientCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_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:
|
||||
if db.scalar(select(RawMaterial.id).where(RawMaterial.id == payload.raw_material_id, RawMaterial.tenant_id == session.tenant_id)) is None:
|
||||
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||
db.add(MixIngredient(mix_id=mix_id, raw_material_id=payload.raw_material_id, quantity_kg=payload.quantity_kg, notes=payload.notes))
|
||||
db.add(
|
||||
MixIngredient(
|
||||
tenant_id=session.tenant_id,
|
||||
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))
|
||||
def update_mix_ingredient(
|
||||
mix_id: int,
|
||||
ingredient_id: int,
|
||||
payload: MixIngredientUpdate,
|
||||
session: AuthSession = Depends(require_client_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
ingredient = db.scalar(
|
||||
select(MixIngredient).where(
|
||||
MixIngredient.id == ingredient_id,
|
||||
MixIngredient.mix_id == mix_id,
|
||||
MixIngredient.tenant_id == session.tenant_id,
|
||||
)
|
||||
)
|
||||
if ingredient is None:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
@@ -84,8 +112,14 @@ def update_mix_ingredient(mix_id: int, ingredient_id: int, payload: MixIngredien
|
||||
|
||||
|
||||
@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))
|
||||
def delete_mix_ingredient(mix_id: int, ingredient_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
ingredient = db.scalar(
|
||||
select(MixIngredient).where(
|
||||
MixIngredient.id == ingredient_id,
|
||||
MixIngredient.mix_id == mix_id,
|
||||
MixIngredient.tenant_id == session.tenant_id,
|
||||
)
|
||||
)
|
||||
if ingredient is None:
|
||||
raise HTTPException(status_code=404, detail="Ingredient not found")
|
||||
db.delete(ingredient)
|
||||
@@ -94,8 +128,7 @@ def delete_mix_ingredient(mix_id: int, ingredient_id: int, db: Session = Depends
|
||||
|
||||
|
||||
@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:
|
||||
def get_mix_cost_breakdown(mix_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
if db.scalar(select(Mix.id).where(Mix.id == mix_id, Mix.tenant_id == session.tenant_id)) is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
return calculate_mix_cost(db, mix_id)
|
||||
|
||||
|
||||
+19
-13
@@ -2,37 +2,39 @@ from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession, require_admin_session, require_client_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.client_access_service import build_client_access_export, list_client_accounts
|
||||
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()
|
||||
def raw_material_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
materials = db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == session.tenant_id).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()
|
||||
def mix_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
mixes = db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id).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()
|
||||
def product_costs(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).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()
|
||||
def scenario_results(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
scenarios = db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).order_by(Scenario.created_at.desc())).all()
|
||||
return [
|
||||
{
|
||||
"scenario_id": scenario.id,
|
||||
@@ -45,20 +47,24 @@ def scenario_results(db: Session = Depends(get_db)):
|
||||
]
|
||||
|
||||
|
||||
@router.get("/client-access")
|
||||
def client_access_export(_: AuthSession = Depends(require_admin_session), db: Session = Depends(get_db)):
|
||||
return build_client_access_export(list_client_accounts(db))
|
||||
|
||||
|
||||
@router.get("/data-quality-issues")
|
||||
def data_quality_issues(db: Session = Depends(get_db)):
|
||||
def data_quality_issues(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
issues: list[dict] = []
|
||||
for mix in db.scalars(select(Mix)).all():
|
||||
for mix in db.scalars(select(Mix).where(Mix.tenant_id == session.tenant_id)).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():
|
||||
for product in db.scalars(select(Product).where(Product.tenant_id == session.tenant_id)).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():
|
||||
for material in db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == session.tenant_id)).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
|
||||
|
||||
|
||||
+17
-13
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession, require_client_session
|
||||
from app.db.session import get_db
|
||||
from app.models.mix import Mix
|
||||
from app.models.product import Product
|
||||
@@ -33,16 +34,16 @@ def _serialize_product(product: Product) -> dict:
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProductRead])
|
||||
def list_products(db: Session = Depends(get_db)):
|
||||
products = db.scalars(select(Product).order_by(Product.name)).all()
|
||||
def list_products(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
products = db.scalars(select(Product).where(Product.tenant_id == session.tenant_id).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:
|
||||
def create_product(payload: ProductCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
if db.scalar(select(Mix.id).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_id)) is None:
|
||||
raise HTTPException(status_code=404, detail="Mix not found")
|
||||
product = Product(**payload.model_dump())
|
||||
product = Product(tenant_id=session.tenant_id, **payload.model_dump())
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
@@ -50,19 +51,19 @@ def create_product(payload: ProductCreate, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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))
|
||||
def get_product(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_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))
|
||||
def update_product(product_id: int, payload: ProductUpdate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
product = db.scalar(select(Product).where(Product.id == product_id, Product.tenant_id == session.tenant_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:
|
||||
if payload.mix_id is not None and db.scalar(select(Mix.id).where(Mix.id == payload.mix_id, Mix.tenant_id == session.tenant_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)
|
||||
@@ -72,7 +73,9 @@ def update_product(product_id: int, payload: ProductUpdate, db: Session = Depend
|
||||
|
||||
|
||||
@router.get("/{product_id}/cost-breakdown", response_model=ProductCostBreakdown)
|
||||
def get_product_cost_breakdown(product_id: int, db: Session = Depends(get_db)):
|
||||
def get_product_cost_breakdown(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
try:
|
||||
return calculate_product_cost(db, product_id)
|
||||
except ValueError as exc:
|
||||
@@ -80,9 +83,10 @@ def get_product_cost_breakdown(product_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.get("/{product_id}/price-output", response_model=ProductCostBreakdown)
|
||||
def get_product_price_output(product_id: int, db: Session = Depends(get_db)):
|
||||
def get_product_price_output(product_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
if db.scalar(select(Product.id).where(Product.id == product_id, Product.tenant_id == session.tenant_id)) is None:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
try:
|
||||
return calculate_product_cost(db, product_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import AuthSession, require_client_session
|
||||
from app.db.session import get_db
|
||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||
from app.schemas.raw_material import (
|
||||
@@ -33,14 +34,20 @@ def _serialize_price(material: RawMaterial, price: RawMaterialPriceVersion) -> d
|
||||
|
||||
|
||||
@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()
|
||||
def list_raw_materials(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
materials = db.scalars(
|
||||
select(RawMaterial)
|
||||
.where(RawMaterial.tenant_id == session.tenant_id)
|
||||
.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)):
|
||||
def create_raw_material(payload: RawMaterialCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
material = RawMaterial(
|
||||
tenant_id=session.tenant_id,
|
||||
name=payload.name,
|
||||
supplier=payload.supplier,
|
||||
unit_of_measure=payload.unit_of_measure,
|
||||
@@ -50,6 +57,7 @@ def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db
|
||||
)
|
||||
material.price_versions.append(
|
||||
RawMaterialPriceVersion(
|
||||
tenant_id=session.tenant_id,
|
||||
market_value=payload.initial_price.market_value,
|
||||
waste_percentage=payload.initial_price.waste_percentage,
|
||||
effective_date=payload.initial_price.effective_date,
|
||||
@@ -64,9 +72,11 @@ def create_raw_material(payload: RawMaterialCreate, db: Session = Depends(get_db
|
||||
|
||||
|
||||
@router.get("/{raw_material_id}", response_model=RawMaterialRead)
|
||||
def get_raw_material(raw_material_id: int, db: Session = Depends(get_db)):
|
||||
def get_raw_material(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
material = db.scalar(
|
||||
select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions))
|
||||
select(RawMaterial)
|
||||
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
|
||||
.options(selectinload(RawMaterial.price_versions))
|
||||
)
|
||||
if material is None:
|
||||
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||
@@ -74,9 +84,16 @@ def get_raw_material(raw_material_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@router.patch("/{raw_material_id}", response_model=RawMaterialRead)
|
||||
def update_raw_material(raw_material_id: int, payload: RawMaterialUpdate, db: Session = Depends(get_db)):
|
||||
def update_raw_material(
|
||||
raw_material_id: int,
|
||||
payload: RawMaterialUpdate,
|
||||
session: AuthSession = Depends(require_client_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
material = db.scalar(
|
||||
select(RawMaterial).where(RawMaterial.id == raw_material_id).options(selectinload(RawMaterial.price_versions))
|
||||
select(RawMaterial)
|
||||
.where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id)
|
||||
.options(selectinload(RawMaterial.price_versions))
|
||||
)
|
||||
if material is None:
|
||||
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||
@@ -88,11 +105,17 @@ def update_raw_material(raw_material_id: int, payload: RawMaterialUpdate, db: Se
|
||||
|
||||
|
||||
@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))
|
||||
def add_price_version(
|
||||
raw_material_id: int,
|
||||
payload: RawMaterialPriceVersionCreate,
|
||||
session: AuthSession = Depends(require_client_session),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_id))
|
||||
if material is None:
|
||||
raise HTTPException(status_code=404, detail="Raw material not found")
|
||||
price = RawMaterialPriceVersion(
|
||||
tenant_id=session.tenant_id,
|
||||
raw_material_id=raw_material_id,
|
||||
market_value=payload.market_value,
|
||||
waste_percentage=payload.waste_percentage,
|
||||
@@ -107,13 +130,16 @@ def add_price_version(raw_material_id: int, payload: RawMaterialPriceVersionCrea
|
||||
|
||||
|
||||
@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))
|
||||
def get_price_history(raw_material_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
material = db.scalar(select(RawMaterial).where(RawMaterial.id == raw_material_id, RawMaterial.tenant_id == session.tenant_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)
|
||||
.where(
|
||||
RawMaterialPriceVersion.raw_material_id == raw_material_id,
|
||||
RawMaterialPriceVersion.tenant_id == session.tenant_id,
|
||||
)
|
||||
.order_by(RawMaterialPriceVersion.effective_date.desc())
|
||||
).all()
|
||||
items = []
|
||||
|
||||
@@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession, require_client_session
|
||||
from app.db.session import get_db
|
||||
from app.models.scenario import CostingResult, Scenario
|
||||
from app.schemas.scenario import ScenarioCreate, ScenarioRead, ScenarioRunResponse
|
||||
@@ -11,13 +12,13 @@ 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()
|
||||
def list_scenarios(session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
return db.scalars(select(Scenario).where(Scenario.tenant_id == session.tenant_id).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)
|
||||
def create_scenario(payload: ScenarioCreate, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
scenario = Scenario(tenant_id=session.tenant_id, name=payload.name, description=payload.description, overrides=payload.overrides)
|
||||
db.add(scenario)
|
||||
db.commit()
|
||||
db.refresh(scenario)
|
||||
@@ -25,16 +26,16 @@ def create_scenario(payload: ScenarioCreate, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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))
|
||||
def get_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_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))
|
||||
def run_scenario_endpoint(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="Scenario not found")
|
||||
results = run_scenario(db, scenario)
|
||||
@@ -43,11 +44,13 @@ def run_scenario_endpoint(scenario_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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))
|
||||
def get_scenario_results(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_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()
|
||||
results = db.scalars(
|
||||
select(CostingResult).where(CostingResult.scenario_id == scenario_id, CostingResult.tenant_id == session.tenant_id)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
"product_id": result.product_id,
|
||||
@@ -62,8 +65,8 @@ def get_scenario_results(scenario_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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))
|
||||
def approve_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="Scenario not found")
|
||||
scenario.status = "approved"
|
||||
@@ -73,12 +76,11 @@ def approve_scenario(scenario_id: int, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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))
|
||||
def reject_scenario(scenario_id: int, session: AuthSession = Depends(require_client_session), db: Session = Depends(get_db)):
|
||||
scenario = db.scalar(select(Scenario).where(Scenario.id == scenario_id, Scenario.tenant_id == session.tenant_id))
|
||||
if scenario is None:
|
||||
raise HTTPException(status_code=404, detail="Scenario not found")
|
||||
scenario.status = "rejected"
|
||||
db.commit()
|
||||
db.refresh(scenario)
|
||||
return scenario
|
||||
|
||||
|
||||
@@ -6,18 +6,28 @@ from dataclasses import dataclass
|
||||
class Settings:
|
||||
app_name: str
|
||||
database_url: str
|
||||
operator_name: str
|
||||
operator_email: str
|
||||
operator_password: str
|
||||
client_name: str
|
||||
client_email: str
|
||||
client_password: str
|
||||
client_tenant_id: str
|
||||
admin_name: str
|
||||
admin_email: str
|
||||
admin_password: str
|
||||
auth_secret: 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"),
|
||||
client_name=os.getenv("CLIENT_NAME", "Hunter Premium Produce"),
|
||||
client_email=os.getenv("CLIENT_EMAIL", "operator@example.com"),
|
||||
client_password=os.getenv("CLIENT_PASSWORD", "changeme"),
|
||||
client_tenant_id=os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce"),
|
||||
admin_name=os.getenv("ADMIN_NAME", "Lean 101"),
|
||||
admin_email=os.getenv("ADMIN_EMAIL", "admin@lean101.local"),
|
||||
admin_password=os.getenv("ADMIN_PASSWORD", "lean101-admin"),
|
||||
auth_secret=os.getenv("AUTH_SECRET", "lean-101-local-dev-secret"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def _encode(data: dict[str, Any]) -> str:
|
||||
raw = json.dumps(data, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def _decode(value: str) -> dict[str, Any]:
|
||||
padding = "=" * (-len(value) % 4)
|
||||
raw = base64.urlsafe_b64decode(f"{value}{padding}".encode("utf-8"))
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
|
||||
|
||||
def _sign(value: str) -> str:
|
||||
signature = hmac.new(settings.auth_secret.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(signature).decode("utf-8").rstrip("=")
|
||||
|
||||
|
||||
def issue_token(payload: dict[str, Any], ttl_seconds: int = 60 * 60 * 12) -> str:
|
||||
body = {**payload, "exp": int(time.time()) + ttl_seconds}
|
||||
encoded = _encode(body)
|
||||
return f"{encoded}.{_sign(encoded)}"
|
||||
|
||||
|
||||
def verify_token(token: str) -> dict[str, Any]:
|
||||
try:
|
||||
body, signature = token.split(".", 1)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token") from exc
|
||||
|
||||
expected_signature = _sign(body)
|
||||
if not hmac.compare_digest(signature, expected_signature):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication token")
|
||||
|
||||
payload = _decode(body)
|
||||
if int(payload.get("exp", 0)) < int(time.time()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication token has expired")
|
||||
return payload
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import inspect, text
|
||||
from sqlalchemy.engine import Engine
|
||||
|
||||
|
||||
TENANT_TABLES = {
|
||||
"client_users": None,
|
||||
"client_feature_access": None,
|
||||
"raw_materials": None,
|
||||
"raw_material_price_versions": None,
|
||||
"mixes": None,
|
||||
"mix_ingredients": None,
|
||||
"products": None,
|
||||
"scenarios": None,
|
||||
"costing_results": None,
|
||||
"process_cost_rules": None,
|
||||
"packaging_cost_rules": None,
|
||||
"freight_cost_rules": None,
|
||||
}
|
||||
|
||||
|
||||
def _has_column(engine: Engine, table_name: str, column_name: str) -> bool:
|
||||
inspector = inspect(engine)
|
||||
try:
|
||||
columns = inspector.get_columns(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(column["name"] == column_name for column in columns)
|
||||
|
||||
|
||||
def _add_tenant_column(engine: Engine, table_name: str) -> None:
|
||||
if _has_column(engine, table_name, "tenant_id"):
|
||||
return
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"ALTER TABLE {table_name} ADD COLUMN tenant_id VARCHAR(64)"))
|
||||
|
||||
|
||||
def _table_exists(engine: Engine, table_name: str) -> bool:
|
||||
return inspect(engine).has_table(table_name)
|
||||
|
||||
|
||||
def ensure_tenant_columns(engine: Engine) -> None:
|
||||
for table_name in TENANT_TABLES:
|
||||
if _table_exists(engine, table_name):
|
||||
_add_tenant_column(engine, table_name)
|
||||
|
||||
|
||||
def sync_tenant_ids(engine: Engine) -> None:
|
||||
if not _table_exists(engine, "client_accounts"):
|
||||
return
|
||||
|
||||
with engine.begin() as connection:
|
||||
default_tenant = connection.execute(
|
||||
text("SELECT tenant_id FROM client_accounts ORDER BY id LIMIT 1")
|
||||
).scalar_one_or_none()
|
||||
if not default_tenant:
|
||||
return
|
||||
|
||||
statements = [
|
||||
text(
|
||||
"""
|
||||
UPDATE client_users
|
||||
SET tenant_id = (
|
||||
SELECT client_accounts.tenant_id
|
||||
FROM client_accounts
|
||||
WHERE client_accounts.id = client_users.client_account_id
|
||||
)
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE client_feature_access
|
||||
SET tenant_id = (
|
||||
SELECT client_accounts.tenant_id
|
||||
FROM client_accounts
|
||||
WHERE client_accounts.id = client_feature_access.client_account_id
|
||||
)
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE raw_materials
|
||||
SET tenant_id = :default_tenant
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE raw_material_price_versions
|
||||
SET tenant_id = (
|
||||
SELECT raw_materials.tenant_id
|
||||
FROM raw_materials
|
||||
WHERE raw_materials.id = raw_material_price_versions.raw_material_id
|
||||
)
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE mixes
|
||||
SET tenant_id = COALESCE(
|
||||
(
|
||||
SELECT client_accounts.tenant_id
|
||||
FROM client_accounts
|
||||
WHERE client_accounts.name = mixes.client_name
|
||||
),
|
||||
:default_tenant
|
||||
)
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE mix_ingredients
|
||||
SET tenant_id = (
|
||||
SELECT mixes.tenant_id
|
||||
FROM mixes
|
||||
WHERE mixes.id = mix_ingredients.mix_id
|
||||
)
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE products
|
||||
SET tenant_id = COALESCE(
|
||||
(
|
||||
SELECT client_accounts.tenant_id
|
||||
FROM client_accounts
|
||||
WHERE client_accounts.name = products.client_name
|
||||
),
|
||||
(
|
||||
SELECT mixes.tenant_id
|
||||
FROM mixes
|
||||
WHERE mixes.id = products.mix_id
|
||||
),
|
||||
:default_tenant
|
||||
)
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE scenarios
|
||||
SET tenant_id = :default_tenant
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE costing_results
|
||||
SET tenant_id = COALESCE(
|
||||
(
|
||||
SELECT products.tenant_id
|
||||
FROM products
|
||||
WHERE products.id = costing_results.product_id
|
||||
),
|
||||
(
|
||||
SELECT scenarios.tenant_id
|
||||
FROM scenarios
|
||||
WHERE scenarios.id = costing_results.scenario_id
|
||||
),
|
||||
:default_tenant
|
||||
)
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE process_cost_rules
|
||||
SET tenant_id = :default_tenant
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE packaging_cost_rules
|
||||
SET tenant_id = :default_tenant
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
text(
|
||||
"""
|
||||
UPDATE freight_cost_rules
|
||||
SET tenant_id = :default_tenant
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
]
|
||||
|
||||
for statement in statements:
|
||||
connection.execute(statement, {"default_tenant": default_tenant})
|
||||
+9
-2
@@ -11,6 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.client_access import router as client_access_router
|
||||
from app.api.mixes import router as mixes_router
|
||||
from app.api.powerbi import router as powerbi_router
|
||||
from app.api.products import router as products_router
|
||||
@@ -18,13 +19,16 @@ 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.db.migrations import ensure_tenant_columns, sync_tenant_ids
|
||||
from app.seed import seed_if_empty
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
Base.metadata.create_all(bind=engine)
|
||||
ensure_tenant_columns(engine)
|
||||
seed_if_empty()
|
||||
sync_tenant_ids(engine)
|
||||
yield
|
||||
|
||||
|
||||
@@ -32,13 +36,14 @@ app = FastAPI(title=settings.app_name, lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_origins=["http://localhost:5173", "http://localhost:5174"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(client_access_router)
|
||||
app.include_router(raw_materials_router)
|
||||
app.include_router(mixes_router)
|
||||
app.include_router(products_router)
|
||||
@@ -58,11 +63,13 @@ def root():
|
||||
"Confirm finished product pricing outputs",
|
||||
],
|
||||
"endpoints": {
|
||||
"login": "/api/auth/login",
|
||||
"client_login": "/api/auth/client/login",
|
||||
"admin_login": "/api/auth/admin/login",
|
||||
"raw_materials": "/api/raw-materials",
|
||||
"mixes": "/api/mixes",
|
||||
"products": "/api/products",
|
||||
"scenarios": "/api/scenarios",
|
||||
"client_access": "/api/client-access",
|
||||
"docs": "/docs",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
|
||||
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__ = [
|
||||
"ClientAccount",
|
||||
"ClientFeatureAccess",
|
||||
"ClientUser",
|
||||
"CostingResult",
|
||||
"FreightCostRule",
|
||||
"Mix",
|
||||
@@ -16,4 +20,3 @@ __all__ = [
|
||||
"RawMaterialPriceVersion",
|
||||
"Scenario",
|
||||
]
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ class ProcessCostRule(Base):
|
||||
__tablename__ = "process_cost_rules"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
|
||||
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)
|
||||
@@ -21,6 +22,7 @@ class PackagingCostRule(Base):
|
||||
__tablename__ = "packaging_cost_rules"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
|
||||
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)
|
||||
@@ -32,6 +34,7 @@ class FreightCostRule(Base):
|
||||
__tablename__ = "freight_cost_rules"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class ClientAccount(Base):
|
||||
__tablename__ = "client_accounts"
|
||||
|
||||
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)
|
||||
client_code: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
powerbi_workspace: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
users: Mapped[list["ClientUser"]] = relationship(
|
||||
back_populates="client_account",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ClientUser.created_at.desc()",
|
||||
)
|
||||
features: Mapped[list["ClientFeatureAccess"]] = relationship(
|
||||
back_populates="client_account",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="ClientFeatureAccess.feature_group, ClientFeatureAccess.feature_name",
|
||||
)
|
||||
|
||||
|
||||
class ClientUser(Base):
|
||||
__tablename__ = "client_users"
|
||||
__table_args__ = (UniqueConstraint("client_account_id", "email", name="uq_client_user_email"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
full_name: Mapped[str] = mapped_column(String(255))
|
||||
email: Mapped[str] = mapped_column(String(255))
|
||||
role: Mapped[str] = mapped_column(String(64), default="viewer")
|
||||
status: Mapped[str] = mapped_column(String(32), default="invited")
|
||||
is_new_user: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
client_account: Mapped[ClientAccount] = relationship(back_populates="users")
|
||||
|
||||
|
||||
class ClientFeatureAccess(Base):
|
||||
__tablename__ = "client_feature_access"
|
||||
__table_args__ = (UniqueConstraint("client_account_id", "feature_key", name="uq_client_feature"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
|
||||
client_account_id: Mapped[int] = mapped_column(ForeignKey("client_accounts.id"), index=True)
|
||||
feature_key: Mapped[str] = mapped_column(String(64))
|
||||
feature_name: Mapped[str] = mapped_column(String(255))
|
||||
feature_group: Mapped[str] = mapped_column(String(64), default="operations")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
client_account: Mapped[ClientAccount] = relationship(back_populates="features")
|
||||
@@ -32,6 +32,7 @@ class MixIngredient(Base):
|
||||
__table_args__ = (UniqueConstraint("mix_id", "raw_material_id", name="uq_mix_ingredient"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
|
||||
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)
|
||||
@@ -43,4 +44,3 @@ class MixIngredient(Base):
|
||||
|
||||
from app.models.product import Product # noqa: E402
|
||||
from app.models.raw_material import RawMaterial # noqa: E402
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ class RawMaterialPriceVersion(Base):
|
||||
__tablename__ = "raw_material_price_versions"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
|
||||
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)
|
||||
@@ -41,4 +42,3 @@ class RawMaterialPriceVersion(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
raw_material: Mapped[RawMaterial] = relationship(back_populates="price_versions")
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ class Scenario(Base):
|
||||
__tablename__ = "scenarios"
|
||||
|
||||
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)
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft")
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
@@ -28,6 +29,7 @@ class CostingResult(Base):
|
||||
__tablename__ = "costing_results"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default")
|
||||
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)
|
||||
@@ -38,4 +40,3 @@ class CostingResult(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
scenario: Mapped[Scenario] = relationship(back_populates="results")
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ClientUserCreate(BaseModel):
|
||||
client_account_id: int
|
||||
full_name: str
|
||||
email: str
|
||||
role: str = "viewer"
|
||||
status: str = "invited"
|
||||
is_new_user: bool = True
|
||||
|
||||
|
||||
class ClientUserUpdate(BaseModel):
|
||||
full_name: str | None = None
|
||||
email: str | None = None
|
||||
role: str | None = None
|
||||
status: str | None = None
|
||||
is_new_user: bool | None = None
|
||||
|
||||
|
||||
class ClientFeatureUpdate(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class ClientUserRead(BaseModel):
|
||||
id: int
|
||||
client_account_id: int
|
||||
full_name: str
|
||||
email: str
|
||||
role: str
|
||||
status: str
|
||||
is_new_user: bool
|
||||
last_login_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ClientFeatureRead(BaseModel):
|
||||
id: int
|
||||
client_account_id: int
|
||||
feature_key: str
|
||||
feature_name: str
|
||||
feature_group: str
|
||||
description: str | None
|
||||
enabled: bool
|
||||
updated_at: datetime
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ClientAccessRead(BaseModel):
|
||||
id: int
|
||||
tenant_id: str
|
||||
name: str
|
||||
client_code: str
|
||||
status: str
|
||||
powerbi_workspace: str | None
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
users: list[ClientUserRead]
|
||||
features: list[ClientFeatureRead]
|
||||
active_user_count: int
|
||||
new_user_count: int
|
||||
enabled_feature_count: int
|
||||
total_feature_count: int
|
||||
+156
-58
@@ -1,76 +1,174 @@
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import Base, SessionLocal, engine
|
||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product
|
||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||
|
||||
|
||||
CLIENT_FEATURES = [
|
||||
("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"),
|
||||
("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"),
|
||||
("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"),
|
||||
("products", "Products", "pricing", "Review finished product pricing"),
|
||||
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
|
||||
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
|
||||
]
|
||||
|
||||
|
||||
def seed_client_access(db):
|
||||
existing = db.scalar(select(ClientAccount.id))
|
||||
if existing is not None:
|
||||
return
|
||||
|
||||
specialty = ClientAccount(
|
||||
tenant_id="hunter-premium-produce",
|
||||
name="Hunter Premium Produce",
|
||||
client_code="HPP",
|
||||
status="active",
|
||||
powerbi_workspace="hunter-premium-produce-prod",
|
||||
notes="Primary production client for the Lean 101 admin and access workflows",
|
||||
)
|
||||
loft = ClientAccount(
|
||||
tenant_id="loft-grains",
|
||||
name="Loft Grains",
|
||||
client_code="LOFT",
|
||||
status="onboarding",
|
||||
powerbi_workspace="farm-ops-sandbox",
|
||||
notes="Onboarding workspace used to test staged user enablement",
|
||||
)
|
||||
|
||||
db.add_all([specialty, loft])
|
||||
db.flush()
|
||||
|
||||
specialty.users.extend(
|
||||
[
|
||||
ClientUser(
|
||||
tenant_id=specialty.tenant_id,
|
||||
full_name="Amelia Hart",
|
||||
email="operator@example.com",
|
||||
role="admin",
|
||||
status="active",
|
||||
is_new_user=False,
|
||||
last_login_at=datetime(2026, 4, 24, 11, 30),
|
||||
),
|
||||
ClientUser(
|
||||
tenant_id=specialty.tenant_id,
|
||||
full_name="Ethan Cole",
|
||||
email="ethan.cole@hunterpremiumproduce.example",
|
||||
role="operator",
|
||||
status="invited",
|
||||
is_new_user=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
loft.users.extend(
|
||||
[
|
||||
ClientUser(
|
||||
tenant_id=loft.tenant_id,
|
||||
full_name="Ruby Singh",
|
||||
email="ruby.singh@loftgrains.example",
|
||||
role="viewer",
|
||||
status="active",
|
||||
is_new_user=False,
|
||||
last_login_at=datetime(2026, 4, 22, 9, 10),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
enabled_feature_map = {
|
||||
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export"},
|
||||
"loft-grains": {"dashboard", "products", "powerbi_export"},
|
||||
}
|
||||
|
||||
for client in (specialty, loft):
|
||||
enabled_keys = enabled_feature_map[client.tenant_id]
|
||||
for feature_key, feature_name, feature_group, description in CLIENT_FEATURES:
|
||||
client.features.append(
|
||||
ClientFeatureAccess(
|
||||
tenant_id=client.tenant_id,
|
||||
feature_key=feature_key,
|
||||
feature_name=feature_name,
|
||||
feature_group=feature_group,
|
||||
description=description,
|
||||
enabled=feature_key in enabled_keys,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def seed_costing_workspace(db):
|
||||
existing = db.scalar(select(RawMaterial.id))
|
||||
if existing is not None:
|
||||
return
|
||||
|
||||
tenant_id = "hunter-premium-produce"
|
||||
|
||||
maize = RawMaterial(tenant_id=tenant_id, name="Maize", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
barley = RawMaterial(tenant_id=tenant_id, name="Barley", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
acid_buf = RawMaterial(tenant_id=tenant_id, name="Acid Buf", supplier="Example Supplier", unit_of_measure="bag", kg_per_unit=25, status="active")
|
||||
|
||||
maize.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
|
||||
barley.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
|
||||
acid_buf.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, 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(tenant_id=tenant_id, process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0),
|
||||
ProcessCostRule(tenant_id=tenant_id, process_name="bulk_loadout", grading_cost=0.03, bagging_cost=0.0, cracking_cost=0.0),
|
||||
PackagingCostRule(tenant_id=tenant_id, sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63),
|
||||
PackagingCostRule(tenant_id=tenant_id, sale_type="bulka", unit_of_measure="550kg bulka", own_bag=False, bag_cost=7.5),
|
||||
FreightCostRule(tenant_id=tenant_id, sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45),
|
||||
FreightCostRule(tenant_id=tenant_id, sale_type="bulka", unit_of_measure="550kg bulka", cost_per_unit=18.0),
|
||||
]
|
||||
)
|
||||
db.flush()
|
||||
|
||||
mix = Mix(tenant_id=tenant_id, client_name="Hunter Premium Produce", name="Hunter Orchard Blend", status="active", version=1, notes="Seed recipe for MVP")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
|
||||
db.add_all(
|
||||
[
|
||||
MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
|
||||
MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=95),
|
||||
MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=acid_buf.id, quantity_kg=5),
|
||||
]
|
||||
)
|
||||
db.flush()
|
||||
|
||||
db.add(
|
||||
Product(
|
||||
tenant_id=tenant_id,
|
||||
client_name="Hunter Premium Produce",
|
||||
item_id="SKU-001",
|
||||
name="Hunter Orchard Blend 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",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
)
|
||||
)
|
||||
seed_costing_workspace(db)
|
||||
seed_client_access(db)
|
||||
db.commit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_if_empty()
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Select, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.models.client_access import ClientAccount, ClientFeatureAccess, ClientUser
|
||||
|
||||
|
||||
def client_access_query() -> Select[tuple[ClientAccount]]:
|
||||
return (
|
||||
select(ClientAccount)
|
||||
.options(selectinload(ClientAccount.users), selectinload(ClientAccount.features))
|
||||
.order_by(ClientAccount.name)
|
||||
)
|
||||
|
||||
|
||||
def list_client_accounts(db: Session) -> list[ClientAccount]:
|
||||
return db.scalars(client_access_query()).all()
|
||||
|
||||
|
||||
def serialize_client_user(user: ClientUser) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
"client_account_id": user.client_account_id,
|
||||
"full_name": user.full_name,
|
||||
"email": user.email,
|
||||
"role": user.role,
|
||||
"status": user.status,
|
||||
"is_new_user": user.is_new_user,
|
||||
"last_login_at": user.last_login_at,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_client_feature(feature: ClientFeatureAccess) -> dict:
|
||||
return {
|
||||
"id": feature.id,
|
||||
"client_account_id": feature.client_account_id,
|
||||
"feature_key": feature.feature_key,
|
||||
"feature_name": feature.feature_name,
|
||||
"feature_group": feature.feature_group,
|
||||
"description": feature.description,
|
||||
"enabled": feature.enabled,
|
||||
"updated_at": feature.updated_at,
|
||||
"created_at": feature.created_at,
|
||||
}
|
||||
|
||||
|
||||
def serialize_client_account(client: ClientAccount) -> dict:
|
||||
users = [serialize_client_user(user) for user in client.users]
|
||||
features = [serialize_client_feature(feature) for feature in client.features]
|
||||
active_users = sum(1 for user in users if user["status"] == "active")
|
||||
new_users = sum(1 for user in users if user["is_new_user"])
|
||||
enabled_features = sum(1 for feature in features if feature["enabled"])
|
||||
|
||||
return {
|
||||
"id": client.id,
|
||||
"tenant_id": client.tenant_id,
|
||||
"name": client.name,
|
||||
"client_code": client.client_code,
|
||||
"status": client.status,
|
||||
"powerbi_workspace": client.powerbi_workspace,
|
||||
"notes": client.notes,
|
||||
"created_at": client.created_at,
|
||||
"users": users,
|
||||
"features": features,
|
||||
"active_user_count": active_users,
|
||||
"new_user_count": new_users,
|
||||
"enabled_feature_count": enabled_features,
|
||||
"total_feature_count": len(features),
|
||||
}
|
||||
|
||||
|
||||
def build_client_access_export(clients: list[ClientAccount]) -> dict:
|
||||
serialized_clients = [serialize_client_account(client) for client in clients]
|
||||
client_rows = []
|
||||
user_rows = []
|
||||
feature_rows = []
|
||||
|
||||
for client in serialized_clients:
|
||||
client_rows.append(
|
||||
{
|
||||
"client_id": client["id"],
|
||||
"tenant_id": client["tenant_id"],
|
||||
"client_name": client["name"],
|
||||
"client_code": client["client_code"],
|
||||
"client_status": client["status"],
|
||||
"powerbi_workspace": client["powerbi_workspace"],
|
||||
"active_user_count": client["active_user_count"],
|
||||
"new_user_count": client["new_user_count"],
|
||||
"enabled_feature_count": client["enabled_feature_count"],
|
||||
"total_feature_count": client["total_feature_count"],
|
||||
}
|
||||
)
|
||||
|
||||
for user in client["users"]:
|
||||
user_rows.append(
|
||||
{
|
||||
"client_id": client["id"],
|
||||
"client_name": client["name"],
|
||||
"user_id": user["id"],
|
||||
"full_name": user["full_name"],
|
||||
"email": user["email"],
|
||||
"role": user["role"],
|
||||
"status": user["status"],
|
||||
"is_new_user": user["is_new_user"],
|
||||
"last_login_at": user["last_login_at"],
|
||||
"created_at": user["created_at"],
|
||||
}
|
||||
)
|
||||
|
||||
for feature in client["features"]:
|
||||
feature_rows.append(
|
||||
{
|
||||
"client_id": client["id"],
|
||||
"client_name": client["name"],
|
||||
"feature_id": feature["id"],
|
||||
"feature_key": feature["feature_key"],
|
||||
"feature_name": feature["feature_name"],
|
||||
"feature_group": feature["feature_group"],
|
||||
"enabled": feature["enabled"],
|
||||
"updated_at": feature["updated_at"],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"generated_at": datetime.utcnow(),
|
||||
"client_rows": client_rows,
|
||||
"user_rows": user_rows,
|
||||
"feature_rows": feature_rows,
|
||||
"clients": serialized_clients,
|
||||
}
|
||||
@@ -123,7 +123,11 @@ def _get_process_costs(db: Session, process_name: str | None, overrides: dict) -
|
||||
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))
|
||||
tenant_id = overrides.get("tenant_id")
|
||||
query = select(ProcessCostRule).where(ProcessCostRule.process_name == process_name)
|
||||
if tenant_id:
|
||||
query = query.where(ProcessCostRule.tenant_id == tenant_id)
|
||||
rule = db.scalar(query)
|
||||
if rule is None:
|
||||
return 0.0, 0.0, 0.0, [f"Process rule not found for {process_name}"]
|
||||
|
||||
@@ -138,13 +142,14 @@ def _get_packaging_cost(db: Session, product: Product, overrides: dict) -> tuple
|
||||
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,
|
||||
)
|
||||
query = select(PackagingCostRule).where(
|
||||
PackagingCostRule.sale_type == product.sale_type,
|
||||
PackagingCostRule.unit_of_measure == product.unit_of_measure,
|
||||
PackagingCostRule.own_bag == product.own_bag,
|
||||
)
|
||||
if product.tenant_id:
|
||||
query = query.where(PackagingCostRule.tenant_id == product.tenant_id)
|
||||
rule = db.scalar(query)
|
||||
if rule is None:
|
||||
return 0.0, ["Packaging rule not found"]
|
||||
|
||||
@@ -152,12 +157,13 @@ def _get_packaging_cost(db: Session, product: Product, overrides: dict) -> tuple
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
query = select(FreightCostRule).where(
|
||||
FreightCostRule.sale_type == product.sale_type,
|
||||
FreightCostRule.unit_of_measure == product.unit_of_measure,
|
||||
)
|
||||
if product.tenant_id:
|
||||
query = query.where(FreightCostRule.tenant_id == product.tenant_id)
|
||||
rule = db.scalar(query)
|
||||
if rule is None:
|
||||
return 0.0, ["Freight rule not found"]
|
||||
return overrides.get("freight_costs", {}).get(str(rule.id), rule.cost_per_unit), []
|
||||
@@ -185,9 +191,11 @@ def _extract_unit_quantity_kg(unit_of_measure: str) -> float:
|
||||
|
||||
def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict:
|
||||
overrides = overrides or {}
|
||||
overrides = {**overrides, "tenant_id": overrides.get("tenant_id")}
|
||||
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")
|
||||
overrides["tenant_id"] = product.tenant_id
|
||||
|
||||
mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
|
||||
warnings = list(mix_result["warnings"])
|
||||
|
||||
@@ -8,13 +8,14 @@ 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()
|
||||
products = db.scalars(select(Product).where(Product.tenant_id == scenario.tenant_id).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(
|
||||
tenant_id=scenario.tenant_id,
|
||||
scenario_id=scenario.id,
|
||||
product_id=product.id,
|
||||
finished_product_delivered=breakdown["finished_product_delivered"],
|
||||
@@ -29,4 +30,3 @@ def run_scenario(db: Session, scenario: Scenario) -> list[dict]:
|
||||
scenario.status = "reviewed"
|
||||
db.commit()
|
||||
return results
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ 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.client_access import ClientAccount, ClientFeatureAccess, ClientUser
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product
|
||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||
from app.services.client_access_service import build_client_access_export, serialize_client_account
|
||||
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost
|
||||
|
||||
|
||||
@@ -85,11 +87,97 @@ def test_root_and_login_endpoints():
|
||||
|
||||
root_response = client.get("/")
|
||||
assert root_response.status_code == 200
|
||||
assert root_response.json()["endpoints"]["login"] == "/api/auth/login"
|
||||
assert root_response.json()["endpoints"]["client_login"] == "/api/auth/client/login"
|
||||
assert root_response.json()["endpoints"]["admin_login"] == "/api/auth/admin/login"
|
||||
|
||||
login_response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": settings.operator_email, "password": settings.operator_password},
|
||||
client_login_response = client.post(
|
||||
"/api/auth/client/login",
|
||||
json={"email": settings.client_email, "password": settings.client_password},
|
||||
)
|
||||
assert login_response.status_code == 200
|
||||
assert login_response.json()["email"] == settings.operator_email
|
||||
assert client_login_response.status_code == 200
|
||||
assert client_login_response.json()["email"] == settings.client_email
|
||||
assert client_login_response.json()["tenant_id"] == settings.client_tenant_id
|
||||
|
||||
admin_login_response = client.post(
|
||||
"/api/auth/admin/login",
|
||||
json={"email": settings.admin_email, "password": settings.admin_password},
|
||||
)
|
||||
assert admin_login_response.status_code == 200
|
||||
assert admin_login_response.json()["email"] == settings.admin_email
|
||||
|
||||
|
||||
def test_client_access_export_helpers():
|
||||
db = build_session()
|
||||
|
||||
client = ClientAccount(
|
||||
tenant_id="specialty-feeds",
|
||||
name="Specialty Feeds",
|
||||
client_code="SPEC",
|
||||
status="active",
|
||||
powerbi_workspace="farm-ops-prod",
|
||||
)
|
||||
client.users.extend(
|
||||
[
|
||||
ClientUser(
|
||||
full_name="Amelia Hart",
|
||||
email="amelia.hart@specialtyfeeds.example",
|
||||
role="admin",
|
||||
status="active",
|
||||
is_new_user=False,
|
||||
),
|
||||
ClientUser(
|
||||
full_name="Ethan Cole",
|
||||
email="ethan.cole@specialtyfeeds.example",
|
||||
role="operator",
|
||||
status="invited",
|
||||
is_new_user=True,
|
||||
),
|
||||
]
|
||||
)
|
||||
client.features.extend(
|
||||
[
|
||||
ClientFeatureAccess(
|
||||
feature_key="dashboard",
|
||||
feature_name="Dashboard",
|
||||
feature_group="workspace",
|
||||
enabled=True,
|
||||
),
|
||||
ClientFeatureAccess(
|
||||
feature_key="products",
|
||||
feature_name="Products",
|
||||
feature_group="pricing",
|
||||
enabled=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.add(client)
|
||||
db.commit()
|
||||
db.refresh(client)
|
||||
|
||||
serialized = serialize_client_account(client)
|
||||
export = build_client_access_export([client])
|
||||
|
||||
assert serialized["active_user_count"] == 1
|
||||
assert serialized["new_user_count"] == 1
|
||||
assert serialized["enabled_feature_count"] == 1
|
||||
assert export["client_rows"][0]["client_code"] == "SPEC"
|
||||
assert export["user_rows"][0]["client_name"] == "Specialty Feeds"
|
||||
assert len(export["feature_rows"]) == 2
|
||||
|
||||
|
||||
def test_client_access_endpoints():
|
||||
with TestClient(app) as client:
|
||||
login_response = client.post(
|
||||
"/api/auth/admin/login",
|
||||
json={"email": settings.admin_email, "password": settings.admin_password},
|
||||
)
|
||||
token = login_response.json()["token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
access_response = client.get("/api/client-access", headers=headers)
|
||||
assert access_response.status_code == 200
|
||||
assert len(access_response.json()) >= 1
|
||||
|
||||
export_response = client.get("/api/powerbi/client-access", headers=headers)
|
||||
assert export_response.status_code == 200
|
||||
assert "client_rows" in export_response.json()
|
||||
|
||||
Reference in New Issue
Block a user