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()