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>
This commit is contained in:
2026-03-06 18:05:01 +01:00
parent 7706c514c8
commit f19a6ccde8
34 changed files with 1940 additions and 14 deletions
+37 -2
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index, ForeignKey
from sqlalchemy import String, Boolean, Date, DateTime, Text, Numeric, Integer, UniqueConstraint, Index, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.database import Base
@@ -31,3 +31,38 @@ class PricingTier(Base):
UniqueConstraint("category_key", "quality_level", name="uq_pricing_tier"),
Index("ix_pricing_tiers_category_key", "category_key"),
)
class Invoice(Base):
__tablename__ = "invoices"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True)
invoice_number: Mapped[str] = mapped_column(String(20), nullable=False, unique=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="draft")
issued_at: Mapped[date | None] = mapped_column(Date, nullable=True)
due_at: Mapped[date | None] = mapped_column(Date, nullable=True)
total_net: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
total_vat: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
vat_rate: Mapped[Decimal] = mapped_column(Numeric(5, 4), nullable=False, default=Decimal("0.19"))
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="EUR")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
pdf_key: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
lines: Mapped[list["InvoiceLine"]] = relationship("InvoiceLine", back_populates="invoice", cascade="all, delete-orphan")
class InvoiceLine(Base):
__tablename__ = "invoice_lines"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
invoice_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("invoices.id", ondelete="CASCADE"), nullable=False)
order_line_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("order_lines.id", ondelete="SET NULL"), nullable=True)
description: Mapped[str] = mapped_column(Text, nullable=False)
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
unit_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
total: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
invoice: Mapped["Invoice"] = relationship("Invoice", back_populates="lines")
+100 -3
View File
@@ -1,4 +1,101 @@
# Re-export from original router.
from app.api.routers.pricing import router
"""Billing router — Invoice CRUD + PDF."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
__all__ = ["router"]
from app.database import get_db
from app.utils.auth import require_admin_or_pm
from app.domains.billing.schemas import InvoiceCreate, InvoiceOut, InvoiceStatusUpdate
from app.domains.billing.service import (
create_invoice, get_invoices, get_invoice,
update_invoice_status, delete_invoice, render_pdf,
)
# Keep the old pricing router re-export for backward compat
from app.api.routers.pricing import router as pricing_router
invoice_router = APIRouter(prefix="/billing", tags=["billing"])
@invoice_router.get("/invoices", response_model=list[InvoiceOut])
async def list_invoices(
skip: int = 0,
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
return await get_invoices(db, skip=skip, limit=limit)
@invoice_router.post("/invoices", response_model=InvoiceOut, status_code=status.HTTP_201_CREATED)
async def create_invoice_endpoint(
body: InvoiceCreate,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
tenant_id = getattr(current_user, 'tenant_id', None)
return await create_invoice(
db,
tenant_id=tenant_id,
order_line_ids=body.order_line_ids,
notes=body.notes,
issued_at=body.issued_at,
due_at=body.due_at,
vat_rate=body.vat_rate,
currency=body.currency,
)
@invoice_router.get("/invoices/{invoice_id}", response_model=InvoiceOut)
async def get_invoice_endpoint(
invoice_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
inv = await get_invoice(db, invoice_id)
if not inv:
raise HTTPException(status_code=404, detail="Invoice not found")
return inv
@invoice_router.patch("/invoices/{invoice_id}", response_model=InvoiceOut)
async def update_invoice_status_endpoint(
invoice_id: uuid.UUID,
body: InvoiceStatusUpdate,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
inv = await update_invoice_status(db, invoice_id, body.status)
if not inv:
raise HTTPException(status_code=404, detail="Invoice not found")
return inv
@invoice_router.get("/invoices/{invoice_id}/pdf")
async def download_invoice_pdf(
invoice_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
key = await render_pdf(db, invoice_id)
if not key:
raise HTTPException(status_code=503, detail="PDF generation unavailable (WeasyPrint not installed)")
from app.core.storage import get_storage
url = get_storage().get_url(key)
return RedirectResponse(url=url)
@invoice_router.delete("/invoices/{invoice_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_invoice_endpoint(
invoice_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user=Depends(require_admin_or_pm),
):
ok = await delete_invoice(db, invoice_id)
if not ok:
raise HTTPException(status_code=400, detail="Only draft invoices can be deleted")
__all__ = ["invoice_router", "pricing_router"]
+57
View File
@@ -0,0 +1,57 @@
"""Billing schemas — Invoice + InvoiceLine Pydantic models."""
from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel
class InvoiceLineCreate(BaseModel):
order_line_id: uuid.UUID | None = None
description: str
quantity: int = 1
unit_price: Decimal | None = None
class InvoiceLineOut(BaseModel):
id: uuid.UUID
invoice_id: uuid.UUID
order_line_id: uuid.UUID | None
description: str
quantity: int
unit_price: Decimal | None
total: Decimal | None
model_config = {"from_attributes": True}
class InvoiceCreate(BaseModel):
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"
class InvoiceStatusUpdate(BaseModel):
status: str # draft|sent|paid|cancelled
class InvoiceOut(BaseModel):
id: uuid.UUID
tenant_id: uuid.UUID | None
invoice_number: str
status: str
issued_at: date | None
due_at: date | None
total_net: Decimal | None
total_vat: Decimal | None
vat_rate: Decimal
currency: str
notes: str | None
pdf_key: str | None
created_at: datetime
lines: list[InvoiceLineOut] = []
model_config = {"from_attributes": True}
+190 -3
View File
@@ -1,4 +1,4 @@
"""Pricing service — price lookup and order price computation.
"""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
@@ -6,14 +6,23 @@ Price resolution cascade for order lines:
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 select, update as sql_update
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 PricingTier
from app.domains.billing.models import Invoice, InvoiceLine, PricingTier
logger = logging.getLogger(__name__)
async def get_price_for(
@@ -181,3 +190,181 @@ async def refresh_order_price(db: AsyncSession, order_id) -> Decimal | None:
)
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>"""