271 lines
9.8 KiB
Python
271 lines
9.8 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.db.engine import get_db
|
|
from app.models.catalog import Material
|
|
from app.models.procurement import ItemDecision, LineItem, Project, Quote, 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"])
|
|
|
|
|
|
# --- list filter helpers ---------------------------------------------------
|
|
|
|
def _quote_category_exists(category_id) -> exists:
|
|
return exists(
|
|
select(1).where(
|
|
LineItem.quote_id == Quote.id,
|
|
Material.id == LineItem.material_id,
|
|
Material.category_id == category_id,
|
|
)
|
|
)
|
|
|
|
|
|
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,
|
|
category_id=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,
|
|
category_id=category_id,
|
|
)
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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,
|
|
category_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:
|
|
stmt = stmt.where(Quote.project_id == project_id)
|
|
if category_id:
|
|
stmt = stmt.where(_quote_category_exists(category_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)):
|
|
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)
|
|
):
|
|
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)
|
|
):
|
|
"""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)
|
|
):
|
|
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)):
|
|
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)
|
|
):
|
|
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)
|
|
):
|
|
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)):
|
|
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)):
|
|
result = await quote_service.delete_quote(db, quote_id)
|
|
return {"data": result}
|