"""Valuation round workflow endpoints.""" from datetime import datetime from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select, col from ten31portal.audit import record_audit from ten31portal.auth import get_current_user, require_writer, require_approver from ten31portal.database import get_session from ten31portal.models import ( Entity, Holding, Position, Valuation, ValuationRound, RoundStatus, User, ) from ten31portal.schemas import ( RoundCreate, RoundResponse, ValuationBulkUpdate, ValuationResponse, ReturnNote, ) router = APIRouter(tags=["rounds"]) def _round_response(round: ValuationRound, session: Session) -> RoundResponse: vals = session.exec(select(Valuation).where(Valuation.round_id == round.id)).all() return RoundResponse( **round.model_dump(), valuations=[ValuationResponse.model_validate(v, from_attributes=True) for v in vals], ) @router.get("/api/entities/{entity_id}/rounds") def list_rounds( entity_id: int, user: User = Depends(get_current_user), session: Session = Depends(get_session), ) -> list[RoundResponse]: entity = session.get(Entity, entity_id) if entity is None: raise HTTPException(status_code=404, detail="Entity not found") rounds = session.exec( select(ValuationRound) .where(ValuationRound.entity_id == entity_id) .order_by(col(ValuationRound.quarter_end).desc()) ).all() return [_round_response(r, session) for r in rounds] @router.get("/api/rounds/{round_id}") def get_round( round_id: int, user: User = Depends(get_current_user), session: Session = Depends(get_session), ) -> RoundResponse: round = session.get(ValuationRound, round_id) if round is None: raise HTTPException(status_code=404, detail="Round not found") return _round_response(round, session) @router.post("/api/entities/{entity_id}/rounds", status_code=201) def create_round( entity_id: int, body: RoundCreate, user: User = Depends(require_writer), session: Session = Depends(get_session), ) -> RoundResponse: """Create a draft round. Pre-populate valuations with last approved marks (carry forward).""" entity = session.get(Entity, entity_id) if entity is None: raise HTTPException(status_code=404, detail="Entity not found") # Check for existing non-returned round at this quarter existing = session.exec( select(ValuationRound).where( ValuationRound.entity_id == entity_id, ValuationRound.quarter_end == body.quarter_end, ValuationRound.status != RoundStatus.returned, ) ).first() if existing: raise HTTPException(status_code=409, detail="A round already exists for this entity and quarter.") round = ValuationRound(entity_id=entity_id, quarter_end=body.quarter_end) session.add(round) session.flush() # Get all positions for this entity holdings = session.exec(select(Holding).where(Holding.entity_id == entity_id)).all() for holding in holdings: positions = session.exec(select(Position).where(Position.holding_id == holding.id)).all() for pos in positions: # Find most recent approved value for this position last_val = session.exec( select(Valuation) .join(ValuationRound) .where( Valuation.position_id == pos.id, ValuationRound.status == RoundStatus.approved, ) .order_by(col(ValuationRound.quarter_end).desc()) ).first() val = Valuation( round_id=round.id, position_id=pos.id, value_cents=last_val.value_cents if last_val else 0, ) session.add(val) record_audit(session, user.id, "create", "round", round.id, { "entity_id": entity_id, "quarter_end": str(body.quarter_end), }) session.commit() session.refresh(round) return _round_response(round, session) @router.patch("/api/rounds/{round_id}/valuations") def bulk_update_valuations( round_id: int, body: ValuationBulkUpdate, user: User = Depends(require_writer), session: Session = Depends(get_session), ) -> RoundResponse: """Bulk set value_cents for positions in a draft or returned round.""" round = session.get(ValuationRound, round_id) if round is None: raise HTTPException(status_code=404, detail="Round not found") if round.status not in (RoundStatus.draft, RoundStatus.returned): raise HTTPException(status_code=409, detail="Can only edit valuations in draft or returned rounds.") for item in body.valuations: val = session.exec( select(Valuation).where( Valuation.round_id == round_id, Valuation.position_id == item.position_id, ) ).first() if val is None: raise HTTPException(status_code=404, detail=f"Valuation not found for position {item.position_id}") val.value_cents = item.value_cents session.add(val) record_audit(session, user.id, "update_valuations", "round", round.id, { "count": len(body.valuations), }) session.commit() session.refresh(round) return _round_response(round, session) @router.post("/api/rounds/{round_id}/submit") def submit_round( round_id: int, user: User = Depends(require_writer), session: Session = Depends(get_session), ) -> RoundResponse: round = session.get(ValuationRound, round_id) if round is None: raise HTTPException(status_code=404, detail="Round not found") if round.status not in (RoundStatus.draft, RoundStatus.returned): raise HTTPException(status_code=409, detail="Only draft or returned rounds can be submitted.") round.status = RoundStatus.submitted round.submitted_by = user.id round.submitted_at = datetime.utcnow() session.add(round) record_audit(session, user.id, "submit", "round", round.id, { "quarter_end": str(round.quarter_end), }) session.commit() session.refresh(round) return _round_response(round, session) @router.post("/api/rounds/{round_id}/approve") def approve_round( round_id: int, user: User = Depends(require_approver), session: Session = Depends(get_session), ) -> RoundResponse: round = session.get(ValuationRound, round_id) if round is None: raise HTTPException(status_code=404, detail="Round not found") if round.status != RoundStatus.submitted: raise HTTPException(status_code=409, detail="Only submitted rounds can be approved.") round.status = RoundStatus.approved round.approved_by = user.id round.approved_at = datetime.utcnow() session.add(round) # Record both roles if self-approving detail = {"quarter_end": str(round.quarter_end)} if round.submitted_by == user.id: detail["note"] = "Self-approved (submitter and approver are the same user)" record_audit(session, user.id, "approve", "round", round.id, detail) session.commit() session.refresh(round) return _round_response(round, session) @router.post("/api/rounds/{round_id}/return") def return_round( round_id: int, body: ReturnNote, user: User = Depends(require_approver), session: Session = Depends(get_session), ) -> RoundResponse: round = session.get(ValuationRound, round_id) if round is None: raise HTTPException(status_code=404, detail="Round not found") if round.status != RoundStatus.submitted: raise HTTPException(status_code=409, detail="Only submitted rounds can be returned.") round.status = RoundStatus.returned round.return_note = body.note session.add(round) record_audit(session, user.id, "return", "round", round.id, { "quarter_end": str(round.quarter_end), "note": body.note, }) session.commit() session.refresh(round) return _round_response(round, session)