341d0c6f38
Backend (ruff): - F821: Add TYPE_CHECKING imports for Vehicle, Contact, File in models (file.py, retouch.py, sale.py, vehicle.py) - E741: Rename ambiguous variable to / (copilot_service.py, price_compare_service.py, openrouter.py) - F841: Remove unused variable in sale_service.py Frontend (ESLint): - react/no-unescaped-entities: Escape quotes in ContractPreview.tsx - @next/next/no-img-element: Replace <img> with <Image> from next/image (FileGallery.tsx, FileList.tsx, FilePreview.tsx, BeforeAfterSlider.tsx) Tests: 392 backend passed, 112 frontend passed, next build successful
109 lines
3.0 KiB
Python
109 lines
3.0 KiB
Python
"""DATEV CSV format helper (Buchungsstapel).
|
|
|
|
Generates CSV in DATEV-compatible format with headers:
|
|
Datum, Konto, Gegenkonto, Betrag, Belegfeld, Buchungstext
|
|
"""
|
|
|
|
import csv
|
|
import io
|
|
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
|