feat(T06): Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
This commit is contained in:
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}"'},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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