feat(T06): Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
"""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
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
# 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)
|
||||
@@ -0,0 +1,109 @@
|
||||
"""DATEV CSV format helper (Buchungsstapel).
|
||||
|
||||
Generates CSV in DATEV-compatible format with headers:
|
||||
Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
# DATEV Buchungsstapel CSV headers
|
||||
DATEV_HEADERS = [
|
||||
"Datum",
|
||||
"Konto",
|
||||
"Gegenkonto",
|
||||
"Betrag",
|
||||
"Belegfeld",
|
||||
"Buchungstext",
|
||||
]
|
||||
|
||||
|
||||
def generate_datev_csv(sales: list[Any]) -> str:
|
||||
"""Generate DATEV CSV content from a list of sale objects.
|
||||
|
||||
Each sale produces one booking row:
|
||||
- Datum: sale_date in DD.MM.YYYY format
|
||||
- Konto: buyer account (1200 = Forderungen aus Lieferungen/Leistungen)
|
||||
- Gegenkonto: revenue account (8400 = Erlöse aus Warenverkäufen)
|
||||
- Betrag: sale_price as decimal with comma separator
|
||||
- Belegfeld: sale ID (short form)
|
||||
- Buchungstext: vehicle make + model + FIN
|
||||
|
||||
Args:
|
||||
sales: List of Sale model instances with nested vehicle and buyer.
|
||||
|
||||
Returns:
|
||||
CSV string with DATEV headers and one row per sale.
|
||||
"""
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, delimiter=";", quoting=csv.QUOTE_MINIMAL)
|
||||
|
||||
# Write header row
|
||||
writer.writerow(DATEV_HEADERS)
|
||||
|
||||
for sale in sales:
|
||||
# Format date as DD.MM.YYYY (DATEV convention)
|
||||
datum = sale.sale_date.strftime("%d.%m.%Y") if sale.sale_date else ""
|
||||
|
||||
# Account numbers (standard DATEV SKR03)
|
||||
konto = "1200" # Forderungen aus LuL
|
||||
gegenkonto = "8400" # Erlöse aus Warenverkäufen
|
||||
|
||||
# Amount with comma as decimal separator (DATEV convention)
|
||||
betrag = _format_amount(sale.sale_price)
|
||||
|
||||
# Belegfeld: short sale ID (first 8 chars)
|
||||
belegfeld = str(sale.id)[:8].upper()
|
||||
|
||||
# Buchungstext: vehicle description
|
||||
vehicle = getattr(sale, "vehicle", None)
|
||||
if vehicle:
|
||||
buchungstext = f"{vehicle.make} {vehicle.model} {vehicle.fin}"
|
||||
else:
|
||||
buchungstext = f"Verkauf {str(sale.id)[:8]}"
|
||||
|
||||
writer.writerow([datum, konto, gegenkonto, betrag, belegfeld, buchungstext])
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _format_amount(amount: Decimal) -> str:
|
||||
"""Format a Decimal amount for DATEV CSV (comma as decimal separator)."""
|
||||
if amount is None:
|
||||
return "0,00"
|
||||
# Convert to string with 2 decimal places, replace dot with comma
|
||||
formatted = f"{amount:.2f}"
|
||||
return formatted.replace(".", ",")
|
||||
|
||||
|
||||
def validate_datev_csv(csv_content: str) -> bool:
|
||||
"""Validate that CSV content has correct DATEV headers and structure.
|
||||
|
||||
Args:
|
||||
csv_content: CSV string to validate.
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise.
|
||||
"""
|
||||
if not csv_content or not csv_content.strip():
|
||||
return False
|
||||
|
||||
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||
try:
|
||||
header = next(reader)
|
||||
except StopIteration:
|
||||
return False
|
||||
|
||||
if header != DATEV_HEADERS:
|
||||
return False
|
||||
|
||||
# Check at least that rows have 6 columns each
|
||||
for row in reader:
|
||||
if len(row) != 6:
|
||||
return False
|
||||
|
||||
return True
|
||||
Reference in New Issue
Block a user