chore: stop tracking frontend node_modules
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""add enrollment_target to sites
|
||||
|
||||
Revision ID: 20260116_02
|
||||
Revises: 20260116_01
|
||||
Create Date: 2026-01-16 16:10:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '20260116_02'
|
||||
down_revision: Union[str, None] = '20260116_01'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 添加 enrollment_target 字段到 sites 表
|
||||
op.add_column('sites', sa.Column('enrollment_target', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 删除 enrollment_target 字段
|
||||
op.drop_column('sites', 'enrollment_target')
|
||||
@@ -0,0 +1,199 @@
|
||||
"""seed overview demo data
|
||||
|
||||
Revision ID: 20260116_03
|
||||
Revises: 20260116_02
|
||||
Create Date: 2026-01-16 16:30:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from datetime import date, timedelta
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '20260116_03'
|
||||
down_revision: Union[str, None] = '20260116_02'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 获取connection
|
||||
conn = op.get_bind()
|
||||
|
||||
# 首先获取第一个项目ID
|
||||
result = conn.execute(sa.text("SELECT id FROM studies LIMIT 1"))
|
||||
study_row = result.first()
|
||||
|
||||
if not study_row:
|
||||
print("没有项目,跳过示例数据注入")
|
||||
return
|
||||
|
||||
study_id = study_row[0]
|
||||
|
||||
# 获取该项目的中心
|
||||
result = conn.execute(sa.text("SELECT id, name FROM sites WHERE study_id = :study_id ORDER BY created_at LIMIT 5"), {"study_id": study_id})
|
||||
sites = result.fetchall()
|
||||
|
||||
if not sites:
|
||||
print("没有中心,跳过示例数据注入")
|
||||
return
|
||||
|
||||
print(f"为 {len(sites)} 个中心注入示例数据...")
|
||||
|
||||
# 清理可能存在的演示数据(使用ON CONFLICT处理或先删除)
|
||||
# 收集site_ids
|
||||
site_ids = [site[0] for site in sites]
|
||||
|
||||
# 删除这些中心的existing数据 (如果是演示数据)
|
||||
# 由于我们无法判断哪些是演示数据,使用ON CONFLICT DO NOTHING策略
|
||||
|
||||
# 更新入组目标
|
||||
targets = [80, 60, 70, 50, 40]
|
||||
for idx, site in enumerate(sites):
|
||||
site_id = site[0]
|
||||
target = targets[idx] if idx < len(targets) else 50
|
||||
conn.execute(
|
||||
sa.text("UPDATE sites SET enrollment_target = :target WHERE id = :site_id"),
|
||||
{"target": target, "site_id": site_id}
|
||||
)
|
||||
|
||||
# 基准日期
|
||||
base_date = date(2024, 12, 1)
|
||||
|
||||
# 为每个中心添加启动流程数据
|
||||
for idx, site in enumerate(sites):
|
||||
site_id = site[0]
|
||||
|
||||
# 机构立项 - 使用 ON CONFLICT DO NOTHING (假设有唯一约束)
|
||||
submit_date = base_date + timedelta(days=idx * 10)
|
||||
accept_date = base_date + timedelta(days=idx * 10 + 5)
|
||||
approved_date = base_date + timedelta(days=idx * 10 + 15) if idx < 4 else None
|
||||
|
||||
# 检查是否已存在
|
||||
existing = conn.execute(sa.text(
|
||||
"SELECT 1 FROM startup_feasibility WHERE study_id = :study_id AND site_id = :site_id LIMIT 1"
|
||||
), {"study_id": study_id, "site_id": site_id}).first()
|
||||
|
||||
if not existing:
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO startup_feasibility (id, study_id, site_id, submit_date, accept_date, approved_date, project_no, created_at)
|
||||
VALUES (gen_random_uuid(), :study_id, :site_id, :submit_date, :accept_date, :approved_date, :project_no, NOW())
|
||||
"""), {
|
||||
"study_id": study_id,
|
||||
"site_id": site_id,
|
||||
"submit_date": submit_date,
|
||||
"accept_date": accept_date,
|
||||
"approved_date": approved_date,
|
||||
"project_no": f"PROJ-{idx+1:03d}"
|
||||
})
|
||||
|
||||
# 伦理审批
|
||||
ethics_submit = base_date + timedelta(days=idx * 10 + 20)
|
||||
ethics_accept = base_date + timedelta(days=idx * 10 + 25)
|
||||
meeting_date = base_date + timedelta(days=idx * 10 + 35) if idx < 4 else None
|
||||
ethics_approved = base_date + timedelta(days=idx * 10 + 40) if idx < 4 else None
|
||||
|
||||
existing = conn.execute(sa.text(
|
||||
"SELECT 1 FROM startup_ethics WHERE study_id = :study_id AND site_id = :site_id LIMIT 1"
|
||||
), {"study_id": study_id, "site_id": site_id}).first()
|
||||
|
||||
if not existing:
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO startup_ethics (id, study_id, site_id, submit_date, accept_date, meeting_date, approved_date, approval_no, created_at)
|
||||
VALUES (gen_random_uuid(), :study_id, :site_id, :submit_date, :accept_date, :meeting_date, :approved_date, :approval_no, NOW())
|
||||
"""), {
|
||||
"study_id": study_id,
|
||||
"site_id": site_id,
|
||||
"submit_date": ethics_submit,
|
||||
"accept_date": ethics_accept,
|
||||
"meeting_date": meeting_date,
|
||||
"approved_date": ethics_approved,
|
||||
"approval_no": f"EC-{idx+1:03d}" if idx < 4 else None
|
||||
})
|
||||
|
||||
# 合同签署 (前3个中心)
|
||||
if idx < 3:
|
||||
site_name = site[1]
|
||||
signed_date = base_date + timedelta(days=idx * 10 + 50)
|
||||
|
||||
# 检查合同是否已存在
|
||||
existing = conn.execute(sa.text(
|
||||
"SELECT 1 FROM finance_contracts WHERE study_id = :study_id AND site_name = :site_name LIMIT 1"
|
||||
), {"study_id": study_id, "site_name": site_name}).first()
|
||||
|
||||
if not existing:
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO finance_contracts (id, study_id, site_name, contract_no, signed_date, amount, currency, created_at)
|
||||
VALUES (gen_random_uuid(), :study_id, :site_name, :contract_no, :signed_date, 500000.0, 'CNY', NOW())
|
||||
"""), {
|
||||
"study_id": study_id,
|
||||
"site_name": site_name,
|
||||
"contract_no": f"CT-{idx+1:03d}",
|
||||
"signed_date": signed_date
|
||||
})
|
||||
|
||||
# 启动会 (前3个中心)
|
||||
if idx < 3:
|
||||
kickoff_date = base_date + timedelta(days=idx * 10 + 60)
|
||||
|
||||
# 检查启动会是否已存在
|
||||
existing = conn.execute(sa.text(
|
||||
"SELECT 1 FROM kickoff_meetings WHERE study_id = :study_id AND site_id = :site_id LIMIT 1"
|
||||
), {"study_id": study_id, "site_id": site_id}).first()
|
||||
|
||||
if not existing:
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO kickoff_meetings (id, study_id, site_id, kickoff_date, attendees, created_at)
|
||||
VALUES (gen_random_uuid(), :study_id, :site_id, :kickoff_date, '["研究者", "CRA", "PM"]'::jsonb, NOW())
|
||||
"""), {
|
||||
"study_id": study_id,
|
||||
"site_id": site_id,
|
||||
"kickoff_date": kickoff_date
|
||||
})
|
||||
|
||||
# 为前3个中心添加受试者
|
||||
enrollment_counts = [52, 18, 70]
|
||||
start_enrollment = date(2025, 1, 1)
|
||||
|
||||
for idx in range(min(3, len(sites))):
|
||||
site_id = sites[idx][0]
|
||||
count = enrollment_counts[idx] if idx < len(enrollment_counts) else 0
|
||||
|
||||
# 检查该中心是否已有受试者数据
|
||||
existing_count = conn.execute(sa.text(
|
||||
"SELECT COUNT(*) FROM subjects WHERE study_id = :study_id AND site_id = :site_id"
|
||||
), {"study_id": study_id, "site_id": site_id}).scalar()
|
||||
|
||||
# 如果已有数据,跳过该中心
|
||||
if existing_count > 0:
|
||||
print(f" 中心 {idx+1} 已有 {existing_count} 名受试者,跳过")
|
||||
continue
|
||||
|
||||
for i in range(count):
|
||||
enrollment_date = start_enrollment + timedelta(days=i * 4)
|
||||
screening_date = enrollment_date - timedelta(days=7)
|
||||
consent_date = enrollment_date - timedelta(days=3)
|
||||
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO subjects (id, study_id, site_id, subject_no, status, screening_date, consent_date, enrollment_date, created_at)
|
||||
VALUES (gen_random_uuid(), :study_id, :site_id, :subject_no, 'ENROLLED', :screening_date, :consent_date, :enrollment_date, NOW())
|
||||
"""), {
|
||||
"study_id": study_id,
|
||||
"site_id": site_id,
|
||||
"subject_no": f"S{idx+1:02d}-{i+1:03d}",
|
||||
"screening_date": screening_date,
|
||||
"consent_date": consent_date,
|
||||
"enrollment_date": enrollment_date
|
||||
})
|
||||
|
||||
print("✅ 示例数据注入完成")
|
||||
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 删除示例数据 (可选 - 也可以留空)
|
||||
pass
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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]
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
项目概览示例数据注入脚本
|
||||
|
||||
运行方式:
|
||||
docker compose run --rm backend python scripts/seed_overview_data.py
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# 添加app目录到Python路径
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sqlalchemy import select
|
||||
from app.db.session import SessionLocal
|
||||
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 seed_data():
|
||||
"""注入示例数据"""
|
||||
async with SessionLocal() as db:
|
||||
# 获取第一个项目用于测试
|
||||
stmt = select(Site.study_id).limit(1)
|
||||
result = await db.execute(stmt)
|
||||
study_id_row = result.first()
|
||||
|
||||
if not study_id_row:
|
||||
print("❌ 数据库中没有项目,请先创建项目和中心")
|
||||
return
|
||||
|
||||
study_id = study_id_row[0]
|
||||
print(f"✓ 使用项目 ID: {study_id}")
|
||||
|
||||
# 获取该项目的所有中心
|
||||
stmt = select(Site).where(Site.study_id == study_id)
|
||||
result = await db.execute(stmt)
|
||||
sites = result.scalars().all()
|
||||
|
||||
if not sites:
|
||||
print("❌ 项目下没有中心,请先创建中心")
|
||||
return
|
||||
|
||||
print(f"✓ 找到 {len(sites)} 个中心")
|
||||
|
||||
# 为每个中心设置入组目标
|
||||
targets = [80, 60, 70, 50, 40]
|
||||
for idx, site in enumerate(sites[:5]):
|
||||
site.enrollment_target = targets[idx] if idx < len(targets) else 50
|
||||
print(f" • {site.name}: 目标入组 {site.enrollment_target} 人")
|
||||
|
||||
await db.commit()
|
||||
print("✓ 入组目标已更新")
|
||||
|
||||
# 为中心添加启动流程数据
|
||||
base_date = date(2024, 12, 1)
|
||||
|
||||
for idx, site in enumerate(sites[:5]):
|
||||
# 机构立项
|
||||
feasibility = StartupFeasibility(
|
||||
id=uuid.uuid4(),
|
||||
study_id=study_id,
|
||||
site_id=site.id,
|
||||
submit_date=base_date + timedelta(days=idx * 10),
|
||||
accept_date=base_date + timedelta(days=idx * 10 + 5),
|
||||
approved_date=base_date + timedelta(days=idx * 10 + 15) if idx < 4 else None,
|
||||
project_no=f"PROJ-{idx+1:03d}"
|
||||
)
|
||||
db.add(feasibility)
|
||||
|
||||
# 伦理审批
|
||||
ethics = StartupEthics(
|
||||
id=uuid.uuid4(),
|
||||
study_id=study_id,
|
||||
site_id=site.id,
|
||||
submit_date=base_date + timedelta(days=idx * 10 + 20),
|
||||
accept_date=base_date + timedelta(days=idx * 10 + 25),
|
||||
meeting_date=base_date + timedelta(days=idx * 10 + 35) if idx < 4 else None,
|
||||
approved_date=base_date + timedelta(days=idx * 10 + 40) if idx < 4 else None,
|
||||
approval_no=f"EC-{idx+1:03d}" if idx < 4 else None
|
||||
)
|
||||
db.add(ethics)
|
||||
|
||||
# 合同签署 (前3个中心)
|
||||
if idx < 3:
|
||||
contract = FinanceContract(
|
||||
id=uuid.uuid4(),
|
||||
study_id=study_id,
|
||||
site_name=site.name,
|
||||
contract_no=f"CT-{idx+1:03d}",
|
||||
signed_date=base_date + timedelta(days=idx * 10 + 50),
|
||||
amount=500000.0,
|
||||
currency="CNY"
|
||||
)
|
||||
db.add(contract)
|
||||
|
||||
# 启动会 (前3个中心)
|
||||
if idx < 3:
|
||||
kickoff = KickoffMeeting(
|
||||
id=uuid.uuid4(),
|
||||
study_id=study_id,
|
||||
site_id=site.id,
|
||||
kickoff_date=base_date + timedelta(days=idx * 10 + 60),
|
||||
attendees=["研究者", "CRA", "PM"]
|
||||
)
|
||||
db.add(kickoff)
|
||||
|
||||
await db.commit()
|
||||
print("✓ 启动流程数据已添加")
|
||||
|
||||
# 为前3个中心添加入组受试者
|
||||
enrollment_counts = [52, 18, 70]
|
||||
start_enrollment_date = date(2025, 1, 1)
|
||||
|
||||
for idx, site in enumerate(sites[:3]):
|
||||
count = enrollment_counts[idx] if idx < len(enrollment_counts) else 0
|
||||
|
||||
for i in range(count):
|
||||
# 分散在6个月内入组
|
||||
enrollment_date = start_enrollment_date + timedelta(days=i * 4)
|
||||
|
||||
subject = Subject(
|
||||
id=uuid.uuid4(),
|
||||
study_id=study_id,
|
||||
site_id=site.id,
|
||||
subject_no=f"S{idx+1:02d}-{i+1:03d}",
|
||||
status="ENROLLED",
|
||||
screening_date=enrollment_date - timedelta(days=7),
|
||||
consent_date=enrollment_date - timedelta(days=3),
|
||||
enrollment_date=enrollment_date
|
||||
)
|
||||
db.add(subject)
|
||||
|
||||
print(f" • {site.name}: 已入组 {count} 人")
|
||||
|
||||
await db.commit()
|
||||
print("✓ 受试者入组数据已添加")
|
||||
|
||||
print("\n✅ 示例数据注入完成!")
|
||||
print("请刷新项目概览页面查看效果")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed_data())
|
||||
Reference in New Issue
Block a user