Files
HartOMat/backend/app/domains/billing/service.py
T
Hartmut f19a6ccde8 feat(F-G-H-I): STL cache, invoices, import validation, notification settings
Phase F — STL Hash Cache:
- Migration 041: step_file_hash column on cad_files
- cache_service.py: SHA256 hash + MinIO-backed STL cache (check/store)
- render_step_thumbnail: compute+persist hash before render
- generate_stl_cache: check MinIO cache before cadquery conversion, store after

Phase G — Invoices:
- Migration 042: invoices + invoice_lines tables with RLS
- Invoice/InvoiceLine models + schemas
- billing service: generate_invoice_number (INV-YYYY-NNNN), create/list/get/delete/PDF
- WeasyPrint PDF generation; backend Dockerfile + pyproject.toml deps
- invoice_router with 6 endpoints; registered in main.py
- frontend: Billing.tsx page + api/billing.ts; route + nav link

Phase H — Import Sanity Check:
- Migration 043: import_validations table
- ImportValidation model + schemas
- run_sanity_check: material fuzzy-match (cutoff=0.8), STEP availability, duplicate detection
- validate_excel_import Celery task (queue: step_processing)
- uploads.py: create ImportValidation on /excel, fire task, expose GET /validations/{id}
- frontend: Upload.tsx polling ValidationDialog with Ampel status indicators

Phase I — Notification Settings:
- Migration 044: notification_configs table (user×event×channel toggles)
- NotificationConfig model + seeds (in_app=true, email=false)
- get/upsert/reset config endpoints on /notifications/config
- frontend: NotificationSettings.tsx page + api/notifications.ts extensions

Infrastructure:
- docker-compose.yml: add worker-thumbnail service (concurrency=1, Q=thumbnail_rendering)
- Fix Dockerfile: libgdk-pixbuf-2.0-0 (correct Debian bookworm package name)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 18:05:01 +01:00

371 lines
12 KiB
Python

"""Billing service — pricing (price lookup + order price computation) and invoice CRUD + PDF generation.
Price resolution cascade for order lines:
1. OutputType's linked pricing_tier (if active) → use its price_per_item
2. Product's category_key → look up PricingTier by category
3. "default" category tier → global fallback
4. None if nothing configured
"""
from __future__ import annotations
import logging
import os
import tempfile
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import func, select, update as sql_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.domains.billing.models import Invoice, InvoiceLine, PricingTier
logger = logging.getLogger(__name__)
async def get_price_for(
db: AsyncSession,
category_key: str,
quality_level: str = "Normal",
) -> Decimal | None:
"""Return price_per_item for the given category + quality level."""
# 1. Exact match
result = await db.execute(
select(PricingTier).where(
PricingTier.category_key == category_key,
PricingTier.quality_level == quality_level,
PricingTier.is_active.is_(True),
)
)
tier = result.scalar_one_or_none()
if tier is not None:
return tier.price_per_item
if category_key == "default":
return None
# 2. Fallback: default category
result = await db.execute(
select(PricingTier).where(
PricingTier.category_key == "default",
PricingTier.quality_level == quality_level,
PricingTier.is_active.is_(True),
)
)
tier = result.scalar_one_or_none()
return tier.price_per_item if tier is not None else None
async def resolve_line_price(
db: AsyncSession,
output_type_id: str | None,
product_category_key: str | None,
) -> Decimal | None:
"""Resolve the unit price for a single order line using the cascade."""
if output_type_id is not None:
from app.domains.rendering.models import OutputType
result = await db.execute(
select(OutputType)
.options(selectinload(OutputType.pricing_tier))
.where(OutputType.id == output_type_id)
)
ot = result.scalar_one_or_none()
if ot and ot.pricing_tier and ot.pricing_tier.is_active:
return ot.pricing_tier.price_per_item
# Step 2+3: category lookup with default fallback
cat = product_category_key or "default"
return await get_price_for(db, cat)
async def estimate_order_price(
db: AsyncSession,
lines: list[dict[str, Any]],
) -> dict:
"""Estimate price for a list of prospective order lines."""
from app.domains.products.models import Product
breakdown: list[dict] = []
total = Decimal("0.00")
has_unpriced = False
for line in lines:
product_id = line.get("product_id")
output_type_id = line.get("output_type_id")
# Get product category
cat = None
if product_id:
prod_result = await db.execute(
select(Product).where(Product.id == product_id)
)
prod = prod_result.scalar_one_or_none()
if prod:
cat = prod.category_key
price = await resolve_line_price(db, output_type_id, cat)
breakdown.append({
"output_type_id": str(output_type_id) if output_type_id else None,
"product_id": str(product_id) if product_id else None,
"unit_price": float(price) if price is not None else None,
})
if price is not None:
total += price
else:
has_unpriced = True
return {
"total": float(total),
"line_count": len(lines),
"breakdown": breakdown,
"has_unpriced": has_unpriced,
}
async def refresh_order_price(db: AsyncSession, order_id) -> Decimal | None:
"""Re-fetch order + lines, resolve per-line prices, snapshot to unit_price, update order total."""
from app.domains.orders.models import Order, OrderLine
from app.domains.rendering.models import OutputType
order_result = await db.execute(select(Order).where(Order.id == order_id))
order = order_result.scalar_one_or_none()
if order is None:
return None
lines_result = await db.execute(
select(OrderLine)
.options(
selectinload(OrderLine.output_type).selectinload(OutputType.pricing_tier),
selectinload(OrderLine.product),
)
.where(
OrderLine.order_id == order_id,
OrderLine.output_type_id.is_not(None),
)
)
lines = lines_result.scalars().all()
if not lines:
await db.execute(
sql_update(Order)
.where(Order.id == order_id)
.values(estimated_price=Decimal("0.00"))
)
await db.commit()
return Decimal("0.00")
total = Decimal("0.00")
any_priced = False
for line in lines:
# Cascade: 1) OT pricing tier, 2) product category, 3) default
price = None
if line.output_type and line.output_type.pricing_tier and line.output_type.pricing_tier.is_active:
price = line.output_type.pricing_tier.price_per_item
else:
cat = line.product.category_key if line.product else None
price = await get_price_for(db, cat or "default")
# Snapshot to line
await db.execute(
sql_update(OrderLine)
.where(OrderLine.id == line.id)
.values(unit_price=price)
)
if price is not None:
total += price
any_priced = True
new_price = total if any_priced else None
await db.execute(
sql_update(Order)
.where(Order.id == order_id)
.values(estimated_price=new_price)
)
await db.commit()
return new_price
# ---------------------------------------------------------------------------
# Invoice CRUD
# ---------------------------------------------------------------------------
VALID_STATUSES = {"draft", "sent", "paid", "cancelled"}
async def generate_invoice_number(db: AsyncSession, tenant_id: uuid.UUID | None) -> str:
"""Generate sequential invoice number: INV-YYYY-NNNN."""
year = datetime.utcnow().year
count_result = await db.execute(
select(func.count()).select_from(Invoice).where(
func.extract("year", Invoice.created_at) == year
)
)
seq = (count_result.scalar() or 0) + 1
return f"INV-{year}-{seq:04d}"
async def create_invoice(
db: AsyncSession,
tenant_id: uuid.UUID | None,
order_line_ids: list[uuid.UUID],
notes: str | None = None,
issued_at: date | None = None,
due_at: date | None = None,
vat_rate: Decimal = Decimal("0.19"),
currency: str = "EUR",
) -> Invoice:
"""Create invoice with lines derived from order lines."""
from app.domains.orders.models import OrderLine
invoice_number = await generate_invoice_number(db, tenant_id)
invoice = Invoice(
tenant_id=tenant_id,
invoice_number=invoice_number,
status="draft",
issued_at=issued_at or date.today(),
due_at=due_at,
notes=notes,
vat_rate=vat_rate,
currency=currency,
)
db.add(invoice)
await db.flush() # get invoice.id
total_net = Decimal("0")
for ol_id in order_line_ids:
result = await db.execute(select(OrderLine).where(OrderLine.id == ol_id))
ol = result.scalar_one_or_none()
if not ol:
continue
unit_price = ol.unit_price or Decimal("0")
line = InvoiceLine(
invoice_id=invoice.id,
order_line_id=ol.id,
description=f"Render: {ol.id}",
quantity=1,
unit_price=unit_price,
total=unit_price,
)
db.add(line)
total_net += unit_price
invoice.total_net = total_net
invoice.total_vat = (total_net * vat_rate).quantize(Decimal("0.01"))
await db.commit()
await db.refresh(invoice)
return invoice
async def get_invoices(
db: AsyncSession,
tenant_id: uuid.UUID | None = None,
skip: int = 0,
limit: int = 50,
) -> list[Invoice]:
q = (
select(Invoice)
.options(selectinload(Invoice.lines))
.order_by(Invoice.created_at.desc())
.offset(skip)
.limit(limit)
)
result = await db.execute(q)
return list(result.scalars().all())
async def get_invoice(db: AsyncSession, invoice_id: uuid.UUID) -> Invoice | None:
result = await db.execute(
select(Invoice)
.options(selectinload(Invoice.lines))
.where(Invoice.id == invoice_id)
)
return result.scalar_one_or_none()
async def update_invoice_status(db: AsyncSession, invoice_id: uuid.UUID, status: str) -> Invoice | None:
invoice = await get_invoice(db, invoice_id)
if not invoice:
return None
invoice.status = status
invoice.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(invoice)
return invoice
async def delete_invoice(db: AsyncSession, invoice_id: uuid.UUID) -> bool:
invoice = await get_invoice(db, invoice_id)
if not invoice or invoice.status != "draft":
return False
await db.delete(invoice)
await db.commit()
return True
async def render_pdf(db: AsyncSession, invoice_id: uuid.UUID) -> str | None:
"""Generate PDF via WeasyPrint, upload to storage, return storage key."""
try:
from weasyprint import HTML
except ImportError:
logger.warning("WeasyPrint not installed — PDF generation skipped")
return None
invoice = await get_invoice(db, invoice_id)
if not invoice:
return None
html_content = _build_invoice_html(invoice)
pdf_bytes = HTML(string=html_content).write_pdf()
from app.core.storage import get_storage
storage = get_storage()
key = f"invoices/{invoice_id}.pdf"
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
tmp.write(pdf_bytes)
tmp_path = tmp.name
try:
storage.upload(tmp_path, key)
finally:
os.unlink(tmp_path)
invoice.pdf_key = key
invoice.updated_at = datetime.utcnow()
await db.commit()
return key
def _build_invoice_html(invoice: Invoice) -> str:
lines_html = "".join(
f"<tr><td>{l.description}</td><td>{l.quantity}</td>"
f"<td>{l.unit_price or 0:.2f} {invoice.currency}</td>"
f"<td>{l.total or 0:.2f} {invoice.currency}</td></tr>"
for l in invoice.lines
)
return f"""<!DOCTYPE html><html><head><meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; color: #333; }}
h1 {{ color: #1a56db; }} table {{ width: 100%; border-collapse: collapse; margin-top: 20px; }}
th, td {{ padding: 8px 12px; border-bottom: 1px solid #e5e7eb; text-align: left; }}
th {{ background: #f9fafb; font-weight: 600; }}
.totals {{ text-align: right; margin-top: 16px; }}
</style></head><body>
<h1>Invoice {invoice.invoice_number}</h1>
<p>Status: <strong>{invoice.status}</strong> | Currency: {invoice.currency}</p>
<p>Issued: {invoice.issued_at} | Due: {invoice.due_at or ""}</p>
<table><thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Total</th></tr></thead>
<tbody>{lines_html}</tbody></table>
<div class="totals">
<p>Net: <strong>{invoice.total_net or 0:.2f} {invoice.currency}</strong></p>
<p>VAT ({float(invoice.vat_rate) * 100:.0f}%): {invoice.total_vat or 0:.2f} {invoice.currency}</p>
<p>Gross: <strong>{(invoice.total_net or 0) + (invoice.total_vat or 0):.2f} {invoice.currency}</strong></p>
</div>
{f'<p><em>Notes: {invoice.notes}</em></p>' if invoice.notes else ''}
</body></html>"""