"""Item decisions — 1:1 with line items. State machine (P1 cross-endpoint rules): pending -> chosen (requires chosen_item_termsheet_id of the SAME line item) pending -> rejected chosen/rejected -> pending/anything-else = 409 """ 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, LineItem from app.schemas.procurement import DecisionPatch, DecisionPut, DecisionStatusUpdate from app.services.expand_service import decision_full from app.services.quote_service import utcnow from app.utils.errors import ConflictError, NotFoundError, ValidationError router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/decision", tags=["item-decisions"], ) 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 _get_or_create_pending(db: AsyncSession, line_item_id) -> ItemDecision: decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id)) if decision is None: decision = ItemDecision(line_item_id=line_item_id, status="pending") db.add(decision) await db.flush() return decision async def _validate_chosen_link(db: AsyncSession, line_item_id, item_termsheet_id) -> None: """chosen_item_termsheet_id must reference an item_termsheets row of the SAME line item.""" if item_termsheet_id is None: raise ValidationError("برای وضعیت chosen، chosen_item_termsheet_id الزامی است", field="chosen_item_termsheet_id") it = await db.scalar( select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) ) if it is None: raise ValidationError("اتصال قیمت باید متعلق به همین خط آیتم باشد", field="chosen_item_termsheet_id") @router.get("") async def get_decision(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): """Always returns a record — pending is auto-created (P0 contract).""" await _line_item_or_404(db, quote_id, line_item_id) decision = await _get_or_create_pending(db, line_item_id) return {"data": decision_full(decision)} @router.put("") async def put_decision( quote_id, line_item_id, payload: DecisionPut, db: AsyncSession = Depends(get_db) ): """UPSERT — pending -> chosen | pending -> rejected.""" created = False async with db.begin(): await _line_item_or_404(db, quote_id, line_item_id) decision = await db.scalar( select(ItemDecision).where(ItemDecision.line_item_id == line_item_id).with_for_update() ) if decision is None: decision = ItemDecision(line_item_id=line_item_id, status="pending") db.add(decision) await db.flush() created = True if decision.status != "pending": raise ConflictError( f"بازگشت از وضعیت {decision.status} به {payload.status} مجاز نیست" ) if payload.status == "chosen": await _validate_chosen_link(db, line_item_id, payload.chosen_item_termsheet_id) decision.chosen_item_termsheet_id = payload.chosen_item_termsheet_id decision.adjusted_unit_price = payload.adjusted_unit_price decision.adjusted_notes = payload.adjusted_notes else: # rejected decision.chosen_item_termsheet_id = None decision.adjusted_unit_price = None decision.adjusted_notes = payload.adjusted_notes decision.status = payload.status decision.decided_at = utcnow() return {"data": decision_full(decision), "status_code_hint": 201 if created else 200} @router.patch("/status") async def patch_decision_status( quote_id, line_item_id, payload: DecisionStatusUpdate, db: AsyncSession = Depends(get_db) ): """Status-only transition; chosen requires chosen_item_termsheet_id already set.""" async with db.begin(): await _line_item_or_404(db, quote_id, line_item_id) decision = await db.scalar( select(ItemDecision).where(ItemDecision.line_item_id == line_item_id).with_for_update() ) if decision is None: raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است") if decision.status != "pending": raise ConflictError( f"بازگشت از وضعیت {decision.status} به {payload.status} مجاز نیست" ) if payload.status == "chosen" and decision.chosen_item_termsheet_id is None: raise ValidationError( "برای انتخاب، ابتدا باید chosen_item_termsheet_id تنظیم شود", field="chosen_item_termsheet_id" ) decision.status = payload.status decision.decided_at = utcnow() return {"data": decision_full(decision)} @router.patch("") async def patch_decision( quote_id, line_item_id, payload: DecisionPatch, db: AsyncSession = Depends(get_db) ): """Update chosen link / adjusted fields — status is NOT changeable here.""" await _line_item_or_404(db, quote_id, line_item_id) decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id)) if decision is None: raise NotFoundError("تصمیمی برای این خط آیتم ثبت نشده است") if payload.chosen_item_termsheet_id is not None: await _validate_chosen_link(db, line_item_id, payload.chosen_item_termsheet_id) decision.chosen_item_termsheet_id = payload.chosen_item_termsheet_id if payload.adjusted_unit_price is not None: decision.adjusted_unit_price = payload.adjusted_unit_price if payload.adjusted_notes is not None: decision.adjusted_notes = payload.adjusted_notes await db.flush() return {"data": decision_full(decision)}