Files
data-entry-app/backend/app/services/mix_calculator_pdf.py
T
2026-06-03 15:09:21 +12:00

354 lines
12 KiB
Python

from __future__ import annotations
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
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 _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.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()
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")
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,
)
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,
)
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:
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)
table_font_size = clamp(row_height * 0.44, 8.5, 12.5)
table_height = table_header_height + (row_height * row_count)
table_bottom = table_top - table_height
pdf.setFillColor(palette["muted"])
pdf.setFont("Helvetica-Bold", 8.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
for line in session_record.lines:
y_cursor -= row_height
pdf.setStrokeColor(palette["line"])
pdf.setLineWidth(0.6)
pdf.line(margin, y_cursor, margin + content_width, y_cursor)
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),
)
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)
pdf.showPage()
pdf.save()
return buffer.getvalue()