2026-07-17 01:58:34 +02:00
|
|
|
"""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),
|
2026-07-17 21:28:58 +02:00
|
|
|
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)"
|
|
|
|
|
),
|
2026-07-17 01:58:34 +02:00
|
|
|
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,
|
2026-07-17 21:28:58 +02:00
|
|
|
detail={
|
|
|
|
|
"error": {
|
|
|
|
|
"code": "INVALID_DATE",
|
|
|
|
|
"message": f"Invalid date_from format: {date_from}",
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-07-17 01:58:34 +02:00
|
|
|
)
|
|
|
|
|
if date_to:
|
|
|
|
|
try:
|
|
|
|
|
parsed_date_to = date_type.fromisoformat(date_to)
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
2026-07-17 21:28:58 +02:00
|
|
|
detail={
|
|
|
|
|
"error": {
|
|
|
|
|
"code": "INVALID_DATE",
|
|
|
|
|
"message": f"Invalid date_to format: {date_to}",
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-07-17 01:58:34 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 21:28:58 +02:00
|
|
|
@router.delete(
|
|
|
|
|
"/{sale_id}", response_model=SaleResponse, status_code=status.HTTP_200_OK
|
|
|
|
|
)
|
2026-07-17 01:58:34 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 21:28:58 +02:00
|
|
|
@router.post(
|
|
|
|
|
"/{sale_id}/contract",
|
|
|
|
|
response_model=ContractResponse,
|
|
|
|
|
status_code=status.HTTP_200_OK,
|
|
|
|
|
)
|
2026-07-17 01:58:34 +02:00
|
|
|
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,
|
2026-07-17 21:28:58 +02:00
|
|
|
detail={
|
|
|
|
|
"error": {
|
|
|
|
|
"code": "CONTRACT_NOT_FOUND",
|
|
|
|
|
"message": "Contract PDF not generated yet",
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-07-17 01:58:34 +02:00
|
|
|
)
|
|
|
|
|
return FileResponse(
|
|
|
|
|
path=sale.contract_pdf_path,
|
|
|
|
|
media_type="application/pdf",
|
|
|
|
|
filename=f"contract_{sale.id}.pdf",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 21:28:58 +02:00
|
|
|
@router.post(
|
|
|
|
|
"/{sale_id}/verify-ust-id",
|
|
|
|
|
response_model=UstIdVerifyResponse,
|
|
|
|
|
status_code=status.HTTP_200_OK,
|
|
|
|
|
)
|
2026-07-17 01:58:34 +02:00
|
|
|
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)
|