Files
ERP/backend/app/routers/quotes.py
T
ZCode b469238153 refactor(catalog): reduce the category taxonomy to a supplier label
Categories are kept only as the label each supplier carries (suppliers.category_id
+ the material_categories list it points at). Everything that treated category as
a shared key across materials, inventory, quotes and unit conversions is removed —
including two features that were never populated (0 rows on every volume).

Removed (backend):
- routers category_attributes, category_ref_units, unit_conversions + registration
- ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one
  stays — it filters suppliers by their own label)
- GET /inventory/materials/options: required a category and had no caller
- the material-payload category derivation (expand_service.category_by_material,
  the inventory serializer lookup) and the material category on /pm/on-the-way;
  the wire keys stay, now always null, so the payload shape is unchanged
- startup DDL: DROP the empty category_attributes / category_reference_units /
  unit_conversions tables (idempotent) and their CREATE statements; seed.py
  truncate list updated

Removed (frontend):
- the category-keyed conversion path: refUnitForCategory / findConversion /
  refConverted, the categoryUnits + unitConversions store slices, api clients,
  hooks and the "≈ converted quantity" line on quote detail and project pages
  (it needed rows in those tables to render, and there were none)
- material.supplier_category and its read sites (the edit modal grew one
  yesterday to restore labels; the label lives on the supplier now)
- the inventory page's category chips, the row filter and the «دسته» column,
  plus the lookup's categories fetch and catName helper

Kept: supplier label + its list and picker, /suppliers?category_id=, the global
standard_conversions feature and the units page (material_units conversions).

Verified against a restore of the production dump: startup DDL drops exactly the
three tables, suppliers keep their label (3/3), material_categories still serves
its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/
standard-conversions all return 200 with quote line items intact.
2026-09-20 19:44:23 +00:00

282 lines
10 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_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)):
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}