Files
HartOMat/backend/app/domains/orders/models.py
T
Hartmut 1cc10d4bbb feat(phase7.4): order rejection + resubmit flow
- Migration 053: rejection_reason TEXT NULL on orders table
- POST /api/orders/{id}/reject (PM+): sets rejected status, cancels
  active renders, stores reason, notifies creator, broadcasts WS event
- POST /api/orders/{id}/resubmit (creator or PM+): resets to draft,
  clears rejection fields, notifies PMs
- OrderDetail: Reject button (PM+) + inline modal with reason textarea
  and notify-client checkbox; Resubmit button; rejection reason amber
  alert box shown below header when order is rejected
- Orders Kanban: rejection_reason shown as red italic note on card
- Order interface: rejected_at, rejection_reason fields

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

163 lines
8.5 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,
)
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"
)