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:
@@ -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")
|
||||
|
||||
@@ -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"',
|
||||
},
|
||||
)
|
||||
@@ -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", "")
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user