296 lines
11 KiB
Python
296 lines
11 KiB
Python
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", "<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)])
|
|
|
|
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"<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),
|
|
]
|
|
)
|
|
|
|
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()
|