tweaks
This commit is contained in:
+14
-14
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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} · {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()
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user