tweaks
This commit is contained in:
@@ -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