"""Item termsheets — pricing links between line items and termsheets. total_price is COMPUTED (unit_price × quantity via the VIEW item_termsheets_with_total) and never accepted from the client. """ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.engine import get_db from app.models.procurement import ( ItemDecision, ItemTermsheet, ItemTermsheetsWithTotal, LineItem, Quote, Termsheet, ) from app.schemas.procurement import ItemTermsheetCreate, ItemTermsheetUpdate from app.utils.errors import ConflictError, NotFoundError router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", tags=["item-termsheets"], ) async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: li = await db.scalar( select(LineItem).where(LineItem.id == line_item_id, LineItem.quote_id == quote_id) ) if li is None: raise NotFoundError("خط آیتم یافت نشد") return li def it_to_dict(row: ItemTermsheetsWithTotal | ItemTermsheet, total_price=None) -> dict: return { "id": str(row.id), "line_item_id": str(row.line_item_id), "termsheet_id": str(row.termsheet_id), "unit_price": float(row.unit_price), "min_order_qty": float(row.min_order_qty) if row.min_order_qty is not None else None, "total_price": float(total_price) if total_price is not None else None, } @router.get("") async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): await _line_item_or_404(db, quote_id, line_item_id) rows = ( await db.execute( select(ItemTermsheetsWithTotal).where(ItemTermsheetsWithTotal.line_item_id == line_item_id) ) ).scalars().all() return {"data": [it_to_dict(r, r.total_price) for r in rows]} @router.post("", status_code=201) async def create_item_termsheet( quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db) ): async with db.begin(): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await db.get(Quote, quote_id) if quote is None or quote.status != "offered": raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل افزودن است") # termsheet must belong to the SAME quote (contract P0-35) ts = await db.scalar(select(Termsheet).where(Termsheet.id == payload.termsheet_id, Termsheet.quote_id == quote_id)) if ts is None: raise NotFoundError("ترم‌شیت یافت نشد یا متعلق به این پیشنهاد نیست", field="termsheet_id") duplicate = await db.scalar( select(ItemTermsheet.id).where( ItemTermsheet.line_item_id == line_item_id, ItemTermsheet.termsheet_id == payload.termsheet_id ) ) if duplicate is not None: raise ConflictError("این خط آیتم قبلاً به این ترم‌شیت متصل شده است") it = ItemTermsheet( line_item_id=line_item_id, termsheet_id=payload.termsheet_id, unit_price=payload.unit_price, min_order_qty=payload.min_order_qty, ) db.add(it) await db.flush() total = it.unit_price * li.quantity return {"data": it_to_dict(it, total)} @router.patch("/{item_termsheet_id}") async def update_item_termsheet( quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db) ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await db.get(Quote, quote_id) if quote is None or quote.status != "offered": raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل ویرایش است") it = await db.scalar( select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) ) if it is None: raise NotFoundError("اتصال قیمت یافت نشد") if payload.unit_price is not None: it.unit_price = payload.unit_price if payload.min_order_qty is not None: it.min_order_qty = payload.min_order_qty await db.flush() return {"data": it_to_dict(it, it.unit_price * li.quantity)} @router.delete("/{item_termsheet_id}") async def delete_item_termsheet( quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db) ): it = await db.scalar( select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) ) if it is None: raise NotFoundError("اتصال قیمت یافت نشد") quote = await db.get(Quote, quote_id) if quote is None or quote.status != "offered": raise ConflictError("اتصال قیمت فقط در پیشنهادهای offered قابل حذف است") chosen = await db.scalar( select(ItemDecision.id).where( ItemDecision.chosen_item_termsheet_id == item_termsheet_id, ItemDecision.status == "chosen" ).limit(1) ) if chosen: raise ConflictError("اتصال قیمت انتخاب‌شده قابل حذف نیست") await db.delete(it) await db.flush() return {"data": {"deleted": True}}