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