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