Files
erp-nutzfahrzeuge/backend/app/services/sale_service.py
T
Leopoldadmin 341d0c6f38 fix: resolve all ruff and ESLint lint errors
Backend (ruff):
- F821: Add TYPE_CHECKING imports for Vehicle, Contact, File in models
  (file.py, retouch.py, sale.py, vehicle.py)
- E741: Rename ambiguous variable  to /
  (copilot_service.py, price_compare_service.py, openrouter.py)
- F841: Remove unused variable  in sale_service.py

Frontend (ESLint):
- react/no-unescaped-entities: Escape quotes in ContractPreview.tsx
- @next/next/no-img-element: Replace <img> with <Image> from next/image
  (FileGallery.tsx, FileList.tsx, FilePreview.tsx, BeforeAfterSlider.tsx)

Tests: 392 backend passed, 112 frontend passed, next build successful
2026-07-17 21:16:38 +02:00

241 lines
6.5 KiB
Python

"""Sale service: CRUD, contract PDF generation, GwG logic, USt-IdNr. verification."""
from __future__ import annotations
import uuid
from datetime import date
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,
}