feat(T06): Verkaufsmodul + Rechtsdokumente + DATEV-Export + Sales UI
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user