This commit is contained in:
2026-05-31 20:19:44 +12:00
parent 2f2466ecac
commit 84792c0947
59 changed files with 5412 additions and 898 deletions
+2
View File
@@ -8,6 +8,8 @@ WORKDIR /app
RUN addgroup --system app && adduser --system --ingroup app app
COPY backend /app
COPY ["input_data/1.xlsx", "/app/input_data/1.xlsx"]
COPY ["Input Cost Spreadsheet(1).xlsx", "/app/Input Cost Spreadsheet(1).xlsx"]
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir . && \
+14 -14
View File
@@ -109,15 +109,11 @@ def _serialize_session(user: User, *, include_token: bool = False) -> UserSessio
def login(payload: LoginRequest, response: Response, request: Request, db: Session = Depends(get_db)):
"""Internal-user login.
Authenticates against a shared internal password (``ADMIN_PASSWORD``) and
looks up the user by email. Inactive or unknown users are rejected with
a generic 401 to avoid leaking which emails are valid.
Authenticates against the per-user password hash stored on ``users``.
Inactive or unknown users are rejected with a generic 401 to avoid
leaking which emails are valid.
"""
login_rate_limiter.hit(request_client_key(request, suffix="internal-login"))
if payload.password != settings.admin_password:
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
email = payload.email.strip().lower()
user = db.scalar(
select(User)
@@ -127,6 +123,12 @@ def login(payload: LoginRequest, response: Response, request: Request, db: Sessi
if user is None or not user.is_active:
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
if not (
verify_password(payload.password, user.password_hash)
or (user.password_hash is None and payload.password == settings.admin_password)
):
log_security_event("auth.login_failed", audience="internal", ip=request_client_key(request))
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
session = _serialize_session(user, include_token=True)
if session.token:
@@ -161,13 +163,11 @@ def update_me(
):
"""Allow an internal user to update their own name, email, or password."""
if payload.new_password:
# Require current password verification before allowing a password change.
# Users who have never set a personal password must supply the shared
# admin password as the current credential.
current_ok = (
verify_password(payload.current_password or "", user.password_hash)
if user.password_hash
else (payload.current_password or "") == settings.admin_password
# Require current password verification before allowing a password
# change. Keep a narrow fallback for legacy rows that still have no
# password hash yet.
current_ok = verify_password(payload.current_password or "", user.password_hash) or (
user.password_hash is None and (payload.current_password or "") == settings.admin_password
)
if not current_ok:
raise HTTPException(
+23 -1
View File
@@ -22,7 +22,7 @@ from app.services.mix_calculator_service import (
update_mix_calculator_session,
)
from app.services.mix_calculator_pdf import MixCalculatorPdfUnavailableError, build_mix_calculator_pdf
from app.services.mix_calculator_filenames import mix_calculator_pdf_filename
from app.services.mix_calculator_filenames import mix_calculator_pdf_filename, mix_calculator_preview_pdf_filename
router = APIRouter(prefix="/api/mix-calculator", tags=["mix-calculator"])
@@ -56,6 +56,28 @@ def preview_mix_calculator_session(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.post("/preview/pdf")
def preview_mix_calculator_session_pdf(
payload: MixCalculatorSessionCreate,
session: AuthSession = Depends(require_client_module_access("mix_calculator", "edit")),
db: Session = Depends(get_db),
):
try:
preview = calculate_mix_calculator_preview(db, tenant_id=session.tenant_id or "", payload=payload)
pdf_bytes = build_mix_calculator_pdf(preview)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except MixCalculatorPdfUnavailableError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
filename = mix_calculator_preview_pdf_filename(MixCalculatorPreviewRead.model_validate(preview))
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("", response_model=MixCalculatorSessionRead, status_code=status.HTTP_201_CREATED)
def create_saved_mix_calculator_session(
payload: MixCalculatorSessionCreate,
+245
View File
@@ -0,0 +1,245 @@
from __future__ import annotations
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.schemas.throughput import (
ThroughputEntryCreate,
ThroughputEntryRead,
ThroughputEntryUpdate,
ThroughputProductCreate,
ThroughputProductRead,
ThroughputProductUpdate,
)
from app.services.throughput_service import (
calculate_kg,
normalise_staff_name,
serialize_entry,
)
router = APIRouter(prefix="/api/throughput", tags=["operations-throughput"])
MODULE_KEY = "operations_throughput"
@router.get("/products", response_model=list[ThroughputProductRead])
def list_products(
include_inactive: bool = Query(default=False),
session: AuthSession = Depends(require_client_module_access(MODULE_KEY)),
db: Session = Depends(get_db),
):
stmt = select(ThroughputProduct).where(ThroughputProduct.tenant_id == session.tenant_id)
if not include_inactive:
stmt = stmt.where(ThroughputProduct.active.is_(True))
stmt = stmt.order_by(ThroughputProduct.name)
return db.scalars(stmt).all()
@router.post("/products", response_model=ThroughputProductRead, status_code=status.HTTP_201_CREATED)
def create_product(
payload: ThroughputProductCreate,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
if payload.item_id:
existing = db.scalar(
select(ThroughputProduct).where(
ThroughputProduct.tenant_id == session.tenant_id,
ThroughputProduct.item_id == payload.item_id,
)
)
if existing is not None:
raise HTTPException(status_code=409, detail="A product with this item_id already exists")
product = ThroughputProduct(
tenant_id=session.tenant_id,
item_id=payload.item_id,
name=payload.name,
default_bag_size=payload.default_bag_size,
is_bulka_default=payload.is_bulka_default,
active=payload.active,
is_stock_item=payload.is_stock_item,
notes=payload.notes,
)
db.add(product)
db.commit()
db.refresh(product)
return product
@router.patch("/products/{product_id}", response_model=ThroughputProductRead)
def update_product(
product_id: int,
payload: ThroughputProductUpdate,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
product = db.scalar(
select(ThroughputProduct).where(
ThroughputProduct.id == product_id,
ThroughputProduct.tenant_id == session.tenant_id,
)
)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(product, field, value)
db.commit()
db.refresh(product)
return product
@router.get("/entries", response_model=list[ThroughputEntryRead])
def list_entries(
date_from: date | None = Query(default=None),
date_to: date | None = Query(default=None),
product_id: int | None = Query(default=None),
staff_name: str | None = Query(default=None),
quantity_type: str | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=1000),
session: AuthSession = Depends(require_client_module_access(MODULE_KEY)),
db: Session = Depends(get_db),
):
stmt = select(ProductionThroughput).where(ProductionThroughput.tenant_id == session.tenant_id)
if date_from is not None:
stmt = stmt.where(ProductionThroughput.production_date >= date_from)
if date_to is not None:
stmt = stmt.where(ProductionThroughput.production_date <= date_to)
if product_id is not None:
stmt = stmt.where(ProductionThroughput.product_id == product_id)
if staff_name:
stmt = stmt.where(ProductionThroughput.staff_name == staff_name.strip())
if quantity_type in {"bags", "kg"}:
stmt = stmt.where(ProductionThroughput.quantity_type == quantity_type)
stmt = stmt.order_by(ProductionThroughput.production_date.desc(), ProductionThroughput.id.desc()).limit(limit)
return [serialize_entry(entry) for entry in db.scalars(stmt).all()]
@router.post("/entries", response_model=ThroughputEntryRead, status_code=status.HTTP_201_CREATED)
def create_entry(
payload: ThroughputEntryCreate,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
product = None
if payload.product_id is not None:
product = db.scalar(
select(ThroughputProduct).where(
ThroughputProduct.id == payload.product_id,
ThroughputProduct.tenant_id == session.tenant_id,
)
)
if product is None:
raise HTTPException(status_code=400, detail="product_id does not match an existing product")
snapshot = payload.product_name_snapshot or (product.name if product else None)
if not snapshot:
raise HTTPException(status_code=400, detail="product_name_snapshot or product_id is required")
bag_size = payload.bag_size
if bag_size is None and product is not None:
bag_size = product.default_bag_size
if payload.quantity_type == "bags" and (bag_size is None or bag_size <= 0):
raise HTTPException(status_code=400, detail="bag_size is required when quantity_type is 'bags'")
calculated = calculate_kg(payload.quantity, payload.quantity_type, bag_size)
entry = ProductionThroughput(
tenant_id=session.tenant_id,
production_date=payload.production_date,
product_id=product.id if product else None,
product_name_snapshot=snapshot,
bag_size=bag_size,
scales_checked=payload.scales_checked,
label_correct=payload.label_correct,
bag_sealed=payload.bag_sealed,
pallet_good_condition=payload.pallet_good_condition,
sample_box_no=payload.sample_box_no,
test_weight_1=payload.test_weight_1,
test_weight_2=payload.test_weight_2,
test_weight_3=payload.test_weight_3,
test_weight_4=payload.test_weight_4,
test_weight_5=payload.test_weight_5,
quantity=payload.quantity,
quantity_type=payload.quantity_type,
calculated_kg=calculated,
staff_name=normalise_staff_name(payload.staff_name),
notes=payload.notes,
created_by=session.email,
)
db.add(entry)
db.commit()
db.refresh(entry)
return serialize_entry(entry)
@router.get("/entries/{entry_id}", response_model=ThroughputEntryRead)
def get_entry(
entry_id: int,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY)),
db: Session = Depends(get_db),
):
entry = db.scalar(
select(ProductionThroughput).where(
ProductionThroughput.id == entry_id,
ProductionThroughput.tenant_id == session.tenant_id,
)
)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
return serialize_entry(entry)
@router.patch("/entries/{entry_id}", response_model=ThroughputEntryRead)
def update_entry(
entry_id: int,
payload: ThroughputEntryUpdate,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
db: Session = Depends(get_db),
):
entry = db.scalar(
select(ProductionThroughput).where(
ProductionThroughput.id == entry_id,
ProductionThroughput.tenant_id == session.tenant_id,
)
)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
data = payload.model_dump(exclude_unset=True)
if "staff_name" in data:
data["staff_name"] = normalise_staff_name(data["staff_name"])
for field, value in data.items():
setattr(entry, field, value)
entry.calculated_kg = calculate_kg(entry.quantity, entry.quantity_type, entry.bag_size)
db.commit()
db.refresh(entry)
return serialize_entry(entry)
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_entry(
entry_id: int,
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "manage")),
db: Session = Depends(get_db),
):
entry = db.scalar(
select(ProductionThroughput).where(
ProductionThroughput.id == entry_id,
ProductionThroughput.tenant_id == session.tenant_id,
)
)
if entry is None:
raise HTTPException(status_code=404, detail="Entry not found")
db.delete(entry)
db.commit()
return None
+2
View File
@@ -51,6 +51,8 @@ _PERMISSION_TO_MODULE_LEVEL: dict[str, tuple[str, str]] = {
"edit_products": ("products", "edit"),
"view_mixes": ("mix_master", "view"),
"edit_mixes": ("mix_master", "edit"),
"view_throughput": ("operations_throughput", "view"),
"edit_throughput": ("operations_throughput", "edit"),
# Admin-only permissions (view_users, manage_users, manage_permissions,
# view_settings, edit_settings) are intentionally excluded — they don't
# correspond to any of the legacy module keys and remain accessible only
+9 -1
View File
@@ -1,5 +1,10 @@
import os
from dataclasses import dataclass
from pathlib import Path
DEFAULT_SQLITE_PATH = (Path(__file__).resolve().parents[2] / "data_entry_app.db").as_posix()
DEFAULT_DATABASE_URL = f"sqlite:///{DEFAULT_SQLITE_PATH}"
DEFAULT_CORS_ALLOW_ORIGIN_REGEX = (
@@ -63,7 +68,10 @@ class Settings:
port=int(os.getenv("PORT", "8000")),
log_level=os.getenv("LOG_LEVEL", "DEBUG" if os.getenv("LOG_VERBOSE") in {"1", "true", "TRUE", "yes", "on"} else "INFO"),
log_verbose=_env_flag("LOG_VERBOSE"),
database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"),
# Keep the default SQLite location stable regardless of the current
# working directory so local dev does not silently fork data into
# multiple `data_entry_app.db` files.
database_url=os.getenv("DATABASE_URL", DEFAULT_DATABASE_URL),
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"),
+18
View File
@@ -25,6 +25,7 @@ TENANT_TABLES = {
"raw_material_price_versions": None,
"mixes": None,
"mix_ingredients": None,
"product_ingredients": None,
"mix_calculator_sessions": None,
"mix_calculator_session_lines": None,
"products": None,
@@ -33,6 +34,8 @@ TENANT_TABLES = {
"process_cost_rules": None,
"packaging_cost_rules": None,
"freight_cost_rules": None,
"throughput_products": None,
"production_throughput_entries": None,
}
@@ -99,6 +102,7 @@ def ensure_tenant_columns(engine: Engine) -> tuple[str, ...]:
_LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
("users", "password_hash", "VARCHAR(255)"),
("products", "visible", "BOOLEAN NOT NULL DEFAULT TRUE"),
("throughput_products", "is_stock_item", "BOOLEAN NOT NULL DEFAULT TRUE"),
)
@@ -243,6 +247,20 @@ def sync_tenant_ids(engine: Engine) -> dict[str, int]:
"""
),
),
(
"product_ingredients",
text(
"""
UPDATE product_ingredients
SET tenant_id = (
SELECT products.tenant_id
FROM products
WHERE products.id = product_ingredients.product_id
)
WHERE tenant_id IS NULL OR tenant_id = '' OR tenant_id = 'default'
"""
),
),
(
"products",
text(
+9 -3
View File
@@ -28,6 +28,7 @@ from app.api.powerbi import router as powerbi_router
from app.api.products import router as products_router
from app.api.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_router
from app.api.throughput import router as throughput_router
from app.core.config import settings
from app.core.logging import (
LoggingSettings,
@@ -46,7 +47,7 @@ from app.core.logging import (
)
from app.db.session import Base, engine
from app.db.migrations import MigrationReport, bootstrap_schema, sync_product_visibility, sync_tenant_ids
from app.seed import seed_if_empty
from app.seed import seed_startup_basics
def _resolve_version() -> str:
@@ -100,7 +101,7 @@ def ensure_database_ready() -> MigrationReport:
return MigrationReport()
schema_report = bootstrap_schema(engine, Base.metadata)
seed_if_empty()
seed_startup_basics()
tenant_sync_report = sync_tenant_ids(engine)
hidden_product_count = sync_product_visibility(engine)
@@ -153,7 +154,10 @@ async def lifespan(app: FastAPI):
section_heading("Services")
success("HTTP API ready")
info("Docs available at /docs", logger_name="data_entry_app.services")
if settings.docs_enabled:
info("Docs available at /docs", logger_name="data_entry_app.services")
else:
info("Docs disabled in this environment", logger_name="data_entry_app.services")
info("Health probe available at /health", logger_name="data_entry_app.services")
yield
@@ -195,6 +199,7 @@ app.include_router(mixes_router)
app.include_router(mix_calculator_router)
app.include_router(products_router)
app.include_router(scenarios_router)
app.include_router(throughput_router)
app.include_router(powerbi_router)
@@ -300,6 +305,7 @@ def root():
"mix_calculator": "/api/mix-calculator",
"products": "/api/products",
"scenarios": "/api/scenarios",
"operations_throughput": "/api/throughput",
"client_access": "/api/client-access",
"docs": "/docs",
},
+5 -1
View File
@@ -3,9 +3,10 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
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.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.scenario import CostingResult, Scenario
from app.models.throughput import ProductionThroughput, ThroughputProduct
__all__ = [
"ClientAccount",
@@ -23,6 +24,9 @@ __all__ = [
"Permission",
"ProcessCostRule",
"Product",
"ProductIngredient",
"ProductionThroughput",
"ThroughputProduct",
"RawMaterial",
"RawMaterialPriceVersion",
"Role",
+23 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
@@ -29,6 +29,28 @@ class Product(Base):
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
mix: Mapped["Mix"] = relationship(back_populates="products")
ingredients: Mapped[list["ProductIngredient"]] = relationship(
back_populates="product",
cascade="all, delete-orphan",
order_by="ProductIngredient.sort_order",
)
class ProductIngredient(Base):
__tablename__ = "product_ingredients"
__table_args__ = (UniqueConstraint("product_id", "raw_material_id", name="uq_product_ingredient"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
product_id: Mapped[int] = mapped_column(ForeignKey("products.id"), index=True)
raw_material_id: Mapped[int] = mapped_column(ForeignKey("raw_materials.id"), index=True)
quantity_kg: Mapped[float] = mapped_column(Float)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
product: Mapped[Product] = relationship(back_populates="ingredients")
raw_material: Mapped["RawMaterial"] = relationship()
from app.models.mix import Mix # noqa: E402
from app.models.raw_material import RawMaterial # noqa: E402
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.session import Base
class ThroughputProduct(Base):
__tablename__ = "throughput_products"
__table_args__ = (
Index("ix_throughput_products_tenant_item", "tenant_id", "item_id", unique=True),
Index("ix_throughput_products_tenant_name", "tenant_id", "name"),
)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
item_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
name: Mapped[str] = mapped_column(String(255))
default_bag_size: Mapped[float | None] = mapped_column(Float, nullable=True)
is_bulka_default: Mapped[bool] = mapped_column(Boolean, default=False)
active: Mapped[bool] = mapped_column(Boolean, default=True)
is_stock_item: Mapped[bool] = mapped_column(Boolean, default=True)
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)
entries: Mapped[list["ProductionThroughput"]] = relationship(back_populates="product")
class ProductionThroughput(Base):
__tablename__ = "production_throughput_entries"
__table_args__ = (
Index("ix_throughput_entries_tenant_date", "tenant_id", "production_date"),
)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
production_date: Mapped[date] = mapped_column(Date)
product_id: Mapped[int | None] = mapped_column(ForeignKey("throughput_products.id"), nullable=True, index=True)
product_name_snapshot: Mapped[str] = mapped_column(String(255))
bag_size: Mapped[float | None] = mapped_column(Float, nullable=True)
scales_checked: Mapped[bool] = mapped_column(Boolean, default=True)
label_correct: Mapped[bool] = mapped_column(Boolean, default=True)
bag_sealed: Mapped[bool] = mapped_column(Boolean, default=True)
pallet_good_condition: Mapped[bool] = mapped_column(Boolean, default=True)
sample_box_no: Mapped[str | None] = mapped_column(String(64), nullable=True)
test_weight_1: Mapped[float | None] = mapped_column(Float, nullable=True)
test_weight_2: Mapped[float | None] = mapped_column(Float, nullable=True)
test_weight_3: Mapped[float | None] = mapped_column(Float, nullable=True)
test_weight_4: Mapped[float | None] = mapped_column(Float, nullable=True)
test_weight_5: Mapped[float | None] = mapped_column(Float, nullable=True)
quantity: Mapped[float] = mapped_column(Float, default=0.0)
quantity_type: Mapped[str] = mapped_column(String(8), default="bags")
calculated_kg: Mapped[float] = mapped_column(Float, default=0.0)
staff_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(255), 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)
product: Mapped[ThroughputProduct | None] = relationship(back_populates="entries")
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
QuantityType = Literal["bags", "kg"]
class ThroughputProductBase(BaseModel):
model_config = ConfigDict(extra="forbid")
item_id: str | None = Field(default=None, max_length=64)
name: str = Field(min_length=1, max_length=255)
default_bag_size: float | None = Field(default=None, ge=0)
is_bulka_default: bool = False
active: bool = True
is_stock_item: bool = True
notes: str | None = Field(default=None, max_length=2000)
class ThroughputProductCreate(ThroughputProductBase):
pass
class ThroughputProductUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
item_id: str | None = Field(default=None, max_length=64)
name: str | None = Field(default=None, min_length=1, max_length=255)
default_bag_size: float | None = Field(default=None, ge=0)
is_bulka_default: bool | None = None
active: bool | None = None
is_stock_item: bool | None = None
notes: str | None = Field(default=None, max_length=2000)
class ThroughputProductRead(ThroughputProductBase):
id: int
tenant_id: str
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class ThroughputEntryBase(BaseModel):
model_config = ConfigDict(extra="forbid")
production_date: date
product_id: int | None = None
product_name_snapshot: str | None = Field(default=None, max_length=255)
bag_size: float | None = Field(default=None, ge=0)
scales_checked: bool = True
label_correct: bool = True
bag_sealed: bool = True
pallet_good_condition: bool = True
sample_box_no: str | None = Field(default=None, max_length=64)
test_weight_1: float | None = Field(default=None, ge=0)
test_weight_2: float | None = Field(default=None, ge=0)
test_weight_3: float | None = Field(default=None, ge=0)
test_weight_4: float | None = Field(default=None, ge=0)
test_weight_5: float | None = Field(default=None, ge=0)
quantity: float = Field(ge=0)
quantity_type: QuantityType = "bags"
staff_name: str | None = Field(default=None, max_length=255)
notes: str | None = Field(default=None, max_length=2000)
@field_validator("staff_name")
@classmethod
def _normalize_staff(cls, value: str | None) -> str | None:
if value is None:
return None
stripped = value.strip()
return stripped or None
class ThroughputEntryCreate(ThroughputEntryBase):
pass
class ThroughputEntryUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
production_date: date | None = None
product_id: int | None = None
product_name_snapshot: str | None = Field(default=None, max_length=255)
bag_size: float | None = Field(default=None, ge=0)
scales_checked: bool | None = None
label_correct: bool | None = None
bag_sealed: bool | None = None
pallet_good_condition: bool | None = None
sample_box_no: str | None = Field(default=None, max_length=64)
test_weight_1: float | None = Field(default=None, ge=0)
test_weight_2: float | None = Field(default=None, ge=0)
test_weight_3: float | None = Field(default=None, ge=0)
test_weight_4: float | None = Field(default=None, ge=0)
test_weight_5: float | None = Field(default=None, ge=0)
quantity: float | None = Field(default=None, ge=0)
quantity_type: QuantityType | None = None
staff_name: str | None = Field(default=None, max_length=255)
notes: str | None = Field(default=None, max_length=2000)
class ThroughputEntryRead(BaseModel):
id: int
tenant_id: str
production_date: date
product_id: int | None
product_name_snapshot: str
bag_size: float | None
scales_checked: bool
label_correct: bool
bag_sealed: bool
pallet_good_condition: bool
sample_box_no: str | None
test_weight_1: float | None
test_weight_2: float | None
test_weight_3: float | None
test_weight_4: float | None
test_weight_5: float | None
quantity: float
quantity_type: QuantityType
calculated_kg: float
staff_name: str | None
notes: str | None
qa_passed: bool
created_by: str | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+318 -13
View File
@@ -9,21 +9,26 @@ import re
from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.db.session import Base, SessionLocal, engine
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.seed_access import seed_access
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
from app.services.throughput_service import import_workbook as import_throughput_workbook
from app.services.throughput_service import resolve_workbook_path as resolve_throughput_workbook_path
TENANT_ID = "hunter-premium-produce"
WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1)
WORKBOOK_SENTINEL_ITEM_ID = "404266"
WORKBOOK_FILENAME = "Input Cost Spreadsheet(1).xlsx"
WORKBOOK_FILENAME = "1.xlsx"
LEGACY_WORKBOOK_FILENAME = "Input Cost Spreadsheet(1).xlsx"
logger = logging.getLogger("data_entry_app.seed")
HIDDEN_PRODUCT_CLIENTS = frozenset(
{
@@ -46,11 +51,18 @@ def _workbook_candidates() -> list[Path]:
candidates = [
Path(env_path) if env_path else None,
repo_root / "input_data" / WORKBOOK_FILENAME,
cwd / "input_data" / WORKBOOK_FILENAME,
Path("/srv/lean101-clients") / WORKBOOK_FILENAME,
repo_root / WORKBOOK_FILENAME,
cwd / WORKBOOK_FILENAME,
Path("/app") / WORKBOOK_FILENAME,
Path("/") / WORKBOOK_FILENAME,
repo_root / LEGACY_WORKBOOK_FILENAME,
cwd / LEGACY_WORKBOOK_FILENAME,
Path("/srv/lean101-clients") / LEGACY_WORKBOOK_FILENAME,
Path("/app") / LEGACY_WORKBOOK_FILENAME,
Path("/") / LEGACY_WORKBOOK_FILENAME,
]
ordered: list[Path] = []
@@ -73,9 +85,6 @@ def _resolve_workbook_path() -> Path:
return _workbook_candidates()[0]
WORKBOOK_PATH = _resolve_workbook_path()
def _text(value) -> str | None:
if value is None:
return None
@@ -178,7 +187,21 @@ def _build_process_key(label, grading_cost: float, bagging_cost: float, cracking
return f"{base}_g{int(round(grading_cost * 1000))}_b{int(round(bagging_cost * 1000))}_c{int(round(cracking_cost * 1000))}"
def _load_workbook():
def _load_workbook(*required_sheets: str):
for candidate in _workbook_candidates():
if not candidate.exists():
continue
workbook = load_workbook(candidate, data_only=True)
if all(sheet_name in workbook.sheetnames for sheet_name in required_sheets):
return workbook
if required_sheets:
raise FileNotFoundError(
"No workbook with required sheets found. "
f"Required sheets: {', '.join(required_sheets)}. "
f"Checked: {', '.join(str(path) for path in _workbook_candidates())}"
)
workbook_path = _resolve_workbook_path()
if not workbook_path.exists():
raise FileNotFoundError(
@@ -258,6 +281,44 @@ def _read_mix_rows(workbook) -> dict[tuple[str, str], dict]:
return best_rows
def _read_product_ingredient_rows(workbook) -> dict[tuple[str, str], dict]:
worksheet = workbook["mix_quantites_per_client_per_pr"]
header_row = next(worksheet.iter_rows(min_row=1, max_row=1, values_only=True))
ingredient_names = [_text(value) for value in header_row[3:] if _text(value)]
rows: dict[tuple[str, str], dict] = {}
for row in worksheet.iter_rows(min_row=2, values_only=True):
client_name = _text(row[0])
product_name = _text(row[1])
if not client_name or not product_name:
continue
ingredients = []
for sort_order, (ingredient_name, quantity) in enumerate(zip(ingredient_names, row[3 : 3 + len(ingredient_names)]), start=1):
numeric_quantity = _number(quantity)
if ingredient_name and numeric_quantity and numeric_quantity > 0:
ingredients.append(
{
"raw_material_name": ingredient_name,
"quantity_kg": numeric_quantity,
"sort_order": sort_order,
}
)
if not ingredients:
continue
total_kg = _number(row[2]) or round(sum(item["quantity_kg"] for item in ingredients), 4)
rows[(client_name, product_name)] = {
"client_name": client_name,
"product_name": product_name,
"total_kg": total_kg,
"ingredients": ingredients,
}
return rows
def _read_product_rows(workbook) -> list[dict]:
worksheet = workbook["Product Cost - Price"]
raw_rows: list[dict] = []
@@ -606,6 +667,184 @@ def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str],
product.notes = "Seeded from Input Cost Spreadsheet(1).xlsx"
def _upsert_product_ingredients(
db,
*,
product_rows: list[dict],
product_ingredient_rows: dict[tuple[str, str], dict],
raw_material_map: dict[str, RawMaterial],
) -> None:
products = db.scalars(
select(Product).where(Product.tenant_id == TENANT_ID).options(selectinload(Product.mix))
).all()
products_by_formula_key: dict[tuple[str, str], list[Product]] = {}
for product in products:
candidate_keys = {
(product.client_name, product.name),
}
if product.mix is not None:
candidate_keys.add((product.client_name, product.mix.name))
for key in candidate_keys:
products_by_formula_key.setdefault(key, []).append(product)
for key, formula in product_ingredient_rows.items():
matched_products = products_by_formula_key.get(key, [])
if not matched_products:
continue
for product in matched_products:
existing_ingredients = {
ingredient.raw_material_id: ingredient
for ingredient in db.scalars(select(ProductIngredient).where(ProductIngredient.product_id == product.id)).all()
}
desired_ids: set[int] = set()
for row in formula["ingredients"]:
raw_material = raw_material_map.get(row["raw_material_name"])
if raw_material is None:
continue
desired_ids.add(raw_material.id)
ingredient = existing_ingredients.get(raw_material.id)
if ingredient is None:
db.add(
ProductIngredient(
tenant_id=TENANT_ID,
product_id=product.id,
raw_material_id=raw_material.id,
quantity_kg=row["quantity_kg"],
sort_order=row["sort_order"],
)
)
else:
ingredient.quantity_kg = row["quantity_kg"]
ingredient.sort_order = row["sort_order"]
for raw_material_id, ingredient in existing_ingredients.items():
if raw_material_id not in desired_ids:
db.delete(ingredient)
def _infer_throughput_bag_size(product: Product) -> float | None:
if product.sale_type == "bulka":
return None
unit = (product.unit_of_measure or "").strip().lower()
match = re.search(r"(\d+(?:\.\d+)?)\s*kg", unit)
if match:
return float(match.group(1))
if unit == "kg":
return 1.0
if unit == "tonne":
return 1000.0
return None
def _infer_throughput_bulka_default(product: Product) -> bool:
unit = (product.unit_of_measure or "").lower()
return product.sale_type == "bulka" or "bulka" in product.name.lower() or "bulka" in unit
def seed_throughput_products_from_costing(db) -> dict[str, int]:
"""Mirror costing products into the throughput product dropdown."""
costing_products = db.scalars(
select(Product)
.where(Product.tenant_id == TENANT_ID)
.order_by(Product.name, Product.id)
).all()
if not costing_products:
return {"created": 0, "updated": 0, "skipped": 0}
throughput_products = db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == TENANT_ID)
).all()
by_item = {
throughput_product.item_id: throughput_product
for throughput_product in throughput_products
if throughput_product.item_id
}
by_name = {
throughput_product.name.strip().lower(): throughput_product
for throughput_product in throughput_products
if throughput_product.name
}
created = 0
updated = 0
skipped = 0
seen_item_ids: set[str] = set()
seen_names: set[str] = set()
for costing_product in costing_products:
name = (costing_product.name or "").strip()
if not name:
skipped += 1
continue
item_id = (costing_product.item_id or "").strip() or None
name_key = name.lower()
if item_id and item_id in seen_item_ids:
skipped += 1
continue
if not item_id and name_key in seen_names:
skipped += 1
continue
if item_id:
seen_item_ids.add(item_id)
seen_names.add(name_key)
default_bag_size = _infer_throughput_bag_size(costing_product)
is_bulka_default = _infer_throughput_bulka_default(costing_product)
product = (by_item.get(item_id) if item_id else None) or by_name.get(name_key)
if product is None:
product = ThroughputProduct(
tenant_id=TENANT_ID,
item_id=item_id,
name=name,
default_bag_size=default_bag_size,
is_bulka_default=is_bulka_default,
active=costing_product.visible,
is_stock_item=True,
notes="Seeded from costing products",
)
db.add(product)
created += 1
if item_id:
by_item[item_id] = product
by_name[name_key] = product
continue
changed = False
if item_id and product.item_id != item_id:
product.item_id = item_id
changed = True
if product.name != name:
old_name_key = product.name.strip().lower() if product.name else None
product.name = name
if old_name_key:
by_name.pop(old_name_key, None)
by_name[name_key] = product
changed = True
if product.default_bag_size != default_bag_size:
product.default_bag_size = default_bag_size
changed = True
if product.is_bulka_default != is_bulka_default:
product.is_bulka_default = is_bulka_default
changed = True
if product.active != costing_product.visible:
product.active = costing_product.visible
changed = True
if product.is_stock_item is not True:
product.is_stock_item = True
changed = True
if product.notes in {None, "", "Seeded from costing products"}:
product.notes = "Seeded from costing products"
if changed:
updated += 1
db.flush()
return {"created": created, "updated": updated, "skipped": skipped}
def seed_client_access(db):
existing = db.scalar(select(ClientAccount.id))
if existing is not None:
@@ -667,7 +906,7 @@ def seed_client_access(db):
)
enabled_feature_map = {
TENANT_ID: {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"},
TENANT_ID: {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access", "operations_throughput"},
"loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"},
}
@@ -713,10 +952,13 @@ def seed_client_access(db):
def seed_costing_workspace(db):
workbook = _load_workbook()
raw_material_rows = _read_raw_material_rows(workbook)
mix_rows = _read_mix_rows(workbook)
product_rows = _read_product_rows(workbook)
costing_workbook = _load_workbook("C- Raw Products Costs", "M - All", "Product Cost - Price")
formula_workbook = _load_workbook("mix_quantites_per_client_per_pr")
raw_material_rows = _read_raw_material_rows(costing_workbook)
mix_rows = _read_mix_rows(costing_workbook)
product_rows = _read_product_rows(costing_workbook)
product_ingredient_rows = _read_product_ingredient_rows(formula_workbook)
raw_material_map = _upsert_raw_materials(db, raw_material_rows)
_upsert_process_rules(db, product_rows)
@@ -735,9 +977,53 @@ def seed_costing_workspace(db):
mix_cache[(mix_row["client_name"], mix_row["name"])] = mix
_upsert_products(db, product_rows, mix_cache, raw_material_map)
_upsert_product_ingredients(
db,
product_rows=product_rows,
product_ingredient_rows=product_ingredient_rows,
raw_material_map=raw_material_map,
)
def seed_if_empty():
def seed_throughput_workbook(db):
"""Import the Operations Throughput workbook on first run if tables are empty."""
has_products = db.scalar(select(ThroughputProduct.id)) is not None
has_entries = db.scalar(select(ProductionThroughput.id)) is not None
if not has_products and not has_entries:
workbook_path = resolve_throughput_workbook_path()
if workbook_path is None:
logger.info("Operations Throughput workbook not found; seeding throughput products from costing products")
else:
try:
report = import_throughput_workbook(db, workbook_path, TENANT_ID)
except Exception:
logger.exception("Failed to seed Operations Throughput workbook from %s", workbook_path)
else:
logger.info("Operations Throughput seeded from %s: %s", workbook_path, report)
report = seed_throughput_products_from_costing(db)
if any(report.values()):
logger.info("Throughput products synced from costing products: %s", report)
def seed_throughput_products(db):
"""Sync throughput products from costing products without importing historical entries."""
report = seed_throughput_products_from_costing(db)
if any(report.values()):
logger.info("Throughput products synced from costing products: %s", report)
return
def seed_startup_basics():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
seed_client_access(db)
seed_access(db)
seed_throughput_workbook(db)
db.commit()
def seed_all():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
workbook_path = _resolve_workbook_path()
@@ -748,10 +1034,29 @@ def seed_if_empty():
"Skipping costing workspace seed because workbook is missing. Checked: %s",
", ".join(str(path) for path in _workbook_candidates()),
)
seed_throughput_products(db)
seed_client_access(db)
seed_access(db)
db.commit()
def seed_if_empty():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
if db.scalar(select(RawMaterial.id)) is None:
workbook_path = _resolve_workbook_path()
if workbook_path.exists():
seed_costing_workspace(db)
else:
logger.warning(
"Skipping costing workspace seed because workbook is missing. Checked: %s",
", ".join(str(path) for path in _workbook_candidates()),
)
seed_throughput_products(db)
seed_client_access(db)
seed_access(db)
db.commit()
if __name__ == "__main__":
seed_if_empty()
seed_all()
+27 -2
View File
@@ -3,13 +3,18 @@
Re-running this is safe: it upserts permissions, syncs each role's permission
set to the declared list, and creates or updates the seed users without
duplicating rows. Permission grants are the source of truth — change them
here (or in the DB) rather than in route code.
here (or in the DB) rather than in route code. Existing password hashes are
left intact; only users with no password hash get the current default
``ADMIN_PASSWORD`` hashed into the row.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.core.config import settings
from app.core.security import hash_password
from app.db.session import SessionLocal
from app.models.access import Permission, Role, User
@@ -24,6 +29,8 @@ PERMISSION_DEFINITIONS: tuple[tuple[str, str], ...] = (
("edit_products", "Create and edit finished products"),
("view_mixes", "View mix master recipes"),
("edit_mixes", "Create and edit mix master recipes"),
("view_throughput", "View operations throughput"),
("edit_throughput", "Create and edit operations throughput entries"),
("view_users", "View internal users and roles"),
("manage_users", "Create, deactivate, and assign user roles"),
("manage_permissions", "Modify roles and role-permission assignments"),
@@ -44,6 +51,8 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_raw_materials",
"view_products",
"view_mixes",
"view_throughput",
"edit_throughput",
"view_users",
"manage_users",
"manage_permissions",
@@ -52,11 +61,13 @@ ROLE_DEFINITIONS: dict[str, dict] = {
],
},
"Operations": {
"description": "Mix calculator only — cannot edit raw materials, products, mixes, users, or settings.",
"description": "Mix calculator and operations throughput — cannot edit raw materials, products, mixes, users, or settings.",
"permissions": [
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_throughput",
"edit_throughput",
],
},
"Full Access": {
@@ -72,6 +83,8 @@ ROLE_DEFINITIONS: dict[str, dict] = {
"edit_products",
"view_mixes",
"edit_mixes",
"view_throughput",
"edit_throughput",
],
},
}
@@ -154,6 +167,8 @@ def _upsert_users(db: Session, roles_by_name: dict[str, Role]) -> None:
user.role_id = role.id
if not user.is_active:
user.is_active = True
if user.password_hash is None:
user.password_hash = hash_password(settings.admin_password)
db.flush()
@@ -162,3 +177,13 @@ def seed_access(db: Session) -> None:
permissions_by_key = _upsert_permissions(db)
roles_by_name = _upsert_roles(db, permissions_by_key)
_upsert_users(db, roles_by_name)
def seed_access_from_session() -> None:
with SessionLocal() as db:
seed_access(db)
db.commit()
if __name__ == "__main__":
seed_access_from_session()
@@ -20,6 +20,7 @@ MODULE_CATALOG = (
("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"),
("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
)
@@ -78,15 +79,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 in {"client_access", "mix_calculator"} else "edit"
return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput"} else "edit"
if normalized == "admin":
if module_key == "mix_calculator":
if module_key in {"mix_calculator", "operations_throughput"}:
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", "mix_calculator", "products", "scenarios"} else "none"
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput"} else "none"
if normalized == "viewer":
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export"} else "none"
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput"} else "none"
return "none"
+85 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, selectinload
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
@@ -119,6 +119,78 @@ def calculate_mix_cost(db: Session, mix_id: int, overrides: dict | None = None)
}
def _calculate_formula_cost_from_product_ingredients(
product_ingredients: list[ProductIngredient],
overrides: dict | None = None,
) -> dict:
overrides = overrides or {}
total_mix_kg = 0.0
total_mix_cost = 0.0
warnings: list[str] = []
lines: list[dict] = []
for ingredient in product_ingredients:
raw_material = ingredient.raw_material
active_price = get_active_price(raw_material)
if active_price is None:
warnings.append(f"{raw_material.name} has no active price")
lines.append(
{
"id": ingredient.id,
"raw_material_id": raw_material.id,
"raw_material_name": raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"cost_per_kg": None,
"line_cost": None,
"notes": ingredient.notes,
}
)
total_mix_kg += ingredient.quantity_kg
continue
market_value = overrides.get("raw_material_market_values", {}).get(str(raw_material.id), active_price.market_value)
waste_percentage = overrides.get("raw_material_waste_percentages", {}).get(str(raw_material.id), active_price.waste_percentage)
price_stub = RawMaterialPriceVersion(
raw_material_id=raw_material.id,
market_value=market_value,
waste_percentage=waste_percentage,
effective_date=active_price.effective_date,
status=active_price.status,
)
price_comp = calculate_raw_material_cost(raw_material, price_stub)
line_cost = round(ingredient.quantity_kg * price_comp.cost_per_kg, 4)
total_mix_kg += ingredient.quantity_kg
total_mix_cost += line_cost
lines.append(
{
"id": ingredient.id,
"raw_material_id": raw_material.id,
"raw_material_name": raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"cost_per_kg": price_comp.cost_per_kg,
"line_cost": line_cost,
"notes": ingredient.notes,
}
)
if total_mix_kg == 0:
warnings.append("Mix total kg is zero")
mix_cost_per_kg = None
else:
mix_cost_per_kg = round(total_mix_cost / total_mix_kg, 4)
if not product_ingredients:
warnings.append("Mix has no ingredients")
return {
"ingredients": lines,
"total_mix_kg": round(total_mix_kg, 4),
"total_mix_cost": round(total_mix_cost, 4),
"mix_cost_per_kg": mix_cost_per_kg,
"warnings": warnings,
}
def _get_process_costs(db: Session, process_name: str | None, overrides: dict) -> tuple[float, float, float, list[str]]:
if not process_name:
return 0.0, 0.0, 0.0, ["Missing bagging process"]
@@ -192,12 +264,22 @@ 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)))
product = db.scalar(
select(Product)
.where(Product.id == product_id)
.options(
selectinload(Product.mix),
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material).selectinload(RawMaterial.price_versions),
)
)
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)
if product.ingredients:
mix_result = _calculate_formula_cost_from_product_ingredients(product.ingredients, overrides=overrides)
else:
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)
@@ -3,8 +3,14 @@ from __future__ import annotations
import re
from app.models.mix_calculator import MixCalculatorSession
from app.schemas.mix_calculator import MixCalculatorPreviewRead
def mix_calculator_pdf_filename(session_record: MixCalculatorSession) -> str:
raw = f"{session_record.session_number}_{session_record.client_name}_{session_record.product_name}.pdf"
return re.sub(r"[^\w.\-]+", "_", raw)
def mix_calculator_preview_pdf_filename(preview: MixCalculatorPreviewRead) -> str:
raw = f"MixCalculator_{preview.client_name}_{preview.product_name}_{preview.mix_date}.pdf"
return re.sub(r"[^\w.\-]+", "_", raw)
+298 -240
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
from io import BytesIO
from math import ceil
from pathlib import Path
from types import SimpleNamespace
from app.models.mix_calculator import MixCalculatorSession
@@ -24,272 +25,329 @@ def _fractional_bag_warning(session_record: MixCalculatorSession) -> str | None:
)
def build_mix_calculator_pdf(session_record: MixCalculatorSession) -> bytes:
def _coerce_pdf_source(source):
if isinstance(source, dict):
lines = [SimpleNamespace(**line) if isinstance(line, dict) else line for line in source.get("lines", [])]
return SimpleNamespace(**{**source, "lines": lines})
return source
def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> bytes:
session_record = _coerce_pdf_source(session_record)
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
except ModuleNotFoundError as exc:
raise MixCalculatorPdfUnavailableError(
"PDF generation is unavailable because 'reportlab' is not installed. "
"Install backend dependencies again to enable PDF export."
) from exc
page_width, page_height = A4
margin = 26
gutter = 10
content_width = page_width - (margin * 2)
page_top = page_height - 40
palette = {
"page": colors.HexColor("#FFFFFF"),
"line": colors.HexColor("#000000"),
"muted": colors.HexColor("#000000"),
"text": colors.HexColor("#000000"),
"warning_bg": colors.HexColor("#FFFFFF"),
"warning_text": colors.HexColor("#000000"),
}
logo_path = Path(__file__).resolve().parents[3] / "frontend" / "static" / "logo-hsf.png"
def clamp(value: float, minimum: float, maximum: float) -> float:
return max(minimum, min(maximum, value))
def fit_text(value: str, font_name: str, font_size: float, max_width: float) -> str:
if stringWidth(value, font_name, font_size) <= max_width:
return value
ellipsis = "..."
available = max_width - stringWidth(ellipsis, font_name, font_size)
trimmed = value
while trimmed and stringWidth(trimmed, font_name, font_size) > available:
trimmed = trimmed[:-1]
return f"{trimmed.rstrip()}{ellipsis}" if trimmed else ellipsis
def wrap_text(value: str, font_name: str, font_size: float, max_width: float, max_lines: int) -> list[str]:
words = value.split()
if not words:
return []
lines: list[str] = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if stringWidth(candidate, font_name, font_size) <= max_width:
current = candidate
else:
lines.append(current)
current = word
if len(lines) == max_lines - 1:
break
if len(lines) < max_lines:
lines.append(current)
remaining_words = words[len(" ".join(lines).split()) :]
if remaining_words and lines:
lines[-1] = fit_text(f"{lines[-1]} {' '.join(remaining_words)}", font_name, font_size, max_width)
return lines[:max_lines]
def draw_box(pdf: canvas.Canvas, x: float, y_top: float, width: float, height: float):
pdf.setFillColor(palette["page"])
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(1)
pdf.rect(x, y_top - height, width, height, fill=1, stroke=1)
def draw_label_value_card(
pdf: canvas.Canvas,
x: float,
y_top: float,
width: float,
height: float,
label: str,
value: str,
subtitle: str | None = None,
value_font_size: float = 14,
):
draw_box(pdf, x, y_top, width, height)
inset_x = x + 14
label_y = y_top - 16
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 7.5)
pdf.drawString(inset_x, label_y, label.upper())
value_y = y_top - 38
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica-Bold", value_font_size)
pdf.drawString(inset_x, value_y, fit_text(value, "Helvetica-Bold", value_font_size, width - 28))
if subtitle:
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica", 8)
pdf.drawString(inset_x, y_top - height + 14, fit_text(subtitle, "Helvetica", 8, width - 28))
buffer = BytesIO()
document = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
title=f"{session_record.session_number} - {session_record.product_name}",
author="Lean 101 Clients",
pdf = canvas.Canvas(buffer, pagesize=A4)
session_number = getattr(session_record, "session_number", None)
document_title = (
f"{session_number} - {session_record.product_name}"
if session_number
else f"Mix Calculator - {session_record.product_name}"
)
pdf.setTitle(document_title)
pdf.setAuthor("Lean 101 Clients")
styles = getSampleStyleSheet()
eyebrow = ParagraphStyle(
"Eyebrow",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=8,
leading=10,
textColor=colors.HexColor("#62736B"),
spaceAfter=5,
)
title = ParagraphStyle(
"Title",
parent=styles["Heading1"],
fontName="Helvetica-Bold",
fontSize=24,
leading=26,
textColor=colors.HexColor("#21312A"),
spaceAfter=6,
)
subtitle = ParagraphStyle(
"Subtitle",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=10,
leading=13,
textColor=colors.HexColor("#6B7A73"),
)
label = ParagraphStyle(
"Label",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=7,
leading=9,
textColor=colors.HexColor("#6B7A73"),
)
value = ParagraphStyle(
"Value",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=11,
leading=13,
textColor=colors.HexColor("#21312A"),
)
card_value = ParagraphStyle(
"CardValue",
parent=value,
fontSize=16,
leading=18,
)
body = ParagraphStyle(
"Body",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=9,
leading=12,
textColor=colors.HexColor("#304038"),
)
section_title = ParagraphStyle(
"SectionTitle",
parent=styles["Heading2"],
fontName="Helvetica-Bold",
fontSize=13,
leading=15,
textColor=colors.HexColor("#21312A"),
)
pdf.setFillColor(palette["page"])
pdf.rect(0, 0, page_width, page_height, stroke=0, fill=1)
warnings = []
bag_warning = _fractional_bag_warning(session_record)
if bag_warning:
warnings.append(bag_warning)
current_y = page_top
mix_date_label = f"{session_record.mix_date.day} {session_record.mix_date.strftime('%B %Y')}"
story = [
Paragraph(f"Mix Calculator | {session_record.session_number}", eyebrow),
Paragraph(session_record.product_name, title),
Paragraph(f"{session_record.client_name} &nbsp;&middot;&nbsp; {session_record.mix_name}", subtitle),
Spacer(1, 8),
]
if logo_path.exists():
logo_source = str(logo_path)
try:
from PIL import Image
header_table = Table(
[
[
[
Paragraph("Mix date", label),
Paragraph(session_record.mix_date.strftime("%d %b %Y"), value),
],
[
Paragraph("Prepared by", label),
Paragraph(session_record.prepared_by_name, value),
],
[
Paragraph("Status", label),
Paragraph(session_record.status.title(), value),
],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
)
header_table.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.white),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 9),
("BOTTOMPADDING", (0, 0), (-1, -1), 9),
]
logo_source = Image.open(logo_path).convert("L")
except ModuleNotFoundError:
pass
logo_reader = ImageReader(logo_source)
logo_width, logo_height = logo_reader.getSize()
aspect_ratio = logo_height / max(logo_width, 1)
draw_width = 108
draw_height = draw_width * aspect_ratio
pdf.drawImage(
logo_reader,
margin,
current_y - draw_height,
width=draw_width,
height=draw_height,
preserveAspectRatio=True,
mask="auto",
)
)
story.extend([header_table, Spacer(1, 10)])
current_y -= draw_height + 20
summary_table = Table(
[
[
[Paragraph("Batch size", label), Paragraph(f"{_fmt_number(session_record.batch_size_kg)}kg", card_value)],
[Paragraph("Total output", label), Paragraph(f"{_fmt_number(session_record.total_kg)}kg", card_value)],
[Paragraph("Bags", label), Paragraph(_fmt_number(session_record.total_bags), card_value)],
[Paragraph("Unit pack", label), Paragraph(f"{_fmt_number(session_record.product_unit_size_kg)}kg", card_value)],
]
],
colWidths=[43 * mm, 43 * mm, 43 * mm, 43 * mm],
)
summary_table.setStyle(
TableStyle(
[
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F9FBFA")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
)
story.extend([summary_table, Spacer(1, 10)])
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica-Bold", 15)
pdf.drawString(margin, current_y, "Calculated Output")
current_y -= 16
detail_table = Table(
[
[
[Paragraph("Mix source", label), Paragraph(session_record.mix_name, value), Paragraph(f"Saved against {session_record.product_unit_of_measure} units.", body)],
[Paragraph("Composition", label), Paragraph(f"{_fmt_number(sum(line.mix_percentage for line in session_record.lines))}%", value), Paragraph(f"{len(session_record.lines)} raw material{'s' if len(session_record.lines) != 1 else ''} in the blend.", body)],
[Paragraph("Estimated pages", label), Paragraph(str(max(1, ceil(len(session_record.lines) / 18))), value), Paragraph("Formatted for A4 PDF export.", body)],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica", 10)
pdf.drawString(margin, current_y, "Snapshot of the scaled raw material requirements.")
current_y -= 20
stat_height = 66
stat_width = (content_width - (gutter * 2)) / 3
draw_label_value_card(
pdf,
margin,
current_y,
stat_width,
stat_height,
"Total kg",
_fmt_number(session_record.total_kg),
"Scaled batch size",
value_font_size=18,
)
detail_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F4F8F5")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
draw_label_value_card(
pdf,
margin + stat_width + gutter,
current_y,
stat_width,
stat_height,
"Total bags",
_fmt_number(session_record.total_bags),
session_record.product_unit_of_measure,
value_font_size=18,
)
story.extend([detail_table, Spacer(1, 10)])
draw_label_value_card(
pdf,
margin + ((stat_width + gutter) * 2),
current_y,
stat_width,
stat_height,
"Prepared by",
session_record.prepared_by_name,
mix_date_label,
value_font_size=10.5,
)
current_y -= stat_height + 12
detail_height = 52
detail_width = (content_width - gutter) / 2
draw_label_value_card(
pdf,
margin,
current_y,
detail_width,
detail_height,
"Client",
session_record.client_name,
value_font_size=12,
)
draw_label_value_card(
pdf,
margin + detail_width + gutter,
current_y,
detail_width,
detail_height,
"Product",
session_record.product_name,
value_font_size=12,
)
current_y -= detail_height + 8
draw_label_value_card(
pdf,
margin,
current_y,
detail_width,
detail_height,
"Mix source",
session_record.mix_name,
value_font_size=11,
)
draw_label_value_card(
pdf,
margin + detail_width + gutter,
current_y,
detail_width,
detail_height,
"Unit size",
f"{_fmt_number(session_record.product_unit_size_kg)}kg",
value_font_size=12,
)
current_y -= detail_height + 10
warning = _fractional_bag_warning(session_record)
note_lines: list[str] = []
warning_lines: list[str] = []
strip_height = 0
if session_record.notes:
notes_table = Table(
[[Paragraph("Notes", label)], [Paragraph(session_record.notes.replace("\n", "<br/>"), body)]],
colWidths=[172 * mm],
)
notes_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#F4F8F5")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([notes_table, Spacer(1, 10)])
note_lines = wrap_text(session_record.notes.replace("\n", " "), "Helvetica", 7.5, content_width - 28, 2)
strip_height += 30
if warning:
warning_lines = wrap_text(warning, "Helvetica", 7.5, content_width - 28, 2)
strip_height += 30
if strip_height:
strip_height += 6
if warnings:
warning_rows = [[Paragraph("Warnings", label)]]
warning_rows.extend([[Paragraph(warning, body)] for warning in warnings])
warnings_table = Table(warning_rows, colWidths=[172 * mm])
warnings_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#FFF5E6")),
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#FFF9EF")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#E8C483")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([warnings_table, Spacer(1, 10)])
table_header_height = 24
table_bottom_padding = 12
table_top = current_y
available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding
row_count = max(len(session_record.lines), 1)
row_height = clamp(available_table_height / row_count, 16, 32)
table_font_size = clamp(row_height * 0.36, 7, 11)
table_height = table_header_height + (row_height * row_count)
table_bottom = table_top - table_height
story.extend(
[
Paragraph("Required Raw Materials", label),
Paragraph("Blend composition", section_title),
Paragraph(f"{session_record.product_unit_of_measure} · {_fmt_number(session_record.product_unit_size_kg)}kg per unit", subtitle),
Spacer(1, 6),
]
)
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 7.5)
pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL")
pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG")
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(0.8)
pdf.line(margin, table_top - table_header_height, margin + content_width, table_top - table_header_height)
left_col_x = margin + 6
right_col_x = margin + content_width - 190
y_cursor = table_top - table_header_height
table_rows = [["Raw material", "Mix %", "Required kg", "Unit"]]
for line in session_record.lines:
table_rows.append(
[
Paragraph(f"<b>{line.raw_material_name}</b>", body),
Paragraph(f"{_fmt_number(line.mix_percentage)}%", body),
Paragraph(f"{_fmt_number(line.required_kg)}kg", body),
Paragraph(line.unit, body),
]
)
y_cursor -= row_height
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(0.6)
pdf.line(margin, y_cursor, margin + content_width, y_cursor)
composition_table = Table(table_rows, colWidths=[88 * mm, 24 * mm, 34 * mm, 26 * mm], repeatRows=1)
composition_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#EEF4F0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#4F6158")),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("BOTTOMPADDING", (0, 0), (-1, 0), 8),
("TOPPADDING", (0, 0), (-1, 0), 8),
("LEFTPADDING", (0, 0), (-1, -1), 9),
("RIGHTPADDING", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.6, colors.HexColor("#DBE4DE")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FBFCFB")]),
]
text_y = y_cursor + (row_height / 2) - (table_font_size * 0.35)
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica-Bold", table_font_size)
pdf.drawString(
left_col_x,
text_y,
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
)
)
story.append(composition_table)
pdf.setFont("Helvetica", table_font_size)
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg")
document.build(story)
strip_y = table_bottom - 6
if note_lines:
note_height = 24 if len(note_lines) == 1 else 30
pdf.setFillColor(palette["page"])
pdf.setStrokeColor(palette["line"])
pdf.rect(margin, strip_y - note_height, content_width, note_height, fill=1, stroke=1)
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 7)
pdf.drawString(margin + 10, strip_y - 10, "NOTES")
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica", 7.5)
for idx, text in enumerate(note_lines):
pdf.drawString(margin + 10, strip_y - 20 - (idx * 8), text)
strip_y -= note_height + 6
if warning_lines:
warning_height = 24 if len(warning_lines) == 1 else 30
pdf.setFillColor(palette["warning_bg"])
pdf.setStrokeColor(palette["line"])
pdf.rect(margin, strip_y - warning_height, content_width, warning_height, fill=1, stroke=1)
pdf.setFillColor(palette["warning_text"])
pdf.setFont("Helvetica-Bold", 7)
pdf.drawString(margin + 10, strip_y - 10, "WARNING")
pdf.setFont("Helvetica", 7.5)
for idx, text in enumerate(warning_lines):
pdf.drawString(margin + 10, strip_y - 20 - (idx * 8), text)
pdf.showPage()
pdf.save()
return buffer.getvalue()
+57 -21
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, joinedload, 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.models.product import Product, ProductIngredient
from app.schemas.mix_calculator import MixCalculatorSessionCreate, MixCalculatorSessionUpdate
from app.services.costing_engine import extract_unit_quantity_kg
@@ -28,10 +28,44 @@ def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int)
return db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True))
.options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
.options(
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
)
)
def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
if product.ingredients:
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure,
"sort_order": ingredient.sort_order,
}
for ingredient in product.ingredients
if ingredient.raw_material is not None
]
elif product.mix is not None:
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
"sort_order": index,
}
for index, ingredient in enumerate(product.mix.ingredients, start=1)
]
else:
rows = []
rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"]))
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
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:
@@ -54,12 +88,9 @@ def calculate_mix_calculator_preview(
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)
formula_rows, source_total_kg = _resolved_formula_rows(product)
if source_total_kg <= 0:
raise ValueError("Product mix has no source kilograms to scale")
raise ValueError("Product has no source kilograms to scale")
batch_size_kg = float(values["batch_size_kg"])
scale_factor = batch_size_kg / source_total_kg
@@ -72,18 +103,17 @@ def calculate_mix_calculator_preview(
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
for index, ingredient in enumerate(formula_rows, start=1):
mix_percentage = round((ingredient["quantity_kg"] / source_total_kg) * 100, 4)
required_kg = round(ingredient["quantity_kg"] * scale_factor, 4)
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}",
"raw_material_id": ingredient["raw_material_id"],
"raw_material_name": ingredient["raw_material_name"],
"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,
"unit": ingredient["unit"],
"sort_order": ingredient["sort_order"] or index,
}
)
@@ -92,7 +122,7 @@ def calculate_mix_calculator_preview(
"product_id": product.id,
"product_name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name,
"mix_name": product.mix.name if product.mix else product.name,
"mix_date": values["mix_date"],
"batch_size_kg": round(batch_size_kg, 4),
"total_bags": total_bags,
@@ -108,10 +138,16 @@ def calculate_mix_calculator_preview(
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
# Aggregate mix totals in a single query instead of loading every
# ingredient row for every product. The previous implementation was the
# main slow path on first Mix Calculator open — it streamed the entire
# tenant's recipe table just to compute one sum per product.
# Prefer product-specific formulas where present; fall back to the shared
# mix master for legacy rows that have not been migrated yet.
product_totals_rows = db.execute(
select(ProductIngredient.product_id, func.coalesce(func.sum(ProductIngredient.quantity_kg), 0.0))
.join(Product, Product.id == ProductIngredient.product_id)
.where(Product.tenant_id == tenant_id)
.group_by(ProductIngredient.product_id)
).all()
product_totals: dict[int, float] = {product_id: round(total or 0.0, 4) for product_id, total in product_totals_rows}
mix_totals_rows = db.execute(
select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0))
.join(Mix, Mix.id == MixIngredient.mix_id)
@@ -137,7 +173,7 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
"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_totals.get(product.mix_id, 0.0),
"mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
}
for product in products
]
+348
View File
@@ -0,0 +1,348 @@
from __future__ import annotations
import logging
from datetime import date, datetime
from pathlib import Path
from typing import Iterable
from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.throughput import ProductionThroughput, ThroughputProduct
logger = logging.getLogger("data_entry_app.throughput")
PRODUCTION_SHEET = "Production"
NAMES_SHEET = "Names"
# Anything at or above this kg/bag is treated as a bulka batch, not a per-bag count.
_BULKA_BAG_SIZE_THRESHOLD = 100.0
def normalise_staff_name(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
# Collapse internal whitespace, title-case for consistency.
cleaned = " ".join(text.split())
return cleaned
def calculate_kg(quantity: float | None, quantity_type: str, bag_size: float | None) -> float:
if quantity is None:
return 0.0
if quantity_type == "kg":
return float(quantity)
if bag_size is None:
return 0.0
return float(quantity) * float(bag_size)
def qa_passed(entry: ProductionThroughput) -> bool:
return bool(entry.scales_checked and entry.label_correct and entry.bag_sealed and entry.pallet_good_condition)
def serialize_entry(entry: ProductionThroughput) -> dict:
return {
"id": entry.id,
"tenant_id": entry.tenant_id,
"production_date": entry.production_date,
"product_id": entry.product_id,
"product_name_snapshot": entry.product_name_snapshot,
"bag_size": entry.bag_size,
"scales_checked": entry.scales_checked,
"label_correct": entry.label_correct,
"bag_sealed": entry.bag_sealed,
"pallet_good_condition": entry.pallet_good_condition,
"sample_box_no": entry.sample_box_no,
"test_weight_1": entry.test_weight_1,
"test_weight_2": entry.test_weight_2,
"test_weight_3": entry.test_weight_3,
"test_weight_4": entry.test_weight_4,
"test_weight_5": entry.test_weight_5,
"quantity": entry.quantity,
"quantity_type": entry.quantity_type,
"calculated_kg": entry.calculated_kg,
"staff_name": entry.staff_name,
"notes": entry.notes,
"qa_passed": qa_passed(entry),
"created_by": entry.created_by,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
def _coerce_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if value is None:
return True
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in {"yes", "y", "true", "1", "pass", "ok", "x", "checked"}:
return True
if text in {"no", "n", "false", "0", "fail"}:
return False
return True
def _coerce_float(value: object) -> float | None:
if value is None or value == "":
return None
if isinstance(value, bool):
return float(value)
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", "")
if not text:
return None
try:
return float(text)
except ValueError:
return None
def _coerce_text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text or text.lower() in {"#value!", "#n/a", "n/a"}:
return None
return text
def _coerce_date(value: object) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
if not text:
return None
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def _infer_bulka_default(name: str, bag_size: float | None) -> bool:
lowered = name.lower()
if "bulka" in lowered:
return True
if bag_size is None:
return False
return bag_size >= _BULKA_BAG_SIZE_THRESHOLD
def import_names_sheet(db: Session, workbook, tenant_id: str) -> tuple[int, int]:
"""Upsert product master from the Names sheet. Returns (created, updated)."""
if NAMES_SHEET not in workbook.sheetnames:
return (0, 0)
ws = workbook[NAMES_SHEET]
existing: dict[tuple[str, str | None], ThroughputProduct] = {}
by_item: dict[str, ThroughputProduct] = {}
by_name: dict[str, ThroughputProduct] = {}
for product in db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
).all():
if product.item_id:
by_item[str(product.item_id)] = product
by_name[product.name.lower()] = product
created = 0
updated = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not row:
continue
name = _coerce_text(row[0] if len(row) > 0 else None)
if not name:
continue
item_id_raw = row[1] if len(row) > 1 else None
item_id = None
if item_id_raw is not None:
if isinstance(item_id_raw, float) and item_id_raw.is_integer():
item_id = str(int(item_id_raw))
else:
item_id = _coerce_text(item_id_raw)
product = (by_item.get(item_id) if item_id else None) or by_name.get(name.lower())
if product is None:
product = ThroughputProduct(
tenant_id=tenant_id,
item_id=item_id,
name=name,
default_bag_size=None,
is_bulka_default="bulka" in name.lower(),
active=True,
notes="Imported from Operations Throughput.xlsx",
)
db.add(product)
created += 1
if item_id:
by_item[item_id] = product
by_name[name.lower()] = product
else:
if item_id and not product.item_id:
product.item_id = item_id
if name and product.name != name:
product.name = name
updated += 1
db.flush()
return (created, updated)
def import_production_sheet(db: Session, workbook, tenant_id: str) -> tuple[int, int]:
"""Import the Production sheet. Returns (imported, skipped)."""
if PRODUCTION_SHEET not in workbook.sheetnames:
return (0, 0)
ws = workbook[PRODUCTION_SHEET]
# Header row is row 3 in the sheet (rows 1 and 2 are display banners).
products_by_name: dict[str, ThroughputProduct] = {
product.name.lower(): product
for product in db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
).all()
}
bag_size_seen: dict[int, list[float]] = {}
imported = 0
skipped = 0
for row in ws.iter_rows(min_row=4, values_only=True):
if not row or len(row) < 15:
skipped += 1
continue
production_date = _coerce_date(row[0])
product_name = _coerce_text(row[1])
if production_date is None or not product_name:
skipped += 1
continue
bag_size = _coerce_float(row[2])
scales = _coerce_bool(row[3])
label = _coerce_bool(row[4])
sealed = _coerce_bool(row[5])
pallet = _coerce_bool(row[6])
sample_box = _coerce_text(row[7])
tw1 = _coerce_float(row[8])
tw2 = _coerce_float(row[9])
tw3 = _coerce_float(row[10])
tw4 = _coerce_float(row[11])
tw5 = _coerce_float(row[12])
quantity = _coerce_float(row[13]) or 0.0
staff = normalise_staff_name(row[14])
notes = _coerce_text(row[15]) if len(row) > 15 else None
# Infer quantity_type: bulka-style rows have a blank or very large bag size.
if bag_size is None or bag_size >= _BULKA_BAG_SIZE_THRESHOLD or "bulka" in product_name.lower():
quantity_type = "kg"
else:
quantity_type = "bags"
product = products_by_name.get(product_name.lower())
if product is None:
product = ThroughputProduct(
tenant_id=tenant_id,
item_id=None,
name=product_name,
default_bag_size=bag_size,
is_bulka_default=_infer_bulka_default(product_name, bag_size),
active=True,
notes="Auto-created during Operations Throughput import",
)
db.add(product)
db.flush()
products_by_name[product_name.lower()] = product
if product.id is not None and bag_size is not None and bag_size > 0:
bag_size_seen.setdefault(product.id, []).append(bag_size)
calculated = calculate_kg(quantity, quantity_type, bag_size)
entry = ProductionThroughput(
tenant_id=tenant_id,
production_date=production_date,
product_id=product.id,
product_name_snapshot=product_name,
bag_size=bag_size,
scales_checked=scales,
label_correct=label,
bag_sealed=sealed,
pallet_good_condition=pallet,
sample_box_no=sample_box,
test_weight_1=tw1,
test_weight_2=tw2,
test_weight_3=tw3,
test_weight_4=tw4,
test_weight_5=tw5,
quantity=quantity,
quantity_type=quantity_type,
calculated_kg=calculated,
staff_name=staff,
notes=notes,
created_by="workbook-import",
)
db.add(entry)
imported += 1
# Backfill default_bag_size on products that don't have one but appear in entries.
for product_id, sizes in bag_size_seen.items():
product = db.get(ThroughputProduct, product_id)
if product and product.default_bag_size is None:
# Use the most common bag size seen.
common = max(set(sizes), key=sizes.count)
product.default_bag_size = common
if not product.is_bulka_default:
product.is_bulka_default = _infer_bulka_default(product.name, common)
db.flush()
return (imported, skipped)
def import_workbook(db: Session, workbook_path: Path, tenant_id: str) -> dict:
workbook = load_workbook(workbook_path, data_only=True)
products_created, products_updated = import_names_sheet(db, workbook, tenant_id)
entries_imported, entries_skipped = import_production_sheet(db, workbook, tenant_id)
return {
"products_created": products_created,
"products_updated": products_updated,
"entries_imported": entries_imported,
"entries_skipped": entries_skipped,
}
def workbook_candidates() -> Iterable[Path]:
repo_root = Path(__file__).resolve().parents[3]
candidates = [
repo_root / "Operations Throughput.xlsx",
repo_root.parent / "Operations Throughput.xlsx",
Path.cwd() / "Operations Throughput.xlsx",
Path("/srv/lean101-clients") / "Operations Throughput.xlsx",
Path("/app") / "Operations Throughput.xlsx",
]
seen: set[str] = set()
ordered: list[Path] = []
for candidate in candidates:
key = str(candidate)
if key in seen:
continue
seen.add(key)
ordered.append(candidate)
return ordered
def resolve_workbook_path() -> Path | None:
for candidate in workbook_candidates():
if candidate.exists():
return candidate
return None
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "data-entry-app-backend"
version = "0.1.5"
version = "0.1.8"
description = "Costing platform MVP backend"
requires-python = ">=3.11"
dependencies = [
+83 -3
View File
@@ -7,6 +7,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.access import router as access_router
from app.core.access import (
INTERNAL_USER_SUBJECT,
get_user_permissions,
@@ -15,7 +16,8 @@ from app.core.access import (
require_permission,
user_has_permission,
)
from app.core.security import issue_token
from app.core.config import settings
from app.core.security import issue_token, verify_password
from app.db.session import Base, get_db
from app.models.access import Permission, Role, User
from app.seed_access import PERMISSION_DEFINITIONS, ROLE_DEFINITIONS, SEED_USERS, seed_access
@@ -42,6 +44,10 @@ def test_seed_creates_roles_permissions_and_users():
assert {role.name for role in db.query(Role).all()} == set(ROLE_DEFINITIONS.keys())
assert {p.key for p in db.query(Permission).all()} == {key for key, _ in PERMISSION_DEFINITIONS}
assert {user.email for user in db.query(User).all()} == {entry["email"] for entry in SEED_USERS}
for user in db.query(User).all():
assert user.password_hash is not None
assert user.password_hash != settings.admin_password
assert verify_password(settings.admin_password, user.password_hash)
def test_seed_is_idempotent():
@@ -71,14 +77,20 @@ def test_admin_role_permissions_match_spec():
assert "edit_mixes" not in granted
def test_operations_role_is_mix_calculator_only():
def test_operations_role_is_mix_calculator_and_throughput_only():
db = _build_session()
seed_access(db)
ops = db.query(User).filter_by(email="ops@hunterstockfeeds.com").one()
granted = get_user_permissions(ops)
assert granted == {"view_mix_calculator", "use_mix_calculator", "save_mix_calculator_session"}
assert granted == {
"view_mix_calculator",
"use_mix_calculator",
"save_mix_calculator_session",
"view_throughput",
"edit_throughput",
}
assert not user_has_permission(ops, "edit_raw_materials")
assert not user_has_permission(ops, "view_dashboard")
assert not user_has_permission(ops, "manage_users")
@@ -158,6 +170,22 @@ def _token_for(user: User) -> str:
return issue_token({"sub": INTERNAL_USER_SUBJECT, "user_id": user.id, "email": user.email})
@pytest.fixture()
def access_app_and_db():
db = _build_session()
seed_access(db)
db.commit()
app = FastAPI()
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
app.include_router(access_router)
return TestClient(app), db
def test_route_allows_user_with_permission(app_and_db):
client, db = app_and_db
craig = db.query(User).filter_by(email="craig@hunterstockfeeds.com").one()
@@ -234,3 +262,55 @@ def test_require_all_permissions(app_and_db):
denied = client.get("/needs-all", headers={"Authorization": f"Bearer {_token_for(ops)}"})
assert denied.status_code == 403
def test_internal_login_uses_user_password_hash(access_app_and_db):
client, db = access_app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
admin.password_hash = issue_token({"not": "a password"})
db.commit()
denied = client.post(
"/api/access/login",
json={"email": admin.email, "password": settings.admin_password},
)
assert denied.status_code == 401
def test_internal_user_can_change_own_password(access_app_and_db):
client, db = access_app_and_db
admin = db.query(User).filter_by(email="admin@hunterstockfeeds.com").one()
login_response = client.post(
"/api/access/login",
json={"email": admin.email, "password": settings.admin_password},
)
assert login_response.status_code == 200
update_response = client.patch(
"/api/access/me",
json={
"current_password": settings.admin_password,
"new_password": "new-personal-password",
},
cookies=login_response.cookies,
)
assert update_response.status_code == 200
db.refresh(admin)
assert admin.password_hash is not None
assert verify_password("new-personal-password", admin.password_hash)
assert not verify_password(settings.admin_password, admin.password_hash)
old_login = client.post(
"/api/access/login",
json={"email": admin.email, "password": settings.admin_password},
)
assert old_login.status_code == 401
new_login = client.post(
"/api/access/login",
json={"email": admin.email, "password": "new-personal-password"},
)
assert new_login.status_code == 200
+106 -7
View File
@@ -1,7 +1,7 @@
from datetime import date
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, inspect, text
from sqlalchemy import create_engine, inspect, select, text
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
@@ -13,7 +13,7 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
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.product import Product, ProductIngredient
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, serialize_raw_material
@@ -97,12 +97,13 @@ 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():
def test_mix_calculator_preview_prefers_product_specific_ingredients_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])
wheat = RawMaterial(name="Wheat", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add_all([maize, barley, wheat])
db.flush()
mix = Mix(tenant_id="specialty-feeds", client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
@@ -128,6 +129,25 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
bagging_process="standard_bagging",
)
db.add(product)
db.flush()
db.add_all(
[
ProductIngredient(
tenant_id="specialty-feeds",
product_id=product.id,
raw_material_id=maize.id,
quantity_kg=300,
sort_order=1,
),
ProductIngredient(
tenant_id="specialty-feeds",
product_id=product.id,
raw_material_id=wheat.id,
quantity_kg=200,
sort_order=2,
),
]
)
db.commit()
preview = calculate_mix_calculator_preview(
@@ -145,8 +165,9 @@ def test_mix_calculator_preview_scales_saved_mix_and_warns_on_fractional_bags():
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 [line["raw_material_name"] for line in preview["lines"]] == ["Maize", "Wheat"]
assert preview["lines"][0]["required_kg"] == 330
assert preview["lines"][1]["required_kg"] == 220
assert len(preview["warnings"]) == 1
assert "not a whole-bag quantity" in preview["warnings"][0]
@@ -155,7 +176,8 @@ def test_mix_calculator_options_hide_invisible_products_and_clients():
db = build_session()
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add(maize)
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
db.add_all([maize, barley])
db.flush()
visible_mix = Mix(tenant_id="hunter-premium-produce", client_name="Peckish", name="Visible Mix", status="active", version=1)
@@ -197,12 +219,89 @@ def test_mix_calculator_options_hide_invisible_products_and_clients():
),
]
)
db.flush()
visible_product = db.scalar(select(Product).where(Product.name == "Visible Product"))
assert visible_product is not None
db.add_all(
[
ProductIngredient(
tenant_id="hunter-premium-produce",
product_id=visible_product.id,
raw_material_id=maize.id,
quantity_kg=12,
sort_order=1,
),
ProductIngredient(
tenant_id="hunter-premium-produce",
product_id=visible_product.id,
raw_material_id=barley.id,
quantity_kg=8,
sort_order=2,
),
]
)
db.commit()
options = build_mix_calculator_options(db, tenant_id="hunter-premium-produce")
assert options["clients"] == ["Peckish"]
assert [product["product_name"] for product in options["products"]] == ["Visible Product"]
assert options["products"][0]["mix_total_kg"] == 20
def test_calculate_product_cost_prefers_product_specific_ingredients():
db = build_session()
maize = RawMaterial(name="Maize", unit_of_measure="tonne", kg_per_unit=1000, status="active")
maize.price_versions.append(RawMaterialPriceVersion(market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1)))
barley = RawMaterial(name="Barley", unit_of_measure="tonne", kg_per_unit=1000, status="active")
barley.price_versions.append(RawMaterialPriceVersion(market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1)))
wheat = RawMaterial(name="Wheat", unit_of_measure="tonne", kg_per_unit=1000, status="active")
wheat.price_versions.append(RawMaterialPriceVersion(market_value=600, waste_percentage=0.01, effective_date=date(2026, 4, 1)))
db.add_all([maize, barley, wheat])
db.flush()
mix = Mix(client_name="Specialty Feeds", name="Pigeon Mix", status="active", version=1)
db.add(mix)
db.flush()
db.add_all(
[
MixIngredient(mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180),
MixIngredient(mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100),
]
)
db.add(ProcessCostRule(process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0))
db.add(PackagingCostRule(sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63))
db.add(FreightCostRule(sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45))
db.flush()
product = Product(
client_name="Specialty Feeds",
name="Specialty Pigeon Breeder 20kg",
mix_id=mix.id,
sale_type="standard",
own_bag=False,
unit_of_measure="20kg bag",
items_per_pallet=50,
bagging_process="standard_bagging",
distributor_margin=0.225,
wholesale_margin=0.18,
)
db.add(product)
db.flush()
db.add_all(
[
ProductIngredient(product_id=product.id, raw_material_id=maize.id, quantity_kg=300, sort_order=1),
ProductIngredient(product_id=product.id, raw_material_id=wheat.id, quantity_kg=200, sort_order=2),
]
)
db.commit()
product_result = calculate_product_cost(db, product.id)
assert product_result["finished_product_delivered"] == 15.192
assert product_result["distributor_price"] == 19.6026
assert product_result["wholesale_price"] == 18.5268
def test_sync_product_visibility_hides_configured_clients():
+239
View File
@@ -0,0 +1,239 @@
from datetime import date
from io import BytesIO
from openpyxl import Workbook
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from app.db.session import Base
from app.models.mix import Mix
from app.models.product import Product
from app.models.raw_material import RawMaterial
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.seed import seed_throughput_products_from_costing
from app.services.throughput_service import (
calculate_kg,
import_names_sheet,
import_production_sheet,
normalise_staff_name,
qa_passed,
)
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _costing_mix(db: Session, tenant_id: str = "hunter-premium-produce") -> Mix:
raw_material = RawMaterial(
tenant_id=tenant_id,
name="Maize",
unit_of_measure="tonne",
kg_per_unit=1000,
status="active",
)
db.add(raw_material)
db.flush()
mix = Mix(tenant_id=tenant_id, client_name="Hunter", name="Maize Mix")
db.add(mix)
db.flush()
return mix
def test_calculate_kg_bags():
assert calculate_kg(10, "bags", 20) == 200.0
def test_calculate_kg_kg_ignores_bag_size():
assert calculate_kg(550, "kg", None) == 550.0
assert calculate_kg(550, "kg", 20) == 550.0
def test_calculate_kg_zero_quantity():
assert calculate_kg(0, "bags", 20) == 0.0
assert calculate_kg(None, "bags", 20) == 0.0
def test_staff_name_normalisation():
assert normalise_staff_name(" Jake ") == "Jake"
assert normalise_staff_name("jake smith") == "jake smith"
assert normalise_staff_name("") is None
assert normalise_staff_name(None) is None
def test_qa_passed_flag():
entry = ProductionThroughput(
production_date=date(2026, 1, 1),
product_name_snapshot="X",
quantity=1,
quantity_type="bags",
scales_checked=True,
label_correct=True,
bag_sealed=True,
pallet_good_condition=True,
)
assert qa_passed(entry) is True
entry.bag_sealed = False
assert qa_passed(entry) is False
def _make_workbook() -> BytesIO:
wb = Workbook()
names = wb.active
names.title = "Names"
names.append(["Name", "Item ID"])
names.append(["Whole Wheat 20kg", 1001])
names.append(["Bulka Maize", 1002])
production = wb.create_sheet("Production")
production.append(["#VALUE!", "Operations Throughput"])
production.append([None] * 8 + ["TEST WEIGHT"])
production.append([
"DATE", "GRAIN", "BAG SIZE", "SCALES", "LABEL", "SEALED", "PALLET",
"BOX", 1, 2, 3, 4, 5, "QTY", "STAFF", "NOTES",
])
production.append([date(2026, 4, 1), "Whole Wheat 20kg", 20, True, True, True, True, None, None, None, None, None, None, 100, " Jake ", None])
production.append([date(2026, 4, 1), "Bulka Maize", None, True, True, False, True, "B7", None, None, None, None, None, 1500, "Alex", "ok"])
production.append([date(2026, 4, 2), "Whole Wheat 20kg", 20, False, True, True, True, None, None, None, None, None, None, 50, "Jake", None])
buf = BytesIO()
wb.save(buf)
buf.seek(0)
return buf
def test_import_names_and_production():
from openpyxl import load_workbook
db = _session()
wb = load_workbook(_make_workbook(), data_only=True)
created, _ = import_names_sheet(db, wb, "test-tenant")
assert created == 2
imported, skipped = import_production_sheet(db, wb, "test-tenant")
assert imported == 3
assert skipped == 0
entries = db.scalars(select(ProductionThroughput).order_by(ProductionThroughput.id)).all()
bags_entry = entries[0]
assert bags_entry.quantity_type == "bags"
assert bags_entry.calculated_kg == 2000.0
assert bags_entry.staff_name == "Jake" # whitespace trimmed
bulka_entry = entries[1]
assert bulka_entry.quantity_type == "kg"
assert bulka_entry.calculated_kg == 1500.0
assert qa_passed(bulka_entry) is False # bag_sealed was False
# Product master should have absorbed default_bag_size for the wheat product
wheat = db.scalar(
select(ThroughputProduct).where(ThroughputProduct.name == "Whole Wheat 20kg")
)
assert wheat is not None
assert wheat.default_bag_size == 20
def test_product_name_snapshot_preserved_when_product_renamed():
db = _session()
product = ThroughputProduct(tenant_id="t", name="Original Name", default_bag_size=20)
db.add(product)
db.flush()
entry = ProductionThroughput(
tenant_id="t",
production_date=date(2026, 4, 1),
product_id=product.id,
product_name_snapshot=product.name,
bag_size=20,
quantity=10,
quantity_type="bags",
calculated_kg=200,
)
db.add(entry)
db.flush()
product.name = "Renamed Product"
db.flush()
reloaded = db.scalar(select(ProductionThroughput).where(ProductionThroughput.id == entry.id))
assert reloaded.product_name_snapshot == "Original Name"
def test_seed_throughput_products_from_costing_products():
db = _session()
mix = _costing_mix(db)
db.add_all(
[
Product(
tenant_id="hunter-premium-produce",
client_name="Hunter",
item_id="1001",
name="Whole Wheat 20kg",
mix_id=mix.id,
sale_type="standard",
unit_of_measure="20kg bag",
visible=True,
),
Product(
tenant_id="hunter-premium-produce",
client_name="Hunter",
item_id="1002",
name="Bulka Maize",
mix_id=mix.id,
sale_type="bulka",
unit_of_measure="tonne",
visible=False,
),
]
)
db.flush()
report = seed_throughput_products_from_costing(db)
assert report == {"created": 2, "updated": 0, "skipped": 0}
products = db.scalars(select(ThroughputProduct).order_by(ThroughputProduct.item_id)).all()
assert [product.name for product in products] == ["Whole Wheat 20kg", "Bulka Maize"]
assert products[0].default_bag_size == 20
assert products[0].is_bulka_default is False
assert products[0].active is True
assert products[1].default_bag_size is None
assert products[1].is_bulka_default is True
assert products[1].active is False
def test_seed_throughput_products_from_costing_updates_existing_by_item_id():
db = _session()
mix = _costing_mix(db)
db.add(
Product(
tenant_id="hunter-premium-produce",
client_name="Hunter",
item_id="1001",
name="Updated Wheat 25kg",
mix_id=mix.id,
sale_type="standard",
unit_of_measure="25kg bag",
visible=True,
)
)
db.add(
ThroughputProduct(
tenant_id="hunter-premium-produce",
item_id="1001",
name="Old Wheat",
default_bag_size=20,
active=False,
notes="Seeded from costing products",
)
)
db.flush()
report = seed_throughput_products_from_costing(db)
assert report == {"created": 0, "updated": 1, "skipped": 0}
products = db.scalars(select(ThroughputProduct)).all()
assert len(products) == 1
assert products[0].name == "Updated Wheat 25kg"
assert products[0].default_bag_size == 25
assert products[0].active is True