feat(backend): add FastAPI backend — procurement, inventory domain, Casdoor auth, PM arrival endpoints
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user