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

This commit is contained in:
2026-07-17 01:58:34 +02:00
parent a38d340ddc
commit 6603e411e9
33 changed files with 3481 additions and 107 deletions
+78
View File
@@ -0,0 +1,78 @@
"""DATEV export router: create exports, list exports, download CSV."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination
from app.models.user import User
from app.schemas.datev import (
DATEVExportCreate,
DATEVExportListResponse,
DATEVExportResponse,
)
from app.services import datev_service
router = APIRouter(prefix="/datev", tags=["datev"])
@router.post("/export", response_model=DATEVExportResponse, status_code=status.HTTP_201_CREATED)
async def create_export(
body: DATEVExportCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a DATEV export for the given date range.
Queries all completed sales within the date range and generates a CSV file.
"""
export = await datev_service.create_export(
db,
start_date=body.start_date,
end_date=body.end_date,
)
return DATEVExportResponse.model_validate(export)
@router.get("/exports", response_model=DATEVExportListResponse, status_code=status.HTTP_200_OK)
async def list_exports(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
current_user: User = Depends(get_current_user),
):
"""List all DATEV exports with pagination."""
exports, total = await datev_service.list_exports(
db,
page=pagination["page"],
page_size=pagination["page_size"],
)
return DATEVExportListResponse(
items=[DATEVExportResponse.model_validate(e) for e in exports],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.get("/exports/{export_id}/download", status_code=status.HTTP_200_OK)
async def download_export(
export_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Download a DATEV export as CSV file."""
result = await datev_service.get_export_csv(db, export_id)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "EXPORT_NOT_FOUND", "message": "DATEV export not found"}},
)
filename, csv_bytes = result
return Response(
content=csv_bytes,
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+203
View File
@@ -0,0 +1,203 @@
"""Sales router: CRUD, contract PDF, USt-IdNr. verification."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import get_current_user, get_pagination
from app.models.user import User
from app.schemas.sale import (
ContractResponse,
SaleCreate,
SaleListResponse,
SaleResponse,
SaleUpdate,
UstIdVerifyResponse,
)
from app.services import sale_service
router = APIRouter(prefix="/sales", tags=["sales"])
@router.get("/", response_model=SaleListResponse, status_code=status.HTTP_200_OK)
async def list_sales(
db: AsyncSession = Depends(get_db),
pagination: dict = Depends(get_pagination),
status_filter: str | None = Query(None, alias="status", description="Filter by sale status"),
date_from: str | None = Query(None, description="Filter sales from this date (YYYY-MM-DD)"),
date_to: str | None = Query(None, description="Filter sales up to this date (YYYY-MM-DD)"),
current_user: User = Depends(get_current_user),
):
"""List sales with pagination, filtering by status and date range."""
from datetime import date as date_type
parsed_date_from = None
parsed_date_to = None
if date_from:
try:
parsed_date_from = date_type.fromisoformat(date_from)
except ValueError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_from format: {date_from}"}},
)
if date_to:
try:
parsed_date_to = date_type.fromisoformat(date_to)
except ValueError:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail={"error": {"code": "INVALID_DATE", "message": f"Invalid date_to format: {date_to}"}},
)
sales, total = await sale_service.list_sales(
db,
page=pagination["page"],
page_size=pagination["page_size"],
status=status_filter,
date_from=parsed_date_from,
date_to=parsed_date_to,
)
return SaleListResponse(
items=[SaleResponse.model_validate(s) for s in sales],
total=total,
page=pagination["page"],
page_size=pagination["page_size"],
)
@router.post("/", response_model=SaleResponse, status_code=status.HTTP_201_CREATED)
async def create_sale(
body: SaleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new sale. Sets vehicle status to 'sold'."""
data = body.model_dump(exclude_unset=False)
try:
sale = await sale_service.create_sale(db, data)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": str(exc)}},
)
return SaleResponse.model_validate(sale)
@router.get("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
async def get_sale(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single sale by ID with nested vehicle and contacts."""
sale = await sale_service.get_sale(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return SaleResponse.model_validate(sale)
@router.put("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
async def update_sale(
sale_id: uuid.UUID,
body: SaleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a sale's fields."""
updates = body.model_dump(exclude_unset=True)
if not updates:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": {"code": "NO_FIELDS", "message": "No fields to update"}},
)
sale = await sale_service.update_sale(db, sale_id, updates)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return SaleResponse.model_validate(sale)
@router.delete("/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK)
async def delete_sale(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Cancel a sale (soft delete). Sets sale status to 'cancelled' and restores vehicle status to 'available'."""
sale = await sale_service.delete_sale(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return SaleResponse.model_validate(sale)
@router.post("/{sale_id}/contract", response_model=ContractResponse, status_code=status.HTTP_200_OK)
async def regenerate_contract(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Regenerate the contract PDF for a sale."""
sale = await sale_service.regenerate_contract_pdf(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
return ContractResponse(
sale_id=sale.id,
contract_pdf_path=sale.contract_pdf_path or "",
message="Contract regenerated",
)
@router.get("/{sale_id}/contract", status_code=status.HTTP_200_OK)
async def download_contract(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Download the contract PDF for a sale."""
sale = await sale_service.get_sale(db, sale_id)
if sale is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "SALE_NOT_FOUND", "message": "Sale not found"}},
)
if not sale.contract_pdf_path or not os.path.exists(sale.contract_pdf_path):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": {"code": "CONTRACT_NOT_FOUND", "message": "Contract PDF not generated yet"}},
)
return FileResponse(
path=sale.contract_pdf_path,
media_type="application/pdf",
filename=f"contract_{sale.id}.pdf",
)
@router.post("/{sale_id}/verify-ust-id", response_model=UstIdVerifyResponse, status_code=status.HTTP_200_OK)
async def verify_ust_id(
sale_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Verify the buyer's USt-IdNr. via BZSt API.
Feature flag: BZST_API_ENABLED (default: False).
When disabled, returns {verified: false, message: 'BZSt API not available'}.
"""
result = await sale_service.verify_ust_id(sale_id, db)
return UstIdVerifyResponse(**result)