Files
selfrelease fad458b2a7 docs(uiux): UIUX 设计方案大改 + 5 份作业指导书对齐 + 开发任务文档
- UIUX 文档:填充 19 个缺口(多主体画像/健康度/AI+看板/增长域/洞察域/创始人端/OODA/助推/商密)
- UIUX 文档:插入 6 个新章节(十四~十九),旧章节重编号为二十~三十一,更新目录和交叉引用
- 作业指导书 x5:导航改为 6 域分组,新增 Context Bar/工作模式/Insight Rail/决策线程/多工作区等 UI 概念
- 新建 docs/2-task-uiux.md:50 个代码落地开发任务,按 P0-P6 分优先级 + 8 Sprint 规划
- 后端/前端:大量新增模型、路由、组件(来自之前 Phase 开发)
2026-07-19 11:53:38 +08:00

416 lines
15 KiB
Python

"""月报路由: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<<<USER_INPUT>>>\n{report.raw_content or '无内容'}\n<<<END_USER_INPUT>>>"
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),
})