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:
Johnny 5
2026-06-08 03:02:59 +00:00
parent 7c33693eab
commit 30d2284feb
5 changed files with 302 additions and 80 deletions
+68 -2
View File
@@ -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),
+90 -32
View File
@@ -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,