diff --git a/backend/app/domains/admin/__init__.py b/backend/app/domains/admin/__init__.py new file mode 100644 index 0000000..9f3420f --- /dev/null +++ b/backend/app/domains/admin/__init__.py @@ -0,0 +1 @@ +# Domain: admin diff --git a/backend/app/domains/admin/router.py b/backend/app/domains/admin/router.py new file mode 100644 index 0000000..4efcd9e --- /dev/null +++ b/backend/app/domains/admin/router.py @@ -0,0 +1,6 @@ +# Re-export from original routers. +from app.api.routers.admin import router as admin_router +from app.api.routers.analytics import router as analytics_router +from app.api.routers.worker import router as worker_router + +__all__ = ["admin_router", "analytics_router", "worker_router"] diff --git a/backend/app/domains/admin/service.py b/backend/app/domains/admin/service.py new file mode 100644 index 0000000..7f28997 --- /dev/null +++ b/backend/app/domains/admin/service.py @@ -0,0 +1,6 @@ +"""Admin / KPI service.""" + +# Re-export from original service file for backward compatibility. +from app.services import kpi_service + +__all__ = ["kpi_service"] diff --git a/backend/app/domains/auth/__init__.py b/backend/app/domains/auth/__init__.py new file mode 100644 index 0000000..f93747c --- /dev/null +++ b/backend/app/domains/auth/__init__.py @@ -0,0 +1 @@ +# Domain: auth diff --git a/backend/app/domains/auth/models.py b/backend/app/domains/auth/models.py new file mode 100644 index 0000000..8d49826 --- /dev/null +++ b/backend/app/domains/auth/models.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base +import enum + + +class UserRole(str, enum.Enum): + admin = "admin" + project_manager = "project_manager" + client = "client" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.client, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + 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) + + orders: Mapped[list["Order"]] = relationship("Order", back_populates="created_by_user", foreign_keys="Order.created_by") + audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="user", foreign_keys="AuditLog.user_id") diff --git a/backend/app/domains/auth/router.py b/backend/app/domains/auth/router.py new file mode 100644 index 0000000..53ab3b4 --- /dev/null +++ b/backend/app/domains/auth/router.py @@ -0,0 +1,3 @@ +# Re-export from original router. +from app.api.routers.auth import router +__all__ = ["router"] diff --git a/backend/app/domains/auth/schemas.py b/backend/app/domains/auth/schemas.py new file mode 100644 index 0000000..16e4b58 --- /dev/null +++ b/backend/app/domains/auth/schemas.py @@ -0,0 +1,39 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel, EmailStr +from app.domains.auth.models import UserRole + + +class UserCreate(BaseModel): + email: EmailStr + password: str + full_name: str + role: UserRole = UserRole.client + + +class UserUpdate(BaseModel): + full_name: str | None = None + is_active: bool | None = None + role: UserRole | None = None + + +class UserOut(BaseModel): + id: uuid.UUID + email: str + full_name: str + role: UserRole + is_active: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserOut + + +class LoginRequest(BaseModel): + email: EmailStr + password: str diff --git a/backend/app/domains/billing/__init__.py b/backend/app/domains/billing/__init__.py new file mode 100644 index 0000000..9c78613 --- /dev/null +++ b/backend/app/domains/billing/__init__.py @@ -0,0 +1 @@ +# Domain: billing diff --git a/backend/app/domains/billing/models.py b/backend/app/domains/billing/models.py new file mode 100644 index 0000000..ec93090 --- /dev/null +++ b/backend/app/domains/billing/models.py @@ -0,0 +1,25 @@ +from datetime import datetime +from decimal import Decimal +from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.database import Base + + +class PricingTier(Base): + __tablename__ = "pricing_tiers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + category_key: Mapped[str] = mapped_column(String(100), nullable=False) + quality_level: Mapped[str] = mapped_column(String(50), nullable=False, default="Normal") + price_per_item: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + 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) + + output_types: Mapped[list["OutputType"]] = relationship("OutputType", back_populates="pricing_tier") + + __table_args__ = ( + UniqueConstraint("category_key", "quality_level", name="uq_pricing_tier"), + Index("ix_pricing_tiers_category_key", "category_key"), + ) diff --git a/backend/app/domains/billing/router.py b/backend/app/domains/billing/router.py new file mode 100644 index 0000000..f745d05 --- /dev/null +++ b/backend/app/domains/billing/router.py @@ -0,0 +1,4 @@ +# Re-export from original router. +from app.api.routers.pricing import router + +__all__ = ["router"] diff --git a/backend/app/domains/billing/service.py b/backend/app/domains/billing/service.py new file mode 100644 index 0000000..ded52c7 --- /dev/null +++ b/backend/app/domains/billing/service.py @@ -0,0 +1,183 @@ +"""Pricing service — price lookup and order price computation. + +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 decimal import Decimal +from typing import Any + +from sqlalchemy import select, update as sql_update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.domains.billing.models import PricingTier + + +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 diff --git a/backend/app/domains/imports/__init__.py b/backend/app/domains/imports/__init__.py new file mode 100644 index 0000000..7f17318 --- /dev/null +++ b/backend/app/domains/imports/__init__.py @@ -0,0 +1 @@ +# Domain: imports diff --git a/backend/app/domains/imports/models.py b/backend/app/domains/imports/models.py new file mode 100644 index 0000000..b6abd8a --- /dev/null +++ b/backend/app/domains/imports/models.py @@ -0,0 +1,24 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Boolean, DateTime, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB +from app.database import Base + + +class Template(Base): + __tablename__ = "templates" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(255), nullable=False) + category_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + # JSONB config for each of the 11 standard columns: {col_index: {label, required, optional}} + standard_fields: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + # JSONB schema for expected component pairs + component_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + description: Mapped[str] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + 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) + + orders: Mapped[list["Order"]] = relationship("Order", back_populates="template") diff --git a/backend/app/domains/imports/router.py b/backend/app/domains/imports/router.py new file mode 100644 index 0000000..b622958 --- /dev/null +++ b/backend/app/domains/imports/router.py @@ -0,0 +1,5 @@ +# Re-export from original routers. +from app.api.routers.uploads import router as uploads_router +from app.api.routers.templates import router as templates_router + +__all__ = ["uploads_router", "templates_router"] diff --git a/backend/app/domains/imports/schemas.py b/backend/app/domains/imports/schemas.py new file mode 100644 index 0000000..3ba1537 --- /dev/null +++ b/backend/app/domains/imports/schemas.py @@ -0,0 +1,43 @@ +from pydantic import BaseModel +from typing import Any + + +class ParsedComponent(BaseModel): + part_name: str | None = None + material: str | None = None + component_type: str | None = None + column_index: int + + +class ParsedRow(BaseModel): + row_index: int + ebene1: str | None = None + ebene2: str | None = None + baureihe: str | None = None + pim_id: str | None = None + produkt_baureihe: str | None = None + gewaehltes_produkt: str | None = None + name_cad_modell: str | None = None + gewuenschte_bildnummer: str | None = None + lagertyp: str | None = None + medias_rendering: bool | None = None + components: list[ParsedComponent] = [] + + +class ParsedExcelResponse(BaseModel): + filename: str + excel_path: str | None = None # server-side path of the saved file + category_key: str | None = None + template_name: str | None = None + row_count: int + column_headers: list[str] + rows: list[ParsedRow] + warnings: list[str] = [] + + +class StepUploadResponse(BaseModel): + cad_file_id: str + original_name: str + file_hash: str + status: str + matched_items: list[str] = [] diff --git a/backend/app/domains/imports/service.py b/backend/app/domains/imports/service.py new file mode 100644 index 0000000..258ada1 --- /dev/null +++ b/backend/app/domains/imports/service.py @@ -0,0 +1,12 @@ +"""Import services — Excel parsing and product import.""" + +# Re-export from original service files for backward compatibility. +from app.services.excel_parser import parse_excel, parsed_excel_to_dict +from app.services.excel_import import import_excel_to_products, preview_excel_rows + +__all__ = [ + "parse_excel", + "parsed_excel_to_dict", + "import_excel_to_products", + "preview_excel_rows", +] diff --git a/backend/app/domains/materials/__init__.py b/backend/app/domains/materials/__init__.py new file mode 100644 index 0000000..7368ba2 --- /dev/null +++ b/backend/app/domains/materials/__init__.py @@ -0,0 +1 @@ +# Domain: materials diff --git a/backend/app/domains/materials/models.py b/backend/app/domains/materials/models.py new file mode 100644 index 0000000..f03334d --- /dev/null +++ b/backend/app/domains/materials/models.py @@ -0,0 +1,37 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Text, ForeignKey, Integer +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID +from app.database import Base + + +class Material(Base): + __tablename__ = "materials" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True) + description: Mapped[str] = mapped_column(Text, nullable=True) + source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual") + schaeffler_code: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_by: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), 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) + + creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="select") # type: ignore[name-defined] + aliases = relationship("MaterialAlias", back_populates="material", cascade="all, delete-orphan") + + +class MaterialAlias(Base): + __tablename__ = "material_aliases" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + material_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False + ) + alias: Mapped[str] = mapped_column(String(300), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + material = relationship("Material", back_populates="aliases") diff --git a/backend/app/domains/materials/router.py b/backend/app/domains/materials/router.py new file mode 100644 index 0000000..0966b89 --- /dev/null +++ b/backend/app/domains/materials/router.py @@ -0,0 +1,4 @@ +# Re-export from original router. +from app.api.routers.materials import router + +__all__ = ["router"] diff --git a/backend/app/domains/materials/service.py b/backend/app/domains/materials/service.py new file mode 100644 index 0000000..c7ec3aa --- /dev/null +++ b/backend/app/domains/materials/service.py @@ -0,0 +1,140 @@ +"""Material alias resolution service. + +Used from Celery tasks (sync context) to resolve raw material names +(from Excel / user input) to SCHAEFFLER library material names via aliases. + +Resolution chain: +1. Alias lookup (case-insensitive) → use alias.material.name +2. Exact Material.name match (case-insensitive) → use it +3. Pass through unchanged → Blender will show FailedMaterial magenta +""" +import logging + +from sqlalchemy import create_engine, select, func +from sqlalchemy.orm import Session, selectinload +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domains.materials.models import Material, MaterialAlias + +logger = logging.getLogger(__name__) + +_engine = None + + +def _get_engine(): + global _engine + if _engine is None: + from app.config import settings as app_settings + _engine = create_engine(app_settings.database_url_sync) + return _engine + + +def resolve_material_map(raw_map: dict[str, str]) -> dict[str, str]: + """Resolve raw material names to SCHAEFFLER library names via aliases. + + For each value in raw_map: + 1. Alias lookup (case-insensitive) → return alias.material.name + 2. Exact Material.name match (case-insensitive) → use canonical name + 3. Pass through unchanged + + Returns a new dict with the same keys but resolved material names. + """ + if not raw_map: + return raw_map + + engine = _get_engine() + with Session(engine) as session: + # Load all materials + materials = session.execute( + select(Material).options(selectinload(Material.aliases)) + ).scalars().all() + + # Build lookup dicts (case-insensitive) + # material name (lower) → canonical Material.name + name_lookup: dict[str, str] = {} + # alias (lower) → Material.name + alias_lookup: dict[str, str] = {} + + for mat in materials: + name_lookup[mat.name.lower()] = mat.name + for a in mat.aliases: + alias_lookup[a.alias.lower()] = mat.name + + resolved = {} + for part_name, raw_material in raw_map.items(): + raw_lower = raw_material.lower() + + # 1. Alias lookup first — aliases explicitly map intermediate/display names + # to the canonical SCHAEFFLER library names + if raw_lower in alias_lookup: + target = alias_lookup[raw_lower] + logger.info("resolved '%s' → '%s' (alias match)", raw_material, target) + resolved[part_name] = target + continue + + # 2. Exact material name match (canonical name used as-is) + if raw_lower in name_lookup: + canonical = name_lookup[raw_lower] + if canonical != raw_material: + logger.info("resolved '%s' → '%s' (exact name match)", raw_material, canonical) + resolved[part_name] = canonical + continue + + # 3. Pass through unchanged + logger.warning("no material match for '%s' — will use FailedMaterial fallback", raw_material) + resolved[part_name] = raw_material + + return resolved + + +async def seed_material_aliases_from_mappings( + db: AsyncSession, mappings: list[dict] +) -> dict: + """Seed material aliases from Excel materialmapping sheet. + + For each {display_name, render_name}: + - Find or create Material by render_name + - Add display_name as alias if not already present + + Returns {"created": N, "skipped": N}. + """ + created = 0 + skipped = 0 + + for mapping in mappings: + display_name = mapping.get("display_name", "").strip() + render_name = mapping.get("render_name", "").strip() + if not display_name or not render_name: + skipped += 1 + continue + + # Find or create Material by render_name + result = await db.execute( + select(Material).where(func.lower(Material.name) == render_name.lower()) + ) + material = result.scalar_one_or_none() + if material is None: + material = Material(name=render_name, source="excel_mapping") + db.add(material) + await db.flush() + + # Check if alias already exists + alias_result = await db.execute( + select(MaterialAlias).where( + func.lower(MaterialAlias.alias) == display_name.lower() + ) + ) + existing_alias = alias_result.scalar_one_or_none() + if existing_alias: + skipped += 1 + continue + + # Create alias + alias = MaterialAlias(material_id=material.id, alias=display_name) + db.add(alias) + created += 1 + + if created > 0: + await db.flush() + + return {"created": created, "skipped": skipped} diff --git a/backend/app/domains/notifications/__init__.py b/backend/app/domains/notifications/__init__.py new file mode 100644 index 0000000..ed1c4aa --- /dev/null +++ b/backend/app/domains/notifications/__init__.py @@ -0,0 +1 @@ +# Domain: notifications diff --git a/backend/app/domains/notifications/models.py b/backend/app/domains/notifications/models.py new file mode 100644 index 0000000..fcc6e11 --- /dev/null +++ b/backend/app/domains/notifications/models.py @@ -0,0 +1,28 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, Boolean, DateTime, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB +from app.database import Base + + +class AuditLog(Base): + __tablename__ = "audit_log" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) + action: Mapped[str] = mapped_column(String(100), nullable=False) + entity_type: Mapped[str] = mapped_column(String(100), nullable=True) + entity_id: Mapped[str] = mapped_column(String(255), nullable=True) + details: Mapped[dict] = mapped_column(JSONB, nullable=True) + timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + + # Notification center columns + target_user_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, + ) + read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + notification: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + user: Mapped["User"] = relationship("User", back_populates="audit_logs", foreign_keys=[user_id]) + target_user: Mapped["User"] = relationship("User", foreign_keys=[target_user_id]) diff --git a/backend/app/domains/notifications/router.py b/backend/app/domains/notifications/router.py new file mode 100644 index 0000000..353f36a --- /dev/null +++ b/backend/app/domains/notifications/router.py @@ -0,0 +1,4 @@ +# Re-export from original router. +from app.api.routers.notifications import router + +__all__ = ["router"] diff --git a/backend/app/domains/notifications/service.py b/backend/app/domains/notifications/service.py new file mode 100644 index 0000000..c093726 --- /dev/null +++ b/backend/app/domains/notifications/service.py @@ -0,0 +1,84 @@ +"""Notification emission helpers. + +Provides async (for routers) and sync (for Celery tasks) entry points +to create notification rows in the audit_log table. +""" +import logging +import uuid +from datetime import datetime + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domains.notifications.models import AuditLog + +logger = logging.getLogger(__name__) + +_engine = None + + +def _get_engine(): + global _engine + if _engine is None: + from app.config import settings as app_settings + _engine = create_engine(app_settings.database_url_sync) + return _engine + + +async def emit_notification( + db: AsyncSession, + *, + actor_user_id: str | uuid.UUID | None = None, + target_user_id: str | uuid.UUID | None = None, + action: str, + entity_type: str | None = None, + entity_id: str | None = None, + details: dict | None = None, +) -> None: + """Create a notification (async — for use inside FastAPI routers).""" + try: + entry = AuditLog( + user_id=str(actor_user_id) if actor_user_id else None, + target_user_id=str(target_user_id) if target_user_id else None, + action=action, + entity_type=entity_type, + entity_id=str(entity_id) if entity_id else None, + details=details, + notification=True, + timestamp=datetime.utcnow(), + ) + db.add(entry) + await db.commit() + except Exception: + logger.exception("Failed to emit notification (async)") + await db.rollback() + + +def emit_notification_sync( + *, + actor_user_id: str | uuid.UUID | None = None, + target_user_id: str | uuid.UUID | None = None, + action: str, + entity_type: str | None = None, + entity_id: str | None = None, + details: dict | None = None, +) -> None: + """Create a notification (sync — for use inside Celery tasks).""" + engine = _get_engine() + try: + with Session(engine) as session: + entry = AuditLog( + user_id=str(actor_user_id) if actor_user_id else None, + target_user_id=str(target_user_id) if target_user_id else None, + action=action, + entity_type=entity_type, + entity_id=str(entity_id) if entity_id else None, + details=details, + notification=True, + timestamp=datetime.utcnow(), + ) + session.add(entry) + session.commit() + except Exception: + logger.exception("Failed to emit notification (sync)") diff --git a/backend/app/domains/orders/__init__.py b/backend/app/domains/orders/__init__.py new file mode 100644 index 0000000..b2ebde9 --- /dev/null +++ b/backend/app/domains/orders/__init__.py @@ -0,0 +1 @@ +# Domain: orders diff --git a/backend/app/domains/orders/models.py b/backend/app/domains/orders/models.py new file mode 100644 index 0000000..ab09565 --- /dev/null +++ b/backend/app/domains/orders/models.py @@ -0,0 +1,150 @@ +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) + estimated_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + + template: Mapped["Template"] = relationship("Template", back_populates="orders") + created_by_user: Mapped["User"] = relationship("User", back_populates="orders", foreign_keys=[created_by]) + 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) + 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) + 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) + 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" + ) diff --git a/backend/app/domains/orders/router.py b/backend/app/domains/orders/router.py new file mode 100644 index 0000000..44bfb5d --- /dev/null +++ b/backend/app/domains/orders/router.py @@ -0,0 +1,5 @@ +# Re-export from original routers. +from app.api.routers.orders import router as orders_router +from app.api.routers.order_items import router as order_items_router + +__all__ = ["orders_router", "order_items_router"] diff --git a/backend/app/domains/orders/schemas.py b/backend/app/domains/orders/schemas.py new file mode 100644 index 0000000..278a310 --- /dev/null +++ b/backend/app/domains/orders/schemas.py @@ -0,0 +1,124 @@ +import uuid +from datetime import datetime +from typing import Any +from pydantic import BaseModel +from app.domains.orders.models import OrderStatus, ItemStatus, AIValidationStatus + + +class ComponentData(BaseModel): + part_name: str | None = None + material: str | None = None + component_type: str | None = None + column_index: int | None = None + + +class OrderItemCreate(BaseModel): + row_index: int + ebene1: str | None = None + ebene2: str | None = None + baureihe: str | None = None + pim_id: str | None = None + produkt_baureihe: str | None = None + gewaehltes_produkt: str | None = None + name_cad_modell: str | None = None + gewuenschte_bildnummer: str | None = None + lagertyp: str | None = None + medias_rendering: bool | None = None + components: list[ComponentData] = [] + + +class OrderItemOut(BaseModel): + id: uuid.UUID + order_id: uuid.UUID + row_index: int + ebene1: str | None + ebene2: str | None + baureihe: str | None + pim_id: str | None + produkt_baureihe: str | None + gewaehltes_produkt: str | None + name_cad_modell: str | None + gewuenschte_bildnummer: str | None + lagertyp: str | None + medias_rendering: bool | None + components: list[dict] + cad_file_id: uuid.UUID | None + thumbnail_path: str | None + cad_parsed_objects: list[str] | None = None + cad_part_materials: list[dict] = [] + ai_validation_status: AIValidationStatus + ai_validation_result: dict | None + item_status: ItemStatus + notes: str | None + created_at: datetime + + model_config = {"from_attributes": True} + + +class OrderLineCreate(BaseModel): + product_id: uuid.UUID + output_type_id: uuid.UUID | None = None + render_position_id: uuid.UUID | None = None + gewuenschte_bildnummer: str | None = None + notes: str | None = None + + +class OrderLineOut(BaseModel): + id: uuid.UUID + order_id: uuid.UUID + product_id: uuid.UUID + product: Any # ProductOut — forward ref to avoid circular imports + output_type_id: uuid.UUID | None + output_type: Any | None # OutputTypeOut + gewuenschte_bildnummer: str | None + item_status: str + render_status: str + result_path: str | None + thumbnail_url: str | None = None + ai_validation_status: str + ai_validation_result: dict | None + render_backend_used: str | None = None + flamenco_job_id: str | None = None + unit_price: float | None = None + render_position_id: uuid.UUID | None = None + render_position_name: str | None = None + notes: str | None + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class OrderCreate(BaseModel): + template_id: uuid.UUID | None = None + source_excel: str | None = None + notes: str | None = None + items: list[OrderItemCreate] = [] + lines: list[OrderLineCreate] = [] + + +class OrderOut(BaseModel): + id: uuid.UUID + order_number: str + template_id: uuid.UUID | None + status: OrderStatus + created_by: uuid.UUID + source_excel: str | None + notes: str | None + created_at: datetime + updated_at: datetime + submitted_at: datetime | None = None + processing_started_at: datetime | None = None + completed_at: datetime | None = None + rejected_at: datetime | None = None + estimated_price: float | None = None + item_count: int = 0 + line_count: int = 0 + render_progress: dict | None = None + + model_config = {"from_attributes": True} + + +class OrderDetailOut(OrderOut): + items: list[OrderItemOut] = [] + lines: list[OrderLineOut] = [] diff --git a/backend/app/domains/orders/service.py b/backend/app/domains/orders/service.py new file mode 100644 index 0000000..06ac5f3 --- /dev/null +++ b/backend/app/domains/orders/service.py @@ -0,0 +1,101 @@ +"""Order service — order number generation and business logic.""" +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, create_engine, update as sql_update +from sqlalchemy.orm import Session +from app.domains.orders.models import Order, OrderLine, OrderStatus +import logging + +logger = logging.getLogger(__name__) + + +async def generate_order_number(db: AsyncSession) -> str: + """Generate next sequential order number: SA-2026-XXXXX.""" + year = datetime.utcnow().year + prefix = f"SA-{year}-" + + # Use MAX to find the highest existing sequence number this year. + # COUNT-based approach breaks when orders are deleted (produces duplicates). + result = await db.execute( + select(func.max(Order.order_number)).where(Order.order_number.like(f"{prefix}%")) + ) + max_num = result.scalar() + if max_num: + last_seq = int(max_num.split("-")[-1]) + return f"{prefix}{last_seq + 1:05d}" + return f"{prefix}00001" + + +def check_order_completion(order_id: str) -> bool: + """If all renderable lines are done, auto-advance order to completed. + + Called from Celery tasks (sync context). + Returns True if the order was advanced to completed. + """ + from app.config import settings as app_settings + + sync_url = app_settings.database_url.replace("+asyncpg", "") + engine = create_engine(sync_url) + + try: + with Session(engine) as session: + # Get all lines that have an output type (i.e. renderable) + lines = session.execute( + select(OrderLine).where( + OrderLine.order_id == order_id, + OrderLine.output_type_id.isnot(None), + ) + ).scalars().all() + + if not lines: + return False + + # Check if all renderable lines are in a terminal state + all_terminal = all( + line.render_status in ("completed", "failed", "cancelled") + for line in lines + ) + + if not all_terminal: + return False + + # Check order is still in processing state + order = session.execute( + select(Order).where(Order.id == order_id) + ).scalar_one_or_none() + + if order is None or order.status != OrderStatus.processing: + return False + + # Auto-advance to completed + now = datetime.utcnow() + session.execute( + sql_update(Order) + .where(Order.id == order_id) + .values( + status=OrderStatus.completed, + completed_at=now, + updated_at=now, + ) + ) + session.commit() + logger.info(f"Order {order_id} auto-advanced to completed (all {len(lines)} lines done)") + + # Notify order creator + try: + from app.domains.notifications.service import emit_notification_sync + emit_notification_sync( + actor_user_id=None, + target_user_id=str(order.created_by), + action="order.completed", + entity_type="order", + entity_id=str(order_id), + details={"order_number": order.order_number}, + ) + except Exception: + logger.exception("Failed to emit order.completed notification") + + return True + + finally: + engine.dispose() diff --git a/backend/app/domains/products/__init__.py b/backend/app/domains/products/__init__.py new file mode 100644 index 0000000..30e090b --- /dev/null +++ b/backend/app/domains/products/__init__.py @@ -0,0 +1 @@ +# Domain: products diff --git a/backend/app/domains/products/models.py b/backend/app/domains/products/models.py new file mode 100644 index 0000000..bc270bd --- /dev/null +++ b/backend/app/domains/products/models.py @@ -0,0 +1,97 @@ +import uuid +import enum +from datetime import datetime +from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey, BigInteger, Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB +from app.database import Base + + +class ProcessingStatus(str, enum.Enum): + pending = "pending" + processing = "processing" + completed = "completed" + failed = "failed" + + +class CadFile(Base): + __tablename__ = "cad_files" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + original_name: Mapped[str] = mapped_column(String(500), nullable=False) + stored_path: Mapped[str] = mapped_column(String(1000), nullable=False) + file_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) + file_size: Mapped[int] = mapped_column(BigInteger, nullable=True) + parsed_objects: Mapped[dict] = mapped_column(JSONB, nullable=True) + thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True) + gltf_path: Mapped[str] = mapped_column(String(1000), nullable=True) + processing_status: Mapped[ProcessingStatus] = mapped_column( + SAEnum(ProcessingStatus), default=ProcessingStatus.pending, nullable=False + ) + error_message: Mapped[str] = mapped_column(String(2000), nullable=True) + render_log: Mapped[dict] = mapped_column(JSONB, 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) + + order_items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="cad_file") + products: Mapped[list["Product"]] = relationship("Product", back_populates="cad_file") + + +class Product(Base): + __tablename__ = "products" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + pim_id: Mapped[str] = mapped_column(String(500), nullable=False) + name: Mapped[str | None] = mapped_column(String(500), nullable=True) + category_key: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) + ebene1: Mapped[str | None] = mapped_column(String(500), nullable=True) + ebene2: Mapped[str | None] = mapped_column(String(500), nullable=True) + baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True) + produkt_baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True) + lagertyp: Mapped[str | None] = mapped_column(String(500), nullable=True) + name_cad_modell: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True) + gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True) + medias_rendering: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + cad_file_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("cad_files.id", ondelete="SET NULL"), nullable=True + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + arbeitspaket: Mapped[str | None] = mapped_column(String(500), nullable=True) + source_excel: Mapped[str | None] = mapped_column(String(1000), 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 + ) + + cad_file: Mapped["CadFile | None"] = relationship("CadFile", back_populates="products") + order_lines: Mapped[list["OrderLine"]] = relationship( + "OrderLine", back_populates="product", cascade="all, delete-orphan" + ) + render_positions: Mapped[list["ProductRenderPosition"]] = relationship( + "ProductRenderPosition", back_populates="product", + cascade="all, delete-orphan", order_by="ProductRenderPosition.sort_order" + ) + + @property + def thumbnail_url(self) -> str | None: + if self.cad_file and self.cad_file.thumbnail_path: + from pathlib import Path + return f"/thumbnails/{Path(self.cad_file.thumbnail_path).name}" + return None + + @property + def processing_status(self) -> str | None: + if self.cad_file: + return self.cad_file.processing_status.value if hasattr( + self.cad_file.processing_status, 'value' + ) else str(self.cad_file.processing_status) + return None + + @property + def cad_parsed_objects(self) -> list[str] | None: + if self.cad_file and self.cad_file.parsed_objects: + return self.cad_file.parsed_objects.get("objects") or [] + return None diff --git a/backend/app/domains/products/router.py b/backend/app/domains/products/router.py new file mode 100644 index 0000000..8b9b805 --- /dev/null +++ b/backend/app/domains/products/router.py @@ -0,0 +1,7 @@ +# Re-export from original routers (products and cad kept as separate files). +# The original app/api/routers/products.py and app/api/routers/cad.py are +# the canonical implementations; they are imported directly in main.py. +from app.api.routers.products import router as products_router +from app.api.routers.cad import router as cad_router + +__all__ = ["products_router", "cad_router"] diff --git a/backend/app/domains/products/schemas.py b/backend/app/domains/products/schemas.py new file mode 100644 index 0000000..233d6ff --- /dev/null +++ b/backend/app/domains/products/schemas.py @@ -0,0 +1,72 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel +from app.domains.rendering.schemas import RenderPositionOut + + +class ProductCreate(BaseModel): + pim_id: str + name: str | None = None + category_key: str | None = None + ebene1: str | None = None + ebene2: str | None = None + baureihe: str | None = None + produkt_baureihe: str | None = None + lagertyp: str | None = None + name_cad_modell: str | None = None + gewuenschte_bildnummer: str | None = None + medias_rendering: bool | None = None + components: list[dict] = [] + cad_part_materials: list[dict] = [] + notes: str | None = None + is_active: bool = True + source_excel: str | None = None + + +class ProductPatch(BaseModel): + name: str | None = None + category_key: str | None = None + ebene1: str | None = None + ebene2: str | None = None + baureihe: str | None = None + produkt_baureihe: str | None = None + lagertyp: str | None = None + name_cad_modell: str | None = None + gewuenschte_bildnummer: str | None = None + medias_rendering: bool | None = None + components: list[dict] | None = None + cad_part_materials: list[dict] | None = None + notes: str | None = None + is_active: bool | None = None + + +class ProductOut(BaseModel): + id: uuid.UUID + pim_id: str + name: str | None + category_key: str | None + ebene1: str | None + ebene2: str | None + baureihe: str | None + produkt_baureihe: str | None + lagertyp: str | None + name_cad_modell: str | None + gewuenschte_bildnummer: str | None + medias_rendering: bool | None + components: list[dict] + cad_part_materials: list[dict] + cad_file_id: uuid.UUID | None + thumbnail_url: str | None = None + render_image_url: str | None = None + processing_status: str | None = None + stl_cached: list[str] = [] + cad_parsed_objects: list[str] | None = None + arbeitspaket: str | None = None + notes: str | None + is_active: bool + source_excel: str | None + render_positions: list[RenderPositionOut] = [] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/domains/products/service.py b/backend/app/domains/products/service.py new file mode 100644 index 0000000..ebebc2d --- /dev/null +++ b/backend/app/domains/products/service.py @@ -0,0 +1,141 @@ +"""Product service — lookup/create products, link CAD files.""" +import uuid +from sqlalchemy import select, func, update as sql_update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.domains.products.models import Product + +# Default render positions added to every newly created product. +DEFAULT_RENDER_POSITIONS = [ + {"name": "3/4 Front", "rotation_x": -15.0, "rotation_y": 45.0, "rotation_z": 0.0, "is_default": True, "sort_order": 0}, + {"name": "3/4 Rear", "rotation_x": -15.0, "rotation_y": -135.0, "rotation_z": 0.0, "is_default": False, "sort_order": 1}, + {"name": "Default", "rotation_x": 0.0, "rotation_y": 0.0, "rotation_z": 0.0, "is_default": False, "sort_order": 2}, +] + + +async def create_default_positions(db: AsyncSession, product_id: uuid.UUID) -> None: + """Insert the default render positions for a newly created product.""" + from app.domains.rendering.models import ProductRenderPosition + for pos_data in DEFAULT_RENDER_POSITIONS: + db.add(ProductRenderPosition(product_id=product_id, **pos_data)) + await db.flush() + + +def _fill_missing_fields(product: Product, pim_id: str | None, fields: dict) -> None: + """Fill in null/empty fields on an existing product without overwriting manual edits.""" + if pim_id and not product.pim_id: + product.pim_id = pim_id + for attr in ( + "name", "category_key", "ebene1", "ebene2", "baureihe", + "lagertyp", "name_cad_modell", "arbeitspaket", + ): + if fields.get(attr) and not getattr(product, attr, None): + setattr(product, attr, fields[attr]) + # Update medias_rendering if not set + if fields.get("medias_rendering") is not None and product.medias_rendering is None: + product.medias_rendering = fields["medias_rendering"] + # Always update components from the latest Excel import (needed for auto-reassign) + if fields.get("components"): + product.components = fields["components"] + + +async def lookup_product( + db: AsyncSession, pim_id: str | None, produkt_baureihe: str | None +) -> Product | None: + """Read-only lookup: produkt_baureihe (primary), then pim_id (fallback). + + Same cascade as lookup_or_create_product but never creates or mutates. + """ + if produkt_baureihe: + result = await db.execute( + select(Product).where( + func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(), + Product.is_active.is_(True), + ) + ) + product = result.scalar_one_or_none() + if product is not None: + return product + # baureihe provided but not found — skip pim_id fallback (same logic) + return None + + if pim_id: + result = await db.execute( + select(Product).where(Product.pim_id == pim_id, Product.is_active.is_(True)) + ) + return result.scalar_one_or_none() + + return None + + +async def lookup_or_create_product( + db: AsyncSession, pim_id: str | None, fields: dict +) -> tuple[Product, bool]: + """Look up by produkt_baureihe (primary), then pim_id (fallback). Create if not found. + + Returns (product, was_created). + Does NOT overwrite existing fields — preserves manual edits. + """ + produkt_baureihe = fields.get("produkt_baureihe") + + # Primary lookup: by produkt_baureihe (case-insensitive) + if produkt_baureihe: + result = await db.execute( + select(Product).where( + func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(), + Product.is_active.is_(True), + ) + ) + product = result.scalar_one_or_none() + if product is not None: + _fill_missing_fields(product, pim_id, fields) + await db.flush() + return product, False + # produkt_baureihe was provided but not found — each baureihe is a + # distinct product, so skip the pim_id fallback and create a new one. + + # Fallback lookup: by pim_id (only when produkt_baureihe is absent, + # e.g. old per-category Excel files that don't have a Baureihe column). + if not produkt_baureihe and pim_id: + result = await db.execute( + select(Product).where(Product.pim_id == pim_id, Product.is_active.is_(True)) + ) + product = result.scalar_one_or_none() + if product is not None: + _fill_missing_fields(product, pim_id, fields) + await db.flush() + return product, False + + product = Product( + pim_id=pim_id or f"auto-{uuid.uuid4().hex[:8]}", + name=fields.get("name"), + category_key=fields.get("category_key"), + ebene1=fields.get("ebene1"), + ebene2=fields.get("ebene2"), + baureihe=fields.get("baureihe"), + produkt_baureihe=produkt_baureihe, + lagertyp=fields.get("lagertyp"), + name_cad_modell=fields.get("name_cad_modell"), + arbeitspaket=fields.get("arbeitspaket"), + components=fields.get("components", []), + cad_part_materials=fields.get("cad_part_materials", []), + source_excel=fields.get("source_excel"), + ) + db.add(product) + await db.flush() + await create_default_positions(db, product.id) + return product, True + + +async def link_cad_to_product( + db: AsyncSession, product_id: uuid.UUID, cad_file_id: uuid.UUID +) -> Product: + """Set product.cad_file_id via direct SQL UPDATE.""" + await db.execute( + sql_update(Product) + .where(Product.id == product_id) + .values(cad_file_id=cad_file_id) + ) + await db.commit() + result = await db.execute(select(Product).where(Product.id == product_id)) + return result.scalar_one() diff --git a/backend/app/domains/rendering/models.py b/backend/app/domains/rendering/models.py new file mode 100644 index 0000000..3562995 --- /dev/null +++ b/backend/app/domains/rendering/models.py @@ -0,0 +1,81 @@ +import uuid +from datetime import datetime +from sqlalchemy import String, DateTime, Boolean, Text, Integer, Float, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.dialects.postgresql import UUID, JSONB +from app.database import Base + +VALID_RENDER_BACKENDS = {"celery"} + + +class OutputType(Base): + __tablename__ = "output_types" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + renderer: Mapped[str] = mapped_column(String(50), nullable=False, default="threejs") + render_settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + output_format: Mapped[str] = mapped_column(String(20), nullable=False, default="png") + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + compatible_categories: Mapped[list] = mapped_column(JSONB, default=list, server_default="[]") + render_backend: Mapped[str] = mapped_column(String(20), nullable=False, default="auto", server_default="auto") + is_animation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + transparent_bg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + cycles_device: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None) + pricing_tier_id: Mapped[int | None] = mapped_column( + Integer, ForeignKey("pricing_tiers.id", ondelete="SET NULL"), nullable=True, index=True + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="output_type") + pricing_tier: Mapped["PricingTier | None"] = relationship("PricingTier", back_populates="output_types") + + +class RenderTemplate(Base): + __tablename__ = "render_templates" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(300), nullable=False) + category_key: Mapped[str | None] = mapped_column(String(100), nullable=True) + output_type_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("output_types.id", ondelete="SET NULL"), nullable=True + ) + blend_file_path: Mapped[str] = mapped_column(Text, nullable=False) + original_filename: Mapped[str] = mapped_column(String(500), nullable=False) + target_collection: Mapped[str] = mapped_column(String(200), nullable=False, default="Product", server_default="Product") + material_replace_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + lighting_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + shadow_catcher_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") + camera_orbit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()") + updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()", onupdate=datetime.utcnow) + + output_type = relationship("OutputType", lazy="joined") + + +class ProductRenderPosition(Base): + __tablename__ = "product_render_positions" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + product_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(200), nullable=False) + rotation_x: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + rotation_y: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + 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 + ) + + product: Mapped["Product"] = relationship("Product", back_populates="render_positions") + order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="render_position") diff --git a/backend/app/domains/rendering/router.py b/backend/app/domains/rendering/router.py new file mode 100644 index 0000000..4fa39e8 --- /dev/null +++ b/backend/app/domains/rendering/router.py @@ -0,0 +1,5 @@ +# Re-export from original routers. +from app.api.routers.render_templates import router as render_templates_router +from app.api.routers.output_types import router as output_types_router + +__all__ = ["render_templates_router", "output_types_router"] diff --git a/backend/app/domains/rendering/schemas.py b/backend/app/domains/rendering/schemas.py new file mode 100644 index 0000000..98f32bc --- /dev/null +++ b/backend/app/domains/rendering/schemas.py @@ -0,0 +1,91 @@ +import uuid +from datetime import datetime +from pydantic import BaseModel + + +class OutputTypeCreate(BaseModel): + name: str + description: str | None = None + renderer: str = "threejs" + render_settings: dict = {} + output_format: str = "png" + sort_order: int = 0 + is_active: bool = True + compatible_categories: list[str] = [] + render_backend: str = "auto" + is_animation: bool = False + transparent_bg: bool = False + pricing_tier_id: int | None = None + cycles_device: str | None = None + + +class OutputTypePatch(BaseModel): + name: str | None = None + description: str | None = None + renderer: str | None = None + render_settings: dict | None = None + output_format: str | None = None + sort_order: int | None = None + is_active: bool | None = None + compatible_categories: list[str] | None = None + render_backend: str | None = None + is_animation: bool | None = None + transparent_bg: bool | None = None + pricing_tier_id: int | None = None + cycles_device: str | None = None + + +class OutputTypeOut(BaseModel): + id: uuid.UUID + name: str + description: str | None + renderer: str + render_settings: dict + output_format: str + sort_order: int + compatible_categories: list[str] + render_backend: str + is_animation: bool + transparent_bg: bool + cycles_device: str | None = None + pricing_tier_id: int | None = None + pricing_tier_name: str | None = None + price_per_item: float | None = None + is_active: bool + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + +class RenderPositionCreate(BaseModel): + name: str + rotation_x: float = 0.0 + rotation_y: float = 0.0 + rotation_z: float = 0.0 + is_default: bool = False + sort_order: int = 0 + + +class RenderPositionPatch(BaseModel): + name: str | None = None + rotation_x: float | None = None + rotation_y: float | None = None + rotation_z: float | None = None + is_default: bool | None = None + sort_order: int | None = None + + +class RenderPositionOut(BaseModel): + id: uuid.UUID + product_id: uuid.UUID + name: str + rotation_x: float + rotation_y: float + rotation_z: float + is_default: bool + sort_order: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/domains/rendering/service.py b/backend/app/domains/rendering/service.py new file mode 100644 index 0000000..023f86a --- /dev/null +++ b/backend/app/domains/rendering/service.py @@ -0,0 +1,14 @@ +"""Rendering services — template resolution, dispatch, and Blender utilities.""" + +# Re-export from original service files for backward compatibility. +from app.services.template_service import resolve_template, get_material_library_path +from app.services.render_dispatcher import dispatch_render +from app.services.render_blender import find_blender, is_blender_available + +__all__ = [ + "resolve_template", + "get_material_library_path", + "dispatch_render", + "find_blender", + "is_blender_available", +] diff --git a/backend/app/main.py b/backend/app/main.py index d91ce21..fab609b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -6,7 +6,17 @@ from pathlib import Path from app.config import settings from app.database import engine, Base -from app.api.routers import auth, uploads, orders, templates, admin, order_items, cad, materials, worker, analytics, pricing, products, output_types, render_templates, notifications + +# Import routers from domain locations +from app.domains.auth.router import router as auth_router +from app.domains.imports.router import uploads_router, templates_router +from app.domains.orders.router import orders_router, order_items_router +from app.domains.admin.router import admin_router, analytics_router, worker_router +from app.domains.products.router import products_router, cad_router +from app.domains.materials.router import router as materials_router +from app.domains.rendering.router import render_templates_router, output_types_router +from app.domains.notifications.router import router as notifications_router +from app.domains.billing.router import router as pricing_router @asynccontextmanager @@ -48,22 +58,22 @@ try: except (PermissionError, OSError): pass -# Include routers -app.include_router(auth.router, prefix="/api") -app.include_router(uploads.router, prefix="/api") -app.include_router(orders.router, prefix="/api") -app.include_router(templates.router, prefix="/api") -app.include_router(admin.router, prefix="/api") -app.include_router(order_items.router, prefix="/api") -app.include_router(cad.router, prefix="/api") -app.include_router(materials.router, prefix="/api") -app.include_router(worker.router, prefix="/api") -app.include_router(analytics.router, prefix="/api") -app.include_router(pricing.router, prefix="/api") -app.include_router(products.router, prefix="/api") -app.include_router(output_types.router, prefix="/api") -app.include_router(render_templates.router, prefix="/api") -app.include_router(notifications.router, prefix="/api") +# Include routers (via domain locations) +app.include_router(auth_router, prefix="/api") +app.include_router(uploads_router, prefix="/api") +app.include_router(orders_router, prefix="/api") +app.include_router(templates_router, prefix="/api") +app.include_router(admin_router, prefix="/api") +app.include_router(order_items_router, prefix="/api") +app.include_router(cad_router, prefix="/api") +app.include_router(materials_router, prefix="/api") +app.include_router(worker_router, prefix="/api") +app.include_router(analytics_router, prefix="/api") +app.include_router(pricing_router, prefix="/api") +app.include_router(products_router, prefix="/api") +app.include_router(output_types_router, prefix="/api") +app.include_router(render_templates_router, prefix="/api") +app.include_router(notifications_router, prefix="/api") @app.get("/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 038c2a6..cf0599e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,20 +1,22 @@ -from app.models.user import User -from app.models.template import Template -from app.models.cad_file import CadFile -from app.models.order import Order -from app.models.order_item import OrderItem -from app.models.audit_log import AuditLog -from app.models.pricing_tier import PricingTier -from app.models.product import Product -from app.models.output_type import OutputType -from app.models.order_line import OrderLine -from app.models.render_template import RenderTemplate -from app.models.material import Material -from app.models.material_alias import MaterialAlias -from app.models.render_position import ProductRenderPosition +"""Re-export all models from domain locations. + +This file ensures that `from app.models import X` continues to work. +The canonical definitions live in app/domains/*/models.py. +""" +from app.domains.auth.models import User +from app.domains.imports.models import Template +from app.domains.products.models import CadFile, Product +from app.domains.orders.models import Order, OrderItem, OrderLine +from app.domains.notifications.models import AuditLog +from app.domains.billing.models import PricingTier +from app.domains.rendering.models import OutputType, RenderTemplate, ProductRenderPosition +from app.domains.materials.models import Material, MaterialAlias + +# Also re-export SystemSetting (no domain assigned — stays as-is) +from app.models.system_setting import SystemSetting __all__ = [ - "User", "Template", "CadFile", "Order", "OrderItem", "AuditLog", - "PricingTier", "Product", "OutputType", "OrderLine", - "RenderTemplate", "Material", "MaterialAlias", "ProductRenderPosition", + "User", "Template", "CadFile", "Product", "Order", "OrderItem", "OrderLine", + "AuditLog", "PricingTier", "OutputType", "RenderTemplate", "ProductRenderPosition", + "Material", "MaterialAlias", "SystemSetting", ] diff --git a/backend/app/models/audit_log.py b/backend/app/models/audit_log.py index fcc6e11..cf23249 100644 --- a/backend/app/models/audit_log.py +++ b/backend/app/models/audit_log.py @@ -1,28 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, Boolean, DateTime, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base - - -class AuditLog(Base): - __tablename__ = "audit_log" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) - action: Mapped[str] = mapped_column(String(100), nullable=False) - entity_type: Mapped[str] = mapped_column(String(100), nullable=True) - entity_id: Mapped[str] = mapped_column(String(255), nullable=True) - details: Mapped[dict] = mapped_column(JSONB, nullable=True) - timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) - - # Notification center columns - target_user_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, - ) - read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - notification: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) - - user: Mapped["User"] = relationship("User", back_populates="audit_logs", foreign_keys=[user_id]) - target_user: Mapped["User"] = relationship("User", foreign_keys=[target_user_id]) +# Compat shim — use app.domains.notifications.models instead +from app.domains.notifications.models import AuditLog +__all__ = ["AuditLog"] diff --git a/backend/app/models/cad_file.py b/backend/app/models/cad_file.py index 14bd24c..ab49eb9 100644 --- a/backend/app/models/cad_file.py +++ b/backend/app/models/cad_file.py @@ -1,37 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Enum as SAEnum, BigInteger -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base -import enum - - -class ProcessingStatus(str, enum.Enum): - pending = "pending" - processing = "processing" - completed = "completed" - failed = "failed" - - -class CadFile(Base): - __tablename__ = "cad_files" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - original_name: Mapped[str] = mapped_column(String(500), nullable=False) - stored_path: Mapped[str] = mapped_column(String(1000), nullable=False) - file_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) - file_size: Mapped[int] = mapped_column(BigInteger, nullable=True) - parsed_objects: Mapped[dict] = mapped_column(JSONB, nullable=True) - thumbnail_path: Mapped[str] = mapped_column(String(1000), nullable=True) - gltf_path: Mapped[str] = mapped_column(String(1000), nullable=True) - processing_status: Mapped[ProcessingStatus] = mapped_column( - SAEnum(ProcessingStatus), default=ProcessingStatus.pending, nullable=False - ) - error_message: Mapped[str] = mapped_column(String(2000), nullable=True) - render_log: Mapped[dict] = mapped_column(JSONB, 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) - - order_items: Mapped[list["OrderItem"]] = relationship("OrderItem", back_populates="cad_file") - products: Mapped[list["Product"]] = relationship("Product", back_populates="cad_file") +# Compat shim — use app.domains.products.models instead +from app.domains.products.models import CadFile, ProcessingStatus +__all__ = ["CadFile", "ProcessingStatus"] diff --git a/backend/app/models/material.py b/backend/app/models/material.py index 43ecd94..3de7045 100644 --- a/backend/app/models/material.py +++ b/backend/app/models/material.py @@ -1,24 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Text, ForeignKey, Integer -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base - - -class Material(Base): - __tablename__ = "materials" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name: Mapped[str] = mapped_column(String(200), nullable=False, unique=True) - description: Mapped[str] = mapped_column(Text, nullable=True) - source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual") - schaeffler_code: Mapped[int | None] = mapped_column(Integer, nullable=True) - created_by: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), 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) - - creator: Mapped["User"] = relationship("User", foreign_keys=[created_by], lazy="select") # type: ignore[name-defined] - aliases = relationship("MaterialAlias", back_populates="material", cascade="all, delete-orphan") +# Compat shim — use app.domains.materials.models instead +from app.domains.materials.models import Material +__all__ = ["Material"] diff --git a/backend/app/models/material_alias.py b/backend/app/models/material_alias.py index 5da3b26..cfd7ae3 100644 --- a/backend/app/models/material_alias.py +++ b/backend/app/models/material_alias.py @@ -1,19 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base - - -class MaterialAlias(Base): - __tablename__ = "material_aliases" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - material_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("materials.id", ondelete="CASCADE"), nullable=False - ) - alias: Mapped[str] = mapped_column(String(300), nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) - - material = relationship("Material", back_populates="aliases") +# Compat shim — use app.domains.materials.models instead +from app.domains.materials.models import MaterialAlias +__all__ = ["MaterialAlias"] diff --git a/backend/app/models/order.py b/backend/app/models/order.py index e07a66a..5999c6c 100644 --- a/backend/app/models/order.py +++ b/backend/app/models/order.py @@ -1,42 +1,3 @@ -import uuid -from datetime import datetime -from decimal import Decimal -from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Text, Integer, Numeric -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base -import enum - - -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) - estimated_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) - - template: Mapped["Template"] = relationship("Template", back_populates="orders") - created_by_user: Mapped["User"] = relationship("User", back_populates="orders", foreign_keys=[created_by]) - 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" - ) +# Compat shim — use app.domains.orders.models instead +from app.domains.orders.models import Order, OrderStatus +__all__ = ["Order", "OrderStatus"] diff --git a/backend/app/models/order_item.py b/backend/app/models/order_item.py index 9845fa6..c988371 100644 --- a/backend/app/models/order_item.py +++ b/backend/app/models/order_item.py @@ -1,71 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Integer, Boolean, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base -import enum - - -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) - 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 +# Compat shim — use app.domains.orders.models instead +from app.domains.orders.models import OrderItem, ItemStatus, AIValidationStatus +__all__ = ["OrderItem", "ItemStatus", "AIValidationStatus"] diff --git a/backend/app/models/order_line.py b/backend/app/models/order_line.py index e7d2364..756ca1e 100644 --- a/backend/app/models/order_line.py +++ b/backend/app/models/order_line.py @@ -1,52 +1,3 @@ -import uuid -import enum -from datetime import datetime -from decimal import Decimal -from sqlalchemy import String, DateTime, Text, ForeignKey, Numeric -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base - - -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) - 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) - 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" - ) +# Compat shim — use app.domains.orders.models instead +from app.domains.orders.models import OrderLine +__all__ = ["OrderLine"] diff --git a/backend/app/models/output_type.py b/backend/app/models/output_type.py index bef1bc6..0cda3d6 100644 --- a/backend/app/models/output_type.py +++ b/backend/app/models/output_type.py @@ -1,36 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Boolean, Text, Integer, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB - -VALID_RENDER_BACKENDS = {"celery"} -from app.database import Base - - -class OutputType(Base): - __tablename__ = "output_types" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name: Mapped[str] = mapped_column(String(200), unique=True, nullable=False) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - renderer: Mapped[str] = mapped_column(String(50), nullable=False, default="threejs") - render_settings: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) - output_format: Mapped[str] = mapped_column(String(20), nullable=False, default="png") - sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - compatible_categories: Mapped[list] = mapped_column(JSONB, default=list, server_default="[]") - render_backend: Mapped[str] = mapped_column(String(20), nullable=False, default="auto", server_default="auto") - is_animation: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") - transparent_bg: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") - cycles_device: Mapped[str | None] = mapped_column(String(10), nullable=True, default=None) - pricing_tier_id: Mapped[int | None] = mapped_column( - Integer, ForeignKey("pricing_tiers.id", ondelete="SET NULL"), nullable=True, index=True - ) - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=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_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="output_type") - pricing_tier: Mapped["PricingTier | None"] = relationship("PricingTier", back_populates="output_types") +# Compat shim — use app.domains.rendering.models instead +from app.domains.rendering.models import OutputType, VALID_RENDER_BACKENDS +__all__ = ["OutputType", "VALID_RENDER_BACKENDS"] diff --git a/backend/app/models/pricing_tier.py b/backend/app/models/pricing_tier.py index ec93090..c3ba0f3 100644 --- a/backend/app/models/pricing_tier.py +++ b/backend/app/models/pricing_tier.py @@ -1,25 +1,3 @@ -from datetime import datetime -from decimal import Decimal -from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index -from sqlalchemy.orm import Mapped, mapped_column, relationship -from app.database import Base - - -class PricingTier(Base): - __tablename__ = "pricing_tiers" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - category_key: Mapped[str] = mapped_column(String(100), nullable=False) - quality_level: Mapped[str] = mapped_column(String(50), nullable=False, default="Normal") - price_per_item: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - 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) - - output_types: Mapped[list["OutputType"]] = relationship("OutputType", back_populates="pricing_tier") - - __table_args__ = ( - UniqueConstraint("category_key", "quality_level", name="uq_pricing_tier"), - Index("ix_pricing_tiers_category_key", "category_key"), - ) +# Compat shim — use app.domains.billing.models instead +from app.domains.billing.models import PricingTier +__all__ = ["PricingTier"] diff --git a/backend/app/models/product.py b/backend/app/models/product.py index 7739c2c..4a8e6c3 100644 --- a/backend/app/models/product.py +++ b/backend/app/models/product.py @@ -1,66 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base - - -class Product(Base): - __tablename__ = "products" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - pim_id: Mapped[str] = mapped_column(String(500), nullable=False) - name: Mapped[str | None] = mapped_column(String(500), nullable=True) - category_key: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) - ebene1: Mapped[str | None] = mapped_column(String(500), nullable=True) - ebene2: Mapped[str | None] = mapped_column(String(500), nullable=True) - baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True) - produkt_baureihe: Mapped[str | None] = mapped_column(String(500), nullable=True) - lagertyp: Mapped[str | None] = mapped_column(String(500), nullable=True) - name_cad_modell: Mapped[str | None] = mapped_column(String(500), nullable=True, index=True) - gewuenschte_bildnummer: Mapped[str | None] = mapped_column(String(500), nullable=True) - medias_rendering: Mapped[bool | None] = mapped_column(Boolean, nullable=True) - components: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) - cad_part_materials: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) - cad_file_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("cad_files.id", ondelete="SET NULL"), nullable=True - ) - notes: Mapped[str | None] = mapped_column(Text, nullable=True) - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) - arbeitspaket: Mapped[str | None] = mapped_column(String(500), nullable=True) - source_excel: Mapped[str | None] = mapped_column(String(1000), 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 - ) - - cad_file: Mapped["CadFile | None"] = relationship("CadFile", back_populates="products") - order_lines: Mapped[list["OrderLine"]] = relationship( - "OrderLine", back_populates="product", cascade="all, delete-orphan" - ) - render_positions: Mapped[list["ProductRenderPosition"]] = relationship( - "ProductRenderPosition", back_populates="product", - cascade="all, delete-orphan", order_by="ProductRenderPosition.sort_order" - ) - - @property - def thumbnail_url(self) -> str | None: - if self.cad_file and self.cad_file.thumbnail_path: - from pathlib import Path - return f"/thumbnails/{Path(self.cad_file.thumbnail_path).name}" - return None - - @property - def processing_status(self) -> str | None: - if self.cad_file: - return self.cad_file.processing_status.value if hasattr( - self.cad_file.processing_status, 'value' - ) else str(self.cad_file.processing_status) - return None - - @property - def cad_parsed_objects(self) -> list[str] | None: - if self.cad_file and self.cad_file.parsed_objects: - return self.cad_file.parsed_objects.get("objects") or [] - return None +# Compat shim — use app.domains.products.models instead +from app.domains.products.models import Product +__all__ = ["Product"] diff --git a/backend/app/models/render_position.py b/backend/app/models/render_position.py index c535e0c..37c5e4f 100644 --- a/backend/app/models/render_position.py +++ b/backend/app/models/render_position.py @@ -1,28 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Boolean, Integer, Float, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base - - -class ProductRenderPosition(Base): - __tablename__ = "product_render_positions" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - product_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True - ) - name: Mapped[str] = mapped_column(String(200), nullable=False) - rotation_x: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) - rotation_y: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) - rotation_z: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) - is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - 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 - ) - - product: Mapped["Product"] = relationship("Product", back_populates="render_positions") - order_lines: Mapped[list["OrderLine"]] = relationship("OrderLine", back_populates="render_position") +# Compat shim — use app.domains.rendering.models instead +from app.domains.rendering.models import ProductRenderPosition +__all__ = ["ProductRenderPosition"] diff --git a/backend/app/models/render_template.py b/backend/app/models/render_template.py index d8ec613..eb16741 100644 --- a/backend/app/models/render_template.py +++ b/backend/app/models/render_template.py @@ -1,30 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID - -from app.database import Base - - -class RenderTemplate(Base): - __tablename__ = "render_templates" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name: Mapped[str] = mapped_column(String(300), nullable=False) - category_key: Mapped[str | None] = mapped_column(String(100), nullable=True) - output_type_id: Mapped[uuid.UUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("output_types.id", ondelete="SET NULL"), nullable=True - ) - blend_file_path: Mapped[str] = mapped_column(Text, nullable=False) - original_filename: Mapped[str] = mapped_column(String(500), nullable=False) - target_collection: Mapped[str] = mapped_column(String(200), nullable=False, default="Product", server_default="Product") - material_replace_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") - lighting_only: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") - shadow_catcher_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false") - camera_orbit: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") - is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()") - updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default="now()", onupdate=datetime.utcnow) - - output_type = relationship("OutputType", lazy="joined") +# Compat shim — use app.domains.rendering.models instead +from app.domains.rendering.models import RenderTemplate +__all__ = ["RenderTemplate"] diff --git a/backend/app/models/template.py b/backend/app/models/template.py index b6abd8a..ed5ddeb 100644 --- a/backend/app/models/template.py +++ b/backend/app/models/template.py @@ -1,24 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, Boolean, DateTime, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID, JSONB -from app.database import Base - - -class Template(Base): - __tablename__ = "templates" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name: Mapped[str] = mapped_column(String(255), nullable=False) - category_key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) - # JSONB config for each of the 11 standard columns: {col_index: {label, required, optional}} - standard_fields: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) - # JSONB schema for expected component pairs - component_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) - description: Mapped[str] = mapped_column(Text, nullable=True) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - 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) - - orders: Mapped[list["Order"]] = relationship("Order", back_populates="template") +# Compat shim — use app.domains.imports.models instead +from app.domains.imports.models import Template +__all__ = ["Template"] diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 8d49826..e114180 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,29 +1,3 @@ -import uuid -from datetime import datetime -from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum -from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID -from app.database import Base -import enum - - -class UserRole(str, enum.Enum): - admin = "admin" - project_manager = "project_manager" - client = "client" - - -class User(Base): - __tablename__ = "users" - - id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) - password_hash: Mapped[str] = mapped_column(String(255), nullable=False) - full_name: Mapped[str] = mapped_column(String(255), nullable=False) - role: Mapped[UserRole] = mapped_column(SAEnum(UserRole), default=UserRole.client, nullable=False) - is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - 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) - - orders: Mapped[list["Order"]] = relationship("Order", back_populates="created_by_user", foreign_keys="Order.created_by") - audit_logs: Mapped[list["AuditLog"]] = relationship("AuditLog", back_populates="user", foreign_keys="AuditLog.user_id") +# Compat shim — use app.domains.auth.models instead +from app.domains.auth.models import User, UserRole +__all__ = ["User", "UserRole"] diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py index ad30607..fe60032 100644 --- a/backend/app/schemas/order.py +++ b/backend/app/schemas/order.py @@ -1,92 +1,11 @@ -import uuid -from datetime import datetime -from typing import Any -from pydantic import BaseModel -from app.models.order import OrderStatus -from app.models.order_item import ItemStatus, AIValidationStatus -from app.schemas.order_line import OrderLineOut, OrderLineCreate # noqa: F401 - - -class ComponentData(BaseModel): - part_name: str | None = None - material: str | None = None - component_type: str | None = None - column_index: int | None = None - - -class OrderItemCreate(BaseModel): - row_index: int - ebene1: str | None = None - ebene2: str | None = None - baureihe: str | None = None - pim_id: str | None = None - produkt_baureihe: str | None = None - gewaehltes_produkt: str | None = None - name_cad_modell: str | None = None - gewuenschte_bildnummer: str | None = None - lagertyp: str | None = None - medias_rendering: bool | None = None - components: list[ComponentData] = [] - - -class OrderItemOut(BaseModel): - id: uuid.UUID - order_id: uuid.UUID - row_index: int - ebene1: str | None - ebene2: str | None - baureihe: str | None - pim_id: str | None - produkt_baureihe: str | None - gewaehltes_produkt: str | None - name_cad_modell: str | None - gewuenschte_bildnummer: str | None - lagertyp: str | None - medias_rendering: bool | None - components: list[dict] - cad_file_id: uuid.UUID | None - thumbnail_path: str | None - cad_parsed_objects: list[str] | None = None - cad_part_materials: list[dict] = [] - ai_validation_status: AIValidationStatus - ai_validation_result: dict | None - item_status: ItemStatus - notes: str | None - created_at: datetime - - model_config = {"from_attributes": True} - - -class OrderCreate(BaseModel): - template_id: uuid.UUID | None = None - source_excel: str | None = None - notes: str | None = None - items: list[OrderItemCreate] = [] - lines: list[OrderLineCreate] = [] - - -class OrderOut(BaseModel): - id: uuid.UUID - order_number: str - template_id: uuid.UUID | None - status: OrderStatus - created_by: uuid.UUID - source_excel: str | None - notes: str | None - created_at: datetime - updated_at: datetime - submitted_at: datetime | None = None - processing_started_at: datetime | None = None - completed_at: datetime | None = None - rejected_at: datetime | None = None - estimated_price: float | None = None - item_count: int = 0 - line_count: int = 0 - render_progress: dict | None = None - - model_config = {"from_attributes": True} - - -class OrderDetailOut(OrderOut): - items: list[OrderItemOut] = [] - lines: list[OrderLineOut] = [] +# Compat shim — use app.domains.orders.schemas instead +from app.domains.orders.schemas import ( + ComponentData, OrderItemCreate, OrderItemOut, + OrderLineCreate, OrderLineOut, + OrderCreate, OrderOut, OrderDetailOut, +) +__all__ = [ + "ComponentData", "OrderItemCreate", "OrderItemOut", + "OrderLineCreate", "OrderLineOut", + "OrderCreate", "OrderOut", "OrderDetailOut", +] diff --git a/backend/app/schemas/order_line.py b/backend/app/schemas/order_line.py index 7657686..ab126a4 100644 --- a/backend/app/schemas/order_line.py +++ b/backend/app/schemas/order_line.py @@ -1,39 +1,3 @@ -import uuid -from datetime import datetime -from pydantic import BaseModel -from app.schemas.product import ProductOut -from app.schemas.output_type import OutputTypeOut - - -class OrderLineCreate(BaseModel): - product_id: uuid.UUID - output_type_id: uuid.UUID | None = None - render_position_id: uuid.UUID | None = None - gewuenschte_bildnummer: str | None = None - notes: str | None = None - - -class OrderLineOut(BaseModel): - id: uuid.UUID - order_id: uuid.UUID - product_id: uuid.UUID - product: ProductOut - output_type_id: uuid.UUID | None - output_type: OutputTypeOut | None - gewuenschte_bildnummer: str | None - item_status: str - render_status: str - result_path: str | None - thumbnail_url: str | None = None - ai_validation_status: str - ai_validation_result: dict | None - render_backend_used: str | None = None - flamenco_job_id: str | None = None - unit_price: float | None = None - render_position_id: uuid.UUID | None = None - render_position_name: str | None = None - notes: str | None - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} +# Compat shim — use app.domains.orders.schemas instead +from app.domains.orders.schemas import OrderLineCreate, OrderLineOut +__all__ = ["OrderLineCreate", "OrderLineOut"] diff --git a/backend/app/schemas/output_type.py b/backend/app/schemas/output_type.py index 9d5b594..716958b 100644 --- a/backend/app/schemas/output_type.py +++ b/backend/app/schemas/output_type.py @@ -1,58 +1,3 @@ -import uuid -from datetime import datetime -from pydantic import BaseModel - - -class OutputTypeCreate(BaseModel): - name: str - description: str | None = None - renderer: str = "threejs" - render_settings: dict = {} - output_format: str = "png" - sort_order: int = 0 - is_active: bool = True - compatible_categories: list[str] = [] - render_backend: str = "auto" - is_animation: bool = False - transparent_bg: bool = False - pricing_tier_id: int | None = None - cycles_device: str | None = None - - -class OutputTypePatch(BaseModel): - name: str | None = None - description: str | None = None - renderer: str | None = None - render_settings: dict | None = None - output_format: str | None = None - sort_order: int | None = None - is_active: bool | None = None - compatible_categories: list[str] | None = None - render_backend: str | None = None - is_animation: bool | None = None - transparent_bg: bool | None = None - pricing_tier_id: int | None = None - cycles_device: str | None = None - - -class OutputTypeOut(BaseModel): - id: uuid.UUID - name: str - description: str | None - renderer: str - render_settings: dict - output_format: str - sort_order: int - compatible_categories: list[str] - render_backend: str - is_animation: bool - transparent_bg: bool - cycles_device: str | None = None - pricing_tier_id: int | None = None - pricing_tier_name: str | None = None - price_per_item: float | None = None - is_active: bool - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} +# Compat shim — use app.domains.rendering.schemas instead +from app.domains.rendering.schemas import OutputTypeCreate, OutputTypePatch, OutputTypeOut +__all__ = ["OutputTypeCreate", "OutputTypePatch", "OutputTypeOut"] diff --git a/backend/app/schemas/product.py b/backend/app/schemas/product.py index ecd5255..e8eee23 100644 --- a/backend/app/schemas/product.py +++ b/backend/app/schemas/product.py @@ -1,72 +1,3 @@ -import uuid -from datetime import datetime -from pydantic import BaseModel -from app.schemas.render_position import RenderPositionOut - - -class ProductCreate(BaseModel): - pim_id: str - name: str | None = None - category_key: str | None = None - ebene1: str | None = None - ebene2: str | None = None - baureihe: str | None = None - produkt_baureihe: str | None = None - lagertyp: str | None = None - name_cad_modell: str | None = None - gewuenschte_bildnummer: str | None = None - medias_rendering: bool | None = None - components: list[dict] = [] - cad_part_materials: list[dict] = [] - notes: str | None = None - is_active: bool = True - source_excel: str | None = None - - -class ProductPatch(BaseModel): - name: str | None = None - category_key: str | None = None - ebene1: str | None = None - ebene2: str | None = None - baureihe: str | None = None - produkt_baureihe: str | None = None - lagertyp: str | None = None - name_cad_modell: str | None = None - gewuenschte_bildnummer: str | None = None - medias_rendering: bool | None = None - components: list[dict] | None = None - cad_part_materials: list[dict] | None = None - notes: str | None = None - is_active: bool | None = None - - -class ProductOut(BaseModel): - id: uuid.UUID - pim_id: str - name: str | None - category_key: str | None - ebene1: str | None - ebene2: str | None - baureihe: str | None - produkt_baureihe: str | None - lagertyp: str | None - name_cad_modell: str | None - gewuenschte_bildnummer: str | None - medias_rendering: bool | None - components: list[dict] - cad_part_materials: list[dict] - cad_file_id: uuid.UUID | None - thumbnail_url: str | None = None - render_image_url: str | None = None - processing_status: str | None = None - stl_cached: list[str] = [] - cad_parsed_objects: list[str] | None = None - arbeitspaket: str | None = None - notes: str | None - is_active: bool - source_excel: str | None - render_positions: list[RenderPositionOut] = [] - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} +# Compat shim — use app.domains.products.schemas instead +from app.domains.products.schemas import ProductCreate, ProductPatch, ProductOut +__all__ = ["ProductCreate", "ProductPatch", "ProductOut"] diff --git a/backend/app/schemas/render_position.py b/backend/app/schemas/render_position.py index 4780b7a..8e6ba9d 100644 --- a/backend/app/schemas/render_position.py +++ b/backend/app/schemas/render_position.py @@ -1,36 +1,3 @@ -import uuid -from datetime import datetime -from pydantic import BaseModel - - -class RenderPositionCreate(BaseModel): - name: str - rotation_x: float = 0.0 - rotation_y: float = 0.0 - rotation_z: float = 0.0 - is_default: bool = False - sort_order: int = 0 - - -class RenderPositionPatch(BaseModel): - name: str | None = None - rotation_x: float | None = None - rotation_y: float | None = None - rotation_z: float | None = None - is_default: bool | None = None - sort_order: int | None = None - - -class RenderPositionOut(BaseModel): - id: uuid.UUID - product_id: uuid.UUID - name: str - rotation_x: float - rotation_y: float - rotation_z: float - is_default: bool - sort_order: int - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} +# Compat shim — use app.domains.rendering.schemas instead +from app.domains.rendering.schemas import RenderPositionCreate, RenderPositionPatch, RenderPositionOut +__all__ = ["RenderPositionCreate", "RenderPositionPatch", "RenderPositionOut"] diff --git a/backend/app/schemas/upload.py b/backend/app/schemas/upload.py index 3ba1537..2615edf 100644 --- a/backend/app/schemas/upload.py +++ b/backend/app/schemas/upload.py @@ -1,43 +1,3 @@ -from pydantic import BaseModel -from typing import Any - - -class ParsedComponent(BaseModel): - part_name: str | None = None - material: str | None = None - component_type: str | None = None - column_index: int - - -class ParsedRow(BaseModel): - row_index: int - ebene1: str | None = None - ebene2: str | None = None - baureihe: str | None = None - pim_id: str | None = None - produkt_baureihe: str | None = None - gewaehltes_produkt: str | None = None - name_cad_modell: str | None = None - gewuenschte_bildnummer: str | None = None - lagertyp: str | None = None - medias_rendering: bool | None = None - components: list[ParsedComponent] = [] - - -class ParsedExcelResponse(BaseModel): - filename: str - excel_path: str | None = None # server-side path of the saved file - category_key: str | None = None - template_name: str | None = None - row_count: int - column_headers: list[str] - rows: list[ParsedRow] - warnings: list[str] = [] - - -class StepUploadResponse(BaseModel): - cad_file_id: str - original_name: str - file_hash: str - status: str - matched_items: list[str] = [] +# Compat shim — use app.domains.imports.schemas instead +from app.domains.imports.schemas import ParsedComponent, ParsedRow, ParsedExcelResponse, StepUploadResponse +__all__ = ["ParsedComponent", "ParsedRow", "ParsedExcelResponse", "StepUploadResponse"] diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 25cd04b..6270d45 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -1,39 +1,3 @@ -import uuid -from datetime import datetime -from pydantic import BaseModel, EmailStr -from app.models.user import UserRole - - -class UserCreate(BaseModel): - email: EmailStr - password: str - full_name: str - role: UserRole = UserRole.client - - -class UserUpdate(BaseModel): - full_name: str | None = None - is_active: bool | None = None - role: UserRole | None = None - - -class UserOut(BaseModel): - id: uuid.UUID - email: str - full_name: str - role: UserRole - is_active: bool - created_at: datetime - - model_config = {"from_attributes": True} - - -class TokenResponse(BaseModel): - access_token: str - token_type: str = "bearer" - user: UserOut - - -class LoginRequest(BaseModel): - email: EmailStr - password: str +# Compat shim — use app.domains.auth.schemas instead +from app.domains.auth.schemas import UserCreate, UserUpdate, UserOut, TokenResponse, LoginRequest +__all__ = ["UserCreate", "UserUpdate", "UserOut", "TokenResponse", "LoginRequest"] diff --git a/backend/app/services/material_service.py b/backend/app/services/material_service.py index ccd8bc9..9626a10 100644 --- a/backend/app/services/material_service.py +++ b/backend/app/services/material_service.py @@ -1,143 +1,3 @@ -"""Material alias resolution service. - -Used from Celery tasks (sync context) to resolve raw material names -(from Excel / user input) to SCHAEFFLER library material names via aliases. - -Resolution chain: -1. Exact Material.name match (case-insensitive) → use it -2. MaterialAlias lookup (case-insensitive) → use alias.material.name -3. Pass through unchanged → Blender will show FailedMaterial magenta -""" -import logging - -from sqlalchemy import create_engine, select, func -from sqlalchemy.orm import Session, selectinload -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.material import Material -from app.models.material_alias import MaterialAlias - -logger = logging.getLogger(__name__) - -_engine = None - - -def _get_engine(): - global _engine - if _engine is None: - from app.config import settings as app_settings - _engine = create_engine(app_settings.database_url_sync) - return _engine - - -def resolve_material_map(raw_map: dict[str, str]) -> dict[str, str]: - """Resolve raw material names to SCHAEFFLER library names via aliases. - - For each value in raw_map: - 1. If it already matches a Material.name (case-insensitive) → keep as-is (use canonical name) - 2. Else look up MaterialAlias.alias (case-insensitive) → return alias.material.name - 3. Else keep original (Blender will use FailedMaterial fallback) - - Returns a new dict with the same keys but resolved material names. - """ - if not raw_map: - return raw_map - - engine = _get_engine() - with Session(engine) as session: - # Load all materials - materials = session.execute( - select(Material).options(selectinload(Material.aliases)) - ).scalars().all() - - # Build lookup dicts (case-insensitive) - # material name (lower) → canonical Material.name - name_lookup: dict[str, str] = {} - # alias (lower) → Material.name - alias_lookup: dict[str, str] = {} - - for mat in materials: - name_lookup[mat.name.lower()] = mat.name - for a in mat.aliases: - alias_lookup[a.alias.lower()] = mat.name - - resolved = {} - for part_name, raw_material in raw_map.items(): - raw_lower = raw_material.lower() - - # 1. Alias lookup first — aliases explicitly map intermediate/display names - # to the canonical SCHAEFFLER library names (e.g. "Steel--Stahl" → - # "SCHAEFFLER_010101_Steel-Bare"). This must take priority over the - # direct name match so that intermediate names are properly redirected. - if raw_lower in alias_lookup: - target = alias_lookup[raw_lower] - logger.info("resolved '%s' → '%s' (alias match)", raw_material, target) - resolved[part_name] = target - continue - - # 2. Exact material name match (canonical name used as-is) - if raw_lower in name_lookup: - canonical = name_lookup[raw_lower] - if canonical != raw_material: - logger.info("resolved '%s' → '%s' (exact name match)", raw_material, canonical) - resolved[part_name] = canonical - continue - - # 3. Pass through unchanged - logger.warning("no material match for '%s' — will use FailedMaterial fallback", raw_material) - resolved[part_name] = raw_material - - return resolved - - -async def seed_material_aliases_from_mappings( - db: AsyncSession, mappings: list[dict] -) -> dict: - """Seed material aliases from Excel materialmapping sheet. - - For each {display_name, render_name}: - - Find or create Material by render_name - - Add display_name as alias if not already present - - Returns {"created": N, "skipped": N}. - """ - created = 0 - skipped = 0 - - for mapping in mappings: - display_name = mapping.get("display_name", "").strip() - render_name = mapping.get("render_name", "").strip() - if not display_name or not render_name: - skipped += 1 - continue - - # Find or create Material by render_name - result = await db.execute( - select(Material).where(func.lower(Material.name) == render_name.lower()) - ) - material = result.scalar_one_or_none() - if material is None: - material = Material(name=render_name, source="excel_mapping") - db.add(material) - await db.flush() - - # Check if alias already exists - alias_result = await db.execute( - select(MaterialAlias).where( - func.lower(MaterialAlias.alias) == display_name.lower() - ) - ) - existing_alias = alias_result.scalar_one_or_none() - if existing_alias: - skipped += 1 - continue - - # Create alias - alias = MaterialAlias(material_id=material.id, alias=display_name) - db.add(alias) - created += 1 - - if created > 0: - await db.flush() - - return {"created": created, "skipped": skipped} +# Compat shim — use app.domains.materials.service instead +from app.domains.materials.service import resolve_material_map, seed_material_aliases_from_mappings +__all__ = ["resolve_material_map", "seed_material_aliases_from_mappings"] diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 21a26d0..2d4be9a 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1,84 +1,3 @@ -"""Notification emission helpers. - -Provides async (for routers) and sync (for Celery tasks) entry points -to create notification rows in the audit_log table. -""" -import logging -import uuid -from datetime import datetime - -from sqlalchemy import create_engine -from sqlalchemy.orm import Session -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.audit_log import AuditLog - -logger = logging.getLogger(__name__) - -_engine = None - - -def _get_engine(): - global _engine - if _engine is None: - from app.config import settings as app_settings - _engine = create_engine(app_settings.database_url_sync) - return _engine - - -async def emit_notification( - db: AsyncSession, - *, - actor_user_id: str | uuid.UUID | None = None, - target_user_id: str | uuid.UUID | None = None, - action: str, - entity_type: str | None = None, - entity_id: str | None = None, - details: dict | None = None, -) -> None: - """Create a notification (async — for use inside FastAPI routers).""" - try: - entry = AuditLog( - user_id=str(actor_user_id) if actor_user_id else None, - target_user_id=str(target_user_id) if target_user_id else None, - action=action, - entity_type=entity_type, - entity_id=str(entity_id) if entity_id else None, - details=details, - notification=True, - timestamp=datetime.utcnow(), - ) - db.add(entry) - await db.commit() - except Exception: - logger.exception("Failed to emit notification (async)") - await db.rollback() - - -def emit_notification_sync( - *, - actor_user_id: str | uuid.UUID | None = None, - target_user_id: str | uuid.UUID | None = None, - action: str, - entity_type: str | None = None, - entity_id: str | None = None, - details: dict | None = None, -) -> None: - """Create a notification (sync — for use inside Celery tasks).""" - engine = _get_engine() - try: - with Session(engine) as session: - entry = AuditLog( - user_id=str(actor_user_id) if actor_user_id else None, - target_user_id=str(target_user_id) if target_user_id else None, - action=action, - entity_type=entity_type, - entity_id=str(entity_id) if entity_id else None, - details=details, - notification=True, - timestamp=datetime.utcnow(), - ) - session.add(entry) - session.commit() - except Exception: - logger.exception("Failed to emit notification (sync)") +# Compat shim — use app.domains.notifications.service instead +from app.domains.notifications.service import emit_notification, emit_notification_sync +__all__ = ["emit_notification", "emit_notification_sync"] diff --git a/backend/app/services/order_service.py b/backend/app/services/order_service.py index 3808cb4..429e19e 100644 --- a/backend/app/services/order_service.py +++ b/backend/app/services/order_service.py @@ -1,22 +1,3 @@ -"""Order number generation and business logic.""" -from datetime import datetime -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func -from app.models.order import Order - - -async def generate_order_number(db: AsyncSession) -> str: - """Generate next sequential order number: SA-2026-XXXXX.""" - year = datetime.utcnow().year - prefix = f"SA-{year}-" - - # Use MAX to find the highest existing sequence number this year. - # COUNT-based approach breaks when orders are deleted (produces duplicates). - result = await db.execute( - select(func.max(Order.order_number)).where(Order.order_number.like(f"{prefix}%")) - ) - max_num = result.scalar() - if max_num: - last_seq = int(max_num.split("-")[-1]) - return f"{prefix}{last_seq + 1:05d}" - return f"{prefix}00001" +# Compat shim — use app.domains.orders.service instead +from app.domains.orders.service import generate_order_number, check_order_completion +__all__ = ["generate_order_number", "check_order_completion"] diff --git a/backend/app/services/order_status_service.py b/backend/app/services/order_status_service.py index ea3f2be..34de49d 100644 --- a/backend/app/services/order_status_service.py +++ b/backend/app/services/order_status_service.py @@ -1,86 +1,3 @@ -"""Service to auto-advance order status when all renders complete.""" -import logging -from datetime import datetime - -from sqlalchemy import create_engine, select, update as sql_update -from sqlalchemy.orm import Session - -from app.models.order import Order, OrderStatus -from app.models.order_line import OrderLine - -logger = logging.getLogger(__name__) - - -def check_order_completion(order_id: str) -> bool: - """If all renderable lines are done, auto-advance order to completed. - - Called from Celery tasks (sync context). - Returns True if the order was advanced to completed. - """ - from app.config import settings as app_settings - - sync_url = app_settings.database_url.replace("+asyncpg", "") - engine = create_engine(sync_url) - - try: - with Session(engine) as session: - # Get all lines that have an output type (i.e. renderable) - lines = session.execute( - select(OrderLine).where( - OrderLine.order_id == order_id, - OrderLine.output_type_id.isnot(None), - ) - ).scalars().all() - - if not lines: - return False - - # Check if all renderable lines are in a terminal state - all_terminal = all( - line.render_status in ("completed", "failed", "cancelled") - for line in lines - ) - - if not all_terminal: - return False - - # Check order is still in processing state - order = session.execute( - select(Order).where(Order.id == order_id) - ).scalar_one_or_none() - - if order is None or order.status != OrderStatus.processing: - return False - - # Auto-advance to completed - now = datetime.utcnow() - session.execute( - sql_update(Order) - .where(Order.id == order_id) - .values( - status=OrderStatus.completed, - completed_at=now, - updated_at=now, - ) - ) - session.commit() - logger.info(f"Order {order_id} auto-advanced to completed (all {len(lines)} lines done)") - - # Notify order creator - try: - from app.services.notification_service import emit_notification_sync - emit_notification_sync( - actor_user_id=None, - target_user_id=str(order.created_by), - action="order.completed", - entity_type="order", - entity_id=str(order_id), - details={"order_number": order.order_number}, - ) - except Exception: - logger.exception("Failed to emit order.completed notification") - - return True - - finally: - engine.dispose() +# Compat shim — use app.domains.orders.service instead +from app.domains.orders.service import check_order_completion +__all__ = ["check_order_completion"] diff --git a/backend/app/services/pricing_service.py b/backend/app/services/pricing_service.py index 53a30ac..340a043 100644 --- a/backend/app/services/pricing_service.py +++ b/backend/app/services/pricing_service.py @@ -1,232 +1,8 @@ -"""Pricing service — price lookup and order price computation. - -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 decimal import Decimal -from typing import Any - -from sqlalchemy import select, update as sql_update -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload - -from app.models.pricing_tier import PricingTier - - -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. - - Falls back to category_key='default' if no exact match is found. - Returns None if nothing is configured. - """ - # 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. - - 1. OutputType's linked pricing_tier (if active) - 2. Product's category_key → PricingTier by category - 3. "default" category tier → global fallback - 4. None - """ - if output_type_id is not None: - from app.models.output_type 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. - - Each line dict should have: product_id, output_type_id. - Returns {total, line_count, breakdown: [{output_type_id, product_id, unit_price}], has_unpriced}. - """ - from app.models.product 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 compute_order_estimated_price( - db: AsyncSession, - order, - items, - quality_level: str = "Normal", -) -> Decimal | None: - """Compute estimated price for an order based on rendering items. - - Returns None if no pricing is configured, or Decimal('0.00') if there - are no rendering items. - """ - rendering_count = sum(1 for i in items if i.medias_rendering) - if rendering_count == 0: - return Decimal("0.00") - - # Resolve category from template - category_key = "default" - if order.template_id is not None: - from app.models.template import Template - tmpl_result = await db.execute( - select(Template).where(Template.id == order.template_id) - ) - tmpl = tmpl_result.scalar_one_or_none() - if tmpl and tmpl.category_key: - category_key = tmpl.category_key - - unit_price = await get_price_for(db, category_key, quality_level) - if unit_price is None: - return None - - return unit_price * rendering_count - - -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.models.order import Order - from app.models.order_line import OrderLine - from app.models.output_type import OutputType - from app.models.product import Product - - 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 +# Compat shim — use app.domains.billing.service instead +from app.domains.billing.service import ( + get_price_for, + resolve_line_price, + estimate_order_price, + refresh_order_price, +) +__all__ = ["get_price_for", "resolve_line_price", "estimate_order_price", "refresh_order_price"] diff --git a/backend/app/services/product_service.py b/backend/app/services/product_service.py index 860e7f1..9de8315 100644 --- a/backend/app/services/product_service.py +++ b/backend/app/services/product_service.py @@ -1,143 +1,15 @@ -"""Product service — lookup/create products, link CAD files.""" -import uuid -from sqlalchemy import select, func, update as sql_update -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.product import Product - -# Default render positions added to every newly created product. -DEFAULT_RENDER_POSITIONS = [ - {"name": "3/4 Front", "rotation_x": -15.0, "rotation_y": 45.0, "rotation_z": 0.0, "is_default": True, "sort_order": 0}, - {"name": "3/4 Rear", "rotation_x": -15.0, "rotation_y": -135.0, "rotation_z": 0.0, "is_default": False, "sort_order": 1}, - {"name": "Default", "rotation_x": 0.0, "rotation_y": 0.0, "rotation_z": 0.0, "is_default": False, "sort_order": 2}, +# Compat shim — use app.domains.products.service instead +from app.domains.products.service import ( + create_default_positions, + lookup_product, + lookup_or_create_product, + link_cad_to_product, + DEFAULT_RENDER_POSITIONS, +) +__all__ = [ + "create_default_positions", + "lookup_product", + "lookup_or_create_product", + "link_cad_to_product", + "DEFAULT_RENDER_POSITIONS", ] - - -async def create_default_positions(db: AsyncSession, product_id: uuid.UUID) -> None: - """Insert the default render positions for a newly created product.""" - from app.models.render_position import ProductRenderPosition - for pos_data in DEFAULT_RENDER_POSITIONS: - db.add(ProductRenderPosition(product_id=product_id, **pos_data)) - await db.flush() - - -def _fill_missing_fields(product: Product, pim_id: str | None, fields: dict) -> None: - """Fill in null/empty fields on an existing product without overwriting manual edits.""" - if pim_id and not product.pim_id: - product.pim_id = pim_id - for attr in ( - "name", "category_key", "ebene1", "ebene2", "baureihe", - "lagertyp", "name_cad_modell", "arbeitspaket", - ): - if fields.get(attr) and not getattr(product, attr, None): - setattr(product, attr, fields[attr]) - # Update medias_rendering if not set - if fields.get("medias_rendering") is not None and product.medias_rendering is None: - product.medias_rendering = fields["medias_rendering"] - # Always update components from the latest Excel import (needed for auto-reassign) - if fields.get("components"): - product.components = fields["components"] - - -async def lookup_product( - db: AsyncSession, pim_id: str | None, produkt_baureihe: str | None -) -> Product | None: - """Read-only lookup: produkt_baureihe (primary), then pim_id (fallback). - - Same cascade as lookup_or_create_product but never creates or mutates. - """ - if produkt_baureihe: - result = await db.execute( - select(Product).where( - func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(), - Product.is_active.is_(True), - ) - ) - product = result.scalar_one_or_none() - if product is not None: - return product - # baureihe provided but not found — skip pim_id fallback (same logic) - return None - - if pim_id: - result = await db.execute( - select(Product).where(Product.pim_id == pim_id, Product.is_active.is_(True)) - ) - return result.scalar_one_or_none() - - return None - - - -async def lookup_or_create_product( - db: AsyncSession, pim_id: str | None, fields: dict -) -> tuple[Product, bool]: - """Look up by produkt_baureihe (primary), then pim_id (fallback). Create if not found. - - Returns (product, was_created). - Does NOT overwrite existing fields — preserves manual edits. - """ - produkt_baureihe = fields.get("produkt_baureihe") - - # Primary lookup: by produkt_baureihe (case-insensitive) - if produkt_baureihe: - result = await db.execute( - select(Product).where( - func.lower(Product.produkt_baureihe) == produkt_baureihe.lower(), - Product.is_active.is_(True), - ) - ) - product = result.scalar_one_or_none() - if product is not None: - _fill_missing_fields(product, pim_id, fields) - await db.flush() - return product, False - # produkt_baureihe was provided but not found — each baureihe is a - # distinct product, so skip the pim_id fallback and create a new one. - - # Fallback lookup: by pim_id (only when produkt_baureihe is absent, - # e.g. old per-category Excel files that don't have a Baureihe column). - if not produkt_baureihe and pim_id: - result = await db.execute( - select(Product).where(Product.pim_id == pim_id, Product.is_active.is_(True)) - ) - product = result.scalar_one_or_none() - if product is not None: - _fill_missing_fields(product, pim_id, fields) - await db.flush() - return product, False - - product = Product( - pim_id=pim_id or f"auto-{uuid.uuid4().hex[:8]}", - name=fields.get("name"), - category_key=fields.get("category_key"), - ebene1=fields.get("ebene1"), - ebene2=fields.get("ebene2"), - baureihe=fields.get("baureihe"), - produkt_baureihe=produkt_baureihe, - lagertyp=fields.get("lagertyp"), - name_cad_modell=fields.get("name_cad_modell"), - arbeitspaket=fields.get("arbeitspaket"), - components=fields.get("components", []), - cad_part_materials=fields.get("cad_part_materials", []), - source_excel=fields.get("source_excel"), - ) - db.add(product) - await db.flush() - await create_default_positions(db, product.id) - return product, True - - - -async def link_cad_to_product( - db: AsyncSession, product_id: uuid.UUID, cad_file_id: uuid.UUID -) -> Product: - """Set product.cad_file_id via direct SQL UPDATE.""" - await db.execute( - sql_update(Product) - .where(Product.id == product_id) - .values(cad_file_id=cad_file_id) - ) - await db.commit() - result = await db.execute(select(Product).where(Product.id == product_id)) - return result.scalar_one() diff --git a/backend/app/services/render_dispatcher.py b/backend/app/services/render_dispatcher.py index 0b24842..952afed 100644 --- a/backend/app/services/render_dispatcher.py +++ b/backend/app/services/render_dispatcher.py @@ -1,96 +1,3 @@ -"""Render dispatcher — routes render jobs to Celery. - -All renders run via Celery workers (Flamenco removed in v2 refactor). -""" -import logging -from datetime import datetime - -from sqlalchemy import select, update as sql_update -from sqlalchemy.orm import Session, joinedload - -from app.models.order_line import OrderLine -from app.models.product import Product -from app.models.system_setting import SystemSetting - -logger = logging.getLogger(__name__) - - -def _load_setting(session: Session, key: str, default: str = "") -> str: - """Load a single system setting (sync).""" - row = session.execute( - select(SystemSetting).where(SystemSetting.key == key) - ).scalar_one_or_none() - return row.value if row else default - - -def dispatch_render(order_line_id: str) -> dict: - """Dispatch a render job to Celery. - - Must be called from a sync context (Celery task or sync wrapper). - Returns {"backend": "celery", "job_ref": str}. - """ - from app.config import settings as app_settings - from app.services.render_log import emit, clear - - clear(order_line_id) - emit(order_line_id, "Dispatch started — loading order line data") - - sync_url = app_settings.database_url.replace("+asyncpg", "") - from sqlalchemy import create_engine - engine_db = create_engine(sync_url) - - with Session(engine_db) as session: - line = session.execute( - select(OrderLine) - .where(OrderLine.id == order_line_id) - .options( - joinedload(OrderLine.product).joinedload(Product.cad_file), - joinedload(OrderLine.output_type), - ) - ).scalar_one_or_none() - - if line is None: - emit(order_line_id, "Order line not found", "error") - logger.error(f"OrderLine {order_line_id} not found") - return {"backend": "none", "job_ref": "", "error": "not_found"} - - product_name = line.product.name or line.product.pim_id or "unknown" - output_name = line.output_type.name if line.output_type else "default" - emit(order_line_id, f"Product: {product_name} | Output: {output_name}") - - if line.product.cad_file_id is None: - emit(order_line_id, "Product has no CAD file — marking as failed", "error") - logger.warning(f"OrderLine {order_line_id}: product has no CAD file") - session.execute( - sql_update(OrderLine) - .where(OrderLine.id == line.id) - .values(render_status="failed") - ) - session.commit() - return {"backend": "none", "job_ref": "", "error": "no_cad_file"} - - cad_name = line.product.cad_file.original_name if line.product.cad_file else "?" - emit(order_line_id, f"CAD file: {cad_name}") - emit(order_line_id, "Dispatching to Celery render worker") - - now = datetime.utcnow() - session.execute( - sql_update(OrderLine) - .where(OrderLine.id == line.id) - .values( - render_status="processing", - render_backend_used="celery", - render_started_at=now, - ) - ) - session.commit() - - engine_db.dispose() - return _dispatch_celery(order_line_id) - - -def _dispatch_celery(order_line_id: str) -> dict: - """Dispatch to the Celery render task.""" - from app.tasks.step_tasks import render_order_line_task - result = render_order_line_task.delay(order_line_id) - return {"backend": "celery", "job_ref": result.id} +# Compat shim — use app.domains.rendering.service instead +from app.domains.rendering.service import dispatch_render +__all__ = ["dispatch_render"] diff --git a/backend/app/services/template_service.py b/backend/app/services/template_service.py index 2646496..45085d1 100644 --- a/backend/app/services/template_service.py +++ b/backend/app/services/template_service.py @@ -1,102 +1,3 @@ -"""Render template resolution service. - -Used from Celery tasks (sync context) to find the best matching .blend template -for a given category + output type combination. - -Cascade priority (first active match wins): -1. Exact: category_key + output_type_id -2. Category only: category_key + output_type_id IS NULL -3. OT only: category_key IS NULL + output_type_id -4. Global: both NULL -5. No template → caller falls back to factory-settings behavior -""" -import logging - -from sqlalchemy import create_engine, select, and_ -from sqlalchemy.orm import Session - -from app.models.render_template import RenderTemplate -from app.models.system_setting import SystemSetting - -logger = logging.getLogger(__name__) - -_engine = None - - -def _get_engine(): - global _engine - if _engine is None: - from app.config import settings as app_settings - _engine = create_engine(app_settings.database_url_sync) - return _engine - - -def resolve_template( - category_key: str | None = None, - output_type_id: str | None = None, -) -> RenderTemplate | None: - """Find the best matching active render template. - - Uses sync SQLAlchemy — safe for Celery tasks. - """ - engine = _get_engine() - with Session(engine) as session: - active = RenderTemplate.is_active == True # noqa: E712 - - # 1. Exact match - if category_key and output_type_id: - row = session.execute( - select(RenderTemplate).where(and_( - active, - RenderTemplate.category_key == category_key, - RenderTemplate.output_type_id == output_type_id, - )) - ).scalar_one_or_none() - if row: - return row - - # 2. Category only - if category_key: - row = session.execute( - select(RenderTemplate).where(and_( - active, - RenderTemplate.category_key == category_key, - RenderTemplate.output_type_id.is_(None), - )) - ).scalar_one_or_none() - if row: - return row - - # 3. OT only - if output_type_id: - row = session.execute( - select(RenderTemplate).where(and_( - active, - RenderTemplate.category_key.is_(None), - RenderTemplate.output_type_id == output_type_id, - )) - ).scalar_one_or_none() - if row: - return row - - # 4. Global fallback (both NULL) - row = session.execute( - select(RenderTemplate).where(and_( - active, - RenderTemplate.category_key.is_(None), - RenderTemplate.output_type_id.is_(None), - )) - ).scalar_one_or_none() - return row - - -def get_material_library_path() -> str | None: - """Read material_library_path from system_settings. Returns None if empty.""" - engine = _get_engine() - with Session(engine) as session: - row = session.execute( - select(SystemSetting).where(SystemSetting.key == "material_library_path") - ).scalar_one_or_none() - if row and row.value and row.value.strip(): - return row.value.strip() - return None +# Compat shim — use app.domains.rendering.service instead +from app.domains.rendering.service import resolve_template, get_material_library_path +__all__ = ["resolve_template", "get_material_library_path"]