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 fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, literal
from sqlmodel import Session, select from sqlmodel import Session, select
from ten31portal.audit import record_audit from ten31portal.audit import record_audit
from ten31portal.auth import get_current_user, require_writer from ten31portal.auth import get_current_user, require_writer
from ten31portal.database import get_session 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 from ten31portal.schemas import EntityCreate, EntityResponse, EntityUpdate
router = APIRouter(prefix="/api/entities", tags=["entities"]) 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("") @router.get("")
def list_entities( def list_entities(
user: User = Depends(get_current_user), 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") @router.post("/schedule")
def import_schedule( def import_schedule(
file: UploadFile = File(...), file: UploadFile = File(...),
entity_id: int = Query(...),
as_of: date = 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), commit: bool = Query(default=True),
user: User = Depends(require_role(UserRole.approver, UserRole.cfo)), user: User = Depends(require_role(UserRole.approver, UserRole.cfo)),
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
entity = session.get(Entity, entity_id) file_bytes = file.file.read()
if entity is None: filename = file.filename or ""
raise HTTPException(status_code=404, detail="Entity not found")
# 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 # Check for any existing round at this quarter
existing_round = session.exec( existing_round = session.exec(
select(ValuationRound).where( select(ValuationRound).where(
ValuationRound.entity_id == entity_id, ValuationRound.entity_id == resolved_entity_id,
ValuationRound.quarter_end == as_of, ValuationRound.quarter_end == as_of,
) )
).first() ).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.", 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 # Commit: create holdings, positions, seed round
holding_map: dict[str, Holding] = {} holding_map: dict[str, Holding] = {}
@@ -406,21 +457,21 @@ def import_schedule(
name = hp["company_name"] name = hp["company_name"]
existing = session.exec( existing = session.exec(
select(Holding).where( select(Holding).where(
Holding.entity_id == entity_id, Holding.entity_id == resolved_entity_id,
Holding.company_name == name, Holding.company_name == name,
) )
).first() ).first()
if existing: if existing:
holding_map[name] = existing holding_map[name] = existing
else: else:
h = Holding(entity_id=entity_id, company_name=name) h = Holding(entity_id=resolved_entity_id, company_name=name)
session.add(h) session.add(h)
session.flush() session.flush()
holding_map[name] = h holding_map[name] = h
# Create seed round # Create seed round
seed_round = ValuationRound( seed_round = ValuationRound(
entity_id=entity_id, entity_id=resolved_entity_id,
quarter_end=as_of, quarter_end=as_of,
status=RoundStatus.approved, status=RoundStatus.approved,
is_seed=True, is_seed=True,
@@ -438,14 +489,14 @@ def import_schedule(
if holding is None: if holding is None:
existing = session.exec( existing = session.exec(
select(Holding).where( select(Holding).where(
Holding.entity_id == entity_id, Holding.entity_id == resolved_entity_id,
Holding.company_name == company, Holding.company_name == company,
) )
).first() ).first()
if existing: if existing:
holding = existing holding = existing
else: else:
holding = Holding(entity_id=entity_id, company_name=company) holding = Holding(entity_id=resolved_entity_id, company_name=company)
session.add(holding) session.add(holding)
session.flush() session.flush()
holding_map[company] = holding holding_map[company] = holding
@@ -487,8 +538,9 @@ def import_schedule(
) )
session.add(val) 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, "source_entity_name": source_entity_name,
"entity_resolution": entity_resolution,
"holdings": len(holding_map), "holdings": len(holding_map),
"positions_created": positions_created, "positions_created": positions_created,
"positions_updated": positions_updated, "positions_updated": positions_updated,
@@ -499,6 +551,12 @@ def import_schedule(
return { return {
"committed": True, "committed": True,
"source_entity_name": source_entity_name, "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), "holdings_count": len(holding_map),
"positions_count": len(positions_preview), "positions_count": len(positions_preview),
"positions_created": positions_created, "positions_created": positions_created,
+11
View File
@@ -118,6 +118,17 @@ export const api = {
// Entities // Entities
listEntities: () => request<Entity[]>("/api/entities"), listEntities: () => request<Entity[]>("/api/entities"),
listEntityRollup: () =>
request<{
id: number;
name: string;
type: EntityType;
vintage_year: number | null;
fund_size_cents: number | null;
status: EntityStatus;
invested_cents: number;
last_signed_value_cents: number;
}[]>("/api/entities/rollup"),
getEntity: (id: number) => request<Entity>(`/api/entities/${id}`), getEntity: (id: number) => request<Entity>(`/api/entities/${id}`),
createEntity: (data: Partial<Entity>) => createEntity: (data: Partial<Entity>) =>
request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }), request<Entity>("/api/entities", { method: "POST", body: JSON.stringify(data) }),
+21 -28
View File
@@ -1,10 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { api, type Entity, type EntityType } from "../api"; import { api, type EntityType } from "../api";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import { formatMoney, formatGainLoss } from "../format"; import { formatMoney, formatGainLoss } from "../format";
interface EntityRow extends Entity { interface EntityRow {
id: number;
name: string;
type: string;
vintage_year: number | null;
fund_size_cents: number | null;
status: string;
investedCents: number; investedCents: number;
lastValueCents: number; lastValueCents: number;
} }
@@ -33,32 +39,19 @@ export default function EntitiesList() {
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
try { try {
const ents = await api.listEntities(); const rollup = await api.listEntityRollup();
const enriched: EntityRow[] = []; setEntities(
rollup.map((r) => ({
for (const ent of ents) { id: r.id,
const holdings = await api.listHoldings(ent.id); name: r.name,
let investedCents = 0; type: r.type,
let lastValueCents = 0; vintage_year: r.vintage_year,
fund_size_cents: r.fund_size_cents,
for (const h of holdings) { status: r.status,
const positions = await api.listPositions(h.id); investedCents: r.invested_cents,
for (const p of positions) { lastValueCents: r.last_signed_value_cents,
investedCents += p.cost_cents; })),
} );
}
const rounds = await api.listRounds(ent.id);
const approved = rounds.filter((r) => r.status === "approved");
if (approved.length > 0) {
const latest = approved[0];
lastValueCents = latest.valuations.reduce((sum, v) => sum + v.value_cents, 0);
}
enriched.push({ ...ent, investedCents, lastValueCents });
}
setEntities(enriched);
} finally { } finally {
setLoading(false); setLoading(false);
} }
+112 -18
View File
@@ -1,11 +1,16 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api, type Entity } from "../api"; import { api, type Entity, type EntityType } from "../api";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
type EntityMode = "existing" | "from_file";
export default function Import() { export default function Import() {
const { user } = useAuth(); const { user } = useAuth();
const [entities, setEntities] = useState<Entity[]>([]); const [entities, setEntities] = useState<Entity[]>([]);
const [entityMode, setEntityMode] = useState<EntityMode>("from_file");
const [entityId, setEntityId] = useState(""); const [entityId, setEntityId] = useState("");
const [createType, setCreateType] = useState<EntityType>("fund");
const [vintageYear, setVintageYear] = useState("");
const [asOf, setAsOf] = useState(""); const [asOf, setAsOf] = useState("");
const [mode, setMode] = useState<"entities" | "schedule">("schedule"); const [mode, setMode] = useState<"entities" | "schedule">("schedule");
const [commit, setCommit] = useState(false); const [commit, setCommit] = useState(false);
@@ -38,13 +43,25 @@ export default function Import() {
if (mode === "entities") { if (mode === "entities") {
url = `/api/import/entities?${params}`; url = `/api/import/entities?${params}`;
} else { } else {
if (!entityId || !asOf) { if (!asOf) {
setError("Select an entity and quarter-end date."); setError("Quarter-end date is required.");
setLoading(false); setLoading(false);
return; return;
} }
params.set("entity_id", entityId);
params.set("as_of", asOf); params.set("as_of", asOf);
if (entityMode === "existing") {
if (!entityId) {
setError("Select an entity.");
setLoading(false);
return;
}
params.set("entity_id", entityId);
} else {
// from_file mode: let backend resolve from row 1
params.set("create_entity_type", createType);
if (vintageYear) params.set("create_vintage_year", vintageYear);
}
url = `/api/import/schedule?${params}`; url = `/api/import/schedule?${params}`;
} }
@@ -54,6 +71,10 @@ export default function Import() {
setError(data.detail || "Import failed"); setError(data.detail || "Import failed");
} else { } else {
setResult(data); setResult(data);
// Refresh entity list after commit
if (data.committed) {
api.listEntities().then(setEntities);
}
} }
} catch (err: any) { } catch (err: any) {
setError(err.message); setError(err.message);
@@ -101,21 +122,74 @@ export default function Import() {
<div className="bg-white border border-gray-200 rounded-lg p-6 space-y-4"> <div className="bg-white border border-gray-200 rounded-lg p-6 space-y-4">
{mode === "schedule" && ( {mode === "schedule" && (
<> <>
{/* Entity resolution mode */}
<div> <div>
<label className="block text-sm text-gray-700 mb-1">Entity</label> <label className="block text-sm text-gray-700 mb-2">Entity</label>
<select <div className="flex gap-4 mb-3">
value={entityId} <label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
onChange={(e) => setEntityId(e.target.value)} <input
className="w-full px-3 py-2 border border-gray-300 rounded text-sm" type="radio"
> name="entityMode"
<option value="">Select entity...</option> checked={entityMode === "from_file"}
{entities.map((e) => ( onChange={() => setEntityMode("from_file")}
<option key={e.id} value={e.id}> className="text-orange-500"
{e.name} />
</option> Create from file
))} </label>
</select> <label className="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input
type="radio"
name="entityMode"
checked={entityMode === "existing"}
onChange={() => setEntityMode("existing")}
className="text-orange-500"
/>
Use existing entity
</label>
</div>
{entityMode === "existing" ? (
<select
value={entityId}
onChange={(e) => setEntityId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
>
<option value="">Select entity...</option>
{entities.map((e) => (
<option key={e.id} value={e.id}>
{e.name}
</option>
))}
</select>
) : (
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Type</label>
<select
value={createType}
onChange={(e) => setCreateType(e.target.value as EntityType)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
>
<option value="fund">Fund</option>
<option value="spv">SPV</option>
<option value="gp">GP</option>
<option value="mgmt_co">Mgmt Co</option>
</select>
</div>
<div className="w-32">
<label className="block text-xs text-gray-500 mb-1">Vintage Year</label>
<input
type="text"
value={vintageYear}
onChange={(e) => setVintageYear(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
placeholder="2021"
/>
</div>
</div>
)}
</div> </div>
<div> <div>
<label className="block text-sm text-gray-700 mb-1"> <label className="block text-sm text-gray-700 mb-1">
Quarter-end date (seed round) Quarter-end date (seed round)
@@ -178,7 +252,27 @@ export default function Import() {
{result.committed ? "Import Complete" : "Dry Run Preview"} {result.committed ? "Import Complete" : "Dry Run Preview"}
</h3> </h3>
{result.source_entity_name && ( {/* Entity resolution info */}
{result.entity && (
<div className="mb-3 p-3 bg-gray-50 border border-gray-200 rounded text-sm">
{result.entity.resolution === "will_create" ? (
<span className="text-purple-700">
Will create entity: <strong>{result.entity.entity_name}</strong> ({result.entity.entity_type})
{result.entity.vintage_year && `, vintage ${result.entity.vintage_year}`}
</span>
) : result.entity.resolution === "matched" ? (
<span className="text-blue-700">
Matched existing entity: <strong>{result.entity.entity_name}</strong> (#{result.entity.entity_id})
</span>
) : (
<span className="text-gray-700">
Entity: <strong>{result.entity.entity_name}</strong> (#{result.entity.entity_id})
</span>
)}
</div>
)}
{result.source_entity_name && !result.entity && (
<p className="text-sm text-gray-600 mb-2"> <p className="text-sm text-gray-600 mb-2">
Source: {result.source_entity_name} Source: {result.source_entity_name}
</p> </p>