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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user