Deployment Script, Postgres migration, UX improvements
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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()
|
||||
Reference in New Issue
Block a user