feat: extract workflow material services phase 3

This commit is contained in:
2026-04-07 09:22:24 +02:00
parent e3cda1c9f7
commit 8f8d2e68b7
5 changed files with 324 additions and 99 deletions
@@ -26,6 +26,7 @@ logger = logging.getLogger(__name__)
EmitFn = Callable[..., None] | None
SetupStatus = Literal["ready", "skip", "failed", "missing"]
QueueThumbnailFn = Callable[[str, dict[str, str]], None] | None
@dataclass(slots=True)
@@ -68,6 +69,24 @@ class TemplateResolutionResult:
output_type_id: str | None
@dataclass(slots=True)
class MaterialResolutionResult:
material_map: dict[str, str] | None
use_materials: bool
override_material: str | None
source_material_count: int = 0
resolved_material_count: int = 0
@dataclass(slots=True)
class AutoPopulateMaterialsResult:
cad_file_id: str
updated_product_ids: list[str] = field(default_factory=list)
queued_thumbnail_regeneration: bool = False
part_colors: dict[str, str] | None = None
cad_parts: list[str] = field(default_factory=list)
def _emit(emit: EmitFn, order_line_id: str, message: str, level: str | None = None) -> None:
if emit is None:
return
@@ -270,34 +289,14 @@ def resolve_order_line_template_context(
output_type_id=output_type_id,
)
material_library = get_material_library_path_for_session(session)
material_map = None
use_materials = bool(material_library and materials_source)
if template and not template.material_replace_enabled:
use_materials = False
if use_materials:
material_map = {
material["part_name"]: material["material"]
for material in materials_source
if material.get("part_name") and material.get("material")
}
material_map = resolve_material_map(material_map)
line_override = getattr(line, "material_override", None)
output_override = line.output_type.material_override if line.output_type else None
override_material = line_override or output_override
if override_material:
override_keys = set(material_map.keys()) if material_map else set()
if cad_file and cad_file.parsed_objects:
for part_name in cad_file.parsed_objects.get("objects", []):
override_keys.add(part_name)
material_map = {key: override_material for key in override_keys}
use_materials = True
_emit(
emit,
str(line.id),
f"Material override active: {len(material_map)} parts → {override_material}",
)
material_resolution = resolve_order_line_material_map(
line,
cad_file,
materials_source,
material_library=material_library,
template=template,
emit=emit,
)
if template:
_emit(
@@ -325,11 +324,151 @@ def resolve_order_line_template_context(
return TemplateResolutionResult(
template=template,
material_library=material_library,
material_map=material_resolution.material_map,
use_materials=material_resolution.use_materials,
override_material=material_resolution.override_material,
category_key=category_key,
output_type_id=output_type_id,
)
def resolve_order_line_material_map(
line: OrderLine,
cad_file: CadFile | None,
materials_source: list[dict[str, Any]],
*,
material_library: str | None,
template: RenderTemplate | None,
emit: EmitFn = None,
) -> MaterialResolutionResult:
"""Resolve the effective order-line material map with legacy precedence rules."""
material_map = None
raw_material_count = 0
use_materials = bool(material_library and materials_source)
if template and not template.material_replace_enabled:
use_materials = False
if use_materials:
material_map = {
material["part_name"]: material["material"]
for material in materials_source
if material.get("part_name") and material.get("material")
}
raw_material_count = len(material_map)
material_map = resolve_material_map(material_map)
line_override = getattr(line, "material_override", None)
output_override = line.output_type.material_override if line.output_type else None
override_material = line_override or output_override
if override_material:
override_keys = set(material_map.keys()) if material_map else set()
if cad_file and cad_file.parsed_objects:
for part_name in cad_file.parsed_objects.get("objects", []):
override_keys.add(part_name)
material_map = {key: override_material for key in override_keys}
use_materials = True
_emit(
emit,
str(line.id),
f"Material override active: {len(material_map)} parts → {override_material}",
)
return MaterialResolutionResult(
material_map=material_map,
use_materials=use_materials,
override_material=override_material,
category_key=category_key,
output_type_id=output_type_id,
source_material_count=raw_material_count,
resolved_material_count=len(material_map or {}),
)
def auto_populate_materials_for_cad(
session: Session,
cad_file_id: str,
*,
enqueue_thumbnail: QueueThumbnailFn = None,
) -> AutoPopulateMaterialsResult:
"""Auto-fill empty CAD material mappings from Excel component data.
This preserves the legacy rules:
- only products with empty/all-blank `cad_part_materials` are updated
- thumbnail regeneration is queued at most once per CAD file
- the queued part-color map comes from the last updated product
"""
from app.api.routers.products import build_materials_from_excel
cad_file = session.execute(
select(CadFile).where(CadFile.id == cad_file_id)
).scalar_one_or_none()
if cad_file is None:
return AutoPopulateMaterialsResult(cad_file_id=str(cad_file_id))
parsed_objects = cad_file.parsed_objects or {}
cad_parts: list[str] = parsed_objects.get("objects", [])
if not cad_parts:
return AutoPopulateMaterialsResult(
cad_file_id=str(cad_file_id),
cad_parts=[],
)
products = session.execute(
select(Product).where(
Product.cad_file_id == cad_file.id,
Product.is_active.is_(True),
)
).scalars().all()
updated_product_ids: list[str] = []
final_part_colors: dict[str, str] | None = None
for product in products:
excel_components: list[dict[str, Any]] = product.components or []
if not excel_components:
continue
existing = product.cad_part_materials or []
if existing and any((entry.get("material") or "").strip() for entry in existing):
continue
new_materials = build_materials_from_excel(cad_parts, excel_components)
session.execute(
sql_update(Product)
.where(Product.id == product.id)
.values(cad_part_materials=new_materials)
)
session.flush()
updated_product_ids.append(str(product.id))
try:
final_part_colors = build_part_colors(cad_parts, new_materials)
except Exception:
logger.exception("Part colors build failed for product %s", product.id)
logger.info(
"Auto-populated %d materials for product %s from %d Excel components",
len(new_materials),
product.id,
len(excel_components),
)
session.commit()
queued_thumbnail_regeneration = False
if final_part_colors is not None:
if enqueue_thumbnail is None:
from app.domains.pipeline.tasks.render_thumbnail import regenerate_thumbnail
enqueue_thumbnail = lambda current_cad_file_id, part_colors: regenerate_thumbnail.delay( # noqa: E731
current_cad_file_id,
part_colors,
)
enqueue_thumbnail(str(cad_file_id), final_part_colors)
queued_thumbnail_regeneration = True
return AutoPopulateMaterialsResult(
cad_file_id=str(cad_file_id),
updated_product_ids=updated_product_ids,
queued_thumbnail_regeneration=queued_thumbnail_regeneration,
part_colors=final_part_colors,
cad_parts=cad_parts,
)