0.2.25: batch historical eNAV backfill + collapsible investor chart
Add POST /api/import/capital-accounts/batch: upload several of a fund's eNAV workbooks at once; each file's ALLOC SI roster is auto-matched to existing members (by fund-admin investor ID, else name/username) and their capital statement is saved at that file's own as-of date, building trend-lines without replacing the latest figures. Members not already in the portal are skipped and reported per file (never created). Capital statements only -- holdings/NAV are untouched. One bad file (wrong password, no ALLOC SI, unreadable date) is reported per-file and does not abort the rest. Import page gains a "Backfill historical capital" batch section (fund picker, multi-file .xlsx input, shared password, per-file results table). Investor portal "Capital over time" chart is now collapsed by default and expands per fund (first login opens clean); applies to InvestorHome and the admin Investor View via the shared component. Tests: backend/tests/test_capital_batch.py (2). Full suite 17 passed; frontend tsc clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
33776d42f4
commit
099459b2f3
@@ -27,6 +27,7 @@ from ten31portal.models import (
|
||||
)
|
||||
from ten31portal.routers.import_router import _open_workbook, _enav_as_of
|
||||
from ten31portal.schemas import (
|
||||
BatchCapitalFileResult, BatchCapitalImportResult,
|
||||
CapitalImportCommit, CapitalImportPreview, ImportInvestorPreview, ImportValueRow,
|
||||
)
|
||||
|
||||
@@ -346,3 +347,118 @@ def commit_import(
|
||||
"matched_accounts_updated": updated_accounts,
|
||||
"statements_written": statements,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def batch_import(
|
||||
files: list[UploadFile] = File(...),
|
||||
entity_id: int = Form(...),
|
||||
password: str | None = Form(None),
|
||||
admin: User = Depends(require_internal_admin),
|
||||
session: Session = Depends(get_session),
|
||||
) -> BatchCapitalImportResult:
|
||||
"""Backfill several quarters of capital history for one fund from a batch of eNAV files.
|
||||
|
||||
Each file is a full eNAV workbook; its ALLOC SI roster is auto-matched (by fund-admin
|
||||
investor ID, else name/username) against existing members and each member's capital
|
||||
statement is upserted at the file's own as-of date — so older files add historical points
|
||||
without touching the latest. Members not already in the system are skipped and reported
|
||||
(no account creation). One file failing (bad password, no ALLOC SI, unreadable date) is
|
||||
reported per-file and does not abort the rest.
|
||||
"""
|
||||
entity = session.get(Entity, entity_id)
|
||||
if entity is None:
|
||||
raise HTTPException(status_code=404, detail="Entity not found")
|
||||
|
||||
investors = session.exec(select(User).where(User.role == UserRole.investor)).all()
|
||||
by_name = {u.name.strip().lower(): u for u in investors}
|
||||
by_username = {u.username.strip().lower(): u for u in investors}
|
||||
by_extid = {u.external_investor_id: u for u in investors if u.external_investor_id}
|
||||
|
||||
def match_for(name: str, external_id: str | None) -> User | None:
|
||||
if external_id and external_id in by_extid:
|
||||
return by_extid[external_id]
|
||||
return by_name.get(name.strip().lower()) or by_username.get(name.strip().lower())
|
||||
|
||||
cents = lambda d: round((d or 0.0) * 100)
|
||||
results: list[BatchCapitalFileResult] = []
|
||||
total_statements = 0
|
||||
|
||||
for upload in files:
|
||||
fname = upload.filename or "(unnamed)"
|
||||
res = BatchCapitalFileResult(filename=fname)
|
||||
try:
|
||||
wb = _open_workbook(upload.file.read(), password)
|
||||
if "ALLOC SI" not in wb.sheetnames:
|
||||
raise HTTPException(status_code=422, detail="No ALLOC SI tab found in this workbook.")
|
||||
as_of, roster = _parse_alloc_si(wb)
|
||||
if as_of is None:
|
||||
raise HTTPException(status_code=422, detail="Could not determine the as-of date from the file.")
|
||||
res.as_of_date = as_of
|
||||
|
||||
for inv in roster:
|
||||
user = match_for(inv["name"], inv["external_id"])
|
||||
if user is None:
|
||||
res.skipped.append(inv["name"])
|
||||
continue
|
||||
|
||||
# Record the fund-admin ID on first sighting so later files match by ID too.
|
||||
if inv["external_id"] and not user.external_investor_id:
|
||||
user.external_investor_id = inv["external_id"]
|
||||
by_extid[inv["external_id"]] = user
|
||||
session.add(user)
|
||||
|
||||
has_access = session.exec(
|
||||
select(EntityAccess).where(
|
||||
EntityAccess.user_id == user.id, EntityAccess.entity_id == entity_id
|
||||
)
|
||||
).first()
|
||||
if has_access is None:
|
||||
session.add(EntityAccess(user_id=user.id, entity_id=entity_id))
|
||||
|
||||
existing = session.exec(
|
||||
select(CapitalAccountStatement).where(
|
||||
CapitalAccountStatement.entity_id == entity_id,
|
||||
CapitalAccountStatement.investor_user_id == user.id,
|
||||
CapitalAccountStatement.as_of_date == as_of,
|
||||
)
|
||||
).first()
|
||||
if existing:
|
||||
existing.commitment_cents = cents(inv["commitment"])
|
||||
existing.contributions_cents = cents(inv["contributions"])
|
||||
existing.distributions_cents = cents(inv["distributions"])
|
||||
existing.ending_balance_cents = cents(inv["ending"])
|
||||
session.add(existing)
|
||||
res.updated += 1
|
||||
else:
|
||||
session.add(CapitalAccountStatement(
|
||||
entity_id=entity_id,
|
||||
investor_user_id=user.id,
|
||||
as_of_date=as_of,
|
||||
commitment_cents=cents(inv["commitment"]),
|
||||
contributions_cents=cents(inv["contributions"]),
|
||||
distributions_cents=cents(inv["distributions"]),
|
||||
ending_balance_cents=cents(inv["ending"]),
|
||||
))
|
||||
res.matched += 1
|
||||
res.statements_written += 1
|
||||
|
||||
record_audit(session, admin.id, "import_batch", "capital_account", entity_id, {
|
||||
"file": fname,
|
||||
"as_of_date": str(as_of),
|
||||
"matched": res.matched,
|
||||
"skipped": len(res.skipped),
|
||||
})
|
||||
session.commit()
|
||||
total_statements += res.statements_written
|
||||
except HTTPException as e:
|
||||
session.rollback()
|
||||
res.error = e.detail
|
||||
except Exception as e: # noqa: BLE001 — surface any parse failure per-file, keep going
|
||||
session.rollback()
|
||||
res.error = str(e) or "Could not process this file."
|
||||
results.append(res)
|
||||
|
||||
return BatchCapitalImportResult(
|
||||
entity_id=entity_id, files=results, total_statements=total_statements
|
||||
)
|
||||
|
||||
@@ -320,6 +320,24 @@ class CapitalImportCommit(BaseModel):
|
||||
investors: list[ImportCommitInvestor]
|
||||
|
||||
|
||||
# --- Batch historical capital backfill (one eNAV file per quarter, auto-matched) ---
|
||||
|
||||
class BatchCapitalFileResult(BaseModel):
|
||||
filename: str
|
||||
as_of_date: date | None = None
|
||||
matched: int = 0 # existing members whose statement was written
|
||||
statements_written: int = 0 # created + updated
|
||||
updated: int = 0 # matched a statement already at this as-of date
|
||||
skipped: list[str] = [] # roster names with no existing member (not created)
|
||||
error: str | None = None # file-level failure (bad password, no ALLOC SI, etc.)
|
||||
|
||||
|
||||
class BatchCapitalImportResult(BaseModel):
|
||||
entity_id: int
|
||||
files: list[BatchCapitalFileResult]
|
||||
total_statements: int
|
||||
|
||||
|
||||
# --- Entity stakes (a GP/mgmt entity's interest in the funds it manages) ---
|
||||
|
||||
class EntityStakeCreate(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user