diff --git a/backend/ten31portal/routers/entity_router.py b/backend/ten31portal/routers/entity_router.py index b4aabab..0270ce0 100644 --- a/backend/ten31portal/routers/entity_router.py +++ b/backend/ten31portal/routers/entity_router.py @@ -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), diff --git a/backend/ten31portal/routers/import_router.py b/backend/ten31portal/routers/import_router.py index 030227d..38f28a4 100644 --- a/backend/ten31portal/routers/import_router.py +++ b/backend/ten31portal/routers/import_router.py @@ -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, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c66b0c2..e15861f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -118,6 +118,17 @@ export const api = { // Entities listEntities: () => request("/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(`/api/entities/${id}`), createEntity: (data: Partial) => request("/api/entities", { method: "POST", body: JSON.stringify(data) }), diff --git a/frontend/src/pages/EntitiesList.tsx b/frontend/src/pages/EntitiesList.tsx index 79594d7..358ee24 100644 --- a/frontend/src/pages/EntitiesList.tsx +++ b/frontend/src/pages/EntitiesList.tsx @@ -1,10 +1,16 @@ import { useEffect, useState } from "react"; 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 { 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; lastValueCents: number; } @@ -33,32 +39,19 @@ export default function EntitiesList() { async function loadData() { setLoading(true); try { - const ents = await api.listEntities(); - const enriched: EntityRow[] = []; - - for (const ent of ents) { - const holdings = await api.listHoldings(ent.id); - let investedCents = 0; - let lastValueCents = 0; - - for (const h of holdings) { - const positions = await api.listPositions(h.id); - for (const p of positions) { - 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); + const rollup = await api.listEntityRollup(); + setEntities( + rollup.map((r) => ({ + id: r.id, + name: r.name, + type: r.type, + vintage_year: r.vintage_year, + fund_size_cents: r.fund_size_cents, + status: r.status, + investedCents: r.invested_cents, + lastValueCents: r.last_signed_value_cents, + })), + ); } finally { setLoading(false); } diff --git a/frontend/src/pages/Import.tsx b/frontend/src/pages/Import.tsx index 054d38a..8ab5461 100644 --- a/frontend/src/pages/Import.tsx +++ b/frontend/src/pages/Import.tsx @@ -1,11 +1,16 @@ 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"; +type EntityMode = "existing" | "from_file"; + export default function Import() { const { user } = useAuth(); const [entities, setEntities] = useState([]); + const [entityMode, setEntityMode] = useState("from_file"); const [entityId, setEntityId] = useState(""); + const [createType, setCreateType] = useState("fund"); + const [vintageYear, setVintageYear] = useState(""); const [asOf, setAsOf] = useState(""); const [mode, setMode] = useState<"entities" | "schedule">("schedule"); const [commit, setCommit] = useState(false); @@ -38,13 +43,25 @@ export default function Import() { if (mode === "entities") { url = `/api/import/entities?${params}`; } else { - if (!entityId || !asOf) { - setError("Select an entity and quarter-end date."); + if (!asOf) { + setError("Quarter-end date is required."); setLoading(false); return; } - params.set("entity_id", entityId); 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}`; } @@ -54,6 +71,10 @@ export default function Import() { setError(data.detail || "Import failed"); } else { setResult(data); + // Refresh entity list after commit + if (data.committed) { + api.listEntities().then(setEntities); + } } } catch (err: any) { setError(err.message); @@ -101,21 +122,74 @@ export default function Import() {
{mode === "schedule" && ( <> + {/* Entity resolution mode */}
- - + +
+ + +
+ + {entityMode === "existing" ? ( + + ) : ( +
+
+ + +
+
+ + setVintageYear(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded text-sm" + placeholder="2021" + /> +
+
+ )}
+