feat(T06): Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI

This commit is contained in:
2026-07-17 01:58:34 +02:00
parent a38d340ddc
commit 6603e411e9
33 changed files with 3481 additions and 107 deletions
+2
View File
@@ -37,6 +37,8 @@ class Settings(BaseSettings):
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
OPENROUTER_OCR_MODEL: str = "qwen/qwen2.5-vl-72b-instruct"
MAX_FILE_SIZE_MB: int = 50
BZST_API_ENABLED: bool = False
WEASYPRINT_OUTPUT_DIR: str = "/tmp/contracts"
@property
def cors_origins_list(self) -> list[str]:
+3 -1
View File
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.routers import auth, contacts, files, ocr, users, vehicles
from app.routers import auth, contacts, datev, files, ocr, sales, users, vehicles
@asynccontextmanager
@@ -44,6 +44,8 @@ api_v1_router.include_router(vehicles.router)
api_v1_router.include_router(contacts.router)
api_v1_router.include_router(files.router)
api_v1_router.include_router(ocr.router)
api_v1_router.include_router(sales.router)
api_v1_router.include_router(datev.router)
# Health endpoint (no auth required)
@api_v1_router.get("/health", tags=["health"])
+27
View File
@@ -0,0 +1,27 @@
"""SQLAlchemy models package.
Import all models here so they register with Base.metadata
before create_all/drop_all is called.
"""
from app.models.contact import Contact, ContactPerson
from app.models.datev_export import DATEVExport
from app.models.file import File
from app.models.ocr_result import OCRResult
from app.models.sale import Sale, SaleStatus
from app.models.user import User, UserRole
from app.models.vehicle import MobileDeListing, Vehicle
__all__ = [
"Contact",
"ContactPerson",
"DATEVExport",
"File",
"OCRResult",
"Sale",
"SaleStatus",
"User",
"UserRole",
"Vehicle",
"MobileDeListing",
]
+50
View File
@@ -0,0 +1,50 @@
"""SQLAlchemy model for DATEV export records."""
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, Numeric, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class DATEVExport(Base):
"""DATEV export record tracking CSV exports for accounting."""
__tablename__ = "datev_exports"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False)
file_path: Mapped[str | None] = mapped_column(
String(500), nullable=True
)
total_amount: Mapped[Decimal] = mapped_column(
Numeric(14, 2), nullable=False, default=0
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
def __repr__(self) -> str:
return f"<DATEVExport id={self.id} start={self.start_date} end={self.end_date}>"
def to_dict(self) -> dict:
"""Serialize DATEV export for API responses."""
return {
"id": str(self.id),
"start_date": self.start_date.isoformat() if self.start_date else None,
"end_date": self.end_date.isoformat() if self.end_date else None,
"file_path": self.file_path,
"total_amount": (
float(self.total_amount) if self.total_amount is not None else None
),
"created_at": self.created_at.isoformat() if self.created_at else None,
}
+120
View File
@@ -0,0 +1,120 @@
"""SQLAlchemy model for sales (Verkauf)."""
import enum
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import (
Boolean,
CheckConstraint,
Date,
DateTime,
ForeignKey,
Numeric,
String,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SaleStatus(str, enum.Enum):
draft = "draft"
completed = "completed"
cancelled = "cancelled"
class Sale(Base):
"""Sale entity linking a vehicle to a buyer (and optionally a seller)."""
__tablename__ = "sales"
__table_args__ = (
CheckConstraint(
"status IN ('draft', 'completed', 'cancelled')",
name="ck_sales_status",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("vehicles.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
buyer_contact_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("contacts.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
seller_contact_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("contacts.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
sale_price: Mapped[Decimal] = mapped_column(
Numeric(12, 2), nullable=False
)
sale_date: Mapped[date] = mapped_column(
Date, nullable=False, default=func.current_date()
)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="draft"
)
is_gwg: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
contract_pdf_path: Mapped[str | None] = mapped_column(
String(500), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)
vehicle: Mapped["Vehicle"] = relationship(lazy="selectin")
buyer: Mapped["Contact"] = relationship(
"Contact",
foreign_keys=[buyer_contact_id],
lazy="selectin",
)
seller: Mapped["Contact | None"] = relationship(
"Contact",
foreign_keys=[seller_contact_id],
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Sale id={self.id} vehicle_id={self.vehicle_id} status={self.status}>"
def to_dict(self) -> dict:
"""Serialize sale for API responses."""
return {
"id": str(self.id),
"vehicle_id": str(self.vehicle_id),
"buyer_contact_id": str(self.buyer_contact_id),
"seller_contact_id": (
str(self.seller_contact_id) if self.seller_contact_id else None
),
"sale_price": float(self.sale_price) if self.sale_price is not None else None,
"sale_date": self.sale_date.isoformat() if self.sale_date else None,
"status": self.status,
"is_gwg": self.is_gwg,
"contract_pdf_path": self.contract_pdf_path,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
+78
View File
@@ -0,0 +1,78 @@
"""DATEV export router: create exports, list exports, download CSV."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination
from app.models.user import User
from app.schemas.datev import (
DATEVExportCreate,
DATEVExportListResponse,
DATEVExportResponse,
)
from app.services import datev_service
router = APIRouter(prefix="/datev", tags=["datev"])
@router.post("/export", response_model=DATEVExportResponse, status_code=status.HTTP_201_CREATED)
async def create_export(
body: DATEVExportCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a DATEV export for the given date range.
Queries all completed sales within the date range and generates a CSV file.
"""
export = await datev_service.create_export(
db,
start_date=body.start_date,
end_date=body.end_date,
)
return DATEVExportResponse.model_validate(export)
@router.get("/exports", response_model=DATEVExportListResponse, status_code=status.HTTP_200_OK)
async def list_exports(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
current_user: User = Depends(get_current_user),
):
"""List all DATEV exports with pagination."""
exports, total = await datev_service.list_exports(
db,
page=pagination["page"],
page_size=pagination["page_size"],
)
return DATEVExportListResponse(
items=[DATEVExportResponse.model_validate(e) for e in exports],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.get("/exports/{export_id}/download", status_code=status.HTTP_200_OK)
async def download_export(
export_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Download a DATEV export as CSV file."""
result = await datev_service.get_export_csv(db, export_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "EXPORT_NOT_FOUND", "message": "DATEV export not found"}},
)
filename, csv_bytes = result
return Response(
content=csv_bytes,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+203
View File
@@ -0,0 +1,203 @@
"""Sales router: CRUD, contract PDF, USt-IdNr. verification."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination
from app.models.user import User
from app.schemas.sale import (
ContractResponse,
SaleCreate,
SaleListResponse,
SaleResponse,
SaleUpdate,
UstIdVerifyResponse,
)
from app.services import sale_service
router = APIRouter(prefix="/sales", tags=["sales"])
@router.get("/", response_model=SaleListResponse, status_code=status.HTTP_200_OK)
async def list_sales(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
status_filter: str | None = Query(None, alias="status", description="Filter by sale status"),
date_from: str | None = Query(None, description="Filter sales from this date (YYYY-MM-DD)"),
date_to: str | None = Query(None, description="Filter sales up to this date (YYYY-MM-DD)"),
current_user: User = Depends(get_current_user),
):
"""List sales with pagination, filtering by status and date range."""
from datetime import date as date_type
parsed_date_from = None
parsed_date_to = None
if date_from:
try:
parsed_date_from = date_type.fromisoformat(date_from)
except ValueError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_from format: {date_from}"}},
)
if date_to:
try:
parsed_date_to = date_type.fromisoformat(date_to)
except ValueError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_to format: {date_to}"}},
)
sales, total = await sale_service.list_sales(
db,
page=pagination["page"],
page_size=pagination["page_size"],
status=status_filter,
date_from=parsed_date_from,
date_to=parsed_date_to,
)
return SaleListResponse(
items=[SaleResponse.model_validate(s) for s in sales],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.post("/", response_model=SaleResponse, status_code=status.HTTP_201_CREATED)
async def create_sale(
body: SaleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new sale. Sets vehicle status to 'sold'."""
data = body.model_dump(exclude_unset=False)
try:
sale = await sale_service.create_sale(db, data)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": str(exc)}},
)
return SaleResponse.model_validate(sale)
@router.get("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
async def get_sale(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single sale by ID with nested vehicle and contacts."""
sale = await sale_service.get_sale(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return SaleResponse.model_validate(sale)
@router.put("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
async def update_sale(
sale_id: uuid.UUID,
body: SaleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a sale's fields."""
updates = body.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
)
sale = await sale_service.update_sale(db, sale_id, updates)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return SaleResponse.model_validate(sale)
@router.delete("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
async def delete_sale(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cancel a sale (soft delete). Sets sale status to 'cancelled' and restores vehicle status to 'available'."""
sale = await sale_service.delete_sale(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return SaleResponse.model_validate(sale)
@router.post("/{sale_id}/contract", response_model=ContractResponse, status_code=status.HTTP_200_OK)
async def regenerate_contract(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Regenerate the contract PDF for a sale."""
sale = await sale_service.regenerate_contract_pdf(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return ContractResponse(
sale_id=sale.id,
contract_pdf_path=sale.contract_pdf_path or "",
message="Contract regenerated",
)
@router.get("/{sale_id}/contract", status_code=status.HTTP_200_OK)
async def download_contract(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Download the contract PDF for a sale."""
sale = await sale_service.get_sale(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
if not sale.contract_pdf_path or not os.path.exists(sale.contract_pdf_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTRACT_NOT_FOUND", "message": "Contract PDF not generated yet"}},
)
return FileResponse(
path=sale.contract_pdf_path,
media_type="application/pdf",
filename=f"contract_{sale.id}.pdf",
)
@router.post("/{sale_id}/verify-ust-id", response_model=UstIdVerifyResponse, status_code=status.HTTP_200_OK)
async def verify_ust_id(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Verify the buyer's USt-IdNr. via BZSt API.
Feature flag: BZST_API_ENABLED (default: False).
When disabled, returns {verified: false, message: 'BZSt API not available'}.
"""
result = await sale_service.verify_ust_id(sale_id, db)
return UstIdVerifyResponse(**result)
+44
View File
@@ -0,0 +1,44 @@
"""Pydantic schemas for DATEV export request and response bodies."""
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
class DATEVExportCreate(BaseModel):
"""POST /api/v1/datev/export request body."""
start_date: date = Field(..., description="Start date of the export range")
end_date: date = Field(..., description="End date of the export range (inclusive)")
@model_validator(mode="after")
def validate_date_range(self) -> "DATEVExportCreate":
"""Ensure start_date is not after end_date."""
if self.start_date > self.end_date:
raise ValueError("start_date must not be after end_date")
return self
class DATEVExportResponse(BaseModel):
"""DATEV export response schema."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
start_date: date
end_date: date
file_path: Optional[str] = None
total_amount: Decimal
created_at: Optional[datetime] = None
class DATEVExportListResponse(BaseModel):
"""Paginated DATEV export list response."""
items: list[DATEVExportResponse]
total: int
page: int
page_size: int
+107
View File
@@ -0,0 +1,107 @@
"""Pydantic schemas for sale-related request and response bodies."""
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field
SALE_STATUSES = Literal["draft", "completed", "cancelled"]
class SaleCreate(BaseModel):
"""POST /api/v1/sales request body."""
vehicle_id: uuid.UUID
buyer_contact_id: uuid.UUID
seller_contact_id: Optional[uuid.UUID] = None
sale_price: Decimal = Field(..., ge=0)
sale_date: Optional[date] = None
status: SALE_STATUSES = "draft"
is_gwg: bool = False
class SaleUpdate(BaseModel):
"""PUT /api/v1/sales/:id request body (all fields optional)."""
sale_price: Optional[Decimal] = Field(None, ge=0)
sale_date: Optional[date] = None
status: Optional[SALE_STATUSES] = None
is_gwg: Optional[bool] = None
seller_contact_id: Optional[uuid.UUID] = None
class VehicleNested(BaseModel):
"""Nested vehicle info in sale response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
make: str
model: str
fin: str
vehicle_type: str
availability: str
price: Decimal
class ContactNested(BaseModel):
"""Nested contact info in sale response."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
company_name: str
address_city: Optional[str] = None
address_country: str
vat_id: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
class SaleResponse(BaseModel):
"""Sale response schema with nested vehicle and contacts."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
vehicle_id: uuid.UUID
buyer_contact_id: uuid.UUID
seller_contact_id: Optional[uuid.UUID] = None
sale_price: Decimal
sale_date: date
status: str
is_gwg: bool
contract_pdf_path: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
vehicle: Optional[VehicleNested] = None
buyer: Optional[ContactNested] = None
seller: Optional[ContactNested] = None
class SaleListResponse(BaseModel):
"""Paginated sale list response."""
items: list[SaleResponse]
total: int
page: int
page_size: int
class ContractResponse(BaseModel):
"""Response for contract PDF generation/download."""
sale_id: uuid.UUID
contract_pdf_path: str
message: str = "Contract generated"
class UstIdVerifyResponse(BaseModel):
"""Response for USt-IdNr. verification."""
verified: bool
message: str
vat_id: Optional[str] = None
+154
View File
@@ -0,0 +1,154 @@
"""DATEV export service: create exports, list exports, generate CSV."""
from __future__ import annotations
import os
import uuid
from datetime import date
from decimal import Decimal
from typing import Any
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.datev_export import DATEVExport
from app.models.sale import Sale
from app.utils.datev import generate_datev_csv, validate_datev_csv
async def create_export(
db: AsyncSession,
start_date: date,
end_date: date,
output_dir: str = "/tmp/datev_exports",
) -> DATEVExport:
"""Create a DATEV export for the given date range.
Queries all completed sales within the date range, generates a CSV file,
and stores the export record.
Args:
db: Async session.
start_date: Start of the date range (inclusive).
end_date: End of the date range (inclusive).
output_dir: Directory to save the CSV file.
Returns:
DATEVExport model instance.
"""
# Query completed sales within date range
stmt = (
select(Sale)
.options(
selectinload(Sale.vehicle),
selectinload(Sale.buyer),
selectinload(Sale.seller),
)
.where(
and_(
Sale.sale_date >= start_date,
Sale.sale_date <= end_date,
Sale.status == "completed",
)
)
.order_by(Sale.sale_date.asc())
)
result = await db.execute(stmt)
sales = list(result.scalars().all())
# Generate CSV content
csv_content = generate_datev_csv(sales)
# Calculate total amount
total_amount = sum(
(s.sale_price or Decimal("0")) for s in sales
)
# Save CSV file
os.makedirs(output_dir, exist_ok=True)
export_id = uuid.uuid4()
file_path = os.path.join(output_dir, f"datev_export_{export_id}.csv")
with open(file_path, "w", encoding="utf-8") as f:
f.write(csv_content)
# Create export record
export = DATEVExport(
id=export_id,
start_date=start_date,
end_date=end_date,
file_path=file_path,
total_amount=total_amount,
)
db.add(export)
await db.flush()
await db.refresh(export)
return export
async def list_exports(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
) -> tuple[list[DATEVExport], int]:
"""List DATEV exports with pagination.
Returns (exports, total_count).
"""
count_stmt = select(func.count(DATEVExport.id))
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
data_stmt = (
select(DATEVExport)
.order_by(DATEVExport.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(data_stmt)
exports = list(result.scalars().all())
return exports, total
async def get_export_csv(db: AsyncSession, export_id: uuid.UUID) -> tuple[str, bytes] | None:
"""Get the CSV content for a DATEV export.
Args:
db: Async session.
export_id: Export ID.
Returns:
Tuple of (filename, csv_bytes) or None if export not found.
"""
stmt = select(DATEVExport).where(DATEVExport.id == export_id)
result = await db.execute(stmt)
export = result.scalar_one_or_none()
if export is None:
return None
if export.file_path and os.path.exists(export.file_path):
with open(export.file_path, "r", encoding="utf-8") as f:
csv_content = f.read()
else:
# Regenerate from sales if file is missing
sales_stmt = (
select(Sale)
.options(selectinload(Sale.vehicle))
.where(
and_(
Sale.sale_date >= export.start_date,
Sale.sale_date <= export.end_date,
Sale.status == "completed",
)
)
.order_by(Sale.sale_date.asc())
)
sales_result = await db.execute(sales_stmt)
sales = list(sales_result.scalars().all())
csv_content = generate_datev_csv(sales)
filename = f"datev_export_{export.id}.csv"
return filename, csv_content.encode("utf-8")
+242
View File
@@ -0,0 +1,242 @@
"""Sale service: CRUD, contract PDF generation, GwG logic, USt-IdNr. verification."""
from __future__ import annotations
import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Any
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import settings
from app.models.sale import Sale
from app.models.vehicle import Vehicle
from app.utils.contract_pdf import generate_contract_pdf
async def create_sale(db: AsyncSession, data: dict[str, Any]) -> Sale:
"""Create a new sale record.
Sets vehicle availability to 'sold' upon creation.
Generates contract PDF if status is 'completed'.
Raises ValueError if vehicle or buyer contact not found.
"""
vehicle_id = data["vehicle_id"]
buyer_contact_id = data["buyer_contact_id"]
# Verify vehicle exists
vehicle = await db.get(Vehicle, vehicle_id)
if vehicle is None or vehicle.deleted_at is not None:
raise ValueError(f"Vehicle {vehicle_id} not found")
# Set sale_date to today if not provided
if data.get("sale_date") is None:
data["sale_date"] = date.today()
sale = Sale(**data)
db.add(sale)
await db.flush()
# Update vehicle status to sold
vehicle.availability = "sold"
await db.flush()
# Load nested relationships for response
await db.refresh(sale)
# Explicitly load relationships
stmt = (
select(Sale)
.options(
selectinload(Sale.vehicle),
selectinload(Sale.buyer),
selectinload(Sale.seller),
)
.where(Sale.id == sale.id)
)
result = await db.execute(stmt)
sale = result.scalar_one()
# Generate contract PDF if status is completed
if sale.status == "completed":
pdf_path = await generate_contract_pdf(sale)
sale.contract_pdf_path = pdf_path
await db.flush()
return sale
async def get_sale(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
"""Get a single sale by ID with nested relationships."""
stmt = (
select(Sale)
.options(
selectinload(Sale.vehicle),
selectinload(Sale.buyer),
selectinload(Sale.seller),
)
.where(Sale.id == sale_id)
)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def list_sales(
db: AsyncSession,
page: int = 1,
page_size: int = 20,
status: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
) -> tuple[list[Sale], int]:
"""List sales with pagination and optional filtering by status and date range.
Returns (sales, total_count).
"""
conditions = []
if status:
conditions.append(Sale.status == status)
if date_from:
conditions.append(Sale.sale_date >= date_from)
if date_to:
conditions.append(Sale.sale_date <= date_to)
# Count query
count_stmt = select(func.count(Sale.id))
if conditions:
count_stmt = count_stmt.where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
# Data query with eager loading
data_stmt = (
select(Sale)
.options(
selectinload(Sale.vehicle),
selectinload(Sale.buyer),
selectinload(Sale.seller),
)
.order_by(Sale.created_at.desc())
)
if conditions:
data_stmt = data_stmt.where(and_(*conditions))
offset = (page - 1) * page_size
data_stmt = data_stmt.offset(offset).limit(page_size)
result = await db.execute(data_stmt)
sales = list(result.scalars().all())
return sales, total
async def update_sale(
db: AsyncSession, sale_id: uuid.UUID, updates: dict[str, Any]
) -> Sale | None:
"""Update a sale's fields. Returns None if not found."""
sale = await get_sale(db, sale_id)
if sale is None:
return None
for key, value in updates.items():
if hasattr(sale, key):
setattr(sale, key, value)
await db.flush()
# Reload with relationships
sale = await get_sale(db, sale_id)
return sale
async def cancel_sale(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
"""Cancel a sale: set status to 'cancelled' and restore vehicle status to 'available'.
Returns None if sale not found.
"""
sale = await get_sale(db, sale_id)
if sale is None:
return None
sale.status = "cancelled"
# Restore vehicle availability
vehicle = await db.get(Vehicle, sale.vehicle_id)
if vehicle is not None:
vehicle.availability = "available"
await db.flush()
sale = await get_sale(db, sale_id)
return sale
async def delete_sale(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
"""Delete a sale (cancel + restore vehicle status to 'in_stock').
This is a soft-cancel: sale status is set to 'cancelled' and
vehicle availability is restored to 'available' (in_stock).
Returns None if sale not found.
"""
return await cancel_sale(db, sale_id)
async def regenerate_contract_pdf(db: AsyncSession, sale_id: uuid.UUID) -> Sale | None:
"""Regenerate the contract PDF for a sale.
Returns the updated sale with new contract_pdf_path, or None if not found.
"""
sale = await get_sale(db, sale_id)
if sale is None:
return None
pdf_path = await generate_contract_pdf(sale)
sale.contract_pdf_path = pdf_path
await db.flush()
sale = await get_sale(db, sale_id)
return sale
async def verify_ust_id(sale_id: uuid.UUID, db: AsyncSession) -> dict[str, Any]:
"""Verify the buyer's USt-IdNr. via BZSt API.
Feature flag: bzst_api_enabled (default: False).
When disabled, returns {verified: false, message: 'BZSt API not available'}.
Args:
sale_id: Sale ID to look up the buyer contact.
db: Async session.
Returns:
Dict with verified, message, and vat_id fields.
"""
# Check feature flag
bzst_enabled = getattr(settings, "BZST_API_ENABLED", False)
if not bzst_enabled:
return {
"verified": False,
"message": "BZSt API not available",
"vat_id": None,
}
# If enabled, we would call the BZSt API here
# For now, return manual review message
sale = await get_sale(db, sale_id)
if sale is None:
return {
"verified": False,
"message": "Sale not found",
"vat_id": None,
}
buyer = sale.buyer
vat_id = getattr(buyer, "vat_id", None) if buyer else None
return {
"verified": False,
"message": "Manual review required",
"vat_id": vat_id,
}
+257
View File
@@ -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)
+109
View File
@@ -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