- 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.
292 lines
14 KiB
Python
Executable File
292 lines
14 KiB
Python
Executable File
#!/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()
|