From 63031dd1838cf3f173cf81f28eec37bacf16c3bc Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 18 Jul 2026 22:21:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20T1.7=20=E6=8A=95=E5=90=8E=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E5=AF=BC=E5=87=BA=20+=20T1.8=20docker-compose.dev.yml?= =?UTF-8?q?=20+=20T0.4=20=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:reports_export 路由(summary 聚合 + JSON 导出) - 测试:3 个导出测试,总计 67 passed - docker-compose.dev.yml:开发覆盖(源码挂载 + hot reload + debugpy) - 前端 14 路由构建成功 --- backend/app/main.py | 2 + backend/app/routers/reports_export.py | 211 ++++++++++++++++++++++++++ backend/tests/test_report_export.py | 101 ++++++++++++ docker-compose.dev.yml | 35 +++++ 4 files changed, 349 insertions(+) create mode 100644 backend/app/routers/reports_export.py create mode 100644 backend/tests/test_report_export.py create mode 100644 docker-compose.dev.yml diff --git a/backend/app/main.py b/backend/app/main.py index 64f0f06..127f65c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/routers/reports_export.py b/backend/app/routers/reports_export.py new file mode 100644 index 0000000..8332aac --- /dev/null +++ b/backend/app/routers/reports_export.py @@ -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"', + }, + ) diff --git a/backend/tests/test_report_export.py b/backend/tests/test_report_export.py new file mode 100644 index 0000000..90557b7 --- /dev/null +++ b/backend/tests/test_report_export.py @@ -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", "") diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..6af2177 --- /dev/null +++ b/docker-compose.dev.yml @@ -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"