from __future__ import annotations
from io import BytesIO
from math import ceil
from app.models.mix_calculator import MixCalculatorSession
class MixCalculatorPdfUnavailableError(RuntimeError):
pass
def _fmt_number(value: float, digits: int = 2) -> str:
return f"{value:.{digits}f}"
def _fractional_bag_warning(session_record: MixCalculatorSession) -> str | None:
rounded_bags = round(session_record.total_bags)
if abs(session_record.total_bags - rounded_bags) < 1e-9:
return None
return (
f"Batch size {session_record.batch_size_kg:g}kg produces {session_record.total_bags:.2f} bags "
f"for {session_record.product_unit_of_measure}. This is not a whole-bag quantity."
)
def build_mix_calculator_pdf(session_record: MixCalculatorSession) -> bytes:
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
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
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",
)
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"),
)
warnings = []
bag_warning = _fractional_bag_warning(session_record)
if bag_warning:
warnings.append(bag_warning)
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),
]
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),
]
)
)
story.extend([header_table, Spacer(1, 10)])
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)])
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],
)
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),
]
)
)
story.extend([detail_table, Spacer(1, 10)])
if session_record.notes:
notes_table = Table(
[[Paragraph("Notes", label)], [Paragraph(session_record.notes.replace("\n", "
"), 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)])
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)])
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),
]
)
table_rows = [["Raw material", "Mix %", "Required kg", "Unit"]]
for line in session_record.lines:
table_rows.append(
[
Paragraph(f"{line.raw_material_name}", body),
Paragraph(f"{_fmt_number(line.mix_percentage)}%", body),
Paragraph(f"{_fmt_number(line.required_kg)}kg", body),
Paragraph(line.unit, body),
]
)
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")]),
]
)
)
story.append(composition_table)
document.build(story)
return buffer.getvalue()