FEATURES IMPLEMENTED: ✅ Full GraphQL integration with Ordrestyring API ✅ Automatic offer creation from Tilbudsgiveren quotes ✅ Customer management (find existing or create new) ✅ Task/Opgave management with full quote description ✅ Material lines (tilbudslinjer) with pricing ✅ Internal notes in bemærkninger field ✅ Department assignment (Tømrer = ID 2) ✅ Status management (Tilbudsgiver Oprettet Tilbud) KEY COMPONENTS: 1. OrdrestyringQuoteService (backend/src/services/ordrestyringQuoteService.js) - generateDetailedQuoteDescription(): Creates formatted quote text - findOrCreateCustomer(): Handles customer, contact, delivery address - createOfferLines(): Updates default task & adds material lines - sendQuoteToOrdrestyring(): Main orchestration method - getOfferWithTasks(): Query offers with task details 2. Smart Task Management: - Finds and updates existing default 'Opgave 1' task - Renames to 'Tilbudsgivertekst' for clarity - Adds full 2000+ character quote description - Creates material lines under the task - Fallback: Creates new task if none exists 3. GraphQL Mutations & Queries: - createOffer: Creates offer with customer, status, department - updateOfferTask: Updates existing task with description - createOfferTask: Fallback for new task creation - createOfferLine: Adds material lines with pricing - Proper error handling and logging throughout 4. Database Integration: - Migration: add_ordrestyring_columns.sql - Tracks ordrestyring_offer_id, ordrestyring_offer_number - Status synchronization with ordrestyring_status - Timestamp tracking with ordrestyring_sent_at 5. Route Updates (backend/routes/ordrestyring.js): - POST /api/ordrestyring/send-quote-graphql - Optional customerId parameter (uses existing or creates new) - Validation for projectId and calculationId - Returns complete offer details TECHNICAL HIGHLIGHTS: • GraphQL Client: graphql-request v7.3.1 • API Endpoint: https://graphql.ordrestyring.dk/graphql • Authentication: Bearer token in headers • Error Handling: Graceful fallbacks, detailed logging • Field Discovery: Used introspection to find correct types - UpdateOfferTaskInput (not OfferTaskInput) - Task fields: header & text (not name & description) - productNumber required for offer lines TESTING: • Test scripts: test_roesevangen_ordrestyring.py • Verification: test_check_offer.js • Live testing with Project 22 (Røsevangen 44) • Confirmed: Single task with full description • Confirmed: 10 material lines with correct pricing • Confirmed: Department, status, and internal notes working RESULT: Complete end-to-end integration from Tilbudsgiveren → Ordrestyring - Creates professional offers with all data - Single 'Tilbudsgivertekst' task (not duplicate Opgave 1) - Full quote description visible in task - Material lines with quantities and prices - Ready for production use Co-authored-by: AI Assistant <copilot@github.com>
607 lines
31 KiB
Python
Executable File
607 lines
31 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, PageBreak
|
|
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()
|
|
|
|
# Company information
|
|
self.company_info = {
|
|
'name': 'Tømrer- og Snedkermester Mikael Holck ApS',
|
|
'address': 'Hornumvej 7',
|
|
'city': '4600 Køge',
|
|
'phone': '42 46 81 10',
|
|
'email': 'mail@mikaelholck.dk',
|
|
'website': 'www.mikaelholck.dk',
|
|
'cvr': '38178059',
|
|
'bank': 'Møns Bank',
|
|
'account': '6140 0002083754'
|
|
}
|
|
|
|
# Logo path
|
|
self.logo_path = '/mnt/HC_Volume_103713257/tilbudgivern/frontend/firmadata/firmabilleder/MIKHOL_logo.png'
|
|
|
|
def setup_custom_styles(self):
|
|
"""Setup custom styles for the PDF matching Mikael Holck professional format"""
|
|
# Company header - larger and centered
|
|
self.styles.add(ParagraphStyle(
|
|
name='CompanyHeader',
|
|
parent=self.styles['Heading1'],
|
|
fontSize=18,
|
|
spaceAfter=8,
|
|
spaceBefore=0,
|
|
textColor=colors.HexColor('#1a3a5c'),
|
|
alignment=TA_CENTER,
|
|
fontName='Helvetica-Bold'
|
|
))
|
|
|
|
# Section headers (ARBEJDE DER UDFØRES, MATERIALER, etc.)
|
|
self.styles.add(ParagraphStyle(
|
|
name='SectionHeader',
|
|
parent=self.styles['Heading2'],
|
|
fontSize=13,
|
|
spaceAfter=8,
|
|
spaceBefore=12,
|
|
textColor=colors.black,
|
|
alignment=TA_LEFT,
|
|
fontName='Helvetica-Bold',
|
|
underline=True
|
|
))
|
|
|
|
# Main task header (Skorsten, Udvidelse af værelse, etc.)
|
|
self.styles.add(ParagraphStyle(
|
|
name='MainTaskHeader',
|
|
parent=self.styles['Heading3'],
|
|
fontSize=12,
|
|
spaceAfter=6,
|
|
spaceBefore=8,
|
|
textColor=colors.black,
|
|
alignment=TA_LEFT,
|
|
fontName='Helvetica-Bold'
|
|
))
|
|
|
|
# Sub task header (Sikring omkring skorsten, Ny konstruktion, etc.)
|
|
self.styles.add(ParagraphStyle(
|
|
name='SubTaskHeader',
|
|
parent=self.styles['Normal'],
|
|
fontSize=11,
|
|
spaceAfter=4,
|
|
spaceBefore=6,
|
|
textColor=colors.black,
|
|
alignment=TA_LEFT,
|
|
fontName='Helvetica-Bold',
|
|
leftIndent=10
|
|
))
|
|
|
|
# Body text with spacing
|
|
self.styles.add(ParagraphStyle(
|
|
name='BodySpaced',
|
|
parent=self.styles['Normal'],
|
|
fontSize=10,
|
|
spaceAfter=5,
|
|
leading=14,
|
|
leftIndent=0
|
|
))
|
|
|
|
# Bullet point text (under subtasks)
|
|
self.styles.add(ParagraphStyle(
|
|
name='BulletText',
|
|
parent=self.styles['Normal'],
|
|
fontSize=10,
|
|
spaceAfter=3,
|
|
leading=13,
|
|
leftIndent=20,
|
|
bulletIndent=10
|
|
))
|
|
|
|
# Price text - bold and prominent
|
|
self.styles.add(ParagraphStyle(
|
|
name='PriceText',
|
|
parent=self.styles['Normal'],
|
|
fontSize=11,
|
|
spaceAfter=4,
|
|
fontName='Helvetica-Bold'
|
|
))
|
|
|
|
# Total price - larger and very prominent
|
|
self.styles.add(ParagraphStyle(
|
|
name='TotalPrice',
|
|
parent=self.styles['Normal'],
|
|
fontSize=13,
|
|
spaceAfter=6,
|
|
spaceBefore=4,
|
|
fontName='Helvetica-Bold',
|
|
textColor=colors.HexColor('#1a3a5c')
|
|
))
|
|
|
|
# Customer info style
|
|
self.styles.add(ParagraphStyle(
|
|
name='CustomerInfo',
|
|
parent=self.styles['Normal'],
|
|
fontSize=11,
|
|
spaceAfter=4,
|
|
leading=14
|
|
))
|
|
|
|
# Small note text (forbehold, noter)
|
|
self.styles.add(ParagraphStyle(
|
|
name='NoteText',
|
|
parent=self.styles['Normal'],
|
|
fontSize=9,
|
|
spaceAfter=3,
|
|
leading=11,
|
|
textColor=colors.grey,
|
|
fontName='Helvetica-Oblique'
|
|
))
|
|
|
|
def load_logo(self, logo_path=None):
|
|
"""Load company logo if available"""
|
|
try:
|
|
path = logo_path or self.logo_path
|
|
if os.path.exists(path):
|
|
return Image(path, width=50*mm, height=25*mm)
|
|
else:
|
|
print(f"Warning: Logo not found at {path}")
|
|
return None
|
|
except Exception as e:
|
|
print(f"Warning: Could not load logo from {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 Mikael Holck professional format with logos"""
|
|
try:
|
|
# Create document with proper margins
|
|
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', {})
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# HEADER SECTION WITH LOGOS
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
# Load logos
|
|
logo_mikhol = self.load_logo()
|
|
logo_byggaranti = self.load_logo('/mnt/HC_Volume_103713257/tilbudgivern/frontend/firmadata/firmabilleder/byggarenti_logo.png')
|
|
|
|
# Create header with both logos
|
|
if logo_mikhol and logo_byggaranti:
|
|
header_table = Table(
|
|
[[logo_mikhol, Paragraph("<b>TILBUD</b>", self.styles['CompanyHeader']), logo_byggaranti]],
|
|
colWidths=[50*mm, 60*mm, 50*mm]
|
|
)
|
|
header_table.setStyle(TableStyle([
|
|
('ALIGN', (0, 0), (0, 0), 'LEFT'),
|
|
('ALIGN', (1, 0), (1, 0), 'CENTER'),
|
|
('ALIGN', (2, 0), (2, 0), 'RIGHT'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
]))
|
|
story.append(header_table)
|
|
elif logo_mikhol:
|
|
header_table = Table(
|
|
[[logo_mikhol, Paragraph("<b>TILBUD</b>", self.styles['CompanyHeader'])]],
|
|
colWidths=[60*mm, 100*mm]
|
|
)
|
|
header_table.setStyle(TableStyle([
|
|
('ALIGN', (0, 0), (0, 0), 'LEFT'),
|
|
('ALIGN', (1, 0), (1, 0), 'RIGHT'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
]))
|
|
story.append(header_table)
|
|
else:
|
|
story.append(Paragraph("<b>TILBUD</b>", self.styles['CompanyHeader']))
|
|
|
|
story.append(Spacer(1, 10*mm))
|
|
|
|
# Customer information box
|
|
story.append(Paragraph(f"<b>{project.get('customer_name', 'N/A')}</b>", self.styles['CustomerInfo']))
|
|
story.append(Paragraph(project.get('customer_address', ''), self.styles['CustomerInfo']))
|
|
story.append(Spacer(1, 8*mm))
|
|
|
|
# Quote details aligned right
|
|
quote_details_data = [
|
|
['', f"Tilbudnr: {quote_data.get('quote_number', 'XXXX')}"],
|
|
['', f"Tilbudsdato: {datetime.now().strftime('%d-%m-%Y')}"],
|
|
['', f"Kundenr: {project.get('id', 'N/A')}"],
|
|
['', 'Side: 1/2']
|
|
]
|
|
quote_details = Table(quote_details_data, colWidths=[110*mm, 50*mm])
|
|
quote_details.setStyle(TableStyle([
|
|
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 11),
|
|
]))
|
|
story.append(quote_details)
|
|
story.append(Spacer(1, 8*mm))
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# INTRODUCTION
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph(f"<b>Tilbud på {project.get('project_description', 'arbejder')}.</b>",
|
|
self.styles['MainTaskHeader']))
|
|
story.append(Spacer(1, 4*mm))
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# MAIN WORK SECTION - Hierarchical structure
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("<b>ARBEJDE DER UDFØRES:</b>", self.styles['SectionHeader']))
|
|
story.append(Spacer(1, 3*mm))
|
|
|
|
# Main task description with hierarchical bullets
|
|
if geometry.get('roof_type'):
|
|
story.append(Paragraph(f"<b>{geometry['roof_type']}</b>", self.styles['MainTaskHeader']))
|
|
story.append(Spacer(1, 2*mm))
|
|
|
|
# Work details in hierarchical structure
|
|
story.append(Paragraph("Opmåling og forberedelse", self.styles['SubTaskHeader']))
|
|
if geometry.get('total_area'):
|
|
story.append(Paragraph(f"• Samlet areal: {geometry['total_area']} m²", self.styles['BulletText']))
|
|
if geometry.get('roof_pitch'):
|
|
story.append(Paragraph(f"• Taghældning: {geometry['roof_pitch']}°", self.styles['BulletText']))
|
|
story.append(Spacer(1, 2*mm))
|
|
|
|
story.append(Paragraph("Arbejdsudførelse", self.styles['SubTaskHeader']))
|
|
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ømrer{'e' if carpenter_count > 1 else ''}",
|
|
self.styles['BulletText']))
|
|
story.append(Paragraph(f"• Timepris: {self.safe_format_price(hourly_rate)} kr/time",
|
|
self.styles['BulletText']))
|
|
story.append(Paragraph("• Alt affald bortkøres", self.styles['BulletText']))
|
|
story.append(Paragraph("• Området ryddes og efterlades rent", self.styles['BulletText']))
|
|
|
|
story.append(Spacer(1, 6*mm))
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# MATERIALS SECTION
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("<b>MATERIALER:</b>", self.styles['SectionHeader']))
|
|
story.append(Spacer(1, 3*mm))
|
|
|
|
# Materials list with proper formatting
|
|
if materials:
|
|
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)
|
|
story.append(Paragraph(f"• {material_category}: {quantity} stk. - {self.safe_format_price(total_price)} kr",
|
|
self.styles['BulletText']))
|
|
else:
|
|
story.append(Paragraph("• Materialer specificeres efter endelig opmåling", self.styles['BulletText']))
|
|
|
|
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 SECTION - Clean and clear (UDEN overhead/fortjeneste til kunde)
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("<b>PRISSPECIFIKATION:</b>", self.styles['SectionHeader']))
|
|
story.append(Spacer(1, 3*mm))
|
|
|
|
# Calculate values
|
|
try:
|
|
labor_cost = self.safe_float(calculation.get('total_labor_cost'), 0)
|
|
material_cost = self.safe_float(calculation.get('total_material_cost'), 0)
|
|
subtotal = labor_cost + material_cost
|
|
overhead_pct = self.safe_float(calculation.get('overhead_percentage'), 15.0)
|
|
profit_pct = self.safe_float(calculation.get('profit_percentage'), 20.0)
|
|
vat_pct = self.safe_float(calculation.get('vat_percentage'), 25.0)
|
|
|
|
# Internal calculations (ikke vist til kunde)
|
|
overhead = subtotal * (overhead_pct / 100)
|
|
profit = subtotal * (profit_pct / 100)
|
|
before_vat = subtotal + overhead + profit
|
|
vat = before_vat * (vat_pct / 100)
|
|
total_with_vat = before_vat + vat
|
|
|
|
# Format prices with Danish thousand separator
|
|
def format_dk_price(amount):
|
|
"""Format price with Danish thousand separator (dot)"""
|
|
return f"{amount:,.2f}".replace(",", ".")
|
|
|
|
# Price table - KUN hvad kunden skal se
|
|
price_data = [
|
|
['Arbejdsløn:', f"{format_dk_price(labor_cost)} kr"],
|
|
['Materialer:', f"{format_dk_price(material_cost)} kr"],
|
|
['', ''], # Spacer
|
|
['Subtotal:', f"{format_dk_price(subtotal)} kr"],
|
|
[f'Moms ({vat_pct:.0f}%):', f"{format_dk_price(vat)} kr"],
|
|
]
|
|
|
|
price_table = Table(price_data, colWidths=[100*mm, 50*mm])
|
|
price_table.setStyle(TableStyle([
|
|
('ALIGN', (0, 0), (0, -1), 'LEFT'),
|
|
('ALIGN', (1, 0), (1, -1), 'RIGHT'),
|
|
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
|
|
('FONTSIZE', (0, 0), (-1, -1), 11),
|
|
('BOTTOMPADDING', (0, 0), (-1, -1), 5),
|
|
('TOPPADDING', (0, 0), (-1, -1), 5),
|
|
('LINEABOVE', (0, 3), (-1, 3), 1, colors.grey), # Line before subtotal
|
|
]))
|
|
story.append(price_table)
|
|
|
|
story.append(Spacer(1, 6*mm))
|
|
|
|
# Total with visual separation
|
|
separator_line = '─' * 60
|
|
story.append(Paragraph(separator_line, self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 2*mm))
|
|
|
|
story.append(Paragraph(f"<b>SAMLET PRIS EKS. MOMS: {format_dk_price(before_vat)} kr</b>",
|
|
self.styles['TotalPrice']))
|
|
|
|
story.append(Spacer(1, 3*mm))
|
|
story.append(Paragraph(f"<b>INKL. MOMS (25%): {format_dk_price(total_with_vat)} kr</b>",
|
|
self.styles['TotalPrice']))
|
|
|
|
except Exception as e:
|
|
print(f"Error in price calculation: {e}")
|
|
story.append(Paragraph("Priser beregnes efter aftale", self.styles['BodySpaced']))
|
|
|
|
story.append(Spacer(1, 8*mm))
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# DISCLAIMERS AND NOTES
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("*Hvis der bruges kortere tid på opgaven end beregnet, gives der et fradrag på prisen.",
|
|
self.styles['NoteText']))
|
|
story.append(Spacer(1, 6*mm))
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# COMPANY PROMISES
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("<b>VORES LØFTE TIL DIG:</b>", self.styles['SectionHeader']))
|
|
story.append(Paragraph("• Præcision og ordentlighed i alt hvad vi laver", self.styles['BulletText']))
|
|
story.append(Paragraph("• Tradition og transformation går hånd i hånd", self.styles['BulletText']))
|
|
story.append(Paragraph("• Meningsfuldt, ordentligt og bæredygtigt håndværk", self.styles['BulletText']))
|
|
story.append(Paragraph("• Vi kommer til tiden og står inde for vores arbejde", self.styles['BulletText']))
|
|
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
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("<b>GARANTIER OG SERVICE:</b>", self.styles['SectionHeader']))
|
|
story.append(Paragraph("• Gratis tagtjek tilbydes", self.styles['BulletText']))
|
|
story.append(Paragraph("• Professionel rådgivning i valg af løsninger", self.styles['BulletText']))
|
|
story.append(Paragraph("• Korrekt dokumentation og tryghed", self.styles['BulletText']))
|
|
story.append(Paragraph("• Kvalitet der holder i mange år", self.styles['BulletText']))
|
|
story.append(Spacer(1, 6*mm))
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# VALIDITY AND DISCLAIMERS
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("<b>TILBUDDET ER GYLDIGT I 30 DAGE</b>", self.styles['PriceText']))
|
|
story.append(Spacer(1, 4*mm))
|
|
story.append(Paragraph("Der tages forbehold for rød og svamp i eksisterende trækonstruktion.",
|
|
self.styles['NoteText']))
|
|
story.append(Spacer(1, 8*mm))
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# CLOSING
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("Med venlig hilsen", self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 10*mm))
|
|
story.append(Paragraph(f"<b>{self.company_info['name']}</b>", self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 2*mm))
|
|
story.append(Paragraph(f"{self.company_info['address']}, {self.company_info['city']}", self.styles['BodySpaced']))
|
|
story.append(Paragraph(f"CVR: {self.company_info['cvr']}", self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 4*mm))
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# CONTACT INFO
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("<b>Kontakt:</b>", self.styles['SubTaskHeader']))
|
|
story.append(Paragraph(f"Telefon: {self.company_info['phone']}", self.styles['BulletText']))
|
|
story.append(Paragraph(f"Email: {self.company_info['email']}", self.styles['BulletText']))
|
|
story.append(Paragraph(f"Website: {self.company_info['website']}", self.styles['BulletText']))
|
|
story.append(Spacer(1, 4*mm))
|
|
|
|
story.append(Paragraph(f"Bank: {self.company_info['bank']} - Kontonr.: {self.company_info['account']}", self.styles['NoteText']))
|
|
story.append(Spacer(1, 6*mm))
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# FOOTER TEXT
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(Paragraph("Med fokus på kvalitet, præcision og tryghed leverer vi et resultat, du kan stole på. Vi vurderer altid byggeteknisk korrekte løsninger og sikrer at arbejdet udføres efter gældende regler.",
|
|
self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 8*mm))
|
|
|
|
# Company footer
|
|
company_footer_data = [
|
|
[
|
|
Paragraph(f"<b>{self.company_info['name']}</b><br/>{self.company_info['address']}<br/>{self.company_info['city']}", self.styles['NoteText']),
|
|
Paragraph(f"Tlf: {self.company_info['phone']}<br/>Bank: {self.company_info['bank']}<br/>Kontonr: {self.company_info['account']}", self.styles['NoteText']),
|
|
Paragraph(f"CVR: {self.company_info['cvr']}<br/>{self.company_info['email']}<br/>{self.company_info['website']}", self.styles['NoteText'])
|
|
]
|
|
]
|
|
footer_table = Table(company_footer_data, colWidths=[53*mm, 53*mm, 54*mm])
|
|
footer_table.setStyle(TableStyle([
|
|
('ALIGN', (0, 0), (0, 0), 'LEFT'),
|
|
('ALIGN', (1, 0), (1, 0), 'CENTER'),
|
|
('ALIGN', (2, 0), (2, 0), 'RIGHT'),
|
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
]))
|
|
story.append(footer_table)
|
|
|
|
# ═══════════════════════════════════════════════════════════
|
|
# PAGE 2 - FIRMABESKRIVELSE
|
|
# ═══════════════════════════════════════════════════════════
|
|
|
|
story.append(PageBreak())
|
|
|
|
# Page 2 header with logos
|
|
if logo_mikhol and logo_byggaranti:
|
|
header_table2 = Table(
|
|
[[logo_mikhol, '', logo_byggaranti]],
|
|
colWidths=[50*mm, 60*mm, 50*mm]
|
|
)
|
|
header_table2.setStyle(TableStyle([
|
|
('ALIGN', (0, 0), (0, 0), 'LEFT'),
|
|
('ALIGN', (2, 0), (2, 0), 'RIGHT'),
|
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
|
]))
|
|
story.append(header_table2)
|
|
story.append(Spacer(1, 10*mm))
|
|
|
|
# Company description
|
|
story.append(Paragraph("Med mange års erfaring inden for snedker- og tømrerarbejde kan vi udføre en bred vifte af opgaver — Store som små. Vi brænder for at realisere drømme, og har gennem årene hjulpet privatpersoner, virksomheder og offentlige institutioner i mål med deres drømme.",
|
|
self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 4*mm))
|
|
|
|
story.append(Paragraph("Som professionelt tømrerfirma værner vi omkring høj kvalitet og god service. Vi er af den overbevisning at de bedste resultater skabes gennem tæt kunde. Derfor bliver dine ønsker og behov altid hørt hos os. Ud fra disse, og med vores brede faglighed, finder vi sammen den bedste løsning til den absolut bedste pris.",
|
|
self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 4*mm))
|
|
|
|
story.append(Paragraph(f"Hos {self.company_info['name']} går vi aldrig på kompromis med kvaliteten af vores arbejde. Hertil er vi medlem af Byg Garanti, hvilket er din garanti for veludført håndværk. Hos os er du en prioritet, og ikke bare endnu en ordre i kalenderen. Vi kommer derfor altid til tiden, og rydder selvfølgelig op efter os selv, når opgaven er udført.",
|
|
self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 6*mm))
|
|
|
|
# Section: Vores tid er vigtig
|
|
story.append(Paragraph("<b>Vores tid er vigtig</b>", self.styles['MainTaskHeader']))
|
|
story.append(Paragraph("Vi bruger hver dag mange timer på at regne tilbud, og derfor vil vi sætte stor pris på at du vender tilbage med feedback på det tilbud vi har givet dig.",
|
|
self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 4*mm))
|
|
|
|
story.append(Paragraph("Det er vigtigt for os med din feedback, og derfor vender vi også selv tilbage hvis vi ikke har hørt noget fra dig. Dette gør vi for at yde den bedste service til alle vi er i dialog med.",
|
|
self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 4*mm))
|
|
|
|
story.append(Paragraph('Husk at det "bedste tilbud" ikke altid er billigste. Det skal udføres korrekt og til den rigtige tid. Du skal kunne stole på din tømrer såvel som snedker — Og det kan du hos os.',
|
|
self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 6*mm))
|
|
|
|
# Section: Anmeld Håndværker
|
|
story.append(Paragraph("<b>Anmeld Håndværker</b>", self.styles['MainTaskHeader']))
|
|
story.append(Paragraph('Vi er medlem af Anmeld Håndværker, og der har vi mærket "ELITE HÅNDVÆRKER" på baggrund af vores mange gode anmeldelser.',
|
|
self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 4*mm))
|
|
|
|
story.append(Paragraph("På Anmeld håndværker har du mulighed for at se anmeldelser på opgaver vi har udført.<br/>Læs vores anmeldelser her: Anmeld Håndværker",
|
|
self.styles['BodySpaced']))
|
|
story.append(Spacer(1, 8*mm))
|
|
|
|
# Elite Håndværker logo
|
|
logo_elite = self.load_logo('/mnt/HC_Volume_103713257/tilbudgivern/frontend/firmadata/firmabilleder/elitehaandvaerker_logo.png')
|
|
if logo_elite:
|
|
elite_table = Table([[logo_elite]], colWidths=[60*mm])
|
|
elite_table.setStyle(TableStyle([
|
|
('ALIGN', (0, 0), (0, 0), 'CENTER'),
|
|
]))
|
|
story.append(elite_table)
|
|
story.append(Spacer(1, 10*mm))
|
|
|
|
# Footer on page 2
|
|
story.append(footer_table)
|
|
|
|
# Build PDF
|
|
doc.build(story)
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"Error generating PDF: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
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()
|