Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live. Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator). Fix: Security headers on all API responses (hardening) Add: New mix button available on the Mix Editor. Add: New ingredient button available on the Ingredient Editor
301 lines
11 KiB
Python
301 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, 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 (
|
|
ThroughputDeleteAllResult,
|
|
ThroughputEntryCreate,
|
|
ThroughputEntryRead,
|
|
ThroughputEntryUpdate,
|
|
ThroughputImportResult,
|
|
ThroughputProductCreate,
|
|
ThroughputProductRead,
|
|
ThroughputProductUpdate,
|
|
)
|
|
from app.services.throughput_service import (
|
|
calculate_kg,
|
|
import_entries_from_file,
|
|
normalise_staff_name,
|
|
serialize_entry,
|
|
)
|
|
|
|
# Uploaded files larger than this are rejected before we read them into memory.
|
|
_MAX_IMPORT_BYTES = 10 * 1024 * 1024 # 10 MB
|
|
|
|
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,
|
|
for_order=payload.for_order,
|
|
for_stock=payload.for_stock,
|
|
job_number=payload.job_number,
|
|
stock_quantity=payload.stock_quantity if payload.for_stock else None,
|
|
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.post("/import", response_model=ThroughputImportResult)
|
|
def import_entries(
|
|
file: UploadFile = File(...),
|
|
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
content = file.file.read()
|
|
if not content:
|
|
raise HTTPException(status_code=400, detail="The uploaded file is empty.")
|
|
if len(content) > _MAX_IMPORT_BYTES:
|
|
raise HTTPException(status_code=413, detail="File is too large. Keep uploads under 10 MB.")
|
|
|
|
try:
|
|
result = import_entries_from_file(
|
|
db,
|
|
filename=file.filename or "upload.csv",
|
|
content=content,
|
|
tenant_id=session.tenant_id,
|
|
created_by=session.email,
|
|
)
|
|
except ValueError as exc:
|
|
db.rollback()
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
return result
|
|
|
|
|
|
@router.delete("/entries", response_model=ThroughputDeleteAllResult)
|
|
def delete_all_entries(
|
|
# Clearing the log is part of correcting a bad import, so it sits at the same
|
|
# "edit" level as deleting a single entry. It is scoped to the caller's
|
|
# tenant, so one client can never wipe another's data.
|
|
session: AuthSession = Depends(require_client_module_access(MODULE_KEY, "edit")),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
deleted = db.query(ProductionThroughput).filter(
|
|
ProductionThroughput.tenant_id == session.tenant_id
|
|
).delete(synchronize_session=False)
|
|
db.commit()
|
|
return ThroughputDeleteAllResult(entries_deleted=deleted)
|
|
|
|
|
|
@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,
|
|
# Correcting a mistaken run is part of day-to-day operating, so deleting an
|
|
# entry sits at the same "edit" level as adding/editing one. (No throughput
|
|
# role is granted "manage", so requiring it here would 403 everyone.)
|
|
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")
|
|
db.delete(entry)
|
|
db.commit()
|
|
return None
|