chore: stop tracking frontend node_modules

This commit is contained in:
Cheng Zhou
2026-01-20 08:52:54 +08:00
parent 7fdcfdaadd
commit 8e258d21a7
17405 changed files with 1643 additions and 2086724 deletions
+35
View File
@@ -0,0 +1,35 @@
"""项目概览API路由"""
import uuid
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db_session, require_study_member
from app.crud import overview as overview_crud
from app.schemas.overview import ProjectOverviewResponse
router = APIRouter()
@router.get(
"/overview",
response_model=ProjectOverviewResponse,
dependencies=[Depends(require_study_member())],
)
async def get_project_overview(
study_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> ProjectOverviewResponse:
"""
获取项目概览数据
返回项目各中心的进度情况,包括:
- 机构立项状态
- 伦理审批状态
- 合同签署状态
- 启动会状态
- 入组进度
- 月度入组统计
"""
data = await overview_crud.get_project_overview(db, study_id)
return ProjectOverviewResponse(**data)
+3 -1
View File
@@ -1,12 +1,14 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(studies.router, prefix="/studies", tags=["studies"])
api_router.include_router(overview.router, prefix="/studies/{study_id}", tags=["overview"])
api_router.include_router(sites.router, prefix="/studies/{study_id}/sites", tags=["sites"])
api_router.include_router(members.router, prefix="/studies/{study_id}/members", tags=["study-members"])
api_router.include_router(attachments.router, prefix="/studies/{study_id}/{entity_type}/{entity_id}/attachments", tags=["attachments"])
+299
View File
@@ -0,0 +1,299 @@
"""概览数据聚合CRUD操作"""
import uuid
from datetime import datetime
from typing import Dict, List, Tuple
from sqlalchemy import func, select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.site import Site
from app.models.startup_feasibility import StartupFeasibility
from app.models.startup_ethics import StartupEthics
from app.models.finance_contract import FinanceContract
from app.models.kickoff_meeting import KickoffMeeting
from app.models.subject import Subject
async def get_project_overview(db: AsyncSession, study_id: uuid.UUID) -> Dict:
"""
获取项目概览数据,聚合各中心的进度信息
返回格式:
{
"study_id": "...",
"updated_at": "2026-01-16T16:10:00",
"centers": [...],
"enrollment_by_month": [...]
}
"""
# 获取所有中心
stmt = select(Site).where(Site.study_id == study_id).order_by(Site.created_at)
result = await db.execute(stmt)
sites = result.scalars().all()
if not sites:
return {
"study_id": str(study_id),
"updated_at": datetime.now().isoformat(),
"centers": [],
"enrollment_by_month": []
}
site_ids = [site.id for site in sites]
# 并行获取各类数据
feasibility_data = await _get_feasibility_data(db, study_id, site_ids)
ethics_data = await _get_ethics_data(db, study_id, site_ids)
contract_data = await _get_contract_data(db, study_id, site_ids)
kickoff_data = await _get_kickoff_data(db, study_id, site_ids)
enrollment_data = await _get_enrollment_data(db, study_id, site_ids)
monthly_enrollment = await _get_monthly_enrollment(db, study_id)
# 组装中心数据
centers = []
for site in sites:
site_id_str = str(site.id)
# 机构立项状态
feas = feasibility_data.get(site_id_str, {})
institution_status = "COMPLETED" if feas.get("approved_date") else "IN_PROGRESS" if feas.get("submit_date") else "NOT_STARTED"
institution_completed = feas.get("approved_date")
# 伦理审批状态 - 依赖机构立项完成
eth = ethics_data.get(site_id_str, {})
if institution_status == "COMPLETED":
ethics_status = "COMPLETED" if eth.get("approved_date") else "IN_PROGRESS" if eth.get("submit_date") else "NOT_STARTED"
else:
# 机构立项未完成,伦理强制为未开始
ethics_status = "NOT_STARTED"
ethics_completed = eth.get("approved_date") if institution_status == "COMPLETED" else None
# 合同签署状态 - 依赖伦理审批完成
contract = contract_data.get(site_id_str, {})
if ethics_status == "COMPLETED":
contract_status = "COMPLETED" if contract.get("signed_date") else "NOT_STARTED"
else:
# 伦理未完成,合同强制为未开始
contract_status = "NOT_STARTED"
contract_completed = contract.get("signed_date") if ethics_status == "COMPLETED" else None
# 启动会状态 - 依赖合同签署完成
kickoff = kickoff_data.get(site_id_str, {})
if contract_status == "COMPLETED":
startup_status = "COMPLETED" if kickoff.get("kickoff_date") else "NOT_STARTED"
else:
# 合同未完成,启动会强制为未开始
startup_status = "NOT_STARTED"
startup_completed = kickoff.get("kickoff_date") if contract_status == "COMPLETED" else None
# 入组状态 - 依赖启动会完成
enrollment = enrollment_data.get(site_id_str, {"actual": 0})
enrollment_actual = enrollment["actual"]
enrollment_target = site.enrollment_target or 0
if startup_status == "COMPLETED":
if enrollment_actual >= enrollment_target and enrollment_target > 0:
enrollment_status = "COMPLETED"
elif enrollment_actual > 0:
enrollment_status = "IN_PROGRESS"
else:
enrollment_status = "NOT_STARTED"
else:
# 启动会未完成,入组强制为未开始
enrollment_status = "NOT_STARTED"
# 检查状态 - 依赖入组完成
# 暂未实现数据源,固定为NOT_STARTED
if enrollment_status == "COMPLETED":
inspection_status = "NOT_STARTED" # 未来从inspection表获取
else:
inspection_status = "NOT_STARTED"
# 关中心状态 - 依赖检查完成
# 暂未实现数据源,固定为NOT_STARTED
if inspection_status == "COMPLETED":
closeout_status = "NOT_STARTED" # 未来从closeout表获取
else:
closeout_status = "NOT_STARTED"
centers.append({
"center_id": str(site.id),
"center_name": site.name,
"institution_initiation_status": institution_status,
"ethics_status": ethics_status,
"contract_sign_status": contract_status,
"startup_status": startup_status,
"enrollment_status": enrollment_status,
"inspection_status": inspection_status,
"closeout_status": closeout_status,
"institution_initiation_completed_at": institution_completed,
"ethics_completed_at": ethics_completed,
"contract_sign_completed_at": contract_completed,
"startup_completed_at": startup_completed,
"enrollment_completed_at": None, # 暂未实现
"inspection_completed_at": None,
"closeout_completed_at": None,
"enrollment_target": enrollment_target,
"enrollment_actual": enrollment_actual,
})
return {
"study_id": str(study_id),
"updated_at": datetime.now().isoformat(),
"centers": centers,
"enrollment_by_month": monthly_enrollment
}
async def _get_feasibility_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List[uuid.UUID]) -> Dict:
"""获取各中心的立项数据"""
stmt = select(
StartupFeasibility.site_id,
StartupFeasibility.submit_date,
StartupFeasibility.approved_date
).where(
and_(
StartupFeasibility.study_id == study_id,
StartupFeasibility.site_id.in_(site_ids)
)
).order_by(StartupFeasibility.created_at.desc())
result = await db.execute(stmt)
rows = result.all()
data = {}
for row in rows:
site_id_str = str(row.site_id)
if site_id_str not in data:
data[site_id_str] = {
"submit_date": row.submit_date.isoformat() if row.submit_date else None,
"approved_date": row.approved_date.isoformat() if row.approved_date else None,
}
return data
async def _get_ethics_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List[uuid.UUID]) -> Dict:
"""获取各中心的伦理数据"""
stmt = select(
StartupEthics.site_id,
StartupEthics.submit_date,
StartupEthics.approved_date
).where(
and_(
StartupEthics.study_id == study_id,
StartupEthics.site_id.in_(site_ids)
)
).order_by(StartupEthics.created_at.desc())
result = await db.execute(stmt)
rows = result.all()
data = {}
for row in rows:
site_id_str = str(row.site_id)
if site_id_str not in data:
data[site_id_str] = {
"submit_date": row.submit_date.isoformat() if row.submit_date else None,
"approved_date": row.approved_date.isoformat() if row.approved_date else None,
}
return data
async def _get_contract_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List[uuid.UUID]) -> Dict:
"""获取各中心的合同签署数据"""
# FinanceContract 通过 site_name 关联,需要先获取site名称映射
stmt_sites = select(Site.id, Site.name).where(Site.id.in_(site_ids))
result = await db.execute(stmt_sites)
site_name_map = {row.name: str(row.id) for row in result.all()}
stmt = select(
FinanceContract.site_name,
func.min(FinanceContract.signed_date).label("signed_date")
).where(
FinanceContract.study_id == study_id
).group_by(FinanceContract.site_name)
result = await db.execute(stmt)
rows = result.all()
data = {}
for row in rows:
site_id_str = site_name_map.get(row.site_name)
if site_id_str:
data[site_id_str] = {
"signed_date": row.signed_date.isoformat() if row.signed_date else None,
}
return data
async def _get_kickoff_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List[uuid.UUID]) -> Dict:
"""获取各中心的启动会数据"""
stmt = select(
KickoffMeeting.site_id,
KickoffMeeting.kickoff_date
).where(
and_(
KickoffMeeting.study_id == study_id,
KickoffMeeting.site_id.in_(site_ids)
)
).order_by(KickoffMeeting.created_at.desc())
result = await db.execute(stmt)
rows = result.all()
data = {}
for row in rows:
site_id_str = str(row.site_id)
if site_id_str not in data:
data[site_id_str] = {
"kickoff_date": row.kickoff_date.isoformat() if row.kickoff_date else None,
}
return data
async def _get_enrollment_data(db: AsyncSession, study_id: uuid.UUID, site_ids: List[uuid.UUID]) -> Dict:
"""获取各中心的入组数据"""
stmt = select(
Subject.site_id,
func.count(Subject.id).label("count")
).where(
and_(
Subject.study_id == study_id,
Subject.site_id.in_(site_ids),
Subject.enrollment_date.isnot(None)
)
).group_by(Subject.site_id)
result = await db.execute(stmt)
rows = result.all()
data = {}
for row in rows:
data[str(row.site_id)] = {"actual": row.count}
return data
async def _get_monthly_enrollment(db: AsyncSession, study_id: uuid.UUID) -> List[Dict]:
"""获取月度入组统计"""
month_col = func.to_char(Subject.enrollment_date, 'YYYY-MM').label("month")
stmt = select(
month_col,
func.count(Subject.id).label("count")
).where(
and_(
Subject.study_id == study_id,
Subject.enrollment_date.isnot(None)
)
).group_by(month_col).order_by(month_col)
result = await db.execute(stmt)
rows = result.all()
return [{"month": row.month, "count": row.count} for row in rows]
+3 -1
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -18,4 +18,6 @@ class Site(Base):
pi_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
contact: Mapped[str | None] = mapped_column(String(100), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="true")
enrollment_target: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
-2
View File
@@ -47,12 +47,10 @@ class DocumentSummary(BaseModel):
id: uuid.UUID
trial_id: uuid.UUID
site_id: Optional[uuid.UUID] = None
doc_no: str
doc_type: str
title: str
scope_type: DocumentScopeType
owner_id: Optional[uuid.UUID]
status: DocumentStatus
current_effective_version_id: Optional[uuid.UUID]
current_effective_version: Optional[DocumentVersionSummary] = None
created_at: datetime
-1
View File
@@ -20,7 +20,6 @@ class DocumentVersionSummary(BaseModel):
id: uuid.UUID
version_no: str
status: DocumentVersionStatus
effective_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
+48
View File
@@ -0,0 +1,48 @@
"""Overview Schema定义"""
import uuid
from datetime import date
from typing import List, Optional
from pydantic import BaseModel
class CenterOverview(BaseModel):
"""单个中心的概览数据"""
center_id: str
center_name: str
# 各阶段状态
institution_initiation_status: str = "NOT_STARTED"
ethics_status: str = "NOT_STARTED"
contract_sign_status: str = "NOT_STARTED"
startup_status: str = "NOT_STARTED"
enrollment_status: str = "NOT_STARTED"
inspection_status: str = "NOT_STARTED"
closeout_status: str = "NOT_STARTED"
# 各阶段完成日期
institution_initiation_completed_at: Optional[str] = None
ethics_completed_at: Optional[str] = None
contract_sign_completed_at: Optional[str] = None
startup_completed_at: Optional[str] = None
enrollment_completed_at: Optional[str] = None
inspection_completed_at: Optional[str] = None
closeout_completed_at: Optional[str] = None
# 入组数据
enrollment_target: int = 0
enrollment_actual: int = 0
class EnrollmentByMonth(BaseModel):
"""月度入组统计"""
month: str
count: int
class ProjectOverviewResponse(BaseModel):
"""项目概览响应"""
study_id: str
updated_at: str
centers: List[CenterOverview]
enrollment_by_month: List[EnrollmentByMonth]
+3
View File
@@ -11,6 +11,7 @@ class SiteCreate(BaseModel):
pi_name: Optional[str] = None
contact: Optional[str] = None
is_active: bool = True
enrollment_target: Optional[int] = None
class SiteUpdate(BaseModel):
@@ -19,6 +20,7 @@ class SiteUpdate(BaseModel):
pi_name: Optional[str] = None
contact: Optional[str] = None
is_active: Optional[bool] = None
enrollment_target: Optional[int] = None
class SiteRead(BaseModel):
@@ -29,6 +31,7 @@ class SiteRead(BaseModel):
pi_name: Optional[str]
contact: Optional[str]
is_active: bool
enrollment_target: Optional[int]
created_at: datetime
model_config = ConfigDict(from_attributes=True)
-4
View File
@@ -163,12 +163,10 @@ async def list_documents(
id=doc.id,
trial_id=doc.trial_id,
site_id=doc.site_id,
doc_no=doc.doc_no,
doc_type=doc.doc_type,
title=doc.title,
scope_type=doc.scope_type,
owner_id=doc.owner_id,
status=doc.status,
current_effective_version_id=doc.current_effective_version_id,
current_effective_version=current_version,
created_at=doc.created_at,
@@ -205,12 +203,10 @@ async def get_document_detail(
id=doc.id,
trial_id=doc.trial_id,
site_id=doc.site_id,
doc_no=doc.doc_no,
doc_type=doc.doc_type,
title=doc.title,
scope_type=doc.scope_type,
owner_id=doc.owner_id,
status=doc.status,
current_effective_version_id=doc.current_effective_version_id,
current_effective_version=current_version,
description=doc.description,