Files
ERP/backend/app/routers/quotes.py
T
MMD 0ea0322967 Revert 253c922: restore the PM/RBAC work to the integration
Reverting the production revert so the pm branch can merge cleanly on
top of the restored RBAC base. Conflicts against newer integration work
resolved by combining:
- App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside
  the supervisor gating (no-project block screen, panel scoping)
- LoginPage: returnUrl navigation kept, supervisor routing added
- migration.sql: bot_drafts table kept, unified-projects ALTERs restored
2026-10-01 15:24:56 +03:30

325 lines
11 KiB
Python

"""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 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 NotFoundError
from app.utils.pagination import sort_query
from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"])
# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
_procurement_writer = require_role("admin", "procurement_manager")
# --- 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),
_auth=Depends(_procurement_writer),
):
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}