"""Quotes router — the dashboard ledger + full lifecycle. ⚠️ Route ordering: GET /quotes/stats is registered BEFORE /quotes/{quote_id} (contract P0-38), otherwise FastAPI would capture "stats" as a UUID path. """ from datetime import datetime from fastapi import APIRouter, Depends, Query from sqlalchemy import exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, allow_quote_create, require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Material from app.models.procurement import ( ItemDecision, LineItem, LineItemProjects, Project, Quote, QuoteProjects, Supplier, Termsheet, ) from app.schemas.procurement import ( AcceptQuoteIn, ArriveItemsIn, QuoteChildrenReplaceIn, QuoteCreate, QuoteUpdate, RejectQuoteIn, ) from app.services import quote_service from app.services.expand_service import ( decision_brief, parse_expand, serialize_quote, serialize_quote_with_children, serialize_quotes, ) from app.utils.errors import ForbiddenError, NotFoundError, ValidationError from app.utils.pagination import sort_query from app.utils.security import get_actor router = APIRouter(prefix="/quotes", tags=["quotes"]) # عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد) _procurement_writer = require_procurement_writer() # --- list filter helpers --------------------------------------------------- def _quote_search(q: str) -> exists: like = f"%{q}%" return or_( Quote.notes.ilike(like), exists(select(1).where(Supplier.id == Quote.supplier_id, Supplier.name.ilike(like))), exists(select(1).where(Project.id == Quote.project_id, Project.name.ilike(like))), exists( select(1).where( LineItem.quote_id == Quote.id, Material.id == LineItem.material_id, Material.name.ilike(like), ) ), ) # --------------------------------------------------------------------------- # GET /quotes/stats — MUST be declared before GET /quotes/{quote_id} # --------------------------------------------------------------------------- @router.get("/stats") async def get_quote_stats( supplier_id=None, project_id=None, quote_date_from: datetime | None = None, quote_date_to: datetime | None = None, db: AsyncSession = Depends(get_db), ): return { "data": await quote_service.quote_stats( db, supplier_id=supplier_id, project_id=project_id, quote_date_from=quote_date_from, quote_date_to=quote_date_to, ) } # --------------------------------------------------------------------------- # List / single # --------------------------------------------------------------------------- @router.get("") async def list_quotes( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = None, supplier_id=None, project_id=None, quote_date_from: datetime | None = None, quote_date_to: datetime | None = None, q: str | None = None, sort: str | None = None, expand: str | None = None, db: AsyncSession = Depends(get_db), ): stmt = select(Quote) if status: stmt = stmt.where(Quote.status == status) if supplier_id: stmt = stmt.where(Quote.supplier_id == supplier_id) if project_id: # any relevant link: legacy mirror, quote-level junction, line-item # candidates, or an accepted (decision) target — NOT just the mirror stmt = stmt.where( Quote.id.in_( select(Quote.id) .outerjoin(QuoteProjects, QuoteProjects.quote_id == Quote.id) .outerjoin(LineItem, LineItem.quote_id == Quote.id) .outerjoin(LineItemProjects, LineItemProjects.line_item_id == LineItem.id) .outerjoin(ItemDecision, ItemDecision.line_item_id == LineItem.id) .where( (Quote.project_id == project_id) | (QuoteProjects.project_id == project_id) | (LineItemProjects.project_id == project_id) | (ItemDecision.project_id == project_id) ) ) ) if quote_date_from is not None: stmt = stmt.where(Quote.quote_date >= quote_date_from) if quote_date_to is not None: stmt = stmt.where(Quote.quote_date <= quote_date_to) if q: stmt = stmt.where(_quote_search(q)) stmt = sort_query(stmt, Quote, sort, default="-quote_date") items, meta = await _paginate_quotes(db, stmt, page, page_size) return {"data": await serialize_quotes(db, items, parse_expand(expand)), "meta": meta} async def _paginate_quotes(db: AsyncSession, stmt, page: int, page_size: int): total = await db.scalar(select(func.count()).select_from(stmt.subquery())) items = (await db.execute(stmt.offset((page - 1) * page_size).limit(page_size))).scalars().all() return items, {"total": total or 0, "page": page, "page_size": page_size} @router.get("/{quote_id}") async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depends(get_db)): quote = await db.get(Quote, quote_id) if quote is None: raise NotFoundError("پیشنهاد (استعلام) یافت نشد") return {"data": await serialize_quote(db, quote, parse_expand(expand))} # --------------------------------------------------------------------------- # Create # --------------------------------------------------------------------------- @router.post("", status_code=201) async def create_quote( payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), ctx: UserContext = Depends(allow_quote_create()), _auth=None, ): # سطح limited: استعلام فقط برای پروژه‌های عضویت خودش if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin": allowed = set(ctx.project_ids or []) | set(ctx.member_project_ids or []) if ctx.project_id: allowed.add(ctx.project_id) allowed_str = {str(pid) for pid in allowed if pid} wanted = {str(pid) for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid} if not wanted: raise ValidationError("برای افزودن استعلام، پروژه الزامی است") if not wanted <= allowed_str: raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است") quote = await quote_service.create_quote(db, payload, actor) return {"data": await serialize_quote_with_children(db, quote)} # --------------------------------------------------------------------------- # Edit (metadata + backward transitions) # --------------------------------------------------------------------------- @router.patch("/{quote_id}") async def update_quote( quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), _auth=Depends(_procurement_writer), ): quote = await quote_service.update_quote(db, quote_id, payload, actor) return {"data": await serialize_quote(db, quote, set())} @router.patch("/{quote_id}/children") async def replace_quote_children( quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), _auth=Depends(_procurement_writer), ): """Full replacement of a quote's line items + termsheets (edit modal).""" quote = await quote_service.replace_quote_children(db, quote_id, payload, actor) return {"data": await serialize_quote_with_children(db, quote)} # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- @router.patch("/{quote_id}/accept") async def accept_quote( quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), _auth=Depends(_procurement_writer), ): quote = await quote_service.accept_quote(db, quote_id, payload, actor) return {"data": await _accept_response(db, quote)} async def _accept_response(db: AsyncSession, quote: Quote) -> dict: """Contract P0-18 response shape: id/status/accepted_date + decisions + termsheet statuses.""" line_items = (await db.execute(select(LineItem).where(LineItem.quote_id == quote.id))).scalars().all() li_ids = [li.id for li in line_items] decisions: dict = {} if li_ids: decisions = { d.line_item_id: d for d in (await db.execute(select(ItemDecision).where(ItemDecision.line_item_id.in_(li_ids)))).scalars() } termsheets = (await db.execute(select(Termsheet).where(Termsheet.quote_id == quote.id))).scalars().all() return { "id": str(quote.id), "status": quote.status, "accepted_date": quote.accepted_date.isoformat() if quote.accepted_date else None, "line_items": [ { "id": str(li.id), "decision": decision_brief(decisions[li.id]) if li.id in decisions else None, } for li in line_items ], "termsheets": [ {"id": str(ts.id), "status": ts.status} for ts in sorted(termsheets, key=lambda t: t.created_at) ], } @router.patch("/{quote_id}/arrive") async def arrive_quote( quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), _auth=Depends(_procurement_writer), ): quote = await quote_service.arrive_quote(db, quote_id, actor) return { "data": { "id": str(quote.id), "status": quote.status, "arrived_date": quote.arrived_date.isoformat() if quote.arrived_date else None, } } @router.patch("/{quote_id}/arrive-items") async def arrive_items( quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), _auth=Depends(_procurement_writer), ): quote = await quote_service.arrive_items(db, quote_id, payload, actor) return { "data": { "id": str(quote.id), "status": quote.status, "arrived_date": quote.arrived_date.isoformat() if quote.arrived_date else None, "items": [ { "line_item_id": str(item.line_item_id), "received": True, "received_qty": float(item.received_qty), } for item in payload.items ], } } @router.patch("/{quote_id}/reject") async def reject_quote( quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), _auth=Depends(_procurement_writer), ): quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor) return {"data": {"id": str(quote.id), "status": quote.status}} @router.patch("/{quote_id}/expire") async def expire_quote( quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), _auth=Depends(_procurement_writer), ): quote = await quote_service.expire_quote(db, quote_id, actor) return {"data": {"id": str(quote.id), "status": quote.status}} # --------------------------------------------------------------------------- # Delete (only offered / rejected / expired) # --------------------------------------------------------------------------- @router.delete("/{quote_id}") async def delete_quote( quote_id, db: AsyncSession = Depends(get_db), _auth=Depends(_procurement_writer), ): result = await quote_service.delete_quote(db, quote_id) return {"data": result}