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")