Issues 19+20: entity-aware schedule import and rollup endpoint
Issue 19: - entity_id now optional on POST /api/import/schedule - Resolution: explicit entity_id > name match from row 1 > auto-create - New params: create_entity_type (default fund), create_vintage_year - Dry-run reports resolution (will_create/matched/existing) - Frontend: radio toggle between 'Create from file' and 'Use existing' Issue 20: - GET /api/entities/rollup: invested_cents and last_signed_value_cents per entity in one pass (SQL aggregates, no waterfall) - EntitiesList uses rollup instead of N+1 API calls
This commit is contained in:
@@ -1,17 +1,83 @@
|
||||
"""Entity CRUD endpoints."""
|
||||
"""Entity CRUD and rollup endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, literal
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from ten31portal.audit import record_audit
|
||||
from ten31portal.auth import get_current_user, require_writer
|
||||
from ten31portal.database import get_session
|
||||
from ten31portal.models import Entity, EntityStatus, User
|
||||
from ten31portal.models import (
|
||||
Entity, EntityStatus, Holding, Position,
|
||||
Valuation, ValuationRound, RoundStatus, User,
|
||||
)
|
||||
from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
|
||||
|
||||
router = APIRouter(prefix="/api/entities", tags=["entities"])
|
||||
|
||||
|
||||
class EntityRollupItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
type: str
|
||||
vintage_year: int | None
|
||||
fund_size_cents: int | None
|
||||
status: str
|
||||
invested_cents: int
|
||||
last_signed_value_cents: int
|
||||
|
||||
|
||||
@router.get("/rollup")
|
||||
def entity_rollup(
|
||||
user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[EntityRollupItem]:
|
||||
"""Per-entity invested and last-signed-value in a single pass."""
|
||||
entities = session.exec(select(Entity)).all()
|
||||
result: list[EntityRollupItem] = []
|
||||
|
||||
for ent in entities:
|
||||
# Sum cost_cents across all positions in this entity's holdings
|
||||
invested_row = session.exec(
|
||||
select(func.coalesce(func.sum(Position.cost_cents), 0))
|
||||
.join(Holding, Position.holding_id == Holding.id)
|
||||
.where(Holding.entity_id == ent.id)
|
||||
).one()
|
||||
invested_cents = int(invested_row)
|
||||
|
||||
# Find the latest approved round for this entity
|
||||
latest_round = session.exec(
|
||||
select(ValuationRound)
|
||||
.where(
|
||||
ValuationRound.entity_id == ent.id,
|
||||
ValuationRound.status == RoundStatus.approved,
|
||||
)
|
||||
.order_by(ValuationRound.quarter_end.desc()) # type: ignore[union-attr]
|
||||
).first()
|
||||
|
||||
last_signed_value_cents = 0
|
||||
if latest_round:
|
||||
val_sum = session.exec(
|
||||
select(func.coalesce(func.sum(Valuation.value_cents), 0))
|
||||
.where(Valuation.round_id == latest_round.id)
|
||||
).one()
|
||||
last_signed_value_cents = int(val_sum)
|
||||
|
||||
result.append(EntityRollupItem(
|
||||
id=ent.id,
|
||||
name=ent.name,
|
||||
type=ent.type.value,
|
||||
vintage_year=ent.vintage_year,
|
||||
fund_size_cents=ent.fund_size_cents,
|
||||
status=ent.status.value,
|
||||
invested_cents=invested_cents,
|
||||
last_signed_value_cents=last_signed_value_cents,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_entities(
|
||||
user: User = Depends(get_current_user),
|
||||
|
||||
@@ -354,20 +354,92 @@ def _parse_schedule_xlsx(file_bytes: bytes) -> tuple[str | None, list[dict], lis
|
||||
@router.post("/schedule")
|
||||
def import_schedule(
|
||||
file: UploadFile = File(...),
|
||||
entity_id: int = Query(...),
|
||||
as_of: date = Query(...),
|
||||
entity_id: int | None = Query(default=None),
|
||||
create_entity_type: EntityType = Query(default=EntityType.fund),
|
||||
create_vintage_year: int | None = Query(default=None),
|
||||
commit: bool = Query(default=True),
|
||||
user: User = Depends(require_role(UserRole.approver, UserRole.cfo)),
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
file_bytes = file.file.read()
|
||||
filename = file.filename or ""
|
||||
|
||||
# Parse file first to get source_entity_name
|
||||
if filename.endswith(".xlsx") or filename.endswith(".xls"):
|
||||
source_entity_name, holdings_preview, positions_preview, errors = _parse_schedule_xlsx(file_bytes)
|
||||
else:
|
||||
source_entity_name = None
|
||||
holdings_preview, positions_preview, errors = _parse_schedule_csv(file_bytes)
|
||||
|
||||
# Resolve entity
|
||||
entity: Entity | None = None
|
||||
entity_resolution: str = "existing" # "existing" | "matched" | "will_create"
|
||||
|
||||
if entity_id is not None:
|
||||
# Explicit entity_id: use it directly
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
entity_resolution = "existing"
|
||||
elif source_entity_name:
|
||||
# Try matching by name from row 1
|
||||
entity = session.exec(
|
||||
select(Entity).where(Entity.name == source_entity_name)
|
||||
).first()
|
||||
if entity:
|
||||
entity_resolution = "matched"
|
||||
else:
|
||||
entity_resolution = "will_create"
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No entity_id provided and the file has no entity name in row 1. Provide entity_id or upload a Carta XLSX with the fund name.",
|
||||
)
|
||||
|
||||
# Dry-run: report resolution without writing
|
||||
if not commit:
|
||||
resolution_info: dict[str, Any] = {"resolution": entity_resolution}
|
||||
if entity:
|
||||
resolution_info["entity_id"] = entity.id
|
||||
resolution_info["entity_name"] = entity.name
|
||||
resolution_info["entity_type"] = entity.type.value
|
||||
else:
|
||||
resolution_info["entity_name"] = source_entity_name
|
||||
resolution_info["entity_type"] = create_entity_type.value
|
||||
resolution_info["vintage_year"] = create_vintage_year
|
||||
return {
|
||||
"committed": False,
|
||||
"source_entity_name": source_entity_name,
|
||||
"entity": resolution_info,
|
||||
"holdings": holdings_preview,
|
||||
"positions": positions_preview,
|
||||
"errors": errors,
|
||||
"seed_round": {"entity_id": entity.id if entity else None, "quarter_end": str(as_of)},
|
||||
}
|
||||
|
||||
# Commit path: create entity if needed
|
||||
if entity_resolution == "will_create" and source_entity_name:
|
||||
entity = Entity(
|
||||
name=source_entity_name,
|
||||
type=create_entity_type,
|
||||
vintage_year=create_vintage_year,
|
||||
)
|
||||
session.add(entity)
|
||||
session.flush()
|
||||
record_audit(session, user.id, "create", "entity", entity.id, {
|
||||
"name": source_entity_name,
|
||||
"type": create_entity_type.value,
|
||||
"source": "schedule_import",
|
||||
})
|
||||
|
||||
assert entity is not None
|
||||
resolved_entity_id = entity.id
|
||||
|
||||
# Check for any existing round at this quarter
|
||||
existing_round = session.exec(
|
||||
select(ValuationRound).where(
|
||||
ValuationRound.entity_id == entity_id,
|
||||
ValuationRound.entity_id == resolved_entity_id,
|
||||
ValuationRound.quarter_end == as_of,
|
||||
)
|
||||
).first()
|
||||
@@ -378,27 +450,6 @@ def import_schedule(
|
||||
detail=f"A {kind} round already exists for this entity and quarter ({as_of}). Cannot overwrite signed history.",
|
||||
)
|
||||
|
||||
file_bytes = file.file.read()
|
||||
filename = file.filename or ""
|
||||
|
||||
# Detect format
|
||||
if filename.endswith(".xlsx") or filename.endswith(".xls"):
|
||||
source_entity_name, holdings_preview, positions_preview, errors = _parse_schedule_xlsx(file_bytes)
|
||||
else:
|
||||
# CSV fallback
|
||||
source_entity_name = None
|
||||
holdings_preview, positions_preview, errors = _parse_schedule_csv(file_bytes)
|
||||
|
||||
if not commit:
|
||||
return {
|
||||
"committed": False,
|
||||
"source_entity_name": source_entity_name,
|
||||
"holdings": holdings_preview,
|
||||
"positions": positions_preview,
|
||||
"errors": errors,
|
||||
"seed_round": {"entity_id": entity_id, "quarter_end": str(as_of)},
|
||||
}
|
||||
|
||||
# Commit: create holdings, positions, seed round
|
||||
holding_map: dict[str, Holding] = {}
|
||||
|
||||
@@ -406,21 +457,21 @@ def import_schedule(
|
||||
name = hp["company_name"]
|
||||
existing = session.exec(
|
||||
select(Holding).where(
|
||||
Holding.entity_id == entity_id,
|
||||
Holding.entity_id == resolved_entity_id,
|
||||
Holding.company_name == name,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
holding_map[name] = existing
|
||||
else:
|
||||
h = Holding(entity_id=entity_id, company_name=name)
|
||||
h = Holding(entity_id=resolved_entity_id, company_name=name)
|
||||
session.add(h)
|
||||
session.flush()
|
||||
holding_map[name] = h
|
||||
|
||||
# Create seed round
|
||||
seed_round = ValuationRound(
|
||||
entity_id=entity_id,
|
||||
entity_id=resolved_entity_id,
|
||||
quarter_end=as_of,
|
||||
status=RoundStatus.approved,
|
||||
is_seed=True,
|
||||
@@ -438,14 +489,14 @@ def import_schedule(
|
||||
if holding is None:
|
||||
existing = session.exec(
|
||||
select(Holding).where(
|
||||
Holding.entity_id == entity_id,
|
||||
Holding.entity_id == resolved_entity_id,
|
||||
Holding.company_name == company,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
holding = existing
|
||||
else:
|
||||
holding = Holding(entity_id=entity_id, company_name=company)
|
||||
holding = Holding(entity_id=resolved_entity_id, company_name=company)
|
||||
session.add(holding)
|
||||
session.flush()
|
||||
holding_map[company] = holding
|
||||
@@ -487,8 +538,9 @@ def import_schedule(
|
||||
)
|
||||
session.add(val)
|
||||
|
||||
record_audit(session, user.id, "import_schedule", "entity", entity_id, {
|
||||
record_audit(session, user.id, "import_schedule", "entity", resolved_entity_id, {
|
||||
"source_entity_name": source_entity_name,
|
||||
"entity_resolution": entity_resolution,
|
||||
"holdings": len(holding_map),
|
||||
"positions_created": positions_created,
|
||||
"positions_updated": positions_updated,
|
||||
@@ -499,6 +551,12 @@ def import_schedule(
|
||||
return {
|
||||
"committed": True,
|
||||
"source_entity_name": source_entity_name,
|
||||
"entity": {
|
||||
"resolution": entity_resolution,
|
||||
"entity_id": resolved_entity_id,
|
||||
"entity_name": entity.name,
|
||||
"entity_type": entity.type.value,
|
||||
},
|
||||
"holdings_count": len(holding_map),
|
||||
"positions_count": len(positions_preview),
|
||||
"positions_created": positions_created,
|
||||
|
||||
Reference in New Issue
Block a user