2026-07-17 01:58:34 +02:00
|
|
|
"""Sale service: CRUD, contract PDF generation, GwG logic, USt-IdNr. verification."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import uuid
|
2026-07-17 21:16:38 +02:00
|
|
|
from datetime import date
|
2026-07-17 01:58:34 +02:00
|
|
|
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"]
|
|
|
|
|
|
|
|
|
|
# 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,
|
|
|
|
|
}
|