From 9d6def84c1149c9baf32ade61216a0878aa629d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 14 Mar 2026 14:33:00 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20per-order-line=20material=20override=20?= =?UTF-8?q?=E2=80=94=20override=20materials=20for=20individual=20renders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- ...47_add_material_override_to_order_lines.py | 30 +++++++++++++++ backend/app/api/routers/orders.py | 37 +++++++++++++++++++ backend/app/domains/orders/models.py | 1 + backend/app/domains/orders/schemas.py | 2 + .../pipeline/tasks/render_order_line.py | 12 +++--- frontend/src/api/orders.ts | 10 +++++ frontend/src/pages/OrderDetail.tsx | 30 ++++++++++++++- 7 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 backend/alembic/versions/42b686e71c47_add_material_override_to_order_lines.py diff --git a/backend/alembic/versions/42b686e71c47_add_material_override_to_order_lines.py b/backend/alembic/versions/42b686e71c47_add_material_override_to_order_lines.py new file mode 100644 index 0000000..6fa13f3 --- /dev/null +++ b/backend/alembic/versions/42b686e71c47_add_material_override_to_order_lines.py @@ -0,0 +1,30 @@ +"""add material_override to order_lines + +Revision ID: 42b686e71c47 +Revises: cfcc7ad1e7d5 +Create Date: 2026-03-14 13:30:03.046793 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '42b686e71c47' +down_revision: Union[str, None] = 'cfcc7ad1e7d5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('order_lines', sa.Column('material_override', sa.String(length=200), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('order_lines', 'material_override') + # ### end Alembic commands ### diff --git a/backend/app/api/routers/orders.py b/backend/app/api/routers/orders.py index 042e6a9..e39cac8 100644 --- a/backend/app/api/routers/orders.py +++ b/backend/app/api/routers/orders.py @@ -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 = "" diff --git a/backend/app/domains/orders/models.py b/backend/app/domains/orders/models.py index 39f0722..a82ad65 100644 --- a/backend/app/domains/orders/models.py +++ b/backend/app/domains/orders/models.py @@ -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 diff --git a/backend/app/domains/orders/schemas.py b/backend/app/domains/orders/schemas.py index d25ef17..23a905c 100644 --- a/backend/app/domains/orders/schemas.py +++ b/backend/app/domains/orders/schemas.py @@ -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 diff --git a/backend/app/domains/pipeline/tasks/render_order_line.py b/backend/app/domains/pipeline/tasks/render_order_line.py index 29c7dfc..4e0a0c8 100644 --- a/backend/app/domains/pipeline/tasks/render_order_line.py +++ b/backend/app/domains/pipeline/tasks/render_order_line.py @@ -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, diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 8195229..fb83dcd 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -55,6 +55,7 @@ export interface OrderLine { render_log: RenderLog | null render_started_at: string | null render_completed_at: string | null + material_override: string | null notes: string | null created_at: string updated_at: string @@ -66,6 +67,7 @@ export interface OrderLineCreate { render_position_id?: string | null global_render_position_id?: string | null gewuenschte_bildnummer?: string | null + material_override?: string | null notes?: string | null } @@ -242,6 +244,14 @@ export async function dispatchLineRender(orderId: string, lineId: string) { return res.data } +export async function patchOrderLine(orderId: string, lineId: string, data: { material_override?: string | null }) { + const res = await api.patch<{ updated: boolean; line_id: string }>( + `/orders/${orderId}/lines/${lineId}`, + data + ) + return res.data +} + export async function cancelOrderRenders(orderId: string) { const res = await api.post<{ cancelled: number; order_status: string; errors: string[] | null }>( `/orders/${orderId}/cancel-renders` diff --git a/frontend/src/pages/OrderDetail.tsx b/frontend/src/pages/OrderDetail.tsx index 00f3fb3..b044ae1 100644 --- a/frontend/src/pages/OrderDetail.tsx +++ b/frontend/src/pages/OrderDetail.tsx @@ -12,8 +12,8 @@ import { XCircle, RotateCw, Info, } from 'lucide-react' import { toast } from 'sonner' -import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine } from '../api/orders' -import { checkOrderMaterials, type UnmappedMaterial } from '../api/materials' +import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine, patchOrderLine } from '../api/orders' +import { checkOrderMaterials, listMaterials, type UnmappedMaterial, type Material } from '../api/materials' import UnmappedMaterialsDialog from '../components/orders/UnmappedMaterialsDialog' import type { OrderItem, OrderLine } from '../api/orders' import { listOutputTypes } from '../api/outputTypes' @@ -903,6 +903,18 @@ function OrderLineRow({ onError: (e: any) => toast.error(e.response?.data?.detail || 'Reject failed'), }) + const { data: allMats } = useQuery({ queryKey: ['materials'], queryFn: listMaterials }) + const libMats = (allMats ?? []).filter((m: Material) => m.schaeffler_code !== null).sort((a: Material, b: Material) => a.name.localeCompare(b.name)) + + const overrideMut = useMutation({ + mutationFn: (val: string | null) => patchOrderLine(orderId, line.id, { material_override: val }), + onSuccess: () => { + toast.success(line.material_override ? 'Material override updated' : 'Material override set') + qc.invalidateQueries({ queryKey: ['order', orderId] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Failed to update'), + }) + const canCancel = isPrivileged && (line.render_status === 'processing' || line.render_status === 'pending') && line.output_type_id const canRejectLine = isPrivileged && line.item_status !== 'rejected' @@ -979,6 +991,20 @@ function OrderLineRow({ {line.render_position_name} )} + {isPrivileged && ( + + )}