feat: per-order-line material override — override materials for individual renders

- Add `material_override` nullable column on OrderLine (DB migration)
- Line override takes priority over OutputType override
- PATCH /orders/{id}/lines/{id} endpoint to update material_override
- Inline dropdown on each order line in the OrderDetail page
- Amber background when override is active
- Same output type, different material per line — no need to create a new output type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-14 14:33:00 +01:00
parent 7e57dba085
commit 9d6def84c1
7 changed files with 115 additions and 7 deletions
+37
View File
@@ -96,6 +96,7 @@ def _build_line_out(line: OrderLine) -> OrderLineOut:
render_log=line.render_log if hasattr(line, 'render_log') else None,
render_started_at=line.render_started_at if hasattr(line, 'render_started_at') else None,
render_completed_at=line.render_completed_at if hasattr(line, 'render_completed_at') else None,
material_override=getattr(line, 'material_override', None),
notes=line.notes,
created_at=line.created_at,
updated_at=line.updated_at,
@@ -1096,6 +1097,42 @@ async def dispatch_single_line_render(
return {"dispatched": True, "line_id": str(line.id)}
class PatchLineBody(BaseModel):
material_override: str | None = None
@router.patch("/{order_id}/lines/{line_id}")
async def patch_order_line(
order_id: uuid.UUID,
line_id: uuid.UUID,
body: PatchLineBody,
user: User = Depends(require_admin_or_pm),
db: AsyncSession = Depends(get_db),
):
"""Update fields on an order line (admin/PM only)."""
result = await db.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
raise HTTPException(404, detail="Order not found")
line_result = await db.execute(
select(OrderLine).where(OrderLine.id == line_id, OrderLine.order_id == order_id)
)
line = line_result.scalar_one_or_none()
if not line:
raise HTTPException(404, detail="Order line not found")
data = body.model_dump(exclude_unset=True)
from sqlalchemy import update as sql_update
if data:
await db.execute(
sql_update(OrderLine).where(OrderLine.id == line.id).values(**data)
)
await db.commit()
return {"updated": True, "line_id": str(line.id)}
class RejectLineBody(BaseModel):
reason: str = ""
+1
View File
@@ -150,6 +150,7 @@ class OrderLine(Base):
ForeignKey("global_render_positions.id", ondelete="SET NULL"),
nullable=True,
)
material_override: Mapped[str | None] = mapped_column(String(200), nullable=True, default=None)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
+2
View File
@@ -66,6 +66,7 @@ class OrderLineCreate(BaseModel):
render_position_id: uuid.UUID | None = None
global_render_position_id: uuid.UUID | None = None
gewuenschte_bildnummer: str | None = None
material_override: str | None = None
notes: str | None = None
@@ -91,6 +92,7 @@ class OrderLineOut(BaseModel):
render_log: dict | None = None
render_started_at: datetime | None = None
render_completed_at: datetime | None = None
material_override: str | None = None
notes: str | None
created_at: datetime
updated_at: datetime
@@ -198,9 +198,11 @@ def render_order_line_task(self, order_line_id: str):
from app.services.material_service import resolve_material_map
material_map = resolve_material_map(material_map)
# Apply global material override from OutputType (e.g. x-ray mode)
if line.output_type and line.output_type.material_override:
override_mat = line.output_type.material_override
# Apply material override: per-line override takes priority over output type override
_line_override = getattr(line, 'material_override', None)
_ot_override = line.output_type.material_override if line.output_type else None
override_mat = _line_override or _ot_override
if override_mat:
# Build override map from existing material_map keys or from parsed STEP parts
override_keys = set()
if material_map:
@@ -360,7 +362,7 @@ def render_order_line_task(self, order_line_id: str):
usd_path=usd_render_path,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=line.output_type.material_override if line.output_type else None,
material_override=override_mat,
)
success = True
render_log = {
@@ -419,7 +421,7 @@ def render_order_line_task(self, order_line_id: str):
rotation_z=rotation_z,
focal_length_mm=focal_length_mm,
sensor_width_mm=sensor_width_mm,
material_override=line.output_type.material_override if line.output_type else None,
material_override=override_mat,
job_id=order_line_id,
order_line_id=order_line_id,
noise_threshold=noise_threshold,