Mix calculator
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import AuthSession, require_client_module_access
|
||||
from app.db.session import get_db
|
||||
from app.schemas.mix_calculator import (
|
||||
MixCalculatorOptionsRead,
|
||||
MixCalculatorPreviewRead,
|
||||
MixCalculatorSessionCreate,
|
||||
MixCalculatorSessionRead,
|
||||
MixCalculatorSessionSummaryRead,
|
||||
MixCalculatorSessionUpdate,
|
||||
)
|
||||
from app.services.mix_calculator_service import (
|
||||
build_mix_calculator_options,
|
||||
calculate_mix_calculator_preview,
|
||||
serialize_mix_calculator_session,
|
||||
create_mix_calculator_session,
|
||||
get_mix_calculator_session,
|
||||
update_mix_calculator_session,
|
||||
list_mix_calculator_sessions,
|
||||
can_view_all_mix_calculator_sessions,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
|
||||
|
||||
|
||||
@router.get("/options", response_model=MixCalculatorOptionsRead)
|
||||
def mix_calculator_options(
|
||||
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return build_mix_calculator_options(db, tenant_id=session.tenant_id or "")
|
||||
|
||||
|
||||
@router.get("", response_model=list[MixCalculatorSessionSummaryRead])
|
||||
def mix_calculator_sessions(
|
||||
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return list_mix_calculator_sessions(db, auth_session=session)
|
||||
|
||||
|
||||
@router.post("/preview", response_model=MixCalculatorPreviewRead)
|
||||
def preview_mix_calculator_session(
|
||||
payload: MixCalculatorSessionCreate,
|
||||
session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return calculate_mix_calculator_preview(db, tenant_id=session.tenant_id or "", payload=payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("", response_model=MixCalculatorSessionRead, status_code=status.HTTP_201_CREATED)
|
||||
def create_saved_mix_calculator_session(
|
||||
payload: MixCalculatorSessionCreate,
|
||||
session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
return create_mix_calculator_session(db, auth_session=session, payload=payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.get("/{session_id}", response_model=MixCalculatorSessionRead)
|
||||
def read_mix_calculator_session(
|
||||
session_id: int,
|
||||
session: AuthSession = Depends(require_client_module_access("mix_calculator")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
session_record = get_mix_calculator_session(db, auth_session=session, session_id=session_id)
|
||||
if session_record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mix calculator session not found")
|
||||
return serialize_mix_calculator_session(session_record, session)
|
||||
|
||||
|
||||
@router.patch("/{session_id}", response_model=MixCalculatorSessionRead)
|
||||
def patch_mix_calculator_session(
|
||||
session_id: int,
|
||||
payload: MixCalculatorSessionUpdate,
|
||||
session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
session_record = get_mix_calculator_session(db, auth_session=session, session_id=session_id)
|
||||
if session_record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Mix calculator session not found")
|
||||
if not can_view_all_mix_calculator_sessions(session) and session_record.prepared_by_user_id != session.user_id:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own mix calculator sessions")
|
||||
try:
|
||||
return update_mix_calculator_session(db, auth_session=session, session_record=session_record, payload=payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
@@ -15,6 +15,8 @@ TENANT_TABLES = {
|
||||
"raw_material_price_versions": None,
|
||||
"mixes": None,
|
||||
"mix_ingredients": None,
|
||||
"mix_calculator_sessions": None,
|
||||
"mix_calculator_session_lines": None,
|
||||
"products": None,
|
||||
"scenarios": None,
|
||||
"costing_results": None,
|
||||
@@ -230,6 +232,37 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]:
|
||||
"""
|
||||
),
|
||||
),
|
||||
(
|
||||
"mix_calculator_sessions",
|
||||
text(
|
||||
"""
|
||||
UPDATE mix_calculator_sessions
|
||||
SET tenant_id = COALESCE(
|
||||
(
|
||||
SELECT products.tenant_id
|
||||
FROM products
|
||||
WHERE products.id = mix_calculator_sessions.product_id
|
||||
),
|
||||
:default_tenant
|
||||
)
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
),
|
||||
(
|
||||
"mix_calculator_session_lines",
|
||||
text(
|
||||
"""
|
||||
UPDATE mix_calculator_session_lines
|
||||
SET tenant_id = (
|
||||
SELECT mix_calculator_sessions.tenant_id
|
||||
FROM mix_calculator_sessions
|
||||
WHERE mix_calculator_sessions.id = mix_calculator_session_lines.session_id
|
||||
)
|
||||
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
|
||||
"""
|
||||
),
|
||||
),
|
||||
(
|
||||
"scenarios",
|
||||
text(
|
||||
|
||||
@@ -14,6 +14,7 @@ import uvicorn
|
||||
|
||||
from app.api.auth import router as auth_router
|
||||
from app.api.client_access import router as client_access_router
|
||||
from app.api.mix_calculator import router as mix_calculator_router
|
||||
from app.api.mixes import router as mixes_router
|
||||
from app.api.powerbi import router as powerbi_router
|
||||
from app.api.products import router as products_router
|
||||
@@ -74,6 +75,7 @@ app.include_router(auth_router)
|
||||
app.include_router(client_access_router)
|
||||
app.include_router(raw_materials_router)
|
||||
app.include_router(mixes_router)
|
||||
app.include_router(mix_calculator_router)
|
||||
app.include_router(products_router)
|
||||
app.include_router(scenarios_router)
|
||||
app.include_router(powerbi_router)
|
||||
@@ -95,6 +97,7 @@ def root():
|
||||
"admin_login": "/api/auth/admin/login",
|
||||
"raw_materials": "/api/raw-materials",
|
||||
"mixes": "/api/mixes",
|
||||
"mix_calculator": "/api/mix-calculator",
|
||||
"products": "/api/products",
|
||||
"scenarios": "/api/scenarios",
|
||||
"client_access": "/api/client-access",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
|
||||
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product
|
||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||
@@ -14,6 +15,8 @@ __all__ = [
|
||||
"CostingResult",
|
||||
"FreightCostRule",
|
||||
"Mix",
|
||||
"MixCalculatorSession",
|
||||
"MixCalculatorSessionLine",
|
||||
"MixIngredient",
|
||||
"PackagingCostRule",
|
||||
"ProcessCostRule",
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.session import Base
|
||||
|
||||
|
||||
class MixCalculatorSession(Base):
|
||||
__tablename__ = "mix_calculator_sessions"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "session_number", name="uq_mix_calculator_session_number"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
session_number: Mapped[str] = mapped_column(String(32), index=True)
|
||||
client_name: Mapped[str] = mapped_column(String(255), index=True)
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
|
||||
product_name: Mapped[str] = mapped_column(String(255))
|
||||
mix_id: Mapped[int] = mapped_column(ForeignKey("mixes.id"), index=True)
|
||||
mix_name: Mapped[str] = mapped_column(String(255))
|
||||
mix_date: Mapped[date] = mapped_column(Date, index=True)
|
||||
batch_size_kg: Mapped[float] = mapped_column(Float)
|
||||
total_bags: Mapped[float] = mapped_column(Float)
|
||||
total_kg: Mapped[float] = mapped_column(Float)
|
||||
product_unit_of_measure: Mapped[str] = mapped_column(String(64))
|
||||
product_unit_size_kg: Mapped[float] = mapped_column(Float)
|
||||
prepared_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("client_users.id"), nullable=True, index=True)
|
||||
prepared_by_name: Mapped[str] = mapped_column(String(255))
|
||||
created_by: Mapped[str] = mapped_column(String(255))
|
||||
status: Mapped[str] = mapped_column(String(32), default="saved")
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
lines: Mapped[list["MixCalculatorSessionLine"]] = relationship(
|
||||
back_populates="session",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="MixCalculatorSessionLine.sort_order",
|
||||
)
|
||||
|
||||
|
||||
class MixCalculatorSessionLine(Base):
|
||||
__tablename__ = "mix_calculator_session_lines"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("mix_calculator_sessions.id"), index=True)
|
||||
raw_material_id: Mapped[int | None] = mapped_column(nullable=True)
|
||||
raw_material_name: Mapped[str] = mapped_column(String(255))
|
||||
required_kg: Mapped[float] = mapped_column(Float)
|
||||
mix_percentage: Mapped[float] = mapped_column(Float)
|
||||
unit: Mapped[str] = mapped_column(String(64))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
session: Mapped[MixCalculatorSession] = relationship(back_populates="lines")
|
||||
@@ -0,0 +1,103 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class MixCalculatorProductOptionRead(BaseModel):
|
||||
product_id: int
|
||||
client_name: str
|
||||
product_name: str
|
||||
mix_id: int
|
||||
mix_name: str
|
||||
unit_of_measure: str
|
||||
unit_size_kg: float
|
||||
mix_total_kg: float
|
||||
|
||||
|
||||
class MixCalculatorOptionsRead(BaseModel):
|
||||
clients: list[str]
|
||||
products: list[MixCalculatorProductOptionRead]
|
||||
|
||||
|
||||
class MixCalculatorSessionLineRead(BaseModel):
|
||||
id: int | None = None
|
||||
raw_material_id: int | None
|
||||
raw_material_name: str
|
||||
required_kg: float
|
||||
mix_percentage: float
|
||||
unit: str
|
||||
sort_order: int
|
||||
|
||||
|
||||
class MixCalculatorSessionBase(BaseModel):
|
||||
mix_date: date
|
||||
client_name: str
|
||||
product_id: int
|
||||
batch_size_kg: float = Field(gt=0)
|
||||
prepared_by_name: str = Field(min_length=1, max_length=255)
|
||||
status: str = "saved"
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class MixCalculatorSessionCreate(MixCalculatorSessionBase):
|
||||
pass
|
||||
|
||||
|
||||
class MixCalculatorSessionUpdate(BaseModel):
|
||||
mix_date: date | None = None
|
||||
client_name: str | None = None
|
||||
product_id: int | None = None
|
||||
batch_size_kg: float | None = Field(default=None, gt=0)
|
||||
prepared_by_name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
status: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class MixCalculatorPreviewRead(BaseModel):
|
||||
client_name: str
|
||||
product_id: int
|
||||
product_name: str
|
||||
mix_id: int
|
||||
mix_name: str
|
||||
mix_date: date
|
||||
batch_size_kg: float
|
||||
total_bags: float
|
||||
total_kg: float
|
||||
product_unit_of_measure: str
|
||||
product_unit_size_kg: float
|
||||
prepared_by_name: str
|
||||
status: str
|
||||
notes: str | None
|
||||
warnings: list[str]
|
||||
lines: list[MixCalculatorSessionLineRead]
|
||||
|
||||
|
||||
class MixCalculatorSessionSummaryRead(BaseModel):
|
||||
id: int
|
||||
tenant_id: str
|
||||
session_number: str
|
||||
client_name: str
|
||||
product_id: int
|
||||
product_name: str
|
||||
mix_id: int
|
||||
mix_name: str
|
||||
mix_date: date
|
||||
batch_size_kg: float
|
||||
total_bags: float
|
||||
total_kg: float
|
||||
product_unit_of_measure: str
|
||||
product_unit_size_kg: float
|
||||
prepared_by_user_id: int | None
|
||||
prepared_by_name: str
|
||||
created_by: str
|
||||
status: str
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
warnings: list[str]
|
||||
is_owner: bool
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MixCalculatorSessionRead(MixCalculatorSessionSummaryRead):
|
||||
lines: list[MixCalculatorSessionLineRead]
|
||||
+2
-2
@@ -72,8 +72,8 @@ def seed_client_access(db):
|
||||
)
|
||||
|
||||
enabled_feature_map = {
|
||||
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "products", "scenarios", "powerbi_export", "client_access"},
|
||||
"loft-grains": {"dashboard", "products", "powerbi_export"},
|
||||
"hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"},
|
||||
"loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"},
|
||||
}
|
||||
|
||||
for client in (specialty, loft):
|
||||
|
||||
@@ -17,6 +17,7 @@ MODULE_CATALOG = (
|
||||
("dashboard", "Dashboard", "workspace", "Top-level operational dashboard"),
|
||||
("raw_materials", "Raw Materials", "costing", "Maintain live material costs and versions"),
|
||||
("mix_master", "Mix Master", "costing", "Create and maintain mix worksheets"),
|
||||
("mix_calculator", "Mix Calculator", "production", "Create and review client-specific mix calculation sessions"),
|
||||
("products", "Products", "pricing", "Review finished product pricing"),
|
||||
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
|
||||
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
|
||||
@@ -43,6 +44,7 @@ def client_access_query() -> Select[tuple[ClientAccount]]:
|
||||
|
||||
def list_client_accounts(db: Session) -> list[ClientAccount]:
|
||||
ensure_client_user_module_permissions(db)
|
||||
ensure_client_feature_access(db)
|
||||
return db.scalars(client_access_query()).all()
|
||||
|
||||
|
||||
@@ -50,9 +52,19 @@ def get_client_user_by_email(db: Session, *, email: str, tenant_id: str | None =
|
||||
statement = select(ClientUser).where(ClientUser.email == email)
|
||||
if tenant_id:
|
||||
statement = statement.where(ClientUser.tenant_id == tenant_id)
|
||||
return db.scalar(
|
||||
user = db.scalar(
|
||||
statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc())
|
||||
)
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
if ensure_user_module_permissions(db, user):
|
||||
db.commit()
|
||||
return db.scalar(
|
||||
statement.options(selectinload(ClientUser.module_permissions)).order_by(ClientUser.id.desc())
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def module_access_map(user: ClientUser) -> dict[str, str]:
|
||||
@@ -66,13 +78,15 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
|
||||
def default_access_level_for_role(role: str, module_key: str) -> str:
|
||||
normalized = role.strip().lower()
|
||||
if normalized == "superadmin":
|
||||
return "manage" if module_key == "client_access" else "edit"
|
||||
return "manage" if module_key in {"client_access", "mix_calculator"} else "edit"
|
||||
if normalized == "admin":
|
||||
if module_key == "mix_calculator":
|
||||
return "manage"
|
||||
return "edit" if module_key != "client_access" else "none"
|
||||
if normalized == "operator":
|
||||
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "products", "scenarios"} else "none"
|
||||
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios"} else "none"
|
||||
if normalized == "viewer":
|
||||
return "view" if module_key in {"dashboard", "products", "powerbi_export"} else "none"
|
||||
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export"} else "none"
|
||||
return "none"
|
||||
|
||||
|
||||
@@ -106,6 +120,45 @@ def ensure_client_user_module_permissions(db: Session) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
def ensure_client_feature_access(db: Session) -> None:
|
||||
clients = db.scalars(
|
||||
select(ClientAccount).options(
|
||||
selectinload(ClientAccount.users).selectinload(ClientUser.module_permissions),
|
||||
selectinload(ClientAccount.features),
|
||||
)
|
||||
).all()
|
||||
changed = False
|
||||
|
||||
for client in clients:
|
||||
existing_feature_keys = {feature.feature_key for feature in client.features}
|
||||
permission_levels: dict[str, str] = {}
|
||||
|
||||
for user in client.users:
|
||||
for permission in user.module_permissions:
|
||||
current_level = permission_levels.get(permission.module_key, "none")
|
||||
if ACCESS_LEVEL_ORDER.get(permission.access_level, 0) > ACCESS_LEVEL_ORDER.get(current_level, 0):
|
||||
permission_levels[permission.module_key] = permission.access_level
|
||||
|
||||
for feature_key, feature_name, feature_group, description in MODULE_CATALOG:
|
||||
if feature_key in existing_feature_keys:
|
||||
continue
|
||||
db.add(
|
||||
ClientFeatureAccess(
|
||||
tenant_id=client.tenant_id,
|
||||
client_account_id=client.id,
|
||||
feature_key=feature_key,
|
||||
feature_name=feature_name,
|
||||
feature_group=feature_group,
|
||||
description=description,
|
||||
enabled=has_access_level(permission_levels.get(feature_key), "view"),
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
db.commit()
|
||||
|
||||
|
||||
def serialize_client_user(user: ClientUser) -> dict:
|
||||
return {
|
||||
"id": user.id,
|
||||
|
||||
@@ -177,7 +177,7 @@ def _apply_margin(cost: float, margin: float | None) -> float | None:
|
||||
return round(cost / (1 - margin), 4)
|
||||
|
||||
|
||||
def _extract_unit_quantity_kg(unit_of_measure: str) -> float:
|
||||
def extract_unit_quantity_kg(unit_of_measure: str) -> float:
|
||||
normalized = unit_of_measure.strip().lower()
|
||||
if normalized == "tonne":
|
||||
return 1000.0
|
||||
@@ -199,7 +199,7 @@ def calculate_product_cost(db: Session, product_id: int, overrides: dict | None
|
||||
|
||||
mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
|
||||
warnings = list(mix_result["warnings"])
|
||||
sale_unit_kg = _extract_unit_quantity_kg(product.unit_of_measure)
|
||||
sale_unit_kg = extract_unit_quantity_kg(product.unit_of_measure)
|
||||
|
||||
mix_cost_per_kg = mix_result["mix_cost_per_kg"] or 0.0
|
||||
cleaned_product_cost = round(mix_cost_per_kg * sale_unit_kg, 4)
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
|
||||
from app.models.product import Product
|
||||
from app.schemas.mix_calculator import MixCalculatorSessionCreate, MixCalculatorSessionUpdate
|
||||
from app.services.costing_engine import extract_unit_quantity_kg
|
||||
|
||||
|
||||
def can_view_all_mix_calculator_sessions(session: AuthSession) -> bool:
|
||||
return session.client_role in {"superadmin", "admin"}
|
||||
|
||||
|
||||
def _build_session_access_query(session: AuthSession):
|
||||
query = select(MixCalculatorSession).where(MixCalculatorSession.tenant_id == session.tenant_id)
|
||||
if can_view_all_mix_calculator_sessions(session):
|
||||
return query
|
||||
return query.where(MixCalculatorSession.prepared_by_user_id == session.user_id)
|
||||
|
||||
|
||||
def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None:
|
||||
return db.scalar(
|
||||
select(Product)
|
||||
.where(Product.id == product_id, Product.tenant_id == tenant_id)
|
||||
.options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
|
||||
)
|
||||
|
||||
|
||||
def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_measure: str) -> str | None:
|
||||
rounded_bags = round(total_bags)
|
||||
if abs(total_bags - rounded_bags) < 1e-9:
|
||||
return None
|
||||
return (
|
||||
f"Batch size {batch_size_kg:g}kg produces {total_bags:.2f} bags for {unit_of_measure}. "
|
||||
"This is not a whole-bag quantity."
|
||||
)
|
||||
|
||||
|
||||
def calculate_mix_calculator_preview(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
|
||||
):
|
||||
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
|
||||
product = _load_product_for_calculation(db, tenant_id, int(values["product_id"]))
|
||||
if product is None:
|
||||
raise ValueError("Product not found")
|
||||
if product.client_name != values["client_name"]:
|
||||
raise ValueError("Selected product does not belong to the chosen client")
|
||||
if product.mix is None:
|
||||
raise ValueError("Product mix is not configured")
|
||||
|
||||
source_total_kg = round(sum(ingredient.quantity_kg for ingredient in product.mix.ingredients), 4)
|
||||
if source_total_kg <= 0:
|
||||
raise ValueError("Product mix has no source kilograms to scale")
|
||||
|
||||
batch_size_kg = float(values["batch_size_kg"])
|
||||
scale_factor = batch_size_kg / source_total_kg
|
||||
unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure)
|
||||
total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0
|
||||
|
||||
warnings: list[str] = []
|
||||
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure)
|
||||
if bag_warning:
|
||||
warnings.append(bag_warning)
|
||||
|
||||
lines = []
|
||||
for index, ingredient in enumerate(product.mix.ingredients, start=1):
|
||||
mix_percentage = round((ingredient.quantity_kg / source_total_kg) * 100, 4)
|
||||
required_kg = round(ingredient.quantity_kg * scale_factor, 4)
|
||||
raw_material = ingredient.raw_material
|
||||
lines.append(
|
||||
{
|
||||
"raw_material_id": raw_material.id if raw_material is not None else ingredient.raw_material_id,
|
||||
"raw_material_name": raw_material.name if raw_material is not None else f"Raw material {ingredient.raw_material_id}",
|
||||
"required_kg": required_kg,
|
||||
"mix_percentage": mix_percentage,
|
||||
"unit": raw_material.unit_of_measure if raw_material is not None else "kg",
|
||||
"sort_order": index,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"client_name": product.client_name,
|
||||
"product_id": product.id,
|
||||
"product_name": product.name,
|
||||
"mix_id": product.mix_id,
|
||||
"mix_name": product.mix.name,
|
||||
"mix_date": values["mix_date"],
|
||||
"batch_size_kg": round(batch_size_kg, 4),
|
||||
"total_bags": total_bags,
|
||||
"total_kg": round(batch_size_kg, 4),
|
||||
"product_unit_of_measure": product.unit_of_measure,
|
||||
"product_unit_size_kg": round(unit_size_kg, 4),
|
||||
"prepared_by_name": values["prepared_by_name"],
|
||||
"status": values.get("status") or "saved",
|
||||
"notes": values.get("notes"),
|
||||
"warnings": warnings,
|
||||
"lines": lines,
|
||||
}
|
||||
|
||||
|
||||
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||
products = db.scalars(
|
||||
select(Product)
|
||||
.where(Product.tenant_id == tenant_id)
|
||||
.options(selectinload(Product.mix).selectinload(Mix.ingredients))
|
||||
.order_by(Product.client_name, Product.name)
|
||||
).all()
|
||||
|
||||
product_rows = []
|
||||
clients = sorted({product.client_name for product in products})
|
||||
for product in products:
|
||||
mix_total_kg = round(sum(ingredient.quantity_kg for ingredient in (product.mix.ingredients if product.mix else [])), 4)
|
||||
product_rows.append(
|
||||
{
|
||||
"product_id": product.id,
|
||||
"client_name": product.client_name,
|
||||
"product_name": product.name,
|
||||
"mix_id": product.mix_id,
|
||||
"mix_name": product.mix.name if product.mix else "",
|
||||
"unit_of_measure": product.unit_of_measure,
|
||||
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
|
||||
"mix_total_kg": mix_total_kg,
|
||||
}
|
||||
)
|
||||
|
||||
return {"clients": clients, "products": product_rows}
|
||||
|
||||
|
||||
def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_session: AuthSession) -> dict:
|
||||
total_bags = round(session_record.total_bags, 4)
|
||||
warnings: list[str] = []
|
||||
bag_warning = _fractional_bag_warning(session_record.batch_size_kg, total_bags, session_record.product_unit_of_measure)
|
||||
if bag_warning:
|
||||
warnings.append(bag_warning)
|
||||
|
||||
return {
|
||||
"id": session_record.id,
|
||||
"tenant_id": session_record.tenant_id,
|
||||
"session_number": session_record.session_number,
|
||||
"client_name": session_record.client_name,
|
||||
"product_id": session_record.product_id,
|
||||
"product_name": session_record.product_name,
|
||||
"mix_id": session_record.mix_id,
|
||||
"mix_name": session_record.mix_name,
|
||||
"mix_date": session_record.mix_date,
|
||||
"batch_size_kg": round(session_record.batch_size_kg, 4),
|
||||
"total_bags": total_bags,
|
||||
"total_kg": round(session_record.total_kg, 4),
|
||||
"product_unit_of_measure": session_record.product_unit_of_measure,
|
||||
"product_unit_size_kg": round(session_record.product_unit_size_kg, 4),
|
||||
"prepared_by_user_id": session_record.prepared_by_user_id,
|
||||
"prepared_by_name": session_record.prepared_by_name,
|
||||
"created_by": session_record.created_by,
|
||||
"status": session_record.status,
|
||||
"notes": session_record.notes,
|
||||
"created_at": session_record.created_at,
|
||||
"updated_at": session_record.updated_at,
|
||||
"warnings": warnings,
|
||||
"is_owner": session_record.prepared_by_user_id == auth_session.user_id,
|
||||
"lines": [
|
||||
{
|
||||
"id": line.id,
|
||||
"raw_material_id": line.raw_material_id,
|
||||
"raw_material_name": line.raw_material_name,
|
||||
"required_kg": round(line.required_kg, 4),
|
||||
"mix_percentage": round(line.mix_percentage, 4),
|
||||
"unit": line.unit,
|
||||
"sort_order": line.sort_order,
|
||||
}
|
||||
for line in session_record.lines
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession) -> list[dict]:
|
||||
sessions = db.scalars(
|
||||
_build_session_access_query(auth_session)
|
||||
.options(selectinload(MixCalculatorSession.lines))
|
||||
.order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc())
|
||||
).all()
|
||||
return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions]
|
||||
|
||||
|
||||
def get_mix_calculator_session(db: Session, *, auth_session: AuthSession, session_id: int) -> MixCalculatorSession | None:
|
||||
return db.scalar(
|
||||
_build_session_access_query(auth_session)
|
||||
.where(MixCalculatorSession.id == session_id)
|
||||
.options(selectinload(MixCalculatorSession.lines))
|
||||
)
|
||||
|
||||
|
||||
def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str:
|
||||
prefix = f"HPP-{mix_date.strftime('%Y%m%d')}-"
|
||||
existing = db.scalars(
|
||||
select(MixCalculatorSession.session_number)
|
||||
.where(
|
||||
MixCalculatorSession.tenant_id == tenant_id,
|
||||
MixCalculatorSession.mix_date == mix_date,
|
||||
MixCalculatorSession.session_number.like(f"{prefix}%"),
|
||||
)
|
||||
).all()
|
||||
sequence = 1
|
||||
if existing:
|
||||
sequence = max(int(value.rsplit("-", 1)[-1]) for value in existing) + 1
|
||||
return f"{prefix}{sequence:04d}"
|
||||
|
||||
|
||||
def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict:
|
||||
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload)
|
||||
session_record = MixCalculatorSession(
|
||||
tenant_id=auth_session.tenant_id or "default",
|
||||
session_number=_next_session_number(db, tenant_id=auth_session.tenant_id or "default", mix_date=payload.mix_date),
|
||||
client_name=preview["client_name"],
|
||||
product_id=preview["product_id"],
|
||||
product_name=preview["product_name"],
|
||||
mix_id=preview["mix_id"],
|
||||
mix_name=preview["mix_name"],
|
||||
mix_date=preview["mix_date"],
|
||||
batch_size_kg=preview["batch_size_kg"],
|
||||
total_bags=preview["total_bags"],
|
||||
total_kg=preview["total_kg"],
|
||||
product_unit_of_measure=preview["product_unit_of_measure"],
|
||||
product_unit_size_kg=preview["product_unit_size_kg"],
|
||||
prepared_by_user_id=auth_session.user_id,
|
||||
prepared_by_name=preview["prepared_by_name"],
|
||||
created_by=auth_session.email,
|
||||
status=preview["status"],
|
||||
notes=preview["notes"],
|
||||
)
|
||||
session_record.lines = [
|
||||
MixCalculatorSessionLine(
|
||||
tenant_id=auth_session.tenant_id or "default",
|
||||
raw_material_id=line["raw_material_id"],
|
||||
raw_material_name=line["raw_material_name"],
|
||||
required_kg=line["required_kg"],
|
||||
mix_percentage=line["mix_percentage"],
|
||||
unit=line["unit"],
|
||||
sort_order=line["sort_order"],
|
||||
)
|
||||
for line in preview["lines"]
|
||||
]
|
||||
db.add(session_record)
|
||||
db.commit()
|
||||
db.refresh(session_record)
|
||||
db.refresh(session_record, attribute_names=["lines"])
|
||||
return serialize_mix_calculator_session(session_record, auth_session)
|
||||
|
||||
|
||||
def update_mix_calculator_session(
|
||||
db: Session,
|
||||
*,
|
||||
auth_session: AuthSession,
|
||||
session_record: MixCalculatorSession,
|
||||
payload: MixCalculatorSessionUpdate,
|
||||
) -> dict:
|
||||
merged_values = {
|
||||
"mix_date": session_record.mix_date,
|
||||
"client_name": session_record.client_name,
|
||||
"product_id": session_record.product_id,
|
||||
"batch_size_kg": session_record.batch_size_kg,
|
||||
"prepared_by_name": session_record.prepared_by_name,
|
||||
"status": session_record.status,
|
||||
"notes": session_record.notes,
|
||||
}
|
||||
merged_values.update(payload.model_dump(exclude_unset=True))
|
||||
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=merged_values)
|
||||
|
||||
session_record.client_name = preview["client_name"]
|
||||
session_record.product_id = preview["product_id"]
|
||||
session_record.product_name = preview["product_name"]
|
||||
session_record.mix_id = preview["mix_id"]
|
||||
session_record.mix_name = preview["mix_name"]
|
||||
session_record.mix_date = preview["mix_date"]
|
||||
session_record.batch_size_kg = preview["batch_size_kg"]
|
||||
session_record.total_bags = preview["total_bags"]
|
||||
session_record.total_kg = preview["total_kg"]
|
||||
session_record.product_unit_of_measure = preview["product_unit_of_measure"]
|
||||
session_record.product_unit_size_kg = preview["product_unit_size_kg"]
|
||||
session_record.prepared_by_name = preview["prepared_by_name"]
|
||||
session_record.status = preview["status"]
|
||||
session_record.notes = preview["notes"]
|
||||
|
||||
session_record.lines.clear()
|
||||
session_record.lines.extend(
|
||||
[
|
||||
MixCalculatorSessionLine(
|
||||
tenant_id=auth_session.tenant_id or "default",
|
||||
raw_material_id=line["raw_material_id"],
|
||||
raw_material_name=line["raw_material_name"],
|
||||
required_kg=line["required_kg"],
|
||||
mix_percentage=line["mix_percentage"],
|
||||
unit=line["unit"],
|
||||
sort_order=line["sort_order"],
|
||||
)
|
||||
for line in preview["lines"]
|
||||
]
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(session_record)
|
||||
db.refresh(session_record, attribute_names=["lines"])
|
||||
return serialize_mix_calculator_session(session_record, auth_session)
|
||||
@@ -11,11 +11,13 @@ from app.db.session import Base
|
||||
from app.main import app
|
||||
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
|
||||
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser
|
||||
from app.schemas.mix_calculator import MixCalculatorSessionCreate
|
||||
from app.models.mix import Mix, MixIngredient
|
||||
from app.models.product import Product
|
||||
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
|
||||
from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account
|
||||
from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost
|
||||
from app.services.mix_calculator_service import calculate_mix_calculator_preview
|
||||
|
||||
|
||||
def build_session() -> Session:
|
||||
@@ -86,30 +88,83 @@ def test_mix_and_product_cost_breakdown():
|
||||
assert product_result["wholesale_price"] == 17.3268
|
||||
|
||||
|
||||
def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
|
||||
db = build_session()
|
||||
|
||||
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
|
||||
db.add_all([maize, barley])
|
||||
db.flush()
|
||||
|
||||
mix = Mix(tenant_id="specialty-feeds", client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
MixIngredient(tenant_id="specialty-feeds", mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
|
||||
MixIngredient(tenant_id="specialty-feeds", mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100),
|
||||
]
|
||||
)
|
||||
db.flush()
|
||||
|
||||
product = Product(
|
||||
tenant_id="specialty-feeds",
|
||||
client_name="Specialty Feeds",
|
||||
name="Specialty Pigeon Breeder 20kg",
|
||||
mix_id=mix.id,
|
||||
sale_type="standard",
|
||||
own_bag=False,
|
||||
unit_of_measure="20kg bag",
|
||||
items_per_pallet=50,
|
||||
bagging_process="standard_bagging",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
|
||||
preview = calculate_mix_calculator_preview(
|
||||
db,
|
||||
tenant_id="specialty-feeds",
|
||||
payload=MixCalculatorSessionCreate(
|
||||
mix_date=date(2026, 4, 29),
|
||||
client_name="Specialty Feeds",
|
||||
product_id=product.id,
|
||||
batch_size_kg=550,
|
||||
prepared_by_name="Shift A",
|
||||
notes="Mid-morning run",
|
||||
),
|
||||
)
|
||||
|
||||
assert preview["batch_size_kg"] == 550
|
||||
assert preview["total_bags"] == 27.5
|
||||
assert preview["lines"][0]["required_kg"] == 353.5714
|
||||
assert preview["lines"][1]["required_kg"] == 196.4286
|
||||
assert len(preview["warnings"]) == 1
|
||||
assert "not a whole-bag quantity" in preview["warnings"][0]
|
||||
|
||||
|
||||
def test_root_and_login_endpoints():
|
||||
client = TestClient(app)
|
||||
with TestClient(app) as client:
|
||||
root_response = client.get("/")
|
||||
assert root_response.status_code == 200
|
||||
assert root_response.json()["endpoints"]["client_login"] == "/api/auth/client/login"
|
||||
assert root_response.json()["endpoints"]["admin_login"] == "/api/auth/admin/login"
|
||||
|
||||
root_response = client.get("/")
|
||||
assert root_response.status_code == 200
|
||||
assert root_response.json()["endpoints"]["client_login"] == "/api/auth/client/login"
|
||||
assert root_response.json()["endpoints"]["admin_login"] == "/api/auth/admin/login"
|
||||
client_login_response = client.post(
|
||||
"/api/auth/client/login",
|
||||
json={"email": settings.client_email, "password": settings.client_password},
|
||||
)
|
||||
assert client_login_response.status_code == 200
|
||||
assert client_login_response.json()["email"] == settings.client_email
|
||||
assert client_login_response.json()["tenant_id"] == settings.client_tenant_id
|
||||
assert client_login_response.json()["client_role"] == "superadmin"
|
||||
assert client_login_response.json()["module_permissions"]["client_access"] == "manage"
|
||||
|
||||
client_login_response = client.post(
|
||||
"/api/auth/client/login",
|
||||
json={"email": settings.client_email, "password": settings.client_password},
|
||||
)
|
||||
assert client_login_response.status_code == 200
|
||||
assert client_login_response.json()["email"] == settings.client_email
|
||||
assert client_login_response.json()["tenant_id"] == settings.client_tenant_id
|
||||
assert client_login_response.json()["client_role"] == "superadmin"
|
||||
assert client_login_response.json()["module_permissions"]["client_access"] == "manage"
|
||||
|
||||
admin_login_response = client.post(
|
||||
"/api/auth/admin/login",
|
||||
json={"email": settings.admin_email, "password": settings.admin_password},
|
||||
)
|
||||
assert admin_login_response.status_code == 200
|
||||
assert admin_login_response.json()["email"] == settings.admin_email
|
||||
admin_login_response = client.post(
|
||||
"/api/auth/admin/login",
|
||||
json={"email": settings.admin_email, "password": settings.admin_password},
|
||||
)
|
||||
assert admin_login_response.status_code == 200
|
||||
assert admin_login_response.json()["email"] == settings.admin_email
|
||||
|
||||
|
||||
def test_client_access_export_helpers():
|
||||
@@ -220,6 +275,61 @@ def test_client_access_endpoints():
|
||||
assert len(superadmin_access_response.json()) == 1
|
||||
|
||||
|
||||
def test_mix_calculator_endpoints_respect_owner_visibility():
|
||||
with TestClient(app) as client:
|
||||
superadmin_login = client.post(
|
||||
"/api/auth/client/login",
|
||||
json={"email": settings.client_email, "password": settings.client_password},
|
||||
)
|
||||
assert superadmin_login.status_code == 200
|
||||
superadmin_headers = {"Authorization": f"Bearer {superadmin_login.json()['token']}"}
|
||||
|
||||
options_response = client.get("/api/mix-calculator/options", headers=superadmin_headers)
|
||||
assert options_response.status_code == 200
|
||||
assert options_response.json()["products"][0]["product_name"] == "Hunter Orchard Blend 20kg"
|
||||
|
||||
create_response = client.post(
|
||||
"/api/mix-calculator",
|
||||
json={
|
||||
"mix_date": "2026-04-29",
|
||||
"client_name": "Hunter Premium Produce",
|
||||
"product_id": 1,
|
||||
"batch_size_kg": 560,
|
||||
"prepared_by_name": "Amelia Hart",
|
||||
"notes": "Morning production run",
|
||||
},
|
||||
headers=superadmin_headers,
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
created = create_response.json()
|
||||
assert created["session_number"].startswith("HPP-20260429-")
|
||||
assert created["total_bags"] == 28
|
||||
assert created["lines"][0]["required_kg"] == 360
|
||||
|
||||
patch_response = client.patch(
|
||||
f"/api/mix-calculator/{created['id']}",
|
||||
json={"batch_size_kg": 550},
|
||||
headers=superadmin_headers,
|
||||
)
|
||||
assert patch_response.status_code == 200
|
||||
assert patch_response.json()["total_bags"] == 27.5
|
||||
assert len(patch_response.json()["warnings"]) == 1
|
||||
|
||||
operator_login = client.post(
|
||||
"/api/auth/client/login",
|
||||
json={"email": "ethan.cole@hunterpremiumproduce.example", "password": settings.client_password},
|
||||
)
|
||||
assert operator_login.status_code == 200
|
||||
operator_headers = {"Authorization": f"Bearer {operator_login.json()['token']}"}
|
||||
|
||||
operator_list_response = client.get("/api/mix-calculator", headers=operator_headers)
|
||||
assert operator_list_response.status_code == 200
|
||||
assert operator_list_response.json() == []
|
||||
|
||||
operator_detail_response = client.get(f"/api/mix-calculator/{created['id']}", headers=operator_headers)
|
||||
assert operator_detail_response.status_code == 404
|
||||
|
||||
|
||||
def test_module_permission_blocks_client_module_access():
|
||||
with TestClient(app) as client:
|
||||
admin_login_response = client.post(
|
||||
@@ -229,7 +339,7 @@ def test_module_permission_blocks_client_module_access():
|
||||
admin_headers = {"Authorization": f"Bearer {admin_login_response.json()['token']}"}
|
||||
access_response = client.get("/api/client-access", headers=admin_headers)
|
||||
first_client = access_response.json()[0]
|
||||
first_user = first_client["users"][0]
|
||||
first_user = next(user for user in first_client["users"] if user["email"] == settings.client_email)
|
||||
|
||||
permission = next(
|
||||
permission for permission in first_user["module_permissions"] if permission["module_key"] == "raw_materials"
|
||||
|
||||
Reference in New Issue
Block a user