refactor(B1): migrate to domain-driven project structure
Move all models/schemas/services/routers into app/domains/. Keep backward-compat shims in old locations for imports. Preserves domains/rendering/tasks.py from Phase A. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
# Domain: admin
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Domain: auth
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Re-export from original router.
|
||||||
|
from app.api.routers.auth import router
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Domain: billing
|
||||||
@@ -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"),
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from original router.
|
||||||
|
from app.api.routers.pricing import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Domain: imports
|
||||||
@@ -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")
|
||||||
@@ -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"]
|
||||||
@@ -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] = []
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Domain: materials
|
||||||
@@ -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")
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from original router.
|
||||||
|
from app.api.routers.materials import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Domain: notifications
|
||||||
@@ -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])
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Re-export from original router.
|
||||||
|
from app.api.routers.notifications import router
|
||||||
|
|
||||||
|
__all__ = ["router"]
|
||||||
@@ -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)")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Domain: orders
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
@@ -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] = []
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# Domain: products
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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}
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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"]
|
||||||
@@ -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}
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
+27
-17
@@ -6,7 +6,17 @@ from pathlib import Path
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import engine, Base
|
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
|
@asynccontextmanager
|
||||||
@@ -48,22 +58,22 @@ try:
|
|||||||
except (PermissionError, OSError):
|
except (PermissionError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Include routers
|
# Include routers (via domain locations)
|
||||||
app.include_router(auth.router, prefix="/api")
|
app.include_router(auth_router, prefix="/api")
|
||||||
app.include_router(uploads.router, prefix="/api")
|
app.include_router(uploads_router, prefix="/api")
|
||||||
app.include_router(orders.router, prefix="/api")
|
app.include_router(orders_router, prefix="/api")
|
||||||
app.include_router(templates.router, prefix="/api")
|
app.include_router(templates_router, prefix="/api")
|
||||||
app.include_router(admin.router, prefix="/api")
|
app.include_router(admin_router, prefix="/api")
|
||||||
app.include_router(order_items.router, prefix="/api")
|
app.include_router(order_items_router, prefix="/api")
|
||||||
app.include_router(cad.router, prefix="/api")
|
app.include_router(cad_router, prefix="/api")
|
||||||
app.include_router(materials.router, prefix="/api")
|
app.include_router(materials_router, prefix="/api")
|
||||||
app.include_router(worker.router, prefix="/api")
|
app.include_router(worker_router, prefix="/api")
|
||||||
app.include_router(analytics.router, prefix="/api")
|
app.include_router(analytics_router, prefix="/api")
|
||||||
app.include_router(pricing.router, prefix="/api")
|
app.include_router(pricing_router, prefix="/api")
|
||||||
app.include_router(products.router, prefix="/api")
|
app.include_router(products_router, prefix="/api")
|
||||||
app.include_router(output_types.router, prefix="/api")
|
app.include_router(output_types_router, prefix="/api")
|
||||||
app.include_router(render_templates.router, prefix="/api")
|
app.include_router(render_templates_router, prefix="/api")
|
||||||
app.include_router(notifications.router, prefix="/api")
|
app.include_router(notifications_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
from app.models.user import User
|
"""Re-export all models from domain locations.
|
||||||
from app.models.template import Template
|
|
||||||
from app.models.cad_file import CadFile
|
This file ensures that `from app.models import X` continues to work.
|
||||||
from app.models.order import Order
|
The canonical definitions live in app/domains/*/models.py.
|
||||||
from app.models.order_item import OrderItem
|
"""
|
||||||
from app.models.audit_log import AuditLog
|
from app.domains.auth.models import User
|
||||||
from app.models.pricing_tier import PricingTier
|
from app.domains.imports.models import Template
|
||||||
from app.models.product import Product
|
from app.domains.products.models import CadFile, Product
|
||||||
from app.models.output_type import OutputType
|
from app.domains.orders.models import Order, OrderItem, OrderLine
|
||||||
from app.models.order_line import OrderLine
|
from app.domains.notifications.models import AuditLog
|
||||||
from app.models.render_template import RenderTemplate
|
from app.domains.billing.models import PricingTier
|
||||||
from app.models.material import Material
|
from app.domains.rendering.models import OutputType, RenderTemplate, ProductRenderPosition
|
||||||
from app.models.material_alias import MaterialAlias
|
from app.domains.materials.models import Material, MaterialAlias
|
||||||
from app.models.render_position import ProductRenderPosition
|
|
||||||
|
# Also re-export SystemSetting (no domain assigned — stays as-is)
|
||||||
|
from app.models.system_setting import SystemSetting
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Template", "CadFile", "Order", "OrderItem", "AuditLog",
|
"User", "Template", "CadFile", "Product", "Order", "OrderItem", "OrderLine",
|
||||||
"PricingTier", "Product", "OutputType", "OrderLine",
|
"AuditLog", "PricingTier", "OutputType", "RenderTemplate", "ProductRenderPosition",
|
||||||
"RenderTemplate", "Material", "MaterialAlias", "ProductRenderPosition",
|
"Material", "MaterialAlias", "SystemSetting",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,28 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.notifications.models instead
|
||||||
from datetime import datetime
|
from app.domains.notifications.models import AuditLog
|
||||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey
|
__all__ = ["AuditLog"]
|
||||||
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])
|
|
||||||
|
|||||||
@@ -1,37 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.products.models instead
|
||||||
from datetime import datetime
|
from app.domains.products.models import CadFile, ProcessingStatus
|
||||||
from sqlalchemy import String, DateTime, Enum as SAEnum, BigInteger
|
__all__ = ["CadFile", "ProcessingStatus"]
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,24 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.materials.models instead
|
||||||
from datetime import datetime
|
from app.domains.materials.models import Material
|
||||||
from sqlalchemy import String, DateTime, Text, ForeignKey, Integer
|
__all__ = ["Material"]
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,19 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.materials.models instead
|
||||||
from datetime import datetime
|
from app.domains.materials.models import MaterialAlias
|
||||||
from sqlalchemy import String, DateTime, ForeignKey
|
__all__ = ["MaterialAlias"]
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,42 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.orders.models instead
|
||||||
from datetime import datetime
|
from app.domains.orders.models import Order, OrderStatus
|
||||||
from decimal import Decimal
|
__all__ = ["Order", "OrderStatus"]
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,71 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.orders.models instead
|
||||||
from datetime import datetime
|
from app.domains.orders.models import OrderItem, ItemStatus, AIValidationStatus
|
||||||
from sqlalchemy import String, DateTime, Enum as SAEnum, ForeignKey, Integer, Boolean, Text
|
__all__ = ["OrderItem", "ItemStatus", "AIValidationStatus"]
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,52 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.orders.models instead
|
||||||
import enum
|
from app.domains.orders.models import OrderLine
|
||||||
from datetime import datetime
|
__all__ = ["OrderLine"]
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,36 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.rendering.models instead
|
||||||
from datetime import datetime
|
from app.domains.rendering.models import OutputType, VALID_RENDER_BACKENDS
|
||||||
from sqlalchemy import String, DateTime, Boolean, Text, Integer, ForeignKey
|
__all__ = ["OutputType", "VALID_RENDER_BACKENDS"]
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,25 +1,3 @@
|
|||||||
from datetime import datetime
|
# Compat shim — use app.domains.billing.models instead
|
||||||
from decimal import Decimal
|
from app.domains.billing.models import PricingTier
|
||||||
from sqlalchemy import String, Boolean, DateTime, Text, Numeric, Integer, UniqueConstraint, Index
|
__all__ = ["PricingTier"]
|
||||||
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"),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,66 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.products.models instead
|
||||||
from datetime import datetime
|
from app.domains.products.models import Product
|
||||||
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey
|
__all__ = ["Product"]
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,28 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.rendering.models instead
|
||||||
from datetime import datetime
|
from app.domains.rendering.models import ProductRenderPosition
|
||||||
from sqlalchemy import String, DateTime, Boolean, Integer, Float, ForeignKey
|
__all__ = ["ProductRenderPosition"]
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,30 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.rendering.models instead
|
||||||
from datetime import datetime
|
from app.domains.rendering.models import RenderTemplate
|
||||||
from sqlalchemy import String, DateTime, Boolean, Text, ForeignKey
|
__all__ = ["RenderTemplate"]
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,24 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.imports.models instead
|
||||||
from datetime import datetime
|
from app.domains.imports.models import Template
|
||||||
from sqlalchemy import String, Boolean, DateTime, Text
|
__all__ = ["Template"]
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,29 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.auth.models instead
|
||||||
from datetime import datetime
|
from app.domains.auth.models import User, UserRole
|
||||||
from sqlalchemy import String, Boolean, DateTime, Enum as SAEnum
|
__all__ = ["User", "UserRole"]
|
||||||
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")
|
|
||||||
|
|||||||
@@ -1,92 +1,11 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.orders.schemas instead
|
||||||
from datetime import datetime
|
from app.domains.orders.schemas import (
|
||||||
from typing import Any
|
ComponentData, OrderItemCreate, OrderItemOut,
|
||||||
from pydantic import BaseModel
|
OrderLineCreate, OrderLineOut,
|
||||||
from app.models.order import OrderStatus
|
OrderCreate, OrderOut, OrderDetailOut,
|
||||||
from app.models.order_item import ItemStatus, AIValidationStatus
|
)
|
||||||
from app.schemas.order_line import OrderLineOut, OrderLineCreate # noqa: F401
|
__all__ = [
|
||||||
|
"ComponentData", "OrderItemCreate", "OrderItemOut",
|
||||||
|
"OrderLineCreate", "OrderLineOut",
|
||||||
class ComponentData(BaseModel):
|
"OrderCreate", "OrderOut", "OrderDetailOut",
|
||||||
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] = []
|
|
||||||
|
|||||||
@@ -1,39 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.orders.schemas instead
|
||||||
from datetime import datetime
|
from app.domains.orders.schemas import OrderLineCreate, OrderLineOut
|
||||||
from pydantic import BaseModel
|
__all__ = ["OrderLineCreate", "OrderLineOut"]
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,58 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.rendering.schemas instead
|
||||||
from datetime import datetime
|
from app.domains.rendering.schemas import OutputTypeCreate, OutputTypePatch, OutputTypeOut
|
||||||
from pydantic import BaseModel
|
__all__ = ["OutputTypeCreate", "OutputTypePatch", "OutputTypeOut"]
|
||||||
|
|
||||||
|
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,72 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.products.schemas instead
|
||||||
from datetime import datetime
|
from app.domains.products.schemas import ProductCreate, ProductPatch, ProductOut
|
||||||
from pydantic import BaseModel
|
__all__ = ["ProductCreate", "ProductPatch", "ProductOut"]
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,36 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.rendering.schemas instead
|
||||||
from datetime import datetime
|
from app.domains.rendering.schemas import RenderPositionCreate, RenderPositionPatch, RenderPositionOut
|
||||||
from pydantic import BaseModel
|
__all__ = ["RenderPositionCreate", "RenderPositionPatch", "RenderPositionOut"]
|
||||||
|
|
||||||
|
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,43 +1,3 @@
|
|||||||
from pydantic import BaseModel
|
# Compat shim — use app.domains.imports.schemas instead
|
||||||
from typing import Any
|
from app.domains.imports.schemas import ParsedComponent, ParsedRow, ParsedExcelResponse, StepUploadResponse
|
||||||
|
__all__ = ["ParsedComponent", "ParsedRow", "ParsedExcelResponse", "StepUploadResponse"]
|
||||||
|
|
||||||
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] = []
|
|
||||||
|
|||||||
@@ -1,39 +1,3 @@
|
|||||||
import uuid
|
# Compat shim — use app.domains.auth.schemas instead
|
||||||
from datetime import datetime
|
from app.domains.auth.schemas import UserCreate, UserUpdate, UserOut, TokenResponse, LoginRequest
|
||||||
from pydantic import BaseModel, EmailStr
|
__all__ = ["UserCreate", "UserUpdate", "UserOut", "TokenResponse", "LoginRequest"]
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,143 +1,3 @@
|
|||||||
"""Material alias resolution service.
|
# Compat shim — use app.domains.materials.service instead
|
||||||
|
from app.domains.materials.service import resolve_material_map, seed_material_aliases_from_mappings
|
||||||
Used from Celery tasks (sync context) to resolve raw material names
|
__all__ = ["resolve_material_map", "seed_material_aliases_from_mappings"]
|
||||||
(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}
|
|
||||||
|
|||||||
@@ -1,84 +1,3 @@
|
|||||||
"""Notification emission helpers.
|
# Compat shim — use app.domains.notifications.service instead
|
||||||
|
from app.domains.notifications.service import emit_notification, emit_notification_sync
|
||||||
Provides async (for routers) and sync (for Celery tasks) entry points
|
__all__ = ["emit_notification", "emit_notification_sync"]
|
||||||
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)")
|
|
||||||
|
|||||||
@@ -1,22 +1,3 @@
|
|||||||
"""Order number generation and business logic."""
|
# Compat shim — use app.domains.orders.service instead
|
||||||
from datetime import datetime
|
from app.domains.orders.service import generate_order_number, check_order_completion
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
__all__ = ["generate_order_number", "check_order_completion"]
|
||||||
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"
|
|
||||||
|
|||||||
@@ -1,86 +1,3 @@
|
|||||||
"""Service to auto-advance order status when all renders complete."""
|
# Compat shim — use app.domains.orders.service instead
|
||||||
import logging
|
from app.domains.orders.service import check_order_completion
|
||||||
from datetime import datetime
|
__all__ = ["check_order_completion"]
|
||||||
|
|
||||||
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()
|
|
||||||
|
|||||||
@@ -1,232 +1,8 @@
|
|||||||
"""Pricing service — price lookup and order price computation.
|
# Compat shim — use app.domains.billing.service instead
|
||||||
|
from app.domains.billing.service import (
|
||||||
Price resolution cascade for order lines:
|
get_price_for,
|
||||||
1. OutputType's linked pricing_tier (if active) → use its price_per_item
|
resolve_line_price,
|
||||||
2. Product's category_key → look up PricingTier by category
|
estimate_order_price,
|
||||||
3. "default" category tier → global fallback
|
refresh_order_price,
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
)
|
__all__ = ["get_price_for", "resolve_line_price", "estimate_order_price", "refresh_order_price"]
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,143 +1,15 @@
|
|||||||
"""Product service — lookup/create products, link CAD files."""
|
# Compat shim — use app.domains.products.service instead
|
||||||
import uuid
|
from app.domains.products.service import (
|
||||||
from sqlalchemy import select, func, update as sql_update
|
create_default_positions,
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
lookup_product,
|
||||||
|
lookup_or_create_product,
|
||||||
from app.models.product import Product
|
link_cad_to_product,
|
||||||
|
DEFAULT_RENDER_POSITIONS,
|
||||||
# Default render positions added to every newly created product.
|
)
|
||||||
DEFAULT_RENDER_POSITIONS = [
|
__all__ = [
|
||||||
{"name": "3/4 Front", "rotation_x": -15.0, "rotation_y": 45.0, "rotation_z": 0.0, "is_default": True, "sort_order": 0},
|
"create_default_positions",
|
||||||
{"name": "3/4 Rear", "rotation_x": -15.0, "rotation_y": -135.0, "rotation_z": 0.0, "is_default": False, "sort_order": 1},
|
"lookup_product",
|
||||||
{"name": "Default", "rotation_x": 0.0, "rotation_y": 0.0, "rotation_z": 0.0, "is_default": False, "sort_order": 2},
|
"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()
|
|
||||||
|
|||||||
@@ -1,96 +1,3 @@
|
|||||||
"""Render dispatcher — routes render jobs to Celery.
|
# Compat shim — use app.domains.rendering.service instead
|
||||||
|
from app.domains.rendering.service import dispatch_render
|
||||||
All renders run via Celery workers (Flamenco removed in v2 refactor).
|
__all__ = ["dispatch_render"]
|
||||||
"""
|
|
||||||
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}
|
|
||||||
|
|||||||
@@ -1,102 +1,3 @@
|
|||||||
"""Render template resolution service.
|
# Compat shim — use app.domains.rendering.service instead
|
||||||
|
from app.domains.rendering.service import resolve_template, get_material_library_path
|
||||||
Used from Celery tasks (sync context) to find the best matching .blend template
|
__all__ = ["resolve_template", "get_material_library_path"]
|
||||||
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user