"""Termsheets nested under quotes. Unlimited per quote (constraint is per line item).""" from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, ItemTermsheet, Quote, Termsheet, TermsheetScheduleStep, ) from app.schemas.procurement import ( TermsheetCreate, TermsheetStatusUpdate, TermsheetUpdate, ) from app.services.expand_service import ( parse_expand, serialize_termsheet, serialize_termsheets, ) from app.services.quote_service import _add_event, next_step_no from app.utils.errors import ConflictError, NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) _procurement_writer = require_procurement_writer() async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: quote = await db.get(Quote, quote_id) if quote is None: raise NotFoundError("پیشنهاد (استعلام) یافت نشد") return quote async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: ts = await db.scalar( select(Termsheet).where(Termsheet.id == termsheet_id, Termsheet.quote_id == quote_id) ) if ts is None: raise NotFoundError("ترم‌شیت یافت نشد") return ts async def _insert_steps(db: AsyncSession, termsheet_id, steps) -> None: """Insert steps with auto-numbered step_no (MAX+1) when omitted.""" next_no = await next_step_no(db, termsheet_id) for index, step in enumerate(steps): step_no = step.step_no if step.step_no is not None else next_no + index db.add( TermsheetScheduleStep( termsheet_id=termsheet_id, step_no=step_no, due_date=step.due_date, amount=step.amount, note=step.note, ) ) @router.get("") async def list_termsheets( quote_id, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), status: str | None = None, sort: str | None = None, expand: str | None = None, db: AsyncSession = Depends(get_db), ): await _quote_or_404(db, quote_id) stmt = select(Termsheet).where(Termsheet.quote_id == quote_id) if status: stmt = stmt.where(Termsheet.status == status) stmt = sort_query(stmt, Termsheet, sort) items, meta = await paginate(stmt, page, page_size, db) return {"data": await serialize_termsheets(db, items, parse_expand(expand)), "meta": meta} @router.post("", status_code=201) async def create_termsheet( quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db), _auth=Depends(_procurement_writer), ): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": raise ConflictError("ترم‌شیت فقط در پیشنهادهای offered قابل افزودن است") if not payload.schedule_steps: raise ValidationError("ترم‌شیت باید حداقل یک مرحله پرداخت داشته باشد", field="schedule_steps") ts = Termsheet( quote_id=quote.id, label=payload.label, status="offered", discount_type=payload.discount_type, discount_value=payload.discount_value, ) db.add(ts) await db.flush() await _insert_steps(db, ts.id, payload.schedule_steps) return {"data": await serialize_termsheet(db, ts, set())} @router.get("/{termsheet_id}") async def get_termsheet( quote_id, termsheet_id, expand: str | None = None, db: AsyncSession = Depends(get_db) ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) return {"data": await serialize_termsheet(db, ts, parse_expand(expand))} @router.patch("/{termsheet_id}") async def update_termsheet( quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db), _auth=Depends(_procurement_writer), ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) if ts.status != "offered": raise ConflictError("ترم‌شیت‌های پذیرفته‌شده یا ردشده قابل ویرایش نیستند") if payload.label is not None: ts.label = payload.label if payload.discount_type is not None: ts.discount_type = payload.discount_type if payload.discount_value is not None: ts.discount_value = payload.discount_value await db.flush() return {"data": await serialize_termsheet(db, ts, set())} @router.patch("/{termsheet_id}/status") async def update_termsheet_status( quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db), _auth=Depends(_procurement_writer), ): async with db.begin(): ts = await _termsheet_or_404(db, quote_id, termsheet_id) if ts.status != "offered": raise ConflictError("فقط ترم‌شیت‌های offered تغییر وضعیت می‌دهند") ts.status = payload.status # offered -> accepted | offered -> rejected (multiple OK) _add_event(db, quote_id, "edited", {"action": "termsheet_status", "termsheet_id": str(ts.id), "status": ts.status}) return {"data": await serialize_termsheet(db, ts, set())} @router.delete("/{termsheet_id}") async def delete_termsheet( quote_id, termsheet_id, db: AsyncSession = Depends(get_db), _auth=Depends(_procurement_writer), ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) quote = await _quote_or_404(db, quote_id) if quote.status != "offered": raise ConflictError("ترم‌شیت فقط در پیشنهادهای offered قابل حذف است") count = await db.scalar(select(func.count()).select_from(Termsheet).where(Termsheet.quote_id == quote_id)) if count <= 1: raise ConflictError("آخرین ترم‌شیت پیشنهاد قابل حذف نیست") chosen = await db.scalar( select(ItemDecision.id) .join(ItemTermsheet, ItemDecision.chosen_item_termsheet_id == ItemTermsheet.id) .where(ItemTermsheet.termsheet_id == termsheet_id, ItemDecision.status == "chosen") .limit(1) ) if chosen: raise ConflictError("ترم‌شیتی که قلم انتخاب‌شده دارد قابل حذف نیست") await db.delete(ts) # steps + item_termsheets cascade via DDL await db.flush() return {"data": {"deleted": True}}