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:
2026-03-08 20:37:05 +01:00
parent 596360e507
commit 1cc10d4bbb
7 changed files with 334 additions and 4 deletions
@@ -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")
+171 -2
View File
@@ -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,
+1
View File
@@ -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
+6
View File
@@ -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
+2
View File
@@ -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",
]