Files
erp-nutzfahrzeuge/backend/app/utils/contract_pdf.py
T

262 lines
8.7 KiB
Python
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.
"""Contract PDF generation using WeasyPrint.
Generates a Verkaufvertrag (sales contract) PDF from an HTML template
with vehicle data, contact data, GwG-Klausel, and USt-IdNr. field.
"""
import asyncio
import os
from datetime import date
from decimal import Decimal
from typing import Any
# HTML template for the sales contract
_CONTRACT_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; font-size: 11pt; margin: 2cm; }}
h1 {{ text-align: center; font-size: 16pt; margin-bottom: 20px; }}
h2 {{ font-size: 13pt; border-bottom: 1px solid #333; padding-bottom: 3px; }}
table {{ width: 100%; border-collapse: collapse; margin: 10px 0; }}
td {{ padding: 4px 8px; vertical-align: top; }}
td.label {{ font-weight: bold; width: 35%; }}
.section {{ margin-bottom: 20px; }}
.gwg-clause {{
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 10px;
margin: 15px 0;
border-radius: 4px;
}}
.gwg-clause h3 {{ margin-top: 0; color: #856404; }}
.signature-block {{ margin-top: 40px; }}
.signature-line {{ border-top: 1px solid #333; width: 45%; margin-top: 50px; display: inline-block; text-align: center; font-size: 9pt; }}
.signature-spacer {{ width: 10%; display: inline-block; }}
.footer {{ margin-top: 30px; font-size: 9pt; color: #666; border-top: 1px solid #ccc; padding-top: 10px; }}
</style>
</head>
<body>
<h1>Kaufvertrag über ein Nutzfahrzeug</h1>
<div class="section">
<h2>1. Vertragsparteien</h2>
<table>
<tr><td class="label">Verkäufer:</td><td>{seller_name}<br>{seller_address}<br>{seller_country}</td></tr>
<tr><td class="label">USt-IdNr. (Verkäufer):</td><td>{seller_vat_id}</td></tr>
<tr><td class="label">Käufer:</td><td>{buyer_name}<br>{buyer_address}<br>{buyer_country}</td></tr>
<tr><td class="label">USt-IdNr. (Käufer):</td><td>{buyer_vat_id}</td></tr>
</table>
</div>
<div class="section">
<h2>2. Vertragsgegenstand</h2>
<table>
<tr><td class="label">Fahrzeug:</td><td>{vehicle_make} {vehicle_model}</td></tr>
<tr><td class="label">Fahrgestellnummer (FIN):</td><td>{vehicle_fin}</td></tr>
<tr><td class="label">Fahrzeugtyp:</td><td>{vehicle_type}</td></tr>
<tr><td class="label">Erstzulassung:</td><td>{first_registration}</td></tr>
<tr><td class="label">Kilometerstand:</td><td>{mileage_km} km</td></tr>
<tr><td class="label">Leistung:</td><td>{power_kw} kW ({power_hp} PS)</td></tr>
<tr><td class="label">Kraftstoff:</td><td>{fuel_type}</td></tr>
</table>
</div>
<div class="section">
<h2>3. Kaufpreis</h2>
<table>
<tr><td class="label">Kaufpreis:</td><td>{sale_price} EUR</td></tr>
<tr><td class="label">Verkaufsdatum:</td><td>{sale_date}</td></tr>
</table>
</div>
{gwg_section}
<div class="section">
<h2>4. Zahlungsbedingungen</h2>
<p>Der Kaufpreis ist bei Übergabe des Fahrzeugs fällig. Die Zahlung erfolgt per
Überweisung auf das Konto des Verkäufers.</p>
</div>
<div class="section">
<h2>5. Gewährleistung</h2>
<p>Das Fahrzeug wird unter Ausschluss der Sachmängelhaftung verkauft, soweit
nicht ausdrücklich etwas anderes vereinbart wurde.</p>
</div>
<div class="section">
<h2>6. Übergabe</h2>
<p>Das Fahrzeug wird am {sale_date} übergeben. Mit der Übergabe geht die
Gefahr auf den Käufer über.</p>
</div>
<div class="signature-block">
<div class="signature-line">Verkäufer</div>
<div class="signature-spacer"></div>
<div class="signature-line">Käufer</div>
</div>
<div class="footer">
Vertrag erstellt am {created_date} | Vertragsnummer: {contract_number}
</div>
</body>
</html>"""
_GWG_CLAUSE_HTML = """
<div class="gwg-clause">
<h3>Geringwertige Wirtschaftsgüter (GwG) § 6 Abs. 2 EStG</h3>
<p>Der Kaufpreis beträgt maximal 800 EUR. Nach § 6 Abs. 2 EStG kann der
Verkäufer den vollständigen Betrag im Jahr der Anschaffung als
Sofortabzug im Betriebsvermögen geltend machen. Die
Abschreibung erfolgt somit sofort und vollständig in der
Anschaffungsperiode.</p>
</div>
"""
def _format_contact_name(contact: Any) -> str:
"""Format contact name for the contract."""
if contact is None:
return "N/A"
return getattr(contact, "company_name", "N/A") or "N/A"
def _format_contact_address(contact: Any) -> str:
"""Format contact address for the contract."""
if contact is None:
return "N/A"
parts = []
street = getattr(contact, "address_street", None)
zip_code = getattr(contact, "address_zip", None)
city = getattr(contact, "address_city", None)
if street:
parts.append(street)
if zip_code and city:
parts.append(f"{zip_code} {city}")
elif city:
parts.append(city)
elif zip_code:
parts.append(zip_code)
return "<br>".join(parts) if parts else "N/A"
def _format_contact_country(contact: Any) -> str:
"""Format contact country for the contract."""
if contact is None:
return "N/A"
return getattr(contact, "address_country", "DE") or "DE"
def _format_vat_id(contact: Any) -> str:
"""Format VAT ID for the contract."""
if contact is None:
return "Nicht angegeben"
vat_id = getattr(contact, "vat_id", None)
return vat_id if vat_id else "Nicht angegeben"
def _format_price(price: Decimal) -> str:
"""Format price for the contract."""
if price is None:
return "0,00"
formatted = f"{price:,.2f}"
return formatted.replace(",", "X").replace(".", ",").replace("X", ".")
def _format_date(d: date) -> str:
"""Format date in German convention DD.MM.YYYY."""
if d is None:
return "N/A"
return d.strftime("%d.%m.%Y")
def build_contract_html(sale: Any) -> str:
"""Build the HTML contract from a Sale model instance.
Args:
sale: Sale model instance with nested vehicle, buyer, seller.
Returns:
HTML string for the contract.
"""
vehicle = getattr(sale, "vehicle", None)
buyer = getattr(sale, "buyer", None)
seller = getattr(sale, "seller", None)
# Determine GwG clause
gwg_section = ""
if (
sale.is_gwg
and sale.sale_price is not None
and sale.sale_price <= Decimal("800")
):
gwg_section = _GWG_CLAUSE_HTML
html = _CONTRACT_HTML_TEMPLATE.format(
seller_name=_format_contact_name(seller) if seller else "N/A",
seller_address=_format_contact_address(seller) if seller else "N/A",
seller_country=_format_contact_country(seller) if seller else "DE",
seller_vat_id=_format_vat_id(seller) if seller else "Nicht angegeben",
buyer_name=_format_contact_name(buyer),
buyer_address=_format_contact_address(buyer),
buyer_country=_format_contact_country(buyer),
buyer_vat_id=_format_vat_id(buyer),
vehicle_make=getattr(vehicle, "make", "N/A") if vehicle else "N/A",
vehicle_model=getattr(vehicle, "model", "N/A") if vehicle else "N/A",
vehicle_fin=getattr(vehicle, "fin", "N/A") if vehicle else "N/A",
vehicle_type=getattr(vehicle, "vehicle_type", "N/A") if vehicle else "N/A",
first_registration=_format_date(getattr(vehicle, "first_registration", None))
if vehicle
else "N/A",
mileage_km=getattr(vehicle, "mileage_km", "N/A") if vehicle else "N/A",
power_kw=getattr(vehicle, "power_kw", "N/A") if vehicle else "N/A",
power_hp=getattr(vehicle, "power_hp", "N/A") if vehicle else "N/A",
fuel_type=getattr(vehicle, "fuel_type", "N/A") if vehicle else "N/A",
sale_price=_format_price(sale.sale_price),
sale_date=_format_date(sale.sale_date),
gwg_section=gwg_section,
created_date=_format_date(date.today()),
contract_number=str(sale.id)[:8].upper(),
)
return html
def generate_contract_pdf_sync(sale: Any, output_dir: str = "/tmp/contracts") -> str:
"""Generate contract PDF synchronously using WeasyPrint.
Args:
sale: Sale model instance with nested vehicle, buyer, seller.
output_dir: Directory to save the PDF.
Returns:
Path to the generated PDF file.
"""
from weasyprint import HTML
os.makedirs(output_dir, exist_ok=True)
html_content = build_contract_html(sale)
pdf_path = os.path.join(output_dir, f"contract_{sale.id}.pdf")
HTML(string=html_content).write_pdf(pdf_path)
return pdf_path
async def generate_contract_pdf(sale: Any, output_dir: str = "/tmp/contracts") -> str:
"""Generate contract PDF asynchronously (runs WeasyPrint in a thread).
WeasyPrint is synchronous, so we offload it to a thread to avoid
blocking the event loop.
Args:
sale: Sale model instance with nested vehicle, buyer, seller.
output_dir: Directory to save the PDF.
Returns:
Path to the generated PDF file.
"""
return await asyncio.to_thread(generate_contract_pdf_sync, sale, output_dir)