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:
@@ -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 ###
|
||||||
@@ -96,6 +96,7 @@ def _build_line_out(line: OrderLine) -> OrderLineOut:
|
|||||||
render_log=line.render_log if hasattr(line, 'render_log') else None,
|
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_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,
|
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,
|
notes=line.notes,
|
||||||
created_at=line.created_at,
|
created_at=line.created_at,
|
||||||
updated_at=line.updated_at,
|
updated_at=line.updated_at,
|
||||||
@@ -1096,6 +1097,42 @@ async def dispatch_single_line_render(
|
|||||||
return {"dispatched": True, "line_id": str(line.id)}
|
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):
|
class RejectLineBody(BaseModel):
|
||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ class OrderLine(Base):
|
|||||||
ForeignKey("global_render_positions.id", ondelete="SET NULL"),
|
ForeignKey("global_render_positions.id", ondelete="SET NULL"),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
material_override: Mapped[str | None] = mapped_column(String(200), nullable=True, default=None)
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
tenant_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
|
UUID(as_uuid=True), ForeignKey("tenants.id"), nullable=True, index=True
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class OrderLineCreate(BaseModel):
|
|||||||
render_position_id: uuid.UUID | None = None
|
render_position_id: uuid.UUID | None = None
|
||||||
global_render_position_id: uuid.UUID | None = None
|
global_render_position_id: uuid.UUID | None = None
|
||||||
gewuenschte_bildnummer: str | None = None
|
gewuenschte_bildnummer: str | None = None
|
||||||
|
material_override: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ class OrderLineOut(BaseModel):
|
|||||||
render_log: dict | None = None
|
render_log: dict | None = None
|
||||||
render_started_at: datetime | None = None
|
render_started_at: datetime | None = None
|
||||||
render_completed_at: datetime | None = None
|
render_completed_at: datetime | None = None
|
||||||
|
material_override: str | None = None
|
||||||
notes: str | None
|
notes: str | None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_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
|
from app.services.material_service import resolve_material_map
|
||||||
material_map = resolve_material_map(material_map)
|
material_map = resolve_material_map(material_map)
|
||||||
|
|
||||||
# Apply global material override from OutputType (e.g. x-ray mode)
|
# Apply material override: per-line override takes priority over output type override
|
||||||
if line.output_type and line.output_type.material_override:
|
_line_override = getattr(line, 'material_override', None)
|
||||||
override_mat = line.output_type.material_override
|
_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
|
# Build override map from existing material_map keys or from parsed STEP parts
|
||||||
override_keys = set()
|
override_keys = set()
|
||||||
if material_map:
|
if material_map:
|
||||||
@@ -360,7 +362,7 @@ def render_order_line_task(self, order_line_id: str):
|
|||||||
usd_path=usd_render_path,
|
usd_path=usd_render_path,
|
||||||
focal_length_mm=focal_length_mm,
|
focal_length_mm=focal_length_mm,
|
||||||
sensor_width_mm=sensor_width_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
|
success = True
|
||||||
render_log = {
|
render_log = {
|
||||||
@@ -419,7 +421,7 @@ def render_order_line_task(self, order_line_id: str):
|
|||||||
rotation_z=rotation_z,
|
rotation_z=rotation_z,
|
||||||
focal_length_mm=focal_length_mm,
|
focal_length_mm=focal_length_mm,
|
||||||
sensor_width_mm=sensor_width_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,
|
job_id=order_line_id,
|
||||||
order_line_id=order_line_id,
|
order_line_id=order_line_id,
|
||||||
noise_threshold=noise_threshold,
|
noise_threshold=noise_threshold,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface OrderLine {
|
|||||||
render_log: RenderLog | null
|
render_log: RenderLog | null
|
||||||
render_started_at: string | null
|
render_started_at: string | null
|
||||||
render_completed_at: string | null
|
render_completed_at: string | null
|
||||||
|
material_override: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -66,6 +67,7 @@ export interface OrderLineCreate {
|
|||||||
render_position_id?: string | null
|
render_position_id?: string | null
|
||||||
global_render_position_id?: string | null
|
global_render_position_id?: string | null
|
||||||
gewuenschte_bildnummer?: string | null
|
gewuenschte_bildnummer?: string | null
|
||||||
|
material_override?: string | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,6 +244,14 @@ export async function dispatchLineRender(orderId: string, lineId: string) {
|
|||||||
return res.data
|
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) {
|
export async function cancelOrderRenders(orderId: string) {
|
||||||
const res = await api.post<{ cancelled: number; order_status: string; errors: string[] | null }>(
|
const res = await api.post<{ cancelled: number; order_status: string; errors: string[] | null }>(
|
||||||
`/orders/${orderId}/cancel-renders`
|
`/orders/${orderId}/cancel-renders`
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
XCircle, RotateCw, Info,
|
XCircle, RotateCw, Info,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
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 { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, dispatchLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder, rejectOrderLine, patchOrderLine } from '../api/orders'
|
||||||
import { checkOrderMaterials, type UnmappedMaterial } from '../api/materials'
|
import { checkOrderMaterials, listMaterials, type UnmappedMaterial, type Material } from '../api/materials'
|
||||||
import UnmappedMaterialsDialog from '../components/orders/UnmappedMaterialsDialog'
|
import UnmappedMaterialsDialog from '../components/orders/UnmappedMaterialsDialog'
|
||||||
import type { OrderItem, OrderLine } from '../api/orders'
|
import type { OrderItem, OrderLine } from '../api/orders'
|
||||||
import { listOutputTypes } from '../api/outputTypes'
|
import { listOutputTypes } from '../api/outputTypes'
|
||||||
@@ -903,6 +903,18 @@ function OrderLineRow({
|
|||||||
onError: (e: any) => toast.error(e.response?.data?.detail || 'Reject failed'),
|
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 canCancel = isPrivileged && (line.render_status === 'processing' || line.render_status === 'pending') && line.output_type_id
|
||||||
const canRejectLine = isPrivileged && line.item_status !== 'rejected'
|
const canRejectLine = isPrivileged && line.item_status !== 'rejected'
|
||||||
|
|
||||||
@@ -979,6 +991,20 @@ function OrderLineRow({
|
|||||||
{line.render_position_name}
|
{line.render_position_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{isPrivileged && (
|
||||||
|
<select
|
||||||
|
className="text-[10px] border border-border-default rounded px-1 py-0.5 w-full mt-1"
|
||||||
|
style={{ backgroundColor: line.material_override ? 'rgba(245, 158, 11, 0.1)' : 'var(--color-bg-surface)' }}
|
||||||
|
value={line.material_override ?? ''}
|
||||||
|
onChange={(e) => overrideMut.mutate(e.target.value || null)}
|
||||||
|
title="Material override — apply a single material to all parts for this render"
|
||||||
|
>
|
||||||
|
<option value="">No material override</option>
|
||||||
|
{libMats.map((m: Material) => (
|
||||||
|
<option key={m.id} value={m.name}>{m.name.replace('SCHAEFFLER_', '').replace(/_/g, ' ')}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user