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 <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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<Record<string, boolean>>({})
|
||||
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<TableFilters>(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 <div className="p-8 text-center text-content-muted">Loading…</div>
|
||||
if (!order) return <div className="p-8 text-center text-red-500">Order not found</div>
|
||||
|
||||
@@ -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'}
|
||||
</button>
|
||||
)}
|
||||
{canReject && (
|
||||
<button
|
||||
onClick={() => setRejectModalOpen(true)}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium border border-red-300 text-red-600 bg-red-50 hover:bg-red-100 transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
<XCircle size={16} />
|
||||
Reject Order
|
||||
</button>
|
||||
)}
|
||||
{canResubmit && (
|
||||
<button
|
||||
onClick={() => resubmitMut.mutate()}
|
||||
className="px-3 py-2 rounded-lg text-sm font-medium border border-border-default text-content-secondary bg-surface hover:bg-surface-hover transition-colors disabled:opacity-50 flex items-center gap-1.5"
|
||||
disabled={resubmitMut.isPending}
|
||||
>
|
||||
<RotateCw size={16} />
|
||||
{resubmitMut.isPending ? 'Moving to draft…' : 'Resubmit (back to draft)'}
|
||||
</button>
|
||||
)}
|
||||
{canSubmit && (
|
||||
<button
|
||||
onClick={() => submitMut.mutate()}
|
||||
@@ -381,6 +428,22 @@ export default function OrderDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.status === 'rejected' && order.rejection_reason && (
|
||||
<div className="card p-4 mb-6 bg-amber-50 border-amber-300 border flex items-start gap-3">
|
||||
<XCircle size={18} className="text-red-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-red-700">Order Rejected</p>
|
||||
<p className="text-sm text-amber-800 mt-0.5">{order.rejection_reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{order.status === 'rejected' && !order.rejection_reason && (
|
||||
<div className="card p-4 mb-6 bg-red-50 border-red-200 border flex items-center gap-3">
|
||||
<XCircle size={18} className="text-red-500 shrink-0" />
|
||||
<p className="text-sm font-semibold text-red-700">This order was rejected.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order.notes && (
|
||||
<div className="card p-4 mb-6 bg-status-info-bg border-border-default">
|
||||
<p className="text-sm text-status-info-text">{order.notes}</p>
|
||||
@@ -688,6 +751,64 @@ export default function OrderDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reject Order Modal */}
|
||||
{rejectModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-surface rounded-xl shadow-2xl border border-border-default w-full max-w-md mx-4 p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<XCircle size={20} className="text-red-500 shrink-0" />
|
||||
<h2 className="text-lg font-semibold text-content">Reject Order</h2>
|
||||
<button
|
||||
onClick={() => { setRejectModalOpen(false); setRejectReason('') }}
|
||||
className="ml-auto p-1 text-content-muted hover:text-content transition-colors rounded"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-content-secondary mb-4">
|
||||
Rejecting <span className="font-semibold font-mono">{order.order_number}</span> will
|
||||
cancel all pending renders and move the order to “rejected” status.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<label className="block text-xs font-medium text-content-secondary mb-1.5">
|
||||
Rejection reason (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
placeholder="Describe why this order is being rejected…"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 text-sm border border-border-default rounded-lg bg-surface-alt focus:outline-none focus:ring-2 focus:ring-accent resize-none"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-content-secondary mb-5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rejectNotifyClient}
|
||||
onChange={(e) => setRejectNotifyClient(e.target.checked)}
|
||||
className="w-4 h-4 rounded accent-accent"
|
||||
/>
|
||||
Notify the order creator
|
||||
</label>
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setRejectModalOpen(false); setRejectReason('') }}
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectMut.mutate()}
|
||||
disabled={rejectMut.isPending}
|
||||
className="px-4 py-2 rounded-lg text-sm font-semibold bg-red-600 hover:bg-red-700 text-white transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<XCircle size={15} />
|
||||
{rejectMut.isPending ? 'Rejecting…' : 'Confirm Rejection'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -711,7 +711,12 @@ function KanbanCard({
|
||||
{rp && rp.total > 0 && (
|
||||
<RenderProgressBar progress={rp} />
|
||||
)}
|
||||
{order.notes && !rp && (
|
||||
{order.status === 'rejected' && order.rejection_reason && (
|
||||
<p className="mt-2 text-xs text-red-500 italic truncate" title={order.rejection_reason}>
|
||||
{order.rejection_reason}
|
||||
</p>
|
||||
)}
|
||||
{order.notes && !rp && order.status !== 'rejected' && (
|
||||
<p className="mt-2 text-xs text-content-muted truncate">{order.notes}</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
Reference in New Issue
Block a user