feat(T06): Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
Collecting weasyprint
|
||||
Downloading weasyprint-69.0-py3-none-any.whl.metadata (3.8 kB)
|
||||
Collecting pydyf>=0.11.0 (from weasyprint)
|
||||
Downloading pydyf-0.12.1-py3-none-any.whl.metadata (2.5 kB)
|
||||
Requirement already satisfied: cffi>=0.6 in /opt/venv/lib/python3.13/site-packages (from weasyprint) (2.1.0)
|
||||
Collecting tinyhtml5>=2.0.0b1 (from weasyprint)
|
||||
Downloading tinyhtml5-2.1.0-py3-none-any.whl.metadata (2.9 kB)
|
||||
Collecting tinycss2>=1.5.0 (from weasyprint)
|
||||
Downloading tinycss2-1.5.1-py3-none-any.whl.metadata (3.0 kB)
|
||||
Collecting cssselect2>=0.8.0 (from weasyprint)
|
||||
Downloading cssselect2-0.9.0-py3-none-any.whl.metadata (2.9 kB)
|
||||
Collecting Pyphen>=0.9.1 (from weasyprint)
|
||||
Downloading pyphen-0.17.2-py3-none-any.whl.metadata (3.2 kB)
|
||||
Requirement already satisfied: Pillow>=9.1.0 in /opt/venv/lib/python3.13/site-packages (from weasyprint) (12.3.0)
|
||||
Collecting fonttools>=4.59.2 (from fonttools[woff]>=4.59.2->weasyprint)
|
||||
Downloading fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (118 kB)
|
||||
Requirement already satisfied: pycparser in /opt/venv/lib/python3.13/site-packages (from cffi>=0.6->weasyprint) (3.0)
|
||||
Collecting webencodings (from cssselect2>=0.8.0->weasyprint)
|
||||
Downloading webencodings-0.5.1-py2.py3-none-any.whl.metadata (2.1 kB)
|
||||
Collecting brotli>=1.0.1 (from fonttools[woff]>=4.59.2->weasyprint)
|
||||
Downloading brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (6.1 kB)
|
||||
Collecting zopfli>=0.1.4 (from fonttools[woff]>=4.59.2->weasyprint)
|
||||
Downloading zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl.metadata (3.5 kB)
|
||||
Downloading weasyprint-69.0-py3-none-any.whl (322 kB)
|
||||
Downloading cssselect2-0.9.0-py3-none-any.whl (15 kB)
|
||||
Downloading fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (5.0 MB)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.0/5.0 MB 141.6 MB/s 0:00:00
|
||||
Downloading brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (1.4 MB)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.4/1.4 MB 68.0 MB/s 0:00:00
|
||||
Downloading pydyf-0.12.1-py3-none-any.whl (8.0 kB)
|
||||
Downloading pyphen-0.17.2-py3-none-any.whl (2.1 MB)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 2.1/2.1 MB 162.7 MB/s 0:00:00
|
||||
Downloading tinycss2-1.5.1-py3-none-any.whl (28 kB)
|
||||
Downloading tinyhtml5-2.1.0-py3-none-any.whl (39 kB)
|
||||
Downloading webencodings-0.5.1-py2.py3-none-any.whl (11 kB)
|
||||
Downloading zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl (829 kB)
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 829.4/829.4 kB 71.9 MB/s 0:00:00
|
||||
Installing collected packages: webencodings, brotli, zopfli, tinyhtml5, tinycss2, Pyphen, pydyf, fonttools, cssselect2, weasyprint
|
||||
|
||||
Successfully installed Pyphen-0.17.2 brotli-1.2.0 cssselect2-0.9.0 fonttools-4.63.0 pydyf-0.12.1 tinycss2-1.5.1 tinyhtml5-2.1.0 weasyprint-69.0 webencodings-0.5.1 zopfli-0.4.3
|
||||
|
||||
[notice] A new release of pip is available: 26.0.1 -> 26.1.2
|
||||
[notice] To update, run: pip install --upgrade pip
|
||||
@@ -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
|
||||
@@ -13,3 +13,4 @@ httpx>=0.27.0
|
||||
pytest-cov>=5.0.0
|
||||
aiosqlite>=0.20.0
|
||||
Pillow>=10.0.0
|
||||
weasyprint>=62.0
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""Tests for DATEV export module: export creation, CSV format, date range validation."""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.sale import Sale
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.models.contact import Contact
|
||||
from app.utils.datev import generate_datev_csv, validate_datev_csv, DATEV_HEADERS
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_vehicle_for_datev(db_session: AsyncSession) -> Vehicle:
|
||||
"""Create a test vehicle for DATEV tests."""
|
||||
vehicle = Vehicle(
|
||||
make="MAN",
|
||||
model="TGX",
|
||||
fin="WMA12345678901234",
|
||||
year=2021,
|
||||
vehicle_type="lkw",
|
||||
price=Decimal("55000.00"),
|
||||
availability="available",
|
||||
condition="used",
|
||||
)
|
||||
db_session.add(vehicle)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_buyer_for_datev(db_session: AsyncSession) -> Contact:
|
||||
"""Create a test buyer contact for DATEV tests."""
|
||||
contact = Contact(
|
||||
company_name="DATEV Test Buyer GmbH",
|
||||
role="kaeufer",
|
||||
address_country="DE",
|
||||
vat_id="DE111222333",
|
||||
address_street="Testweg 3",
|
||||
address_zip="67890",
|
||||
address_city="Hamburg",
|
||||
)
|
||||
db_session.add(contact)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_completed_sales(db_session: AsyncSession, test_vehicle_for_datev, test_buyer_for_datev) -> list[Sale]:
|
||||
"""Create test completed sales within a date range."""
|
||||
sales = []
|
||||
for i, (day, price) in enumerate([(5, Decimal("30000.00")), (10, Decimal("25000.00")), (15, Decimal("40000.00"))]):
|
||||
sale = Sale(
|
||||
vehicle_id=test_vehicle_for_datev.id,
|
||||
buyer_contact_id=test_buyer_for_datev.id,
|
||||
sale_price=price,
|
||||
sale_date=date(2025, 1, day),
|
||||
status="completed",
|
||||
is_gwg=False,
|
||||
)
|
||||
db_session.add(sale)
|
||||
sales.append(sale)
|
||||
await db_session.commit()
|
||||
for s in sales:
|
||||
await db_session.refresh(s)
|
||||
return sales
|
||||
|
||||
|
||||
class TestDATEVExportAPI:
|
||||
"""Test DATEV export API endpoints."""
|
||||
|
||||
async def test_create_export(self, admin_client: AsyncClient, test_completed_sales):
|
||||
"""POST /datev/export with valid date range returns 201."""
|
||||
response = await admin_client.post("/api/v1/datev/export", json={
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-01-31",
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["start_date"] == "2025-01-01"
|
||||
assert data["end_date"] == "2025-01-31"
|
||||
assert data["file_path"] is not None
|
||||
assert float(data["total_amount"]) == 95000.00
|
||||
|
||||
async def test_create_export_invalid_date_range(self, admin_client: AsyncClient):
|
||||
"""POST /datev/export with start > end returns 422."""
|
||||
response = await admin_client.post("/api/v1/datev/export", json={
|
||||
"start_date": "2025-12-31",
|
||||
"end_date": "2025-01-01",
|
||||
})
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_list_exports(self, admin_client: AsyncClient, test_completed_sales):
|
||||
"""GET /datev/exports returns list of exports."""
|
||||
# First create an export
|
||||
await admin_client.post("/api/v1/datev/export", json={
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-01-31",
|
||||
})
|
||||
|
||||
response = await admin_client.get("/api/v1/datev/exports")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
assert len(data["items"]) >= 1
|
||||
assert "start_date" in data["items"][0]
|
||||
assert "end_date" in data["items"][0]
|
||||
|
||||
async def test_download_export_csv(self, admin_client: AsyncClient, test_completed_sales):
|
||||
"""GET /datev/exports/:id/download returns CSV content."""
|
||||
# Create export
|
||||
create_response = await admin_client.post("/api/v1/datev/export", json={
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-01-31",
|
||||
})
|
||||
assert create_response.status_code == 201
|
||||
export_id = create_response.json()["id"]
|
||||
|
||||
# Download
|
||||
response = await admin_client.get(f"/api/v1/datev/exports/{export_id}/download")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "text/csv; charset=utf-8"
|
||||
|
||||
# Parse CSV and verify headers
|
||||
csv_content = response.text
|
||||
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||
header = next(reader)
|
||||
assert header == DATEV_HEADERS
|
||||
|
||||
# Verify at least one data row
|
||||
rows = list(reader)
|
||||
assert len(rows) >= 1
|
||||
for row in rows:
|
||||
assert len(row) == 6
|
||||
|
||||
async def test_download_export_not_found(self, admin_client: AsyncClient):
|
||||
"""GET /datev/exports/:nonexistent/download returns 404."""
|
||||
response = await admin_client.get(f"/api/v1/datev/exports/{uuid.uuid4()}/download")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestDATEVCSVFormat:
|
||||
"""Test DATEV CSV format helper functions."""
|
||||
|
||||
def test_datev_csv_headers(self):
|
||||
"""DATEV CSV has correct headers."""
|
||||
assert DATEV_HEADERS == ["Datum", "Konto", "Gegenkonto", "Betrag", "Belegfeld", "Buchungstext"]
|
||||
|
||||
def test_generate_datev_csv_empty(self):
|
||||
"""Generate DATEV CSV with no sales returns only headers."""
|
||||
csv_content = generate_datev_csv([])
|
||||
assert validate_datev_csv(csv_content)
|
||||
|
||||
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||
header = next(reader)
|
||||
assert header == DATEV_HEADERS
|
||||
# No data rows
|
||||
rows = list(reader)
|
||||
assert len(rows) == 0
|
||||
|
||||
def test_generate_datev_csv_with_sales(self, test_completed_sales):
|
||||
"""Generate DATEV CSV with sales produces correct rows."""
|
||||
# test_completed_sales is a fixture but we need to call it differently for sync test
|
||||
# Instead, create mock objects
|
||||
class MockVehicle:
|
||||
make = "MAN"
|
||||
model = "TGX"
|
||||
fin = "WMA12345678901234"
|
||||
|
||||
class MockSale:
|
||||
def __init__(self, sale_id, sale_date, sale_price):
|
||||
self.id = sale_id
|
||||
self.sale_date = sale_date
|
||||
self.sale_price = sale_price
|
||||
self.vehicle = MockVehicle()
|
||||
|
||||
sales = [
|
||||
MockSale(uuid.uuid4(), date(2025, 1, 5), Decimal("30000.00")),
|
||||
MockSale(uuid.uuid4(), date(2025, 1, 10), Decimal("25000.00")),
|
||||
]
|
||||
|
||||
csv_content = generate_datev_csv(sales)
|
||||
assert validate_datev_csv(csv_content)
|
||||
|
||||
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||
header = next(reader)
|
||||
assert header == DATEV_HEADERS
|
||||
|
||||
rows = list(reader)
|
||||
assert len(rows) == 2
|
||||
|
||||
# Check first row format
|
||||
row1 = rows[0]
|
||||
assert row1[0] == "05.01.2025" # Datum in DD.MM.YYYY
|
||||
assert row1[1] == "1200" # Konto
|
||||
assert row1[2] == "8400" # Gegenkonto
|
||||
assert "," in row1[3] # Betrag with comma separator
|
||||
assert row1[4] # Belegfeld (short UUID)
|
||||
assert "MAN" in row1[5] # Buchungstext contains vehicle make
|
||||
|
||||
def test_validate_datev_csv_invalid_headers(self):
|
||||
"""Validate DATEV CSV with wrong headers returns False."""
|
||||
csv_content = "Wrong;Headers;Here\nval1;val2;val3"
|
||||
assert not validate_datev_csv(csv_content)
|
||||
|
||||
def test_validate_datev_csv_empty(self):
|
||||
"""Validate empty CSV returns False."""
|
||||
assert not validate_datev_csv("")
|
||||
assert not validate_datev_csv(" ")
|
||||
|
||||
def test_validate_datev_csv_valid(self):
|
||||
"""Validate correct DATEV CSV returns True."""
|
||||
csv_content = "Datum;Konto;Gegenkonto;Betrag;Belegfeld;Buchungstext\n05.01.2025;1200;8400;30000,00;ABC12345;MAN TGX"
|
||||
assert validate_datev_csv(csv_content)
|
||||
|
||||
def test_datev_csv_amount_format(self):
|
||||
"""DATEV CSV amount uses comma as decimal separator."""
|
||||
class MockVehicle:
|
||||
make = "VW"
|
||||
model = "Crafter"
|
||||
fin = "WVW12345678901234"
|
||||
|
||||
class MockSale:
|
||||
def __init__(self):
|
||||
self.id = uuid.uuid4()
|
||||
self.sale_date = date(2025, 3, 15)
|
||||
self.sale_price = Decimal("12345.67")
|
||||
self.vehicle = MockVehicle()
|
||||
|
||||
csv_content = generate_datev_csv([MockSale()])
|
||||
reader = csv.reader(io.StringIO(csv_content), delimiter=";")
|
||||
next(reader) # skip header
|
||||
row = next(reader)
|
||||
assert row[3] == "12345,67" # Comma as decimal separator
|
||||
|
||||
|
||||
class TestDATEVExportService:
|
||||
"""Test DATEV export service directly."""
|
||||
|
||||
async def test_create_export_no_sales_in_range(self, db_session: AsyncSession):
|
||||
"""Creating export with no sales in range produces empty CSV with 0 total."""
|
||||
from app.services import datev_service
|
||||
|
||||
export = await datev_service.create_export(
|
||||
db_session,
|
||||
start_date=date(2025, 6, 1),
|
||||
end_date=date(2025, 6, 30),
|
||||
)
|
||||
assert export.total_amount == Decimal("0")
|
||||
assert export.file_path is not None
|
||||
|
||||
async def test_list_exports_pagination(self, db_session: AsyncSession):
|
||||
"""List exports with pagination."""
|
||||
from app.services import datev_service
|
||||
|
||||
# Create a few exports
|
||||
for _ in range(3):
|
||||
await datev_service.create_export(
|
||||
db_session,
|
||||
start_date=date(2025, 1, 1),
|
||||
end_date=date(2025, 1, 31),
|
||||
)
|
||||
|
||||
exports, total = await datev_service.list_exports(db_session, page=1, page_size=2)
|
||||
assert total >= 3
|
||||
assert len(exports) <= 2
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Tests for sales module: CRUD, contract PDF, GwG logic, vehicle status changes."""
|
||||
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.sale import Sale
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.models.contact import Contact
|
||||
from app.utils.contract_pdf import build_contract_html
|
||||
from app.utils.datev import generate_datev_csv, validate_datev_csv, DATEV_HEADERS
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_vehicle(db_session: AsyncSession) -> Vehicle:
|
||||
"""Create a test vehicle."""
|
||||
vehicle = Vehicle(
|
||||
make="Mercedes",
|
||||
model="Actros",
|
||||
fin="WDB96312345678901",
|
||||
year=2020,
|
||||
vehicle_type="lkw",
|
||||
price=Decimal("45000.00"),
|
||||
availability="available",
|
||||
condition="used",
|
||||
)
|
||||
db_session.add(vehicle)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(vehicle)
|
||||
return vehicle
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_buyer(db_session: AsyncSession) -> Contact:
|
||||
"""Create a test buyer contact."""
|
||||
contact = Contact(
|
||||
company_name="Test Buyer GmbH",
|
||||
role="kaeufer",
|
||||
address_country="DE",
|
||||
vat_id="DE123456789",
|
||||
address_street="Teststr. 1",
|
||||
address_zip="12345",
|
||||
address_city="Berlin",
|
||||
email="buyer@test.com",
|
||||
)
|
||||
db_session.add(contact)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_seller(db_session: AsyncSession) -> Contact:
|
||||
"""Create a test seller contact."""
|
||||
contact = Contact(
|
||||
company_name="Test Seller GmbH",
|
||||
role="verkaeufer",
|
||||
address_country="DE",
|
||||
vat_id="DE987654321",
|
||||
address_street="Sellerstr. 2",
|
||||
address_zip="54321",
|
||||
address_city="München",
|
||||
email="seller@test.com",
|
||||
)
|
||||
db_session.add(contact)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def test_sale(db_session: AsyncSession, test_vehicle: Vehicle, test_buyer: Contact) -> Sale:
|
||||
"""Create a test sale."""
|
||||
sale = Sale(
|
||||
vehicle_id=test_vehicle.id,
|
||||
buyer_contact_id=test_buyer.id,
|
||||
sale_price=Decimal("45000.00"),
|
||||
sale_date=date(2025, 1, 15),
|
||||
status="completed",
|
||||
is_gwg=False,
|
||||
)
|
||||
db_session.add(sale)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(sale)
|
||||
return sale
|
||||
|
||||
|
||||
class TestSaleCRUD:
|
||||
"""Test sale CRUD operations via API."""
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def setup_data(self, db_session, test_vehicle, test_buyer):
|
||||
"""Ensure vehicle and buyer exist."""
|
||||
return test_vehicle, test_buyer
|
||||
|
||||
async def test_list_sales_empty(self, admin_client: AsyncClient):
|
||||
"""GET /sales with no sales returns empty list."""
|
||||
response = await admin_client.get("/api/v1/sales/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["items"] == []
|
||||
assert data["total"] == 0
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 20
|
||||
|
||||
async def test_create_sale(self, admin_client: AsyncClient, test_vehicle, test_buyer):
|
||||
"""POST /sales with valid data returns 201."""
|
||||
response = await admin_client.post("/api/v1/sales/", json={
|
||||
"vehicle_id": str(test_vehicle.id),
|
||||
"buyer_contact_id": str(test_buyer.id),
|
||||
"sale_price": "45000.00",
|
||||
"sale_date": "2025-01-15",
|
||||
"status": "draft",
|
||||
"is_gwg": False,
|
||||
})
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["vehicle_id"] == str(test_vehicle.id)
|
||||
assert data["buyer_contact_id"] == str(test_buyer.id)
|
||||
assert data["sale_price"] == "45000.00"
|
||||
assert data["status"] == "draft"
|
||||
assert data["is_gwg"] is False
|
||||
|
||||
async def test_create_sale_without_vehicle_id(self, admin_client: AsyncClient, test_buyer):
|
||||
"""POST /sales without vehicle_id returns 422."""
|
||||
response = await admin_client.post("/api/v1/sales/", json={
|
||||
"buyer_contact_id": str(test_buyer.id),
|
||||
"sale_price": "45000.00",
|
||||
})
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_create_sale_without_buyer_contact_id(self, admin_client: AsyncClient, test_vehicle):
|
||||
"""POST /sales without buyer_contact_id returns 422."""
|
||||
response = await admin_client.post("/api/v1/sales/", json={
|
||||
"vehicle_id": str(test_vehicle.id),
|
||||
"sale_price": "45000.00",
|
||||
})
|
||||
assert response.status_code == 422
|
||||
|
||||
async def test_get_sale_by_id(self, admin_client: AsyncClient, test_sale):
|
||||
"""GET /sales/:id returns sale detail with nested vehicle and contact."""
|
||||
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(test_sale.id)
|
||||
assert data["vehicle"]["make"] == "Mercedes"
|
||||
assert data["buyer"]["company_name"] == "Test Buyer GmbH"
|
||||
|
||||
async def test_get_sale_not_found(self, admin_client: AsyncClient):
|
||||
"""GET /sales/:nonexistent returns 404."""
|
||||
response = await admin_client.get(f"/api/v1/sales/{uuid.uuid4()}")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_update_sale(self, admin_client: AsyncClient, test_sale):
|
||||
"""PUT /sales/:id updates sale fields."""
|
||||
response = await admin_client.put(f"/api/v1/sales/{test_sale.id}", json={
|
||||
"sale_price": "42000.00",
|
||||
"status": "completed",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["sale_price"] == "42000.00"
|
||||
assert data["status"] == "completed"
|
||||
|
||||
async def test_delete_sale_cancels_and_restores_vehicle(self, admin_client: AsyncClient, test_sale, db_session):
|
||||
"""DELETE /sales/:id cancels sale and restores vehicle status to 'available'."""
|
||||
# First set vehicle to sold (as create_sale would)
|
||||
vehicle = await db_session.get(Vehicle, test_sale.vehicle_id)
|
||||
vehicle.availability = "sold"
|
||||
await db_session.commit()
|
||||
|
||||
response = await admin_client.delete(f"/api/v1/sales/{test_sale.id}")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "cancelled"
|
||||
|
||||
# Verify vehicle status restored
|
||||
await db_session.refresh(vehicle)
|
||||
assert vehicle.availability == "available"
|
||||
|
||||
async def test_list_sales_with_filter(self, admin_client: AsyncClient, test_sale):
|
||||
"""GET /sales?status=completed filters by status."""
|
||||
response = await admin_client.get("/api/v1/sales/?status=completed")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
for item in data["items"]:
|
||||
assert item["status"] == "completed"
|
||||
|
||||
async def test_list_sales_with_date_filter(self, admin_client: AsyncClient, test_sale):
|
||||
"""GET /sales?date_from=2025-01-01&date_to=2025-12-31 filters by date range."""
|
||||
response = await admin_client.get("/api/v1/sales/?date_from=2025-01-01&date_to=2025-12-31")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["total"] >= 1
|
||||
|
||||
|
||||
class TestSaleVehicleStatus:
|
||||
"""Test vehicle status changes on sale create/delete."""
|
||||
|
||||
async def test_create_sale_sets_vehicle_sold(self, admin_client: AsyncClient, test_vehicle, test_buyer, db_session):
|
||||
"""Creating a sale sets vehicle availability to 'sold'."""
|
||||
response = await admin_client.post("/api/v1/sales/", json={
|
||||
"vehicle_id": str(test_vehicle.id),
|
||||
"buyer_contact_id": str(test_buyer.id),
|
||||
"sale_price": "45000.00",
|
||||
"sale_date": "2025-01-15",
|
||||
"status": "draft",
|
||||
})
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify vehicle status
|
||||
await db_session.refresh(test_vehicle)
|
||||
assert test_vehicle.availability == "sold"
|
||||
|
||||
async def test_cancel_sale_restores_vehicle_available(self, admin_client: AsyncClient, test_sale, db_session):
|
||||
"""Cancelling a sale restores vehicle availability to 'available'."""
|
||||
# Set vehicle to sold first
|
||||
vehicle = await db_session.get(Vehicle, test_sale.vehicle_id)
|
||||
vehicle.availability = "sold"
|
||||
await db_session.commit()
|
||||
|
||||
response = await admin_client.delete(f"/api/v1/sales/{test_sale.id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
await db_session.refresh(vehicle)
|
||||
assert vehicle.availability == "available"
|
||||
|
||||
|
||||
class TestContractPDF:
|
||||
"""Test contract PDF generation and GwG logic."""
|
||||
|
||||
async def test_regenerate_contract(self, admin_client: AsyncClient, test_sale):
|
||||
"""POST /sales/:id/contract regenerates contract PDF."""
|
||||
with patch("app.services.sale_service.generate_contract_pdf") as mock_pdf:
|
||||
mock_pdf.return_value = "/tmp/contracts/test_contract.pdf"
|
||||
response = await admin_client.post(f"/api/v1/sales/{test_sale.id}/contract")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["sale_id"] == str(test_sale.id)
|
||||
assert "contract_pdf_path" in data
|
||||
|
||||
async def test_download_contract_not_found(self, admin_client: AsyncClient, test_sale):
|
||||
"""GET /sales/:id/contract returns 404 if no PDF generated."""
|
||||
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract")
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_download_contract_pdf(self, admin_client: AsyncClient, test_sale, db_session):
|
||||
"""GET /sales/:id/contract returns PDF content."""
|
||||
# Create a fake PDF file
|
||||
import os
|
||||
os.makedirs("/tmp/contracts", exist_ok=True)
|
||||
pdf_path = f"/tmp/contracts/contract_{test_sale.id}.pdf"
|
||||
with open(pdf_path, "wb") as f:
|
||||
f.write(b"%PDF-1.4 fake pdf content")
|
||||
|
||||
test_sale.contract_pdf_path = pdf_path
|
||||
db_session.add(test_sale)
|
||||
await db_session.commit()
|
||||
|
||||
response = await admin_client.get(f"/api/v1/sales/{test_sale.id}/contract")
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
|
||||
# Cleanup
|
||||
if os.path.exists(pdf_path):
|
||||
os.remove(pdf_path)
|
||||
|
||||
def test_gwg_clause_in_contract_html(self, test_sale, test_vehicle, test_buyer):
|
||||
"""GwG clause appears in contract HTML when is_gwg=true and price <= 800."""
|
||||
test_sale.is_gwg = True
|
||||
test_sale.sale_price = Decimal("500.00")
|
||||
test_sale.vehicle = test_vehicle
|
||||
test_sale.buyer = test_buyer
|
||||
|
||||
html = build_contract_html(test_sale)
|
||||
assert "Geringwertige Wirtschaftsgüter" in html
|
||||
assert "§ 6 Abs. 2 EStG" in html
|
||||
|
||||
def test_gwg_clause_not_in_contract_when_price_too_high(self, test_sale, test_vehicle, test_buyer):
|
||||
"""GwG clause does NOT appear when price > 800 even if is_gwg=true."""
|
||||
test_sale.is_gwg = True
|
||||
test_sale.sale_price = Decimal("5000.00")
|
||||
test_sale.vehicle = test_vehicle
|
||||
test_sale.buyer = test_buyer
|
||||
|
||||
html = build_contract_html(test_sale)
|
||||
assert "Geringwertige Wirtschaftsgüter" not in html
|
||||
|
||||
def test_gwg_clause_not_in_contract_when_not_gwg(self, test_sale, test_vehicle, test_buyer):
|
||||
"""GwG clause does NOT appear when is_gwg=false."""
|
||||
test_sale.is_gwg = False
|
||||
test_sale.sale_price = Decimal("500.00")
|
||||
test_sale.vehicle = test_vehicle
|
||||
test_sale.buyer = test_buyer
|
||||
|
||||
html = build_contract_html(test_sale)
|
||||
assert "Geringwertige Wirtschaftsgüter" not in html
|
||||
|
||||
def test_contract_html_contains_ust_id_field(self, test_sale, test_vehicle, test_buyer):
|
||||
"""Contract HTML contains USt-IdNr. field."""
|
||||
test_sale.vehicle = test_vehicle
|
||||
test_sale.buyer = test_buyer
|
||||
|
||||
html = build_contract_html(test_sale)
|
||||
assert "USt-IdNr" in html
|
||||
assert "DE123456789" in html
|
||||
|
||||
|
||||
class TestUstIdVerification:
|
||||
"""Test USt-IdNr. verification endpoint."""
|
||||
|
||||
async def test_verify_ust_id_disabled(self, admin_client: AsyncClient, test_sale):
|
||||
"""POST /sales/:id/verify-ust-id returns not verified when BZSt API disabled."""
|
||||
response = await admin_client.post(f"/api/v1/sales/{test_sale.id}/verify-ust-id")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["verified"] is False
|
||||
assert data["message"] == "BZSt API not available"
|
||||
Reference in New Issue
Block a user