222 lines
7.9 KiB
Python
222 lines
7.9 KiB
Python
"""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)
|