Files
tilbudgivern/backend/pdf_generator.py
T
alex fc40f65fec Add project data, quotes, and real project details for Kunda a/s
- Created test_project_data.json with project details including customer information, project description, and counts for geometry, labor, materials, and quotes.
- Added test_quote_data.json containing company information, project details, geometry, labor, materials, and calculation summary.
- Introduced test_real_project.json with comprehensive project data including labor, materials, and detailed calculations.
- Generated updated_real_project_quote.pdf reflecting the latest project quote details.
2025-09-17 15:13:52 +02:00

292 lines
14 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
PDF Generator for Tilbudgivern
Uses ReportLab for reliable PDF generation on ARM/Raspberry Pi systems
"""
import sys
import json
import os
from datetime import datetime
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm, cm
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Image, Table, TableStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
from reportlab.lib import colors
from reportlab.lib.utils import ImageReader
from io import BytesIO
import base64
class TilbudgivernPDFGenerator:
def __init__(self):
self.styles = getSampleStyleSheet()
self.setup_custom_styles()
def setup_custom_styles(self):
"""Setup custom styles for the PDF"""
# Header style
self.styles.add(ParagraphStyle(
name='CompanyHeader',
parent=self.styles['Heading1'],
fontSize=16,
spaceAfter=6,
textColor=colors.darkblue,
alignment=TA_CENTER
))
# Quote header style
self.styles.add(ParagraphStyle(
name='QuoteHeader',
parent=self.styles['Heading2'],
fontSize=14,
spaceAfter=12,
textColor=colors.black,
alignment=TA_LEFT
))
# Body text with spacing
self.styles.add(ParagraphStyle(
name='BodySpaced',
parent=self.styles['Normal'],
fontSize=10,
spaceAfter=6,
leading=12
))
# Price text
self.styles.add(ParagraphStyle(
name='PriceText',
parent=self.styles['Normal'],
fontSize=11,
spaceAfter=4,
fontName='Helvetica-Bold'
))
def load_logo(self, logo_path):
"""Load company logo if available"""
try:
if os.path.exists(logo_path):
return Image(logo_path, width=60*mm, height=30*mm)
except Exception as e:
print(f"Warning: Could not load logo from {logo_path}: {e}")
return None
def safe_float(self, value, default=0):
"""Safely convert value to float"""
try:
if value is None:
return default
return float(value)
except (ValueError, TypeError):
return default
def safe_format_price(self, value, default=0):
"""Safely format value as price"""
try:
return f"{self.safe_float(value, default):.2f}"
except:
return f"{default:.2f}"
def generate_pdf(self, quote_data, output_path):
"""Generate PDF from quote data matching the exact HTML quote structure"""
try:
# Create document
doc = SimpleDocTemplate(
output_path,
pagesize=A4,
rightMargin=20*mm,
leftMargin=20*mm,
topMargin=20*mm,
bottomMargin=20*mm
)
# Build content
story = []
# Extract data
project = quote_data.get('project', {})
geometry = quote_data.get('geometry', {})
labor = quote_data.get('labor', {})
materials = quote_data.get('materials', [])
calculation = quote_data.get('calculation', {})
# Company header - exact match to HTML
story.append(Paragraph("TILBUD FRA Tømrer- og Snedkermester Mikael Holck", self.styles['CompanyHeader']))
story.append(Spacer(1, 10*mm))
# Customer and project info - exact match
story.append(Paragraph(f"Til: {project.get('customer_name', 'N/A')}", self.styles['BodySpaced']))
story.append(Paragraph(f"Projekt: {project.get('project_name', 'N/A')}", self.styles['BodySpaced']))
story.append(Paragraph(f"Dato: {datetime.now().strftime('%d.%m.%Y')}", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Greeting - exact match
story.append(Paragraph(f"Kære {project.get('customer_name', 'N/A')},", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
# Thank you message - exact match
story.append(Paragraph(f"Tak for din henvendelse vedrørende {project.get('project_description', 'dit projekt')}.", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
# Company motto - exact match
story.append(Paragraph("Measure once cut thrice - Dette er kernen i alt, hvad vi laver hos Tømrer- og Snedkermester Mikael Holck.", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Project description header - exact match
story.append(Paragraph("PROJEKTBESKRIVELSE:", self.styles['QuoteHeader']))
story.append(Paragraph("Tagrenovering hos os betyder, at du får mere end bare et nyt tag. Vi giver dig professionel rådgivning i valg af den rigtige tagbelægning, så løsningen passer til både husets udtryk, dit budget og holder i mange år.", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Work section header - exact match
story.append(Paragraph("ARBEJDE DER UDFØRES:", self.styles['QuoteHeader']))
# Work details - exact match to HTML format
if geometry.get('roof_type'):
story.append(Paragraph(f"• Tag type: {geometry['roof_type']}", self.styles['BodySpaced']))
if geometry.get('total_area'):
story.append(Paragraph(f"• Samlet areal: {geometry['total_area']} m²", self.styles['BodySpaced']))
if labor.get('total_work_hours'):
carpenter_count = labor.get('carpenter_count', 1)
hourly_rate = self.safe_float(labor.get('hourly_rate'), 580)
story.append(Paragraph(f"• Arbejdstimer: {labor['total_work_hours']} timer med {carpenter_count} tømrere", self.styles['BodySpaced']))
story.append(Paragraph(f"• Timepris: {self.safe_format_price(hourly_rate)} kr/time", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Materials section - exact match
story.append(Paragraph("MATERIALER:", self.styles['QuoteHeader']))
# Materials list - exact match to HTML format
material_total = 0
for material in materials:
material_category = material.get('material_category', 'materialer')
quantity = material.get('quantity', 1)
total_price = self.safe_float(material.get('total_price'), 0)
material_total += total_price
story.append(Paragraph(f"• {material_category}: {quantity} stk. - {self.safe_format_price(total_price)} kr", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
story.append(Paragraph("Vi indhenter tilbud fra flere leverandører, så du får den bedste balance mellem pris, kvalitet og holdbarhed. Alle materialer er godkendte og lever op til gældende byggestandarder.", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Price specification - exact match to HTML
story.append(Paragraph("PRISSPECIFIKATION:", self.styles['QuoteHeader']))
# Calculate values to match HTML exactly
try:
labor_cost = self.safe_float(calculation.get('total_labor_cost'), 0)
material_cost = self.safe_float(calculation.get('total_material_cost'), 285)
subtotal = labor_cost + material_cost
overhead_pct = 15.0
profit_pct = 20.0
vat_pct = 25.0
overhead = subtotal * (overhead_pct / 100)
profit = subtotal * (profit_pct / 100)
before_vat = subtotal + overhead + profit
vat = before_vat * (vat_pct / 100)
total = before_vat + vat
# Price lines - exact format match
story.append(Paragraph(f"Arbejdsløn: {self.safe_format_price(labor_cost)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Materialer: {self.safe_format_price(material_cost)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Subtotal: {self.safe_format_price(subtotal)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Overhead ({overhead_pct:.1f}%): {self.safe_format_price(overhead)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Fortjeneste ({profit_pct:.1f}%): {self.safe_format_price(profit)} kr", self.styles['BodySpaced']))
story.append(Paragraph(f"Moms ({vat_pct:.1f}%): {self.safe_format_price(vat)} kr", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
story.append(Paragraph(f"<b>SAMLET PRIS: {self.safe_format_price(total)} kr</b>", self.styles['PriceText']))
story.append(Spacer(1, 6*mm))
except Exception as e:
print(f"Error in price calculation: {e}")
print(f"Debug - calculation data: {calculation}")
# Fallback to simple pricing
story.append(Paragraph("PRISSPECIFIKATION:", self.styles['QuoteHeader']))
story.append(Paragraph("Priser beregnes efter aftale", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Company promise section - exact match
story.append(Paragraph("VORES LØFTE TIL DIG:", self.styles['QuoteHeader']))
story.append(Paragraph("• Præcision og ordentlighed i alt hvad vi laver", self.styles['BodySpaced']))
story.append(Paragraph("• Tradition og transformation går hånd i hånd", self.styles['BodySpaced']))
story.append(Paragraph("• Meningsfuldt, ordentligt og bæredygtigt håndværk", self.styles['BodySpaced']))
story.append(Paragraph("• Vi kommer til tiden og står inde for vores arbejde", self.styles['BodySpaced']))
story.append(Spacer(1, 3*mm))
story.append(Paragraph("Vi leverer altid det aftalte til tiden og med kvaliteten i top. Vores erfaring sikrer en flot og holdbar løsning, der øger både komforten og værdien af dit hjem.", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Guarantees section - exact match
story.append(Paragraph("GARANTIER OG SERVICE:", self.styles['QuoteHeader']))
story.append(Paragraph("• Gratis tagtjek tilbydes", self.styles['BodySpaced']))
story.append(Paragraph("• Professionel rådgivning i valg af løsninger", self.styles['BodySpaced']))
story.append(Paragraph("• Korrekt dokumentation og tryghed", self.styles['BodySpaced']))
story.append(Paragraph("• Kvalitet der holder i mange år", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Validity - exact match
story.append(Paragraph("<b>TILBUDDET ER GYLDIGT I 30 DAGE</b>", self.styles['PriceText']))
story.append(Spacer(1, 6*mm))
# Closing - exact match
story.append(Paragraph("Med venlig hilsen", self.styles['BodySpaced']))
story.append(Paragraph("Tømrer- og Snedkermester Mikael Holck", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Contact info - exact match
story.append(Paragraph("Kontakt:", self.styles['QuoteHeader']))
story.append(Paragraph("Telefon: XX XX XX XX", self.styles['BodySpaced']))
story.append(Paragraph("Email: [email protected]", self.styles['BodySpaced']))
story.append(Paragraph("Website: www.3byggetilbud.dk", self.styles['BodySpaced']))
story.append(Spacer(1, 6*mm))
# Final paragraph - exact match
story.append(Paragraph("Med fokus på kvalitet, præcision og tryghed leverer vi et resultat, du kan stole på. Vi vurderer altid tagets generelle tilstand og sikrer byggeteknisk korrekte løsninger.", self.styles['BodySpaced']))
# Build PDF
doc.build(story)
return True
except Exception as e:
print(f"Error generating PDF: {e}")
return False
def main():
"""Main function to handle command line arguments"""
if len(sys.argv) != 3:
print("Usage: python3 pdf_generator.py <json_data_file> <output_pdf_path>")
sys.exit(1)
json_file = sys.argv[1]
output_path = sys.argv[2]
try:
# Read quote data from JSON file
with open(json_file, 'r', encoding='utf-8') as f:
quote_data = json.load(f)
# Generate PDF
generator = TilbudgivernPDFGenerator()
success = generator.generate_pdf(quote_data, output_path)
if success:
print(f"SUCCESS: PDF generated at {output_path}")
sys.exit(0)
else:
print("ERROR: Failed to generate PDF")
sys.exit(1)
except FileNotFoundError:
print(f"ERROR: JSON file not found: {json_file}")
sys.exit(1)
except json.JSONDecodeError as e:
print(f"ERROR: Invalid JSON in file {json_file}: {e}")
sys.exit(1)
except Exception as e:
print(f"ERROR: {e}")
sys.exit(1)
if __name__ == "__main__":
main()