Files
ERP/backend/app/routers/quotes.py
T
whqreza 65758b5e78 fix(projects): isolate quote acceptance status per project (decision-target based)
Bug: a multi-project quote accepted for «تست ۱» only also appeared under «تست ۲»'s پذیرفته شده column, because both the Projects page and /projects/{id}/stats classified quotes by their GLOBAL status over any project link.

Rule now: a quote counts as accepted/arrived for a project ONLY if at least one non-rejected line item's decision targets that project (item_decisions.project_id). A quote merely linked as a candidate — including one accepted for a different project — is categorized exclusively under پیشنهاد شده for that project. Legacy quotes without any decision targets keep the old global-status classification.

Backend: project_stats membership now spans all link kinds (legacy mirror, quote_projects, line_item_projects, decision targets) with the per-project decision-based bucketing above; bucket VALUES follow the same isolation (accepted/arrived value counts only items targeting the project; candidate items of a still-proposal quote stay in the offered bucket); the raw path string is normalized to UUID for the Python-side comparisons. /quotes?project_id=… now matches any relevant link kind instead of only the legacy mirror column.

Frontend: ProjectsPage gains acceptedForProject/hasTargets helpers; the three columns (پیشنهاد شده / پذیرفته شده / رسیده) and their value chips use per-project items (target-project items first, then candidates), so totals no longer leak other projects' items.

Verified end-to-end (2 projects × 1 quote × 2 items, accept item-1 for P1 only): P1 accepted=1 then arrived=1 after the PM receipt; P2 stays offered=1 / accepted=0 / arrived=0 at every step; /quotes?project_id=P2 still lists the quote. All fixture data removed; 7/7 checks pass. python compile + api/frontend builds clean.
2026-09-19 15:11:57 +03:30

295 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.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"])
# --- list filter helpers ---------------------------------------------------
def _quote_category_exists(category_id) -> exists:
return exists(
select(1).where(
Supplier.id == Quote.supplier_id,
Supplier.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:
# 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 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}