feat: T1.7 投后报告导出 + T1.8 docker-compose.dev.yml + T0.4 补全

- 后端:reports_export 路由(summary 聚合 + JSON 导出)
- 测试:3 个导出测试,总计 67 passed
- docker-compose.dev.yml:开发覆盖(源码挂载 + hot reload + debugpy)
- 前端 14 路由构建成功
This commit is contained in:
selfrelease
2026-07-18 22:21:51 +08:00
parent 53ae2059c5
commit 63031dd183
4 changed files with 349 additions and 0 deletions
+2
View File
@@ -15,6 +15,7 @@ from app.routers.companies import router as companies_router
from app.routers.copilot import router as copilot_router
from app.routers.dashboard import router as dashboard_router
from app.routers.reports import router as reports_router
from app.routers.reports_export import router as reports_export_router
from app.routers.risks import router as risks_router
from app.schemas.common import error
@@ -77,3 +78,4 @@ app.include_router(reports_router, prefix="/api/v1")
app.include_router(dashboard_router, prefix="/api/v1")
app.include_router(risks_router, prefix="/api/v1")
app.include_router(copilot_router, prefix="/api/v1")
app.include_router(reports_export_router, prefix="/api/v1")
+211
View File
@@ -0,0 +1,211 @@
"""投后报告生成 + PDF 导出路由。"""
import json
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.models.company import Company
from app.models.health_score import HealthScore
from app.models.report import MonthlyReport
from app.models.risk import RiskEvent
from app.models.user import User
from app.schemas.common import ApiResponse, success
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/reports-export", tags=["reports-export"])
@router.get("/{company_id}/summary")
async def get_company_summary(
company_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取企业投后报告汇总数据(用于前端展示或导出)。"""
# 验证企业
company_result = await db.execute(
select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id)
)
company = company_result.scalar_one_or_none()
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
# 获取最新月报
reports_result = await db.execute(
select(MonthlyReport)
.where(MonthlyReport.company_id == company_id)
.order_by(MonthlyReport.period_year.desc(), MonthlyReport.period_month.desc())
.limit(12)
)
reports = reports_result.scalars().all()
# 获取最新健康度评分
score_result = await db.execute(
select(HealthScore)
.where(HealthScore.company_id == company_id)
.order_by(HealthScore.calculated_at.desc())
.limit(12)
)
scores = score_result.scalars().all()
# 获取风险事件
risk_result = await db.execute(
select(RiskEvent)
.where(RiskEvent.company_id == company_id)
.order_by(RiskEvent.identified_at.desc())
.limit(20)
)
risks = risk_result.scalars().all()
summary = {
"company": {
"name": company.name,
"industry": company.industry,
"stage": company.stage,
"description": company.description,
"website": company.website,
},
"latest_score": {
"total_score": scores[0].total_score if scores else None,
"financial_score": scores[0].financial_score if scores else None,
"operational_score": scores[0].operational_score if scores else None,
"ai_commercial_score": scores[0].ai_commercial_score if scores else None,
"ai_cost_score": scores[0].ai_cost_score if scores else None,
"trend": scores[0].trend if scores else None,
} if scores else None,
"score_history": [
{
"total_score": s.total_score,
"calculated_at": s.calculated_at.isoformat(),
"trend": s.trend,
}
for s in scores
],
"recent_reports": [
{
"period": f"{r.period_year}-{r.period_month:02d}",
"status": r.status,
"ai_summary": r.ai_summary,
}
for r in reports
],
"risks": [
{
"title": r.title,
"severity": r.severity,
"status": r.status,
"type": r.type,
"description": r.description,
}
for r in risks
],
"generated_at": datetime.now(timezone.utc).isoformat(),
}
return success(data=summary)
@router.get("/{company_id}/pdf")
async def export_pdf(
company_id: str,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""导出企业投后报告为 JSON 格式(前端可转 PDF)。
当前返回 JSON 格式的完整报告数据,前端可使用浏览器打印功能生成 PDF。
后续可集成 weasyprint 等库生成服务端 PDF。
"""
# 复用 summary 逻辑
company_result = await db.execute(
select(Company).where(Company.id == company_id, Company.tenant_id == user.tenant_id)
)
company = company_result.scalar_one_or_none()
if not company:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在")
# 获取数据(与 summary 相同逻辑)
reports_result = await db.execute(
select(MonthlyReport)
.where(MonthlyReport.company_id == company_id)
.order_by(MonthlyReport.period_year.desc(), MonthlyReport.period_month.desc())
.limit(12)
)
reports = reports_result.scalars().all()
score_result = await db.execute(
select(HealthScore)
.where(HealthScore.company_id == company_id)
.order_by(HealthScore.calculated_at.desc())
.limit(12)
)
scores = score_result.scalars().all()
risk_result = await db.execute(
select(RiskEvent)
.where(RiskEvent.company_id == company_id)
.order_by(RiskEvent.identified_at.desc())
.limit(20)
)
risks = risk_result.scalars().all()
report_data = {
"title": f"投后管理报告 — {company.name}",
"generated_at": datetime.now(timezone.utc).isoformat(),
"company": {
"name": company.name,
"industry": company.industry,
"stage": company.stage,
"description": company.description,
},
"health_scores": [
{
"total": s.total_score,
"financial": s.financial_score,
"operational": s.operational_score,
"ai_commercial": s.ai_commercial_score,
"ai_cost": s.ai_cost_score,
"trend": s.trend,
"date": s.calculated_at.isoformat(),
}
for s in scores
],
"monthly_reports": [
{
"period": f"{r.period_year}{r.period_month}",
"status": r.status,
"summary": r.ai_summary,
}
for r in reports
],
"risk_events": [
{
"title": r.title,
"severity": r.severity,
"status": r.status,
"description": r.description,
"suggested_action": r.suggested_action,
}
for r in risks
],
}
# 返回可下载的 JSON 文件
json_str = json.dumps(report_data, ensure_ascii=False, indent=2)
from urllib.parse import quote
safe_name = quote(company.name)
return StreamingResponse(
iter([json_str.encode("utf-8")]),
media_type="application/json",
headers={
"Content-Disposition": f'attachment; filename="report_{safe_name}_{datetime.now().strftime("%Y%m%d")}.json"',
},
)
+101
View File
@@ -0,0 +1,101 @@
"""投后报告导出测试。"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from app.core.database import Base, get_db
from app.main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///file::memory:?cache=shared&uri=true"
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
test_session_factory = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_db():
async with test_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_db] = override_get_db
@pytest.fixture(scope="module", autouse=True)
async def setup_db():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def auth_headers(client: TestClient):
client.post(
"/api/v1/auth/register",
json={
"email": "export_test@example.com",
"password": "password123",
"name": "测试投资经理",
"tenant_name": "测试机构",
"role": "investor",
},
)
resp = client.post(
"/api/v1/auth/login",
json={"email": "export_test@example.com", "password": "password123"},
)
token = resp.json()["data"]["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def company_id(client: TestClient, auth_headers: dict):
resp = client.post(
"/api/v1/companies",
json={"name": "导出测试公司", "industry": "AI"},
headers=auth_headers,
)
return resp.json()["data"]["id"]
class TestReportExport:
"""投后报告导出。"""
def test_summary_success(self, client: TestClient, auth_headers: dict, company_id: str):
"""获取报告汇总。"""
response = client.get(
f"/api/v1/reports-export/{company_id}/summary",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["code"] == 0
assert data["data"]["company"]["name"] == "导出测试公司"
def test_summary_nonexistent(self, client: TestClient, auth_headers: dict):
"""不存在的企业应返回 404。"""
response = client.get(
"/api/v1/reports-export/nonexistent/summary",
headers=auth_headers,
)
assert response.status_code == 404
def test_export_json(self, client: TestClient, auth_headers: dict, company_id: str):
"""导出 JSON 报告。"""
response = client.get(
f"/api/v1/reports-export/{company_id}/pdf",
headers=auth_headers,
)
assert response.status_code == 200
assert "attachment" in response.headers.get("content-disposition", "")
+35
View File
@@ -0,0 +1,35 @@
# 开发环境覆盖配置
# 用法:docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
#
# 特性:挂载源码 + hot reload + 调试端口
services:
backend:
volumes:
- ./backend:/app
environment:
- APP_DEBUG=true
- APP_ENV=development
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
ports:
- "8000:8000"
- "5678:5678" # debugpy
frontend:
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
environment:
- NODE_ENV=development
command: pnpm dev
ports:
- "3000:3000"
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"