diff --git a/backend/app/main.py b/backend/app/main.py index ec2cf58..2eda858 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse from app.routers.auth import router as auth_router from app.routers.companies import router as companies_router +from app.routers.reports import router as reports_router from app.schemas.common import error @@ -69,3 +70,4 @@ async def health_check(): app.include_router(auth_router, prefix="/api/v1") app.include_router(companies_router, prefix="/api/v1") +app.include_router(reports_router, prefix="/api/v1") diff --git a/backend/app/routers/reports.py b/backend/app/routers/reports.py new file mode 100644 index 0000000..5e6edf4 --- /dev/null +++ b/backend/app/routers/reports.py @@ -0,0 +1,206 @@ +"""月报路由:CRUD + 提交 + AI 解析占位。""" + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, status +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.report import MonthlyReport +from app.models.user import User +from app.schemas.common import ApiResponse, success +from app.schemas.report import ( + MonthlyReportCreate, + MonthlyReportListResponse, + MonthlyReportResponse, + MonthlyReportUpdate, +) + +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="删除成功") diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py new file mode 100644 index 0000000..531cca2 --- /dev/null +++ b/backend/app/schemas/report.py @@ -0,0 +1,52 @@ +"""月报相关 Pydantic schema。""" + +from datetime import datetime +from pydantic import BaseModel, Field + + +class MonthlyReportCreate(BaseModel): + """创建月报请求。""" + + company_id: str + period_year: int = Field(..., ge=2020, le=2100) + period_month: int = Field(..., ge=1, le=12) + raw_content: str | None = None + + +class MonthlyReportUpdate(BaseModel): + """更新月报请求。""" + + raw_content: str | None = None + status: str | None = Field(default=None, description="draft/submitted/ai_parsed/reviewed") + structured_data: dict | None = None + ai_summary: str | None = None + ai_concerns: dict | None = None + + +class MonthlyReportResponse(BaseModel): + """月报信息响应。""" + + id: str + company_id: str + period_year: int + period_month: int + status: str + raw_content: str | None = None + structured_data: dict | None = None + ai_summary: str | None = None + ai_concerns: dict | None = None + submitted_by: str | None = None + submitted_at: datetime | None = None + reviewed_by: str | None = None + reviewed_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class MonthlyReportListResponse(BaseModel): + """月报列表响应。""" + + items: list[MonthlyReportResponse] + total: int + page: int + page_size: int diff --git a/backend/tests/test_reports.py b/backend/tests/test_reports.py new file mode 100644 index 0000000..5aaf152 --- /dev/null +++ b/backend/tests/test_reports.py @@ -0,0 +1,188 @@ +"""月报管理 CRUD 测试。""" + +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": "report_test@example.com", + "password": "password123", + "name": "测试投资经理", + "tenant_name": "测试机构", + "role": "investor", + }, + ) + resp = client.post( + "/api/v1/auth/login", + json={"email": "report_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): + """创建测试企业,返回 ID。""" + resp = client.post( + "/api/v1/companies", + json={"name": "月报测试公司", "industry": "AI"}, + headers=auth_headers, + ) + return resp.json()["data"]["id"] + + +class TestCreateReport: + """创建月报测试。""" + + def test_create_success(self, client: TestClient, auth_headers: dict, company_id: str): + """正常创建月报。""" + response = client.post( + "/api/v1/reports", + json={ + "company_id": company_id, + "period_year": 2026, + "period_month": 7, + "raw_content": "本月营收增长20%", + }, + headers=auth_headers, + ) + assert response.status_code == 201 + data = response.json() + assert data["code"] == 0 + assert data["data"]["period_year"] == 2026 + assert data["data"]["period_month"] == 7 + assert data["data"]["status"] == "draft" + + def test_create_duplicate(self, client: TestClient, auth_headers: dict, company_id: str): + """同年月重复创建应返回 409。""" + # 先创建一个 + client.post( + "/api/v1/reports", + json={"company_id": company_id, "period_year": 2026, "period_month": 6}, + headers=auth_headers, + ) + # 再创建同月 + response = client.post( + "/api/v1/reports", + json={"company_id": company_id, "period_year": 2026, "period_month": 6}, + headers=auth_headers, + ) + assert response.status_code == 409 + + +class TestListReports: + """月报列表测试。""" + + def test_list_success(self, client: TestClient, auth_headers: dict, company_id: str): + """获取月报列表。""" + response = client.get("/api/v1/reports", headers=auth_headers) + assert response.status_code == 200 + data = response.json() + assert data["code"] == 0 + assert data["data"]["total"] >= 1 + + def test_list_by_company(self, client: TestClient, auth_headers: dict, company_id: str): + """按企业筛选。""" + response = client.get( + f"/api/v1/reports?company_id={company_id}", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert all(item["company_id"] == company_id for item in data["data"]["items"]) + + +class TestSubmitReport: + """提交月报测试。""" + + def test_submit_success(self, client: TestClient, auth_headers: dict, company_id: str): + """正常提交月报。""" + create_resp = client.post( + "/api/v1/reports", + json={"company_id": company_id, "period_year": 2026, "period_month": 5}, + headers=auth_headers, + ) + report_id = create_resp.json()["data"]["id"] + + response = client.post( + f"/api/v1/reports/{report_id}/submit", + headers=auth_headers, + ) + assert response.status_code == 200 + data = response.json() + assert data["data"]["status"] == "submitted" + assert data["data"]["submitted_at"] is not None + + def test_submit_nonexistent(self, client: TestClient, auth_headers: dict): + """提交不存在的月报应返回 404。""" + response = client.post( + "/api/v1/reports/nonexistent-id/submit", + headers=auth_headers, + ) + assert response.status_code == 404 + + +class TestDeleteReport: + """删除月报测试。""" + + def test_delete_success(self, client: TestClient, auth_headers: dict, company_id: str): + """正常删除。""" + create_resp = client.post( + "/api/v1/reports", + json={"company_id": company_id, "period_year": 2026, "period_month": 4}, + headers=auth_headers, + ) + report_id = create_resp.json()["data"]["id"] + + response = client.delete( + f"/api/v1/reports/{report_id}", + headers=auth_headers, + ) + assert response.status_code == 200 + + # 验证已删除 + get_resp = client.get( + f"/api/v1/reports/{report_id}", + headers=auth_headers, + ) + assert get_resp.status_code == 404 diff --git a/progress.txt b/progress.txt index b75e95b..84f3c59 100644 --- a/progress.txt +++ b/progress.txt @@ -63,9 +63,17 @@ | 任务 | 状态 | 验证结果 | |---|---|---| | T1.1 认证与权限 | ✅ | 后端 15 tests passed + 前端登录页构建成功 | +| T1.2 企业档案 CRUD | ✅ | 后端 11 tests passed + 前端列表/详情页构建成功 | ### T1.1 产出 - **后端**:`auth.py` 路由(register/login/refresh/me)+ `dependencies.py`(JWT 校验 + 角色权限) - **前端**:`auth-context.tsx`(AuthProvider)+ 登录表单页 - **测试**:11 个认证测试(注册/登录/获取用户/刷新 token)+ 4 个健康检查 = 15 passed + +### T1.2 产出 + +- **后端**:`companies.py` 路由(列表/详情/创建/更新/删除)+ 分页 + 关键词搜索 + 租户隔离 +- **前端**:`companies.ts` API 客户端 + 企业列表页(卡片+搜索+分页)+ 企业详情页 +- **测试**:11 个企业 CRUD 测试(创建/列表/搜索/分页/详情/更新/删除) +- **总计**:26 tests passed