Files
Hartmut b892f72f7e feat: per-line render overrides — override any output type setting at order time
Instead of duplicating output types for every variation (WebP vs PNG,
different resolution), keep one canonical output type and override
specific fields per order line via render_overrides JSONB.

Backend:
- render_overrides JSONB column on OrderLine (DB migration)
- Render task merges overrides with output type settings (format, width,
  height, samples, engine, denoiser, transparent_bg, cycles_device)
- POST /orders/{id}/batch-render-overrides endpoint for bulk override
- PatchLineBody accepts render_overrides for per-line patching

Frontend:
- Batch render overrides section on OrderDetail: output format dropdown
  (PNG/JPG/WebP) + resolution dropdown (512-4096)
- Clear button to remove overrides

MCP:
- create_order tool: accepts product_ids, output_type, render_overrides,
  material_override — enables "render all products as WebP" via Claude
- set_render_overrides tool: batch override on existing orders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 12:26:38 +01:00

173 lines
9.0 KiB
Python

import uuid
import enum
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Text, Integer, Boolean, Numeric
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.database import Base
class OrderStatus(str, enum.Enum):
draft = "draft"
submitted = "submitted"
processing = "processing"
completed = "completed"
rejected = "rejected"
class Order(Base):
__tablename__ = "orders"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_number: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
template_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("templates.id"), nullable=True)
status: Mapped[OrderStatus] = mapped_column(SAEnum(OrderStatus), default=OrderStatus.draft, nullable=False)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
source_excel: Mapped[str] = mapped_column(String(1000), nullable=True)
notes: Mapped[str] = 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)
submitted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
processing_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
estimated_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
)
template: Mapped["Template"] = relationship("Template", back_populates="orders")
created_by_user: Mapped["User"] = relationship("User", back_populates="orders", foreign_keys=[created_by])
tenant: Mapped["Tenant | None"] = relationship("Tenant", back_populates="orders", lazy="noload")
items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="order", cascade="all, delete-orphan")
lines: Mapped[list["OrderLine"]] = relationship(
"OrderLine", back_populates="order", cascade="all, delete-orphan"
)
class ItemStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class AIValidationStatus(str, enum.Enum):
not_started = "not_started"
pending = "pending"
completed = "completed"
failed = "failed"
class OrderItem(Base):
__tablename__ = "order_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("orders.id"), nullable=False)
row_index: Mapped[int] = mapped_column(Integer, nullable=False)
# 11 Standard fields (columns 0-10, skip col 5)
ebene1: Mapped[str] = mapped_column(String(500), nullable=True)
ebene2: Mapped[str] = mapped_column(String(500), nullable=True)
baureihe: Mapped[str] = mapped_column(String(500), nullable=True)
pim_id: Mapped[str] = mapped_column(String(500), nullable=True)
produkt_baureihe: Mapped[str] = mapped_column(String(500), nullable=True)
# col 5 is skipped (separator)
gewaehltes_produkt: Mapped[str] = mapped_column(String(500), nullable=True)
name_cad_modell: Mapped[str] = mapped_column(String(500), nullable=True)
gewuenschte_bildnummer: Mapped[str] = mapped_column(String(500), nullable=True)
lagertyp: Mapped[str] = mapped_column(String(500), nullable=True)
medias_rendering: Mapped[bool] = mapped_column(Boolean, nullable=True)
# Component pairs (cols 11+): [{part_name, material, component_type, column_index}]
components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
# CAD linkage
cad_file_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("cad_files.id"), nullable=True)
thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True)
# Material assignments per CAD part: [{part_name, material}]
cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
# AI validation
ai_validation_status: Mapped[AIValidationStatus] = mapped_column(
SAEnum(AIValidationStatus), default=AIValidationStatus.not_started, nullable=False
)
ai_validation_result: Mapped[dict] = mapped_column(JSONB, nullable=True)
item_status: Mapped[ItemStatus] = mapped_column(SAEnum(ItemStatus), default=ItemStatus.pending, nullable=False)
notes: Mapped[str] = mapped_column(Text, nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=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)
order: Mapped["Order"] = relationship("Order", back_populates="items")
cad_file: Mapped["CadFile"] = relationship("CadFile", back_populates="order_items")
@property
def cad_parsed_objects(self) -> list[str] | None:
"""Part names extracted from the linked STEP file, for Pydantic serialization."""
if self.cad_file and self.cad_file.parsed_objects:
return self.cad_file.parsed_objects.get("objects") or []
return None
class OrderLine(Base):
__tablename__ = "order_lines"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
order_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("orders.id", ondelete="CASCADE"), nullable=False, index=True
)
product_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("products.id"), nullable=False, index=True
)
output_type_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("output_types.id"), nullable=True
)
gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True)
item_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
render_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
result_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
render_log: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
render_job_doc: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
ai_validation_status: Mapped[str] = mapped_column(String(20), nullable=False, default="not_started")
ai_validation_result: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
flamenco_job_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
render_backend_used: Mapped[str | None] = mapped_column(String(20), nullable=True)
render_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
render_completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
unit_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2), nullable=True)
render_position_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("product_render_positions.id", ondelete="SET NULL"),
nullable=True,
)
global_render_position_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("global_render_positions.id", ondelete="SET NULL"),
nullable=True,
)
material_override: Mapped[str | None] = mapped_column(String(200), nullable=True, default=None)
render_overrides: Mapped[dict | None] = mapped_column(JSONB, nullable=True, default=None)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=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
)
order: Mapped["Order"] = relationship("Order", back_populates="lines")
product: Mapped["Product"] = relationship("Product", back_populates="order_lines")
output_type: Mapped["OutputType | None"] = relationship("OutputType", back_populates="order_lines")
render_position: Mapped["ProductRenderPosition | None"] = relationship(
"ProductRenderPosition", back_populates="order_lines"
)
global_render_position: Mapped["GlobalRenderPosition | None"] = relationship(
"GlobalRenderPosition", back_populates="order_lines"
)