2026-05-08 23:07:01 +12:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from io import BytesIO
|
2026-05-31 20:19:44 +12:00
|
|
|
from pathlib import Path
|
|
|
|
|
from types import SimpleNamespace
|
2026-05-08 23:07:01 +12:00
|
|
|
|
|
|
|
|
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."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-31 20:19:44 +12:00
|
|
|
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)
|
2026-05-08 23:07:01 +12:00
|
|
|
try:
|
|
|
|
|
from reportlab.lib import colors
|
|
|
|
|
from reportlab.lib.pagesizes import A4
|
2026-05-31 20:19:44 +12:00
|
|
|
from reportlab.lib.utils import ImageReader
|
|
|
|
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
|
|
|
|
from reportlab.pdfgen import canvas
|
2026-05-08 23:07:01 +12:00
|
|
|
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
|
|
|
|
|
|
2026-05-31 20:19:44 +12:00
|
|
|
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))
|
|
|
|
|
|
2026-05-08 23:07:01 +12:00
|
|
|
buffer = BytesIO()
|
2026-05-31 20:19:44 +12:00
|
|
|
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}"
|
2026-05-08 23:07:01 +12:00
|
|
|
)
|
2026-05-31 20:19:44 +12:00
|
|
|
pdf.setTitle(document_title)
|
|
|
|
|
pdf.setAuthor("Lean 101 Clients")
|
2026-05-08 23:07:01 +12:00
|
|
|
|
2026-05-31 20:19:44 +12:00
|
|
|
pdf.setFillColor(palette["page"])
|
|
|
|
|
pdf.rect(0, 0, page_width, page_height, stroke=0, fill=1)
|
|
|
|
|
|
|
|
|
|
current_y = page_top
|
|
|
|
|
mix_date_label = f"{session_record.mix_date.day} {session_record.mix_date.strftime('%B %Y')}"
|
|
|
|
|
|
|
|
|
|
if logo_path.exists():
|
|
|
|
|
logo_source = str(logo_path)
|
|
|
|
|
try:
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
current_y -= draw_height + 20
|
|
|
|
|
|
|
|
|
|
pdf.setFillColor(palette["text"])
|
|
|
|
|
pdf.setFont("Helvetica-Bold", 15)
|
|
|
|
|
pdf.drawString(margin, current_y, "Calculated Output")
|
|
|
|
|
current_y -= 16
|
|
|
|
|
|
|
|
|
|
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,
|
2026-05-08 23:07:01 +12:00
|
|
|
)
|
2026-05-31 20:19:44 +12:00
|
|
|
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,
|
2026-05-08 23:07:01 +12:00
|
|
|
)
|
2026-05-31 20:19:44 +12:00
|
|
|
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,
|
2026-05-08 23:07:01 +12:00
|
|
|
)
|
2026-05-31 20:19:44 +12:00
|
|
|
current_y -= stat_height + 12
|
2026-05-08 23:07:01 +12:00
|
|
|
|
2026-05-31 20:19:44 +12:00
|
|
|
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,
|
2026-05-08 23:07:01 +12:00
|
|
|
)
|
2026-05-31 20:19:44 +12:00
|
|
|
draw_label_value_card(
|
|
|
|
|
pdf,
|
|
|
|
|
margin + detail_width + gutter,
|
|
|
|
|
current_y,
|
|
|
|
|
detail_width,
|
|
|
|
|
detail_height,
|
2026-06-09 21:28:53 +12:00
|
|
|
"Mix",
|
2026-05-31 20:19:44 +12:00
|
|
|
session_record.product_name,
|
|
|
|
|
value_font_size=12,
|
2026-05-08 23:07:01 +12:00
|
|
|
)
|
2026-05-31 20:19:44 +12:00
|
|
|
current_y -= detail_height + 8
|
|
|
|
|
|
|
|
|
|
draw_label_value_card(
|
|
|
|
|
pdf,
|
|
|
|
|
margin,
|
|
|
|
|
current_y,
|
|
|
|
|
detail_width,
|
|
|
|
|
detail_height,
|
2026-06-09 21:28:53 +12:00
|
|
|
"Formula source",
|
2026-05-31 20:19:44 +12:00
|
|
|
session_record.mix_name,
|
|
|
|
|
value_font_size=11,
|
2026-05-08 23:07:01 +12:00
|
|
|
)
|
2026-05-31 20:19:44 +12:00
|
|
|
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,
|
2026-05-08 23:07:01 +12:00
|
|
|
)
|
2026-05-31 20:19:44 +12:00
|
|
|
current_y -= detail_height + 10
|
|
|
|
|
|
|
|
|
|
warning = _fractional_bag_warning(session_record)
|
|
|
|
|
note_lines: list[str] = []
|
|
|
|
|
warning_lines: list[str] = []
|
|
|
|
|
strip_height = 0
|
2026-05-08 23:07:01 +12:00
|
|
|
|
|
|
|
|
if session_record.notes:
|
2026-05-31 20:19:44 +12:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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)
|
2026-06-03 15:09:21 +12:00
|
|
|
table_font_size = clamp(row_height * 0.44, 8.5, 12.5)
|
2026-05-31 20:19:44 +12:00
|
|
|
table_height = table_header_height + (row_height * row_count)
|
|
|
|
|
table_bottom = table_top - table_height
|
|
|
|
|
|
|
|
|
|
pdf.setFillColor(palette["muted"])
|
2026-06-03 15:09:21 +12:00
|
|
|
pdf.setFont("Helvetica-Bold", 8.5)
|
2026-05-31 20:19:44 +12:00
|
|
|
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
|
2026-05-08 23:07:01 +12:00
|
|
|
|
|
|
|
|
for line in session_record.lines:
|
2026-05-31 20:19:44 +12:00
|
|
|
y_cursor -= row_height
|
|
|
|
|
pdf.setStrokeColor(palette["line"])
|
|
|
|
|
pdf.setLineWidth(0.6)
|
|
|
|
|
pdf.line(margin, y_cursor, margin + content_width, y_cursor)
|
2026-05-08 23:07:01 +12:00
|
|
|
|
2026-05-31 20:19:44 +12:00
|
|
|
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),
|
2026-05-08 23:07:01 +12:00
|
|
|
)
|
2026-05-31 20:19:44 +12:00
|
|
|
pdf.setFont("Helvetica", table_font_size)
|
|
|
|
|
pdf.drawString(right_col_x, text_y, f"{_fmt_number(line.required_kg)}kg")
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-08 23:07:01 +12:00
|
|
|
|
2026-05-31 20:19:44 +12:00
|
|
|
pdf.showPage()
|
|
|
|
|
pdf.save()
|
2026-05-08 23:07:01 +12:00
|
|
|
return buffer.getvalue()
|