"""Line items nested under quotes. Editable only while the quote is offered.""" from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.db.engine import get_db from app.models.catalog import Material, Unit from app.models.procurement import ItemDecision, LineItem, Quote from app.schemas.procurement import LineItemCreate, LineItemUpdate from app.services.expand_service import parse_expand, serialize_line_items from app.services.material_service import ensure_brand_material_belong from app.services.supplier_material_service import link_supplier_material from app.utils.errors import ConflictError, NotFoundError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) 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 _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 @router.get("") async def list_line_items( quote_id, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), sort: str | None = None, expand: str | None = None, db: AsyncSession = Depends(get_db), ): await _quote_or_404(db, quote_id) stmt = select(LineItem).where(LineItem.quote_id == quote_id) stmt = sort_query(stmt, LineItem, sort, default="created_at") items, meta = await paginate(stmt, page, page_size, db) data = await serialize_line_items(db, items, parse_expand(expand)) return {"data": data, "meta": meta} @router.post("", status_code=201) async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل افزودن است") if await db.get(Material, payload.material_id) is None: raise NotFoundError("متریال یافت نشد", field="material_id") if await db.get(Unit, payload.unit_id) is None: raise NotFoundError("واحد یافت نشد", field="unit_id") await ensure_brand_material_belong(db, payload.brand_material_id, payload.material_id) li = LineItem( quote_id=quote.id, material_id=payload.material_id, brand_material_id=payload.brand_material_id, unit_id=payload.unit_id, quantity=payload.quantity, notes=payload.notes, ) db.add(li) await db.flush() # v2.5: auto-link the material to the quote's supplier (catalog edge). await link_supplier_material(db, quote.supplier_id, payload.material_id) # auto-create the 1:1 pending decision (contract P0-22) db.add(ItemDecision(line_item_id=li.id, status="pending")) return {"data": (await serialize_line_items(db, [li], set()))[0]} @router.get("/{line_item_id}") async def get_line_item( quote_id, line_item_id, expand: str | None = None, db: AsyncSession = Depends(get_db) ): li = await _line_item_or_404(db, quote_id, line_item_id) return {"data": (await serialize_line_items(db, [li], parse_expand(expand)))[0]} @router.patch("/{line_item_id}") async def update_line_item( quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db) ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await _quote_or_404(db, quote_id) if quote.status != "offered": raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل ویرایش است") new_material_id = payload.material_id if payload.material_id is not None else li.material_id if payload.material_id is not None: if await db.get(Material, payload.material_id) is None: raise NotFoundError("متریال یافت نشد", field="material_id") if payload.unit_id is not None: if await db.get(Unit, payload.unit_id) is None: raise NotFoundError("واحد یافت نشد", field="unit_id") if payload.brand_material_id is not None or payload.material_id is not None: # If only the material changed, re-validate the EXISTING brand link against it. effective_bm = payload.brand_material_id if payload.brand_material_id is not None else li.brand_material_id await ensure_brand_material_belong(db, effective_bm, new_material_id) old_material_id = li.material_id if payload.material_id is not None: li.material_id = payload.material_id # v2.5: keep the supplier<->material catalog edge in sync. await link_supplier_material(db, quote.supplier_id, payload.material_id) if payload.brand_material_id is not None: li.brand_material_id = payload.brand_material_id if payload.unit_id is not None: li.unit_id = payload.unit_id if payload.quantity is not None: li.quantity = payload.quantity if payload.notes is not None: li.notes = payload.notes await db.flush() if payload.material_id is not None and payload.material_id != old_material_id: from app.services.quote_service import cleanup_orphan_materials await cleanup_orphan_materials(db, [old_material_id]) return {"data": (await serialize_line_items(db, [li], set()))[0]} @router.delete("/{line_item_id}") async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await _quote_or_404(db, quote_id) if quote.status != "offered": raise ConflictError("خط آیتم فقط در پیشنهادهای offered قابل حذف است") decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == li.id)) if decision is not None and decision.status == "chosen": raise ConflictError("خط آیتم انتخاب‌شده قابل حذف نیست") old_material_id = li.material_id await db.delete(li) # specs / links / decision / receipt cascade via DDL await db.flush() if old_material_id: from app.services.quote_service import cleanup_orphan_materials await cleanup_orphan_materials(db, [old_material_id]) return {"data": {"id": str(line_item_id), "deleted": True}}