This commit is contained in:
2026-05-31 20:19:44 +12:00
parent 2f2466ecac
commit 84792c0947
59 changed files with 5412 additions and 898 deletions
@@ -20,6 +20,7 @@ MODULE_CATALOG = (
("mix_calculator", "Mix Calculator", "production", "Create and review client-specific mix calculation sessions"),
("products", "Products", "pricing", "Review finished product pricing"),
("scenarios", "Scenarios", "planning", "Run scenario overrides and comparisons"),
("operations_throughput", "Operations Throughput", "production", "Log production throughput and QA checks for grain/feed packing"),
("powerbi_export", "Power BI Export", "reporting", "Expose client access data to BI consumers"),
("client_access", "Client Access", "administration", "Manage user access, module permissions, and audit history"),
)
@@ -78,15 +79,15 @@ def has_access_level(access_level: str | None, minimum_level: str) -> bool:
def default_access_level_for_role(role: str, module_key: str) -> str:
normalized = role.strip().lower()
if normalized == "superadmin":
return "manage" if module_key in {"client_access", "mix_calculator"} else "edit"
return "manage" if module_key in {"client_access", "mix_calculator", "operations_throughput"} else "edit"
if normalized == "admin":
if module_key == "mix_calculator":
if module_key in {"mix_calculator", "operations_throughput"}:
return "manage"
return "edit" if module_key != "client_access" else "none"
if normalized == "operator":
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios"} else "none"
return "edit" if module_key in {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "operations_throughput"} else "none"
if normalized == "viewer":
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export"} else "none"
return "view" if module_key in {"dashboard", "mix_calculator", "products", "powerbi_export", "operations_throughput"} else "none"
return "none"
+85 -3
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, selectinload
from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCostRule
from app.models.mix import Mix, MixIngredient
from app.models.product import Product
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
@@ -119,6 +119,78 @@ def calculate_mix_cost(db: Session, mix_id: int, overrides: dict | None = None)
}
def _calculate_formula_cost_from_product_ingredients(
product_ingredients: list[ProductIngredient],
overrides: dict | None = None,
) -> dict:
overrides = overrides or {}
total_mix_kg = 0.0
total_mix_cost = 0.0
warnings: list[str] = []
lines: list[dict] = []
for ingredient in product_ingredients:
raw_material = ingredient.raw_material
active_price = get_active_price(raw_material)
if active_price is None:
warnings.append(f"{raw_material.name} has no active price")
lines.append(
{
"id": ingredient.id,
"raw_material_id": raw_material.id,
"raw_material_name": raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"cost_per_kg": None,
"line_cost": None,
"notes": ingredient.notes,
}
)
total_mix_kg += ingredient.quantity_kg
continue
market_value = overrides.get("raw_material_market_values", {}).get(str(raw_material.id), active_price.market_value)
waste_percentage = overrides.get("raw_material_waste_percentages", {}).get(str(raw_material.id), active_price.waste_percentage)
price_stub = RawMaterialPriceVersion(
raw_material_id=raw_material.id,
market_value=market_value,
waste_percentage=waste_percentage,
effective_date=active_price.effective_date,
status=active_price.status,
)
price_comp = calculate_raw_material_cost(raw_material, price_stub)
line_cost = round(ingredient.quantity_kg * price_comp.cost_per_kg, 4)
total_mix_kg += ingredient.quantity_kg
total_mix_cost += line_cost
lines.append(
{
"id": ingredient.id,
"raw_material_id": raw_material.id,
"raw_material_name": raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"cost_per_kg": price_comp.cost_per_kg,
"line_cost": line_cost,
"notes": ingredient.notes,
}
)
if total_mix_kg == 0:
warnings.append("Mix total kg is zero")
mix_cost_per_kg = None
else:
mix_cost_per_kg = round(total_mix_cost / total_mix_kg, 4)
if not product_ingredients:
warnings.append("Mix has no ingredients")
return {
"ingredients": lines,
"total_mix_kg": round(total_mix_kg, 4),
"total_mix_cost": round(total_mix_cost, 4),
"mix_cost_per_kg": mix_cost_per_kg,
"warnings": warnings,
}
def _get_process_costs(db: Session, process_name: str | None, overrides: dict) -> tuple[float, float, float, list[str]]:
if not process_name:
return 0.0, 0.0, 0.0, ["Missing bagging process"]
@@ -192,12 +264,22 @@ def extract_unit_quantity_kg(unit_of_measure: str) -> float:
def calculate_product_cost(db: Session, product_id: int, overrides: dict | None = None) -> dict:
overrides = overrides or {}
overrides = {**overrides, "tenant_id": overrides.get("tenant_id")}
product = db.scalar(select(Product).where(Product.id == product_id).options(selectinload(Product.mix)))
product = db.scalar(
select(Product)
.where(Product.id == product_id)
.options(
selectinload(Product.mix),
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material).selectinload(RawMaterial.price_versions),
)
)
if product is None:
raise ValueError(f"Product {product_id} not found")
overrides["tenant_id"] = product.tenant_id
mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
if product.ingredients:
mix_result = _calculate_formula_cost_from_product_ingredients(product.ingredients, overrides=overrides)
else:
mix_result = calculate_mix_cost(db, product.mix_id, overrides=overrides)
warnings = list(mix_result["warnings"])
sale_unit_kg = extract_unit_quantity_kg(product.unit_of_measure)
@@ -3,8 +3,14 @@ from __future__ import annotations
import re
from app.models.mix_calculator import MixCalculatorSession
from app.schemas.mix_calculator import MixCalculatorPreviewRead
def mix_calculator_pdf_filename(session_record: MixCalculatorSession) -> str:
raw = f"{session_record.session_number}_{session_record.client_name}_{session_record.product_name}.pdf"
return re.sub(r"[^\w.\-]+", "_", raw)
def mix_calculator_preview_pdf_filename(preview: MixCalculatorPreviewRead) -> str:
raw = f"MixCalculator_{preview.client_name}_{preview.product_name}_{preview.mix_date}.pdf"
return re.sub(r"[^\w.\-]+", "_", raw)
+298 -240
View File
@@ -1,7 +1,8 @@
from __future__ import annotations
from io import BytesIO
from math import ceil
from pathlib import Path
from types import SimpleNamespace
from app.models.mix_calculator import MixCalculatorSession
@@ -24,272 +25,329 @@ def _fractional_bag_warning(session_record: MixCalculatorSession) -> str | None:
)
def build_mix_calculator_pdf(session_record: MixCalculatorSession) -> bytes:
def _coerce_pdf_source(source):
if isinstance(source, dict):
lines = [SimpleNamespace(**line) if isinstance(line, dict) else line for line in source.get("lines", [])]
return SimpleNamespace(**{**source, "lines": lines})
return source
def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> bytes:
session_record = _coerce_pdf_source(session_record)
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
except ModuleNotFoundError as exc:
raise MixCalculatorPdfUnavailableError(
"PDF generation is unavailable because 'reportlab' is not installed. "
"Install backend dependencies again to enable PDF export."
) from exc
page_width, page_height = A4
margin = 26
gutter = 10
content_width = page_width - (margin * 2)
page_top = page_height - 40
palette = {
"page": colors.HexColor("#FFFFFF"),
"line": colors.HexColor("#000000"),
"muted": colors.HexColor("#000000"),
"text": colors.HexColor("#000000"),
"warning_bg": colors.HexColor("#FFFFFF"),
"warning_text": colors.HexColor("#000000"),
}
logo_path = Path(__file__).resolve().parents[3] / "frontend" / "static" / "logo-hsf.png"
def clamp(value: float, minimum: float, maximum: float) -> float:
return max(minimum, min(maximum, value))
def fit_text(value: str, font_name: str, font_size: float, max_width: float) -> str:
if stringWidth(value, font_name, font_size) <= max_width:
return value
ellipsis = "..."
available = max_width - stringWidth(ellipsis, font_name, font_size)
trimmed = value
while trimmed and stringWidth(trimmed, font_name, font_size) > available:
trimmed = trimmed[:-1]
return f"{trimmed.rstrip()}{ellipsis}" if trimmed else ellipsis
def wrap_text(value: str, font_name: str, font_size: float, max_width: float, max_lines: int) -> list[str]:
words = value.split()
if not words:
return []
lines: list[str] = []
current = words[0]
for word in words[1:]:
candidate = f"{current} {word}"
if stringWidth(candidate, font_name, font_size) <= max_width:
current = candidate
else:
lines.append(current)
current = word
if len(lines) == max_lines - 1:
break
if len(lines) < max_lines:
lines.append(current)
remaining_words = words[len(" ".join(lines).split()) :]
if remaining_words and lines:
lines[-1] = fit_text(f"{lines[-1]} {' '.join(remaining_words)}", font_name, font_size, max_width)
return lines[:max_lines]
def draw_box(pdf: canvas.Canvas, x: float, y_top: float, width: float, height: float):
pdf.setFillColor(palette["page"])
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(1)
pdf.rect(x, y_top - height, width, height, fill=1, stroke=1)
def draw_label_value_card(
pdf: canvas.Canvas,
x: float,
y_top: float,
width: float,
height: float,
label: str,
value: str,
subtitle: str | None = None,
value_font_size: float = 14,
):
draw_box(pdf, x, y_top, width, height)
inset_x = x + 14
label_y = y_top - 16
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 7.5)
pdf.drawString(inset_x, label_y, label.upper())
value_y = y_top - 38
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica-Bold", value_font_size)
pdf.drawString(inset_x, value_y, fit_text(value, "Helvetica-Bold", value_font_size, width - 28))
if subtitle:
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica", 8)
pdf.drawString(inset_x, y_top - height + 14, fit_text(subtitle, "Helvetica", 8, width - 28))
buffer = BytesIO()
document = SimpleDocTemplate(
buffer,
pagesize=A4,
leftMargin=14 * mm,
rightMargin=14 * mm,
topMargin=14 * mm,
bottomMargin=14 * mm,
title=f"{session_record.session_number} - {session_record.product_name}",
author="Lean 101 Clients",
pdf = canvas.Canvas(buffer, pagesize=A4)
session_number = getattr(session_record, "session_number", None)
document_title = (
f"{session_number} - {session_record.product_name}"
if session_number
else f"Mix Calculator - {session_record.product_name}"
)
pdf.setTitle(document_title)
pdf.setAuthor("Lean 101 Clients")
styles = getSampleStyleSheet()
eyebrow = ParagraphStyle(
"Eyebrow",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=8,
leading=10,
textColor=colors.HexColor("#62736B"),
spaceAfter=5,
)
title = ParagraphStyle(
"Title",
parent=styles["Heading1"],
fontName="Helvetica-Bold",
fontSize=24,
leading=26,
textColor=colors.HexColor("#21312A"),
spaceAfter=6,
)
subtitle = ParagraphStyle(
"Subtitle",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=10,
leading=13,
textColor=colors.HexColor("#6B7A73"),
)
label = ParagraphStyle(
"Label",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=7,
leading=9,
textColor=colors.HexColor("#6B7A73"),
)
value = ParagraphStyle(
"Value",
parent=styles["BodyText"],
fontName="Helvetica-Bold",
fontSize=11,
leading=13,
textColor=colors.HexColor("#21312A"),
)
card_value = ParagraphStyle(
"CardValue",
parent=value,
fontSize=16,
leading=18,
)
body = ParagraphStyle(
"Body",
parent=styles["BodyText"],
fontName="Helvetica",
fontSize=9,
leading=12,
textColor=colors.HexColor("#304038"),
)
section_title = ParagraphStyle(
"SectionTitle",
parent=styles["Heading2"],
fontName="Helvetica-Bold",
fontSize=13,
leading=15,
textColor=colors.HexColor("#21312A"),
)
pdf.setFillColor(palette["page"])
pdf.rect(0, 0, page_width, page_height, stroke=0, fill=1)
warnings = []
bag_warning = _fractional_bag_warning(session_record)
if bag_warning:
warnings.append(bag_warning)
current_y = page_top
mix_date_label = f"{session_record.mix_date.day} {session_record.mix_date.strftime('%B %Y')}"
story = [
Paragraph(f"Mix Calculator | {session_record.session_number}", eyebrow),
Paragraph(session_record.product_name, title),
Paragraph(f"{session_record.client_name} &nbsp;&middot;&nbsp; {session_record.mix_name}", subtitle),
Spacer(1, 8),
]
if logo_path.exists():
logo_source = str(logo_path)
try:
from PIL import Image
header_table = Table(
[
[
[
Paragraph("Mix date", label),
Paragraph(session_record.mix_date.strftime("%d %b %Y"), value),
],
[
Paragraph("Prepared by", label),
Paragraph(session_record.prepared_by_name, value),
],
[
Paragraph("Status", label),
Paragraph(session_record.status.title(), value),
],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
)
header_table.setStyle(
TableStyle(
[
("VALIGN", (0, 0), (-1, -1), "TOP"),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.white),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 9),
("BOTTOMPADDING", (0, 0), (-1, -1), 9),
]
logo_source = Image.open(logo_path).convert("L")
except ModuleNotFoundError:
pass
logo_reader = ImageReader(logo_source)
logo_width, logo_height = logo_reader.getSize()
aspect_ratio = logo_height / max(logo_width, 1)
draw_width = 108
draw_height = draw_width * aspect_ratio
pdf.drawImage(
logo_reader,
margin,
current_y - draw_height,
width=draw_width,
height=draw_height,
preserveAspectRatio=True,
mask="auto",
)
)
story.extend([header_table, Spacer(1, 10)])
current_y -= draw_height + 20
summary_table = Table(
[
[
[Paragraph("Batch size", label), Paragraph(f"{_fmt_number(session_record.batch_size_kg)}kg", card_value)],
[Paragraph("Total output", label), Paragraph(f"{_fmt_number(session_record.total_kg)}kg", card_value)],
[Paragraph("Bags", label), Paragraph(_fmt_number(session_record.total_bags), card_value)],
[Paragraph("Unit pack", label), Paragraph(f"{_fmt_number(session_record.product_unit_size_kg)}kg", card_value)],
]
],
colWidths=[43 * mm, 43 * mm, 43 * mm, 43 * mm],
)
summary_table.setStyle(
TableStyle(
[
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("INNERGRID", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F9FBFA")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
)
story.extend([summary_table, Spacer(1, 10)])
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica-Bold", 15)
pdf.drawString(margin, current_y, "Calculated Output")
current_y -= 16
detail_table = Table(
[
[
[Paragraph("Mix source", label), Paragraph(session_record.mix_name, value), Paragraph(f"Saved against {session_record.product_unit_of_measure} units.", body)],
[Paragraph("Composition", label), Paragraph(f"{_fmt_number(sum(line.mix_percentage for line in session_record.lines))}%", value), Paragraph(f"{len(session_record.lines)} raw material{'s' if len(session_record.lines) != 1 else ''} in the blend.", body)],
[Paragraph("Estimated pages", label), Paragraph(str(max(1, ceil(len(session_record.lines) / 18))), value), Paragraph("Formatted for A4 PDF export.", body)],
]
],
colWidths=[60 * mm, 60 * mm, 52 * mm],
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica", 10)
pdf.drawString(margin, current_y, "Snapshot of the scaled raw material requirements.")
current_y -= 20
stat_height = 66
stat_width = (content_width - (gutter * 2)) / 3
draw_label_value_card(
pdf,
margin,
current_y,
stat_width,
stat_height,
"Total kg",
_fmt_number(session_record.total_kg),
"Scaled batch size",
value_font_size=18,
)
detail_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#F4F8F5")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
draw_label_value_card(
pdf,
margin + stat_width + gutter,
current_y,
stat_width,
stat_height,
"Total bags",
_fmt_number(session_record.total_bags),
session_record.product_unit_of_measure,
value_font_size=18,
)
story.extend([detail_table, Spacer(1, 10)])
draw_label_value_card(
pdf,
margin + ((stat_width + gutter) * 2),
current_y,
stat_width,
stat_height,
"Prepared by",
session_record.prepared_by_name,
mix_date_label,
value_font_size=10.5,
)
current_y -= stat_height + 12
detail_height = 52
detail_width = (content_width - gutter) / 2
draw_label_value_card(
pdf,
margin,
current_y,
detail_width,
detail_height,
"Client",
session_record.client_name,
value_font_size=12,
)
draw_label_value_card(
pdf,
margin + detail_width + gutter,
current_y,
detail_width,
detail_height,
"Product",
session_record.product_name,
value_font_size=12,
)
current_y -= detail_height + 8
draw_label_value_card(
pdf,
margin,
current_y,
detail_width,
detail_height,
"Mix source",
session_record.mix_name,
value_font_size=11,
)
draw_label_value_card(
pdf,
margin + detail_width + gutter,
current_y,
detail_width,
detail_height,
"Unit size",
f"{_fmt_number(session_record.product_unit_size_kg)}kg",
value_font_size=12,
)
current_y -= detail_height + 10
warning = _fractional_bag_warning(session_record)
note_lines: list[str] = []
warning_lines: list[str] = []
strip_height = 0
if session_record.notes:
notes_table = Table(
[[Paragraph("Notes", label)], [Paragraph(session_record.notes.replace("\n", "<br/>"), body)]],
colWidths=[172 * mm],
)
notes_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#F4F8F5")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#DBE4DE")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([notes_table, Spacer(1, 10)])
note_lines = wrap_text(session_record.notes.replace("\n", " "), "Helvetica", 7.5, content_width - 28, 2)
strip_height += 30
if warning:
warning_lines = wrap_text(warning, "Helvetica", 7.5, content_width - 28, 2)
strip_height += 30
if strip_height:
strip_height += 6
if warnings:
warning_rows = [[Paragraph("Warnings", label)]]
warning_rows.extend([[Paragraph(warning, body)] for warning in warnings])
warnings_table = Table(warning_rows, colWidths=[172 * mm])
warnings_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#FFF5E6")),
("BACKGROUND", (0, 1), (-1, -1), colors.HexColor("#FFF9EF")),
("BOX", (0, 0), (-1, -1), 0.8, colors.HexColor("#E8C483")),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
story.extend([warnings_table, Spacer(1, 10)])
table_header_height = 24
table_bottom_padding = 12
table_top = current_y
available_table_height = table_top - margin - strip_height - table_header_height - table_bottom_padding
row_count = max(len(session_record.lines), 1)
row_height = clamp(available_table_height / row_count, 16, 32)
table_font_size = clamp(row_height * 0.36, 7, 11)
table_height = table_header_height + (row_height * row_count)
table_bottom = table_top - table_height
story.extend(
[
Paragraph("Required Raw Materials", label),
Paragraph("Blend composition", section_title),
Paragraph(f"{session_record.product_unit_of_measure} · {_fmt_number(session_record.product_unit_size_kg)}kg per unit", subtitle),
Spacer(1, 6),
]
)
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 7.5)
pdf.drawString(margin + 4, table_top - 7, "RAW MATERIAL")
pdf.drawString(margin + content_width - 190, table_top - 7, "REQUIRED KG")
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(0.8)
pdf.line(margin, table_top - table_header_height, margin + content_width, table_top - table_header_height)
left_col_x = margin + 6
right_col_x = margin + content_width - 190
y_cursor = table_top - table_header_height
table_rows = [["Raw material", "Mix %", "Required kg", "Unit"]]
for line in session_record.lines:
table_rows.append(
[
Paragraph(f"<b>{line.raw_material_name}</b>", body),
Paragraph(f"{_fmt_number(line.mix_percentage)}%", body),
Paragraph(f"{_fmt_number(line.required_kg)}kg", body),
Paragraph(line.unit, body),
]
)
y_cursor -= row_height
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(0.6)
pdf.line(margin, y_cursor, margin + content_width, y_cursor)
composition_table = Table(table_rows, colWidths=[88 * mm, 24 * mm, 34 * mm, 26 * mm], repeatRows=1)
composition_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#EEF4F0")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#4F6158")),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("BOTTOMPADDING", (0, 0), (-1, 0), 8),
("TOPPADDING", (0, 0), (-1, 0), 8),
("LEFTPADDING", (0, 0), (-1, -1), 9),
("RIGHTPADDING", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.6, colors.HexColor("#DBE4DE")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#FBFCFB")]),
]
text_y = y_cursor + (row_height / 2) - (table_font_size * 0.35)
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica-Bold", table_font_size)
pdf.drawString(
left_col_x,
text_y,
fit_text(line.raw_material_name, "Helvetica-Bold", table_font_size, content_width - 210),
)
)
story.append(composition_table)
pdf.setFont("Helvetica", table_font_size)
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg")
document.build(story)
strip_y = table_bottom - 6
if note_lines:
note_height = 24 if len(note_lines) == 1 else 30
pdf.setFillColor(palette["page"])
pdf.setStrokeColor(palette["line"])
pdf.rect(margin, strip_y - note_height, content_width, note_height, fill=1, stroke=1)
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 7)
pdf.drawString(margin + 10, strip_y - 10, "NOTES")
pdf.setFillColor(palette["text"])
pdf.setFont("Helvetica", 7.5)
for idx, text in enumerate(note_lines):
pdf.drawString(margin + 10, strip_y - 20 - (idx * 8), text)
strip_y -= note_height + 6
if warning_lines:
warning_height = 24 if len(warning_lines) == 1 else 30
pdf.setFillColor(palette["warning_bg"])
pdf.setStrokeColor(palette["line"])
pdf.rect(margin, strip_y - warning_height, content_width, warning_height, fill=1, stroke=1)
pdf.setFillColor(palette["warning_text"])
pdf.setFont("Helvetica-Bold", 7)
pdf.drawString(margin + 10, strip_y - 10, "WARNING")
pdf.setFont("Helvetica", 7.5)
for idx, text in enumerate(warning_lines):
pdf.drawString(margin + 10, strip_y - 20 - (idx * 8), text)
pdf.showPage()
pdf.save()
return buffer.getvalue()
+57 -21
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession
from app.models.mix import Mix, MixIngredient
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.product import Product
from app.models.product import Product, ProductIngredient
from app.schemas.mix_calculator import MixCalculatorSessionCreate, MixCalculatorSessionUpdate
from app.services.costing_engine import extract_unit_quantity_kg
@@ -28,10 +28,44 @@ def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int)
return db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True))
.options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
.options(
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
)
)
def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
if product.ingredients:
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure,
"sort_order": ingredient.sort_order,
}
for ingredient in product.ingredients
if ingredient.raw_material is not None
]
elif product.mix is not None:
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
"sort_order": index,
}
for index, ingredient in enumerate(product.mix.ingredients, start=1)
]
else:
rows = []
rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"]))
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_measure: str) -> str | None:
rounded_bags = round(total_bags)
if abs(total_bags - rounded_bags) < 1e-9:
@@ -54,12 +88,9 @@ def calculate_mix_calculator_preview(
raise ValueError("Product not found")
if product.client_name != values["client_name"]:
raise ValueError("Selected product does not belong to the chosen client")
if product.mix is None:
raise ValueError("Product mix is not configured")
source_total_kg = round(sum(ingredient.quantity_kg for ingredient in product.mix.ingredients), 4)
formula_rows, source_total_kg = _resolved_formula_rows(product)
if source_total_kg <= 0:
raise ValueError("Product mix has no source kilograms to scale")
raise ValueError("Product has no source kilograms to scale")
batch_size_kg = float(values["batch_size_kg"])
scale_factor = batch_size_kg / source_total_kg
@@ -72,18 +103,17 @@ def calculate_mix_calculator_preview(
warnings.append(bag_warning)
lines = []
for index, ingredient in enumerate(product.mix.ingredients, start=1):
mix_percentage = round((ingredient.quantity_kg / source_total_kg) * 100, 4)
required_kg = round(ingredient.quantity_kg * scale_factor, 4)
raw_material = ingredient.raw_material
for index, ingredient in enumerate(formula_rows, start=1):
mix_percentage = round((ingredient["quantity_kg"] / source_total_kg) * 100, 4)
required_kg = round(ingredient["quantity_kg"] * scale_factor, 4)
lines.append(
{
"raw_material_id": raw_material.id if raw_material is not None else ingredient.raw_material_id,
"raw_material_name": raw_material.name if raw_material is not None else f"Raw material {ingredient.raw_material_id}",
"raw_material_id": ingredient["raw_material_id"],
"raw_material_name": ingredient["raw_material_name"],
"required_kg": required_kg,
"mix_percentage": mix_percentage,
"unit": raw_material.unit_of_measure if raw_material is not None else "kg",
"sort_order": index,
"unit": ingredient["unit"],
"sort_order": ingredient["sort_order"] or index,
}
)
@@ -92,7 +122,7 @@ def calculate_mix_calculator_preview(
"product_id": product.id,
"product_name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name,
"mix_name": product.mix.name if product.mix else product.name,
"mix_date": values["mix_date"],
"batch_size_kg": round(batch_size_kg, 4),
"total_bags": total_bags,
@@ -108,10 +138,16 @@ def calculate_mix_calculator_preview(
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
# Aggregate mix totals in a single query instead of loading every
# ingredient row for every product. The previous implementation was the
# main slow path on first Mix Calculator open — it streamed the entire
# tenant's recipe table just to compute one sum per product.
# Prefer product-specific formulas where present; fall back to the shared
# mix master for legacy rows that have not been migrated yet.
product_totals_rows = db.execute(
select(ProductIngredient.product_id, func.coalesce(func.sum(ProductIngredient.quantity_kg), 0.0))
.join(Product, Product.id == ProductIngredient.product_id)
.where(Product.tenant_id == tenant_id)
.group_by(ProductIngredient.product_id)
).all()
product_totals: dict[int, float] = {product_id: round(total or 0.0, 4) for product_id, total in product_totals_rows}
mix_totals_rows = db.execute(
select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0))
.join(Mix, Mix.id == MixIngredient.mix_id)
@@ -137,7 +173,7 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
"mix_name": product.mix.name if product.mix else "",
"unit_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"mix_total_kg": mix_totals.get(product.mix_id, 0.0),
"mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
}
for product in products
]
+348
View File
@@ -0,0 +1,348 @@
from __future__ import annotations
import logging
from datetime import date, datetime
from pathlib import Path
from typing import Iterable
from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.throughput import ProductionThroughput, ThroughputProduct
logger = logging.getLogger("data_entry_app.throughput")
PRODUCTION_SHEET = "Production"
NAMES_SHEET = "Names"
# Anything at or above this kg/bag is treated as a bulka batch, not a per-bag count.
_BULKA_BAG_SIZE_THRESHOLD = 100.0
def normalise_staff_name(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text:
return None
# Collapse internal whitespace, title-case for consistency.
cleaned = " ".join(text.split())
return cleaned
def calculate_kg(quantity: float | None, quantity_type: str, bag_size: float | None) -> float:
if quantity is None:
return 0.0
if quantity_type == "kg":
return float(quantity)
if bag_size is None:
return 0.0
return float(quantity) * float(bag_size)
def qa_passed(entry: ProductionThroughput) -> bool:
return bool(entry.scales_checked and entry.label_correct and entry.bag_sealed and entry.pallet_good_condition)
def serialize_entry(entry: ProductionThroughput) -> dict:
return {
"id": entry.id,
"tenant_id": entry.tenant_id,
"production_date": entry.production_date,
"product_id": entry.product_id,
"product_name_snapshot": entry.product_name_snapshot,
"bag_size": entry.bag_size,
"scales_checked": entry.scales_checked,
"label_correct": entry.label_correct,
"bag_sealed": entry.bag_sealed,
"pallet_good_condition": entry.pallet_good_condition,
"sample_box_no": entry.sample_box_no,
"test_weight_1": entry.test_weight_1,
"test_weight_2": entry.test_weight_2,
"test_weight_3": entry.test_weight_3,
"test_weight_4": entry.test_weight_4,
"test_weight_5": entry.test_weight_5,
"quantity": entry.quantity,
"quantity_type": entry.quantity_type,
"calculated_kg": entry.calculated_kg,
"staff_name": entry.staff_name,
"notes": entry.notes,
"qa_passed": qa_passed(entry),
"created_by": entry.created_by,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
def _coerce_bool(value: object) -> bool:
if isinstance(value, bool):
return value
if value is None:
return True
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in {"yes", "y", "true", "1", "pass", "ok", "x", "checked"}:
return True
if text in {"no", "n", "false", "0", "fail"}:
return False
return True
def _coerce_float(value: object) -> float | None:
if value is None or value == "":
return None
if isinstance(value, bool):
return float(value)
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", "")
if not text:
return None
try:
return float(text)
except ValueError:
return None
def _coerce_text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text or text.lower() in {"#value!", "#n/a", "n/a"}:
return None
return text
def _coerce_date(value: object) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
if not text:
return None
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def _infer_bulka_default(name: str, bag_size: float | None) -> bool:
lowered = name.lower()
if "bulka" in lowered:
return True
if bag_size is None:
return False
return bag_size >= _BULKA_BAG_SIZE_THRESHOLD
def import_names_sheet(db: Session, workbook, tenant_id: str) -> tuple[int, int]:
"""Upsert product master from the Names sheet. Returns (created, updated)."""
if NAMES_SHEET not in workbook.sheetnames:
return (0, 0)
ws = workbook[NAMES_SHEET]
existing: dict[tuple[str, str | None], ThroughputProduct] = {}
by_item: dict[str, ThroughputProduct] = {}
by_name: dict[str, ThroughputProduct] = {}
for product in db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
).all():
if product.item_id:
by_item[str(product.item_id)] = product
by_name[product.name.lower()] = product
created = 0
updated = 0
for row in ws.iter_rows(min_row=2, values_only=True):
if not row:
continue
name = _coerce_text(row[0] if len(row) > 0 else None)
if not name:
continue
item_id_raw = row[1] if len(row) > 1 else None
item_id = None
if item_id_raw is not None:
if isinstance(item_id_raw, float) and item_id_raw.is_integer():
item_id = str(int(item_id_raw))
else:
item_id = _coerce_text(item_id_raw)
product = (by_item.get(item_id) if item_id else None) or by_name.get(name.lower())
if product is None:
product = ThroughputProduct(
tenant_id=tenant_id,
item_id=item_id,
name=name,
default_bag_size=None,
is_bulka_default="bulka" in name.lower(),
active=True,
notes="Imported from Operations Throughput.xlsx",
)
db.add(product)
created += 1
if item_id:
by_item[item_id] = product
by_name[name.lower()] = product
else:
if item_id and not product.item_id:
product.item_id = item_id
if name and product.name != name:
product.name = name
updated += 1
db.flush()
return (created, updated)
def import_production_sheet(db: Session, workbook, tenant_id: str) -> tuple[int, int]:
"""Import the Production sheet. Returns (imported, skipped)."""
if PRODUCTION_SHEET not in workbook.sheetnames:
return (0, 0)
ws = workbook[PRODUCTION_SHEET]
# Header row is row 3 in the sheet (rows 1 and 2 are display banners).
products_by_name: dict[str, ThroughputProduct] = {
product.name.lower(): product
for product in db.scalars(
select(ThroughputProduct).where(ThroughputProduct.tenant_id == tenant_id)
).all()
}
bag_size_seen: dict[int, list[float]] = {}
imported = 0
skipped = 0
for row in ws.iter_rows(min_row=4, values_only=True):
if not row or len(row) < 15:
skipped += 1
continue
production_date = _coerce_date(row[0])
product_name = _coerce_text(row[1])
if production_date is None or not product_name:
skipped += 1
continue
bag_size = _coerce_float(row[2])
scales = _coerce_bool(row[3])
label = _coerce_bool(row[4])
sealed = _coerce_bool(row[5])
pallet = _coerce_bool(row[6])
sample_box = _coerce_text(row[7])
tw1 = _coerce_float(row[8])
tw2 = _coerce_float(row[9])
tw3 = _coerce_float(row[10])
tw4 = _coerce_float(row[11])
tw5 = _coerce_float(row[12])
quantity = _coerce_float(row[13]) or 0.0
staff = normalise_staff_name(row[14])
notes = _coerce_text(row[15]) if len(row) > 15 else None
# Infer quantity_type: bulka-style rows have a blank or very large bag size.
if bag_size is None or bag_size >= _BULKA_BAG_SIZE_THRESHOLD or "bulka" in product_name.lower():
quantity_type = "kg"
else:
quantity_type = "bags"
product = products_by_name.get(product_name.lower())
if product is None:
product = ThroughputProduct(
tenant_id=tenant_id,
item_id=None,
name=product_name,
default_bag_size=bag_size,
is_bulka_default=_infer_bulka_default(product_name, bag_size),
active=True,
notes="Auto-created during Operations Throughput import",
)
db.add(product)
db.flush()
products_by_name[product_name.lower()] = product
if product.id is not None and bag_size is not None and bag_size > 0:
bag_size_seen.setdefault(product.id, []).append(bag_size)
calculated = calculate_kg(quantity, quantity_type, bag_size)
entry = ProductionThroughput(
tenant_id=tenant_id,
production_date=production_date,
product_id=product.id,
product_name_snapshot=product_name,
bag_size=bag_size,
scales_checked=scales,
label_correct=label,
bag_sealed=sealed,
pallet_good_condition=pallet,
sample_box_no=sample_box,
test_weight_1=tw1,
test_weight_2=tw2,
test_weight_3=tw3,
test_weight_4=tw4,
test_weight_5=tw5,
quantity=quantity,
quantity_type=quantity_type,
calculated_kg=calculated,
staff_name=staff,
notes=notes,
created_by="workbook-import",
)
db.add(entry)
imported += 1
# Backfill default_bag_size on products that don't have one but appear in entries.
for product_id, sizes in bag_size_seen.items():
product = db.get(ThroughputProduct, product_id)
if product and product.default_bag_size is None:
# Use the most common bag size seen.
common = max(set(sizes), key=sizes.count)
product.default_bag_size = common
if not product.is_bulka_default:
product.is_bulka_default = _infer_bulka_default(product.name, common)
db.flush()
return (imported, skipped)
def import_workbook(db: Session, workbook_path: Path, tenant_id: str) -> dict:
workbook = load_workbook(workbook_path, data_only=True)
products_created, products_updated = import_names_sheet(db, workbook, tenant_id)
entries_imported, entries_skipped = import_production_sheet(db, workbook, tenant_id)
return {
"products_created": products_created,
"products_updated": products_updated,
"entries_imported": entries_imported,
"entries_skipped": entries_skipped,
}
def workbook_candidates() -> Iterable[Path]:
repo_root = Path(__file__).resolve().parents[3]
candidates = [
repo_root / "Operations Throughput.xlsx",
repo_root.parent / "Operations Throughput.xlsx",
Path.cwd() / "Operations Throughput.xlsx",
Path("/srv/lean101-clients") / "Operations Throughput.xlsx",
Path("/app") / "Operations Throughput.xlsx",
]
seen: set[str] = set()
ordered: list[Path] = []
for candidate in candidates:
key = str(candidate)
if key in seen:
continue
seen.add(key)
ordered.append(candidate)
return ordered
def resolve_workbook_path() -> Path | None:
for candidate in workbook_candidates():
if candidate.exists():
return candidate
return None