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: 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()
|
||||
Reference in New Issue
Block a user