"""月报路由:CRUD + 提交 + AI 解析(SSE 流式)。""" import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status from fastapi.responses import StreamingResponse from sqlalchemy import func, 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 from app.schemas.report import ( MonthlyReportCreate, MonthlyReportListResponse, MonthlyReportResponse, MonthlyReportUpdate, ) from app.services.ai_parser import parse_report from app.services.health_calculator import calculate_health_score, determine_trend from app.services.report_tracker import compute_timeliness from app.services.risk_engine import detect_risks from app.services.file_parser import parse_file router = APIRouter(prefix="/reports", tags=["reports"]) async def _get_company_or_404(db: AsyncSession, company_id: str, tenant_id: str) -> Company: """验证企业属于当前租户。""" result = await db.execute( select(Company).where(Company.id == company_id, Company.tenant_id == tenant_id) ) company = result.scalar_one_or_none() if not company: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="企业不存在") return company @router.get("", response_model=ApiResponse[MonthlyReportListResponse]) async def list_reports( company_id: str | None = Query(default=None, description="按企业筛选"), page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """获取月报列表。""" # 获取当前租户的企业 ID 集合 company_query = select(Company.id).where(Company.tenant_id == user.tenant_id) if company_id: company_query = company_query.where(Company.id == company_id) query = select(MonthlyReport).where( MonthlyReport.company_id.in_(company_query) ) count_query = select(func.count()).select_from(query.subquery()) total_result = await db.execute(count_query) total = total_result.scalar_one() offset = (page - 1) * page_size query = query.order_by(MonthlyReport.created_at.desc()).offset(offset).limit(page_size) result = await db.execute(query) reports = result.scalars().all() return success( data=MonthlyReportListResponse( items=[MonthlyReportResponse.model_validate(r, from_attributes=True) for r in reports], total=total, page=page, page_size=page_size, ) ) @router.get("/{report_id}", response_model=ApiResponse[MonthlyReportResponse]) async def get_report( report_id: str, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """获取月报详情。""" result = await db.execute( select(MonthlyReport) .join(Company, MonthlyReport.company_id == Company.id) .where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id) ) report = result.scalar_one_or_none() if not report: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在") return success(data=MonthlyReportResponse.model_validate(report, from_attributes=True)) @router.post("", response_model=ApiResponse[MonthlyReportResponse], status_code=status.HTTP_201_CREATED) async def create_report( req: MonthlyReportCreate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """创建月报。""" await _get_company_or_404(db, req.company_id, user.tenant_id) # 检查同企业同年月是否已有月报 existing = await db.execute( select(MonthlyReport).where( MonthlyReport.company_id == req.company_id, MonthlyReport.period_year == req.period_year, MonthlyReport.period_month == req.period_month, ) ) if existing.scalar_one_or_none(): raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"{req.period_year}年{req.period_month}月月报已存在", ) report = MonthlyReport( company_id=req.company_id, period_year=req.period_year, period_month=req.period_month, raw_content=req.raw_content, status="draft", ) db.add(report) await db.flush() return success( data=MonthlyReportResponse.model_validate(report, from_attributes=True), message="创建成功", ) @router.put("/{report_id}", response_model=ApiResponse[MonthlyReportResponse]) async def update_report( report_id: str, req: MonthlyReportUpdate, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """更新月报。""" result = await db.execute( select(MonthlyReport) .join(Company, MonthlyReport.company_id == Company.id) .where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id) ) report = result.scalar_one_or_none() if not report: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在") update_data = req.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(report, key, value) await db.flush() return success( data=MonthlyReportResponse.model_validate(report, from_attributes=True), message="更新成功", ) @router.post("/{report_id}/submit", response_model=ApiResponse[MonthlyReportResponse]) async def submit_report( report_id: str, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """提交月报(状态从 draft → submitted)。""" result = await db.execute( select(MonthlyReport) .join(Company, MonthlyReport.company_id == Company.id) .where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id) ) report = result.scalar_one_or_none() if not report: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在") if report.status != "draft": raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"当前状态为 {report.status},无法提交", ) report.status = "submitted" report.submitted_by = user.id report.submitted_at = datetime.now(timezone.utc) await db.flush() return success( data=MonthlyReportResponse.model_validate(report, from_attributes=True), message="提交成功", ) @router.delete("/{report_id}", response_model=ApiResponse[None]) async def delete_report( report_id: str, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """删除月报。""" result = await db.execute( select(MonthlyReport) .join(Company, MonthlyReport.company_id == Company.id) .where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id) ) report = result.scalar_one_or_none() if not report: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在") await db.delete(report) return success(message="删除成功") @router.post("/{report_id}/parse") async def parse_report_stream( report_id: str, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """AI 解析月报(SSE 流式输出)。 流式返回解析过程中的 token,最后返回完整结构化结果。 解析完成后自动计算健康度评分并检测风险事件。 """ result = await db.execute( select(MonthlyReport) .join(Company, MonthlyReport.company_id == Company.id) .where(MonthlyReport.id == report_id, Company.tenant_id == user.tenant_id) ) report = result.scalar_one_or_none() if not report: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="月报不存在") async def event_stream(): """SSE 事件流。""" try: # 阶段 1:流式输出 AI 解析 yield f"data: {json.dumps({'type': 'status', 'message': 'AI 解析中...'})}\n\n" from app.services.llm_client import llm_client system_prompt = """你是投后管理领域的专业分析师。请分析以下企业月报内容,提取结构化信息。 输出 JSON 格式如下: { "structured_data": { "revenue": {"value": "", "unit": "万元", "yoy_change": "", "note": ""}, "cash_balance": {"value": "", "unit": "万元", "runway_months": 0, "note": ""}, "burn_rate": {"value": "", "unit": "万元/月", "trend": "up/stable/down", "note": ""}, "headcount": {"total": 0, "new_hires": 0, "departures": 0, "note": ""}, "key_metrics": [{"name": "", "value": "", "change": "", "note": ""}] }, "ai_summary": "一段 100-200 字的月报摘要", "ai_concerns": { "items": [ {"category": "financial/operational/org/ai_specific", "severity": "low/medium/high", "description": ""} ], "highlights": ["本期亮点1", "本期亮点2"] } } 严格输出 JSON,不要包含 markdown 代码块标记。""" user_prompt = f"请分析以下 {report.period_year}年{report.period_month}月 月报内容:\n\n<<>>\n{report.raw_content or '无内容'}\n<<>>" messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] # 流式收集 collected = [] async for token in llm_client.chat_json_stream(messages, temperature=0.3, max_tokens=2000): collected.append(token) yield f"data: {json.dumps({'type': 'token', 'content': token})}\n\n" # 解析完整 JSON full_text = "".join(collected).strip() if full_text.startswith("```"): full_text = full_text.split("\n", 1)[1] if "\n" in full_text else full_text[3:] if full_text.endswith("```"): full_text = full_text[:-3] full_text = full_text.strip() parsed = json.loads(full_text) # 阶段 2:保存解析结果 report.structured_data = parsed.get("structured_data", {}) report.ai_summary = parsed.get("ai_summary", "") report.ai_concerns = parsed.get("ai_concerns", {}) report.status = "ai_parsed" await db.flush() yield f"data: {json.dumps({'type': 'parsed', 'data': parsed})}\n\n" # 阶段 3:计算健康度评分 yield f"data: {json.dumps({'type': 'status', 'message': '计算健康度评分...'})}\n\n" scores = calculate_health_score(parsed.get("structured_data", {})) # 查询上期评分判断趋势 prev_result = await db.execute( select(HealthScore) .where(HealthScore.company_id == report.company_id) .order_by(HealthScore.calculated_at.desc()) .limit(1) ) prev_score = prev_result.scalar_one_or_none() prev_total = prev_score.total_score if prev_score else None trend = determine_trend(scores["total_score"], prev_total) health = HealthScore( company_id=report.company_id, total_score=scores["total_score"], financial_score=scores["financial_score"], operational_score=scores["operational_score"], ai_commercial_score=scores["ai_commercial_score"], ai_cost_score=scores["ai_cost_score"], trend=trend, evidence_json={"report_id": report.id, "period": f"{report.period_year}-{report.period_month}"}, ) db.add(health) await db.flush() yield f"data: {json.dumps({'type': 'health_score', 'data': scores, 'trend': trend})}\n\n" # 阶段 4:风险自动检测 yield f"data: {json.dumps({'type': 'status', 'message': '检测风险事件...'})}\n\n" risks = detect_risks(parsed.get("structured_data", {}), report.company_id) created_risks = [] for risk_data in risks: risk = RiskEvent( company_id=risk_data["company_id"], type=risk_data["type"], severity=risk_data["severity"], title=risk_data["title"], description=risk_data["description"], suggested_action=risk_data["suggested_action"], status="open", ) db.add(risk) await db.flush() created_risks.append({ "id": risk.id, "title": risk_data["title"], "severity": risk_data["severity"], "type": risk_data["type"], }) yield f"data: {json.dumps({'type': 'risks', 'data': created_risks})}\n\n" # 完成 yield f"data: {json.dumps({'type': 'done', 'message': '解析完成'})}\n\n" except json.JSONDecodeError as e: yield f"data: {json.dumps({'type': 'error', 'message': f'JSON 解析失败: {e}'})}\n\n" except Exception as e: yield f"data: {json.dumps({'type': 'error', 'message': str(e)})}\n\n" return StreamingResponse( event_stream(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }, ) @router.get("/timeliness", response_model=ApiResponse[list]) async def get_timeliness( company_id: str | None = Query(default=None), db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """获取月报提交及时性和数据质量评分。""" items = await compute_timeliness(db, user.tenant_id, company_id) return success(data=items) @router.post("/upload", response_model=ApiResponse[dict]) async def upload_report_file( file: UploadFile = File(...), user: User = Depends(get_current_user), ): """上传月报文件 — 自动解析提取文本内容。 支持 .xlsx、.pdf、.txt、.md、.csv 格式。 """ if not file.filename: raise HTTPException(status_code=400, detail="文件名不能为空") allowed_extensions = {".xlsx", ".xls", ".pdf", ".txt", ".md", ".csv"} ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" if f".{ext}" not in allowed_extensions: raise HTTPException(status_code=400, detail=f"不支持的文件格式: .{ext}") content = await file.read() if len(content) > 10 * 1024 * 1024: raise HTTPException(status_code=400, detail="文件大小不能超过 10MB") extracted_text = await parse_file(content, file.filename) return success(data={ "filename": file.filename, "file_type": ext, "extracted_text": extracted_text[:10000], "char_count": len(extracted_text), })