From 1cc10d4bbb3bc0994e441025a638397bc3758c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 8 Mar 2026 20:37:05 +0100 Subject: [PATCH] feat(phase7.4): order rejection + resubmit flow - Migration 053: rejection_reason TEXT NULL on orders table - POST /api/orders/{id}/reject (PM+): sets rejected status, cancels active renders, stores reason, notifies creator, broadcasts WS event - POST /api/orders/{id}/resubmit (creator or PM+): resets to draft, clears rejection fields, notifies PMs - OrderDetail: Reject button (PM+) + inline modal with reason textarea and notify-client checkbox; Resubmit button; rejection reason amber alert box shown below header when order is rejected - Orders Kanban: rejection_reason shown as red italic note on card - Order interface: rejected_at, rejection_reason fields Co-Authored-By: Claude Sonnet 4.6 --- .../alembic/versions/053_order_rejection.py | 26 +++ backend/app/api/routers/orders.py | 173 +++++++++++++++++- backend/app/domains/orders/models.py | 1 + backend/app/domains/orders/schemas.py | 6 + backend/app/schemas/order.py | 2 + frontend/src/pages/OrderDetail.tsx | 123 ++++++++++++- frontend/src/pages/Orders.tsx | 7 +- 7 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 backend/alembic/versions/053_order_rejection.py diff --git a/backend/alembic/versions/053_order_rejection.py b/backend/alembic/versions/053_order_rejection.py new file mode 100644 index 0000000..a96f285 --- /dev/null +++ b/backend/alembic/versions/053_order_rejection.py @@ -0,0 +1,26 @@ +"""Add rejection_reason to orders table. + +Revision ID: 053 +Revises: 052 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine.reflection import Inspector + +revision = "053" +down_revision = "052" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = Inspector.from_engine(bind) + columns = [c["name"] for c in inspector.get_columns("orders")] + + if "rejection_reason" not in columns: + op.add_column("orders", sa.Column("rejection_reason", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("orders", "rejection_reason") diff --git a/backend/app/api/routers/orders.py b/backend/app/api/routers/orders.py index 7927c67..9beb4f7 100644 --- a/backend/app/api/routers/orders.py +++ b/backend/app/api/routers/orders.py @@ -24,12 +24,12 @@ from app.models.product import Product from app.models.output_type import OutputType from app.models.cad_file import CadFile from app.models.user import User -from app.schemas.order import OrderCreate, OrderOut, OrderDetailOut, OrderItemOut +from app.schemas.order import OrderCreate, OrderOut, OrderDetailOut, OrderItemOut, RejectOrderRequest from app.schemas.order_line import OrderLineCreate, OrderLineOut from app.schemas.product import ProductOut from app.schemas.output_type import OutputTypeOut from app.services.order_service import generate_order_number -from app.utils.auth import get_current_user, require_admin_or_pm +from app.utils.auth import get_current_user, require_admin_or_pm, require_pm_or_above router = APIRouter(prefix="/orders", tags=["orders"]) @@ -1067,6 +1067,175 @@ async def cancel_order_renders( } +@router.post("/{order_id}/reject", response_model=OrderOut) +async def reject_order( + order_id: uuid.UUID, + body: RejectOrderRequest, + user: User = Depends(require_pm_or_above), + db: AsyncSession = Depends(get_db), +): + """Reject a submitted or processing order (PM / admin only). + + Cancels all pending/processing render lines and notifies the order creator. + """ + 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") + + if order.status not in (OrderStatus.submitted, OrderStatus.processing): + raise HTTPException( + 400, + detail=f"Cannot reject order in '{order.status.value}' status — only submitted or processing orders can be rejected", + ) + + now = datetime.utcnow() + order.status = OrderStatus.rejected + order.rejected_at = now + order.rejection_reason = body.reason or None + order.updated_at = now + + # Cancel all pending/processing render lines + from sqlalchemy import update as sql_update + from app.tasks.celery_app import celery_app + + active_lines_result = await db.execute( + select(OrderLine).where( + OrderLine.order_id == order.id, + OrderLine.render_status.in_(["pending", "processing"]), + ) + ) + active_lines = active_lines_result.scalars().all() + for line in active_lines: + try: + real_task_id = None + if line.render_job_doc: + real_task_id = line.render_job_doc.get("celery_task_id") + task_id = real_task_id or f"render-{line.id}" + celery_app.control.revoke(task_id, terminate=True, signal="SIGTERM") + except Exception: + pass + + await db.execute( + sql_update(OrderLine) + .where(OrderLine.id == line.id) + .values( + render_status="cancelled", + render_completed_at=now, + render_log={ + "cancelled_by": str(user.id), + "cancelled_at": now.isoformat(), + "reason": "order_rejected", + }, + ) + ) + + # Mark all order lines as rejected + await db.execute( + update(OrderLine) + .where(OrderLine.order_id == order.id) + .values(item_status="rejected") + ) + + await db.commit() + await db.refresh(order) + + # Notify the order creator + if body.notify_client: + from app.services.notification_service import emit_notification + reason_text = f" Reason: {body.reason}" if body.reason else "" + await emit_notification( + db, + actor_user_id=user.id, + target_user_id=order.created_by, + action="order.rejected", + entity_type="order", + entity_id=str(order.id), + details={ + "order_number": order.order_number, + "reason": body.reason or "", + "message": f"Your order {order.order_number} was rejected.{reason_text}", + }, + ) + + # Broadcast WebSocket event + try: + from app.core.websocket import manager as _ws_mgr + _tid = str(user.tenant_id) if user.tenant_id else None + if _tid: + await _ws_mgr.broadcast_to_tenant(_tid, { + "type": "order_status_change", + "order_id": str(order.id), + "status": "rejected", + }) + except Exception: + pass + + return order + + +@router.post("/{order_id}/resubmit", response_model=OrderOut) +async def resubmit_order( + order_id: uuid.UUID, + user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Resubmit a rejected order back to draft (creator or PM+). + + Clears rejection_reason / rejected_at and resets the order to draft + so the client can correct and resubmit. + """ + 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") + + if order.status != OrderStatus.rejected: + raise HTTPException(400, detail=f"Only rejected orders can be resubmitted (current status: {order.status.value})") + + if not _is_privileged(user) and order.created_by != user.id: + raise HTTPException(403, detail="Access denied — only the order creator or a PM/admin can resubmit") + + now = datetime.utcnow() + order.status = OrderStatus.draft + order.rejected_at = None + order.rejection_reason = None + order.updated_at = now + + await db.commit() + await db.refresh(order) + + # Notify PMs/admins about the resubmission (broadcast) + from app.services.notification_service import emit_notification + await emit_notification( + db, + actor_user_id=user.id, + target_user_id=None, + action="order.resubmitted", + entity_type="order", + entity_id=str(order.id), + details={ + "order_number": order.order_number, + "resubmitted_by": str(user.id), + }, + ) + + # Broadcast WebSocket event + try: + from app.core.websocket import manager as _ws_mgr + _tid = str(user.tenant_id) if user.tenant_id else None + if _tid: + await _ws_mgr.broadcast_to_tenant(_tid, { + "type": "order_status_change", + "order_id": str(order.id), + "status": "draft", + }) + except Exception: + pass + + return order + + @router.delete("/{order_id}/lines/{line_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_order_line( order_id: uuid.UUID, diff --git a/backend/app/domains/orders/models.py b/backend/app/domains/orders/models.py index 62f305b..ec21ac6 100644 --- a/backend/app/domains/orders/models.py +++ b/backend/app/domains/orders/models.py @@ -32,6 +32,7 @@ class Order(Base): processing_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True) estimated_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), 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 278a310..cc125a6 100644 --- a/backend/app/domains/orders/schemas.py +++ b/backend/app/domains/orders/schemas.py @@ -55,6 +55,11 @@ class OrderItemOut(BaseModel): model_config = {"from_attributes": True} +class RejectOrderRequest(BaseModel): + reason: str = "" + notify_client: bool = True + + class OrderLineCreate(BaseModel): product_id: uuid.UUID output_type_id: uuid.UUID | None = None @@ -111,6 +116,7 @@ class OrderOut(BaseModel): processing_started_at: datetime | None = None completed_at: datetime | None = None rejected_at: datetime | None = None + rejection_reason: str | None = None estimated_price: float | None = None item_count: int = 0 line_count: int = 0 diff --git a/backend/app/schemas/order.py b/backend/app/schemas/order.py index fe60032..2ad78e1 100644 --- a/backend/app/schemas/order.py +++ b/backend/app/schemas/order.py @@ -3,9 +3,11 @@ from app.domains.orders.schemas import ( ComponentData, OrderItemCreate, OrderItemOut, OrderLineCreate, OrderLineOut, OrderCreate, OrderOut, OrderDetailOut, + RejectOrderRequest, ) __all__ = [ "ComponentData", "OrderItemCreate", "OrderItemOut", "OrderLineCreate", "OrderLineOut", "OrderCreate", "OrderOut", "OrderDetailOut", + "RejectOrderRequest", ] diff --git a/frontend/src/pages/OrderDetail.tsx b/frontend/src/pages/OrderDetail.tsx index 48e5a96..adbd9e2 100644 --- a/frontend/src/pages/OrderDetail.tsx +++ b/frontend/src/pages/OrderDetail.tsx @@ -8,9 +8,10 @@ import { ChevronDown, ChevronUp, ChevronsUpDown, Search, SlidersHorizontal, FileSpreadsheet, Box, Loader2, Play, RefreshCw, ExternalLink, Ban, StopCircle, Scissors, Plus, Wand2, Download, + XCircle, RotateCw, } from 'lucide-react' import { toast } from 'sonner' -import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders } from '../api/orders' +import { getOrder, submitOrder, deleteOrder, unlinkCadFile, regenerateItemThumbnail, patchOrderItem, removeOrderLine, dispatchRenders, cancelLineRender, cancelOrderRenders, splitMissingStep, generateLinesFromItems, downloadOrderRenders, rejectOrder, resubmitOrder } from '../api/orders' import type { OrderItem, OrderLine } from '../api/orders' import { listOutputTypes } from '../api/outputTypes' import type { OutputType } from '../api/outputTypes' @@ -60,6 +61,9 @@ export default function OrderDetailPage() { const [genLinesOpen, setGenLinesOpen] = useState(false) const [genLinesSelected, setGenLinesSelected] = useState>({}) const [isDownloading, setIsDownloading] = useState(false) + const [rejectModalOpen, setRejectModalOpen] = useState(false) + const [rejectReason, setRejectReason] = useState('') + const [rejectNotifyClient, setRejectNotifyClient] = useState(true) // Table state const [filters, setFilters] = useState(EMPTY_FILTERS) @@ -148,6 +152,28 @@ export default function OrderDetailPage() { onError: (e: any) => toast.error(e.response?.data?.detail || 'Split failed'), }) + const rejectMut = useMutation({ + mutationFn: () => rejectOrder(id!, rejectReason, rejectNotifyClient), + onSuccess: () => { + toast.success('Order rejected') + setRejectModalOpen(false) + setRejectReason('') + qc.invalidateQueries({ queryKey: ['order', id] }) + qc.invalidateQueries({ queryKey: ['orders'] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Reject failed'), + }) + + const resubmitMut = useMutation({ + mutationFn: () => resubmitOrder(id!), + onSuccess: () => { + toast.success('Order moved back to draft') + qc.invalidateQueries({ queryKey: ['order', id] }) + qc.invalidateQueries({ queryKey: ['orders'] }) + }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Resubmit failed'), + }) + if (isLoading) return
Loading…
if (!order) return
Order not found
@@ -155,6 +181,8 @@ export default function OrderDetailPage() { const canDelete = order.status === 'draft' || order.status === 'rejected' const isDraft = order.status === 'draft' const isPrivileged = user?.role === 'admin' || user?.role === 'project_manager' + const canReject = isPrivileged && (order.status === 'submitted' || order.status === 'processing') + const canResubmit = order.status === 'rejected' && (isPrivileged || order.created_by === user?.id) const rp = order.render_progress const hasRetryable = rp && (rp.pending > 0 || rp.failed > 0 || (rp as any).cancelled > 0) const canDispatch = isPrivileged && (order.status === 'processing' || order.status === 'submitted' || order.status === 'completed') @@ -272,6 +300,25 @@ export default function OrderDetailPage() { : 'Dispatch Renders'} )} + {canReject && ( + + )} + {canResubmit && ( + + )} {canSubmit && ( + +

+ Rejecting {order.order_number} will + cancel all pending renders and move the order to “rejected” status. +

+
+ +