"""Line item specs — frozen EAV snapshot, full-replacement PUT, editable only while offered.""" from fastapi import APIRouter, Depends from sqlalchemy import 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.catalog import AttributeDefinition, Unit from app.models.procurement import LineItem, LineItemSpec, Quote from app.schemas.procurement import SpecsPutIn from app.services import material_service from app.services.eav_service import flatten_eav_values from app.utils.errors import ConflictError, NotFoundError router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) _procurement_writer = require_procurement_writer() 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 async def _flatten(db: AsyncSession, li: LineItem) -> list[dict]: rows = ( await db.execute( select(LineItemSpec) .where(LineItemSpec.line_item_id == li.id) .order_by(LineItemSpec.attribute_definition_id) ) ).scalars().all() def_ids = {row.attribute_definition_id for row in rows} defs = {} if def_ids: defs = { d.id: d for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars() } unit_ids = {d.unit_id for d in defs.values() if d.unit_id is not None} unit_ids |= {row.unit_id_at_time for row in rows if row.unit_id_at_time is not None} units = {} if unit_ids: units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} return flatten_eav_values(rows, defs, units, include_definition_id=True) @router.get("") async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): li = await _line_item_or_404(db, quote_id, line_item_id) return {"data": await _flatten(db, li)} @router.put("") async def put_line_item_specs( quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db), _auth=Depends(_procurement_writer), ): """FULL replacement — specs not in the payload are DELETED (P1-19).""" 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 قابل ویرایش است") seen: set = set() for index, spec in enumerate(payload.specs): if spec.attribute_definition_id in seen: raise ConflictError("attribute_definition_id در درخواست تکراری است", field=f"specs[{index}]") seen.add(spec.attribute_definition_id) validated = await material_service.validate_attributes(db, payload.specs) existing = ( (await db.execute(select(LineItemSpec).where(LineItemSpec.line_item_id == li.id))).scalars().all() ) for row in existing: await db.delete(row) await db.flush() for (ad, values), spec in zip(validated, payload.specs): db.add(LineItemSpec(line_item_id=li.id, attribute_definition_id=ad.id, **values, unit_id_at_time=spec.unit_id_at_time)) return {"data": await _flatten(db, li)}