移除特殊费用模块

This commit is contained in:
Cheng Zhou
2026-05-12 10:15:18 +08:00
parent cd7e12ce1f
commit 6e90370a5f
30 changed files with 117 additions and 2819 deletions
@@ -0,0 +1,114 @@
"""remove special fee modules
Revision ID: 20260512_02
Revises: 20260512_01
Create Date: 2026-05-12 10:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260512_02"
down_revision: Union[str, None] = "20260512_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "attachments"):
op.execute(
"""
DELETE FROM attachments
WHERE entity_type IN (
'finance_special',
'special_expense',
'special_expense_voucher',
'special_expense_invoice',
'special_expense_other'
)
"""
)
if _table_exists(inspector, "fee_attachments"):
op.execute(
"""
DELETE FROM fee_attachments
WHERE entity_type = 'special_expense'
"""
)
if _table_exists(inspector, "audit_logs"):
op.execute(
"""
DELETE FROM audit_logs
WHERE entity_type IN ('finance_special', 'special_expense')
"""
)
if _table_exists(inspector, "finance_specials"):
op.drop_table("finance_specials")
if _table_exists(inspector, "special_expenses"):
op.drop_table("special_expenses")
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "special_expenses"):
op.create_table(
"special_expenses",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("center_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("category", sa.String(length=50), nullable=False),
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
sa.Column("happen_date", sa.Date(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_paid", sa.Boolean(), server_default="false", nullable=False),
sa.Column("paid_date", sa.Date(), nullable=True),
sa.Column("is_verified", sa.Boolean(), server_default="false", nullable=False),
sa.Column("verified_date", sa.Date(), nullable=True),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["center_id"], ["sites.id"]),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["project_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_special_expenses_project_id", "special_expenses", ["project_id"])
op.create_index("ix_special_expenses_center_id", "special_expenses", ["center_id"])
if not _table_exists(inspector, "finance_specials"):
op.create_table(
"finance_specials",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("site_name", sa.String(length=255), nullable=False),
sa.Column("fee_type", sa.String(length=50), nullable=False),
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
sa.Column("occur_date", sa.Date(), nullable=True),
sa.Column("staff_name", sa.String(length=100), nullable=True),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_finance_specials_study_id", "finance_specials", ["study_id"])
+1 -8
View File
@@ -14,7 +14,6 @@ from app.crud import contract_fee as contract_fee_crud
from app.crud import contract_fee_payment as payment_crud
from app.crud import fee_attachment as fee_attachment_crud
from app.crud import member as member_crud
from app.crud import special_expense as special_crud
from app.crud import user as user_crud
from app.schemas.fee_attachment import FeeAttachmentRead
from app.schemas.fee_common import FeeApiResponse
@@ -24,11 +23,10 @@ router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "fees"
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment", "special_expense"}
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment"}
ALLOWED_FILE_TYPES = {
"contract_fee": {"contract", "voucher", "invoice"},
"contract_payment": {"voucher", "invoice"},
"special_expense": {"voucher", "invoice", "other"},
}
@@ -46,11 +44,6 @@ async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uui
if not contract:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
return contract.project_id
if entity_type == "special_expense":
expense = await special_crud.get_special_expense(db, entity_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
return expense.project_id
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型")
-245
View File
@@ -1,245 +0,0 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import member as member_crud
from app.crud import special_expense as special_crud
from app.crud import study as study_crud
from app.crud import site as site_crud
from app.schemas.fee_common import FeeApiResponse
from app.schemas.special_expense import (
SpecialExpenseCreate,
SpecialExpenseListItem,
SpecialExpenseRead,
SpecialExpenseUpdate,
)
router = APIRouter()
ALLOWED_CATEGORIES = {"travel", "meal", "meeting", "supplies", "other"}
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
study = await study_crud.get(db, project_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
if role_value == "ADMIN":
return None
membership = await member_crud.get_member(db, project_id, current_user.id)
if not membership or not membership.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
if write and membership.role_in_study != "PM":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
return membership
def _validate_special_rules(payload: SpecialExpenseCreate | SpecialExpenseUpdate):
if payload.is_verified is True and payload.is_paid is False:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="核销需先打款")
if payload.is_paid and not payload.paid_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已打款需填写打款日期")
if payload.is_verified and not payload.verified_date:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已核销需填写核销日期")
def _validate_category(value: str | None):
if value and value not in ALLOWED_CATEGORIES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="费用类别无效")
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.get(
"/special",
response_model=FeeApiResponse[list[SpecialExpenseListItem]],
dependencies=[Depends(get_current_user)],
)
async def list_special_expenses(
project_id: uuid.UUID = Query(..., alias="projectId"),
center_id: uuid.UUID | None = Query(None, alias="centerId"),
category: str | None = None,
date_from: date | None = Query(None, alias="dateFrom"),
date_to: date | None = Query(None, alias="dateTo"),
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[list[SpecialExpenseListItem]]:
await _ensure_project_access(db, project_id, current_user, write=False)
_validate_category(category)
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await special_crud.list_special_expenses(
db,
project_id,
center_id=center_id,
center_ids=center_ids,
category=category,
date_from=date_from,
date_to=date_to,
)
items: list[SpecialExpenseListItem] = []
for expense, center_name, attachments_count in rows:
items.append(
SpecialExpenseListItem(
id=expense.id,
project_id=expense.project_id,
center_id=expense.center_id,
category=expense.category,
amount=expense.amount,
happen_date=expense.happen_date,
description=expense.description,
is_paid=expense.is_paid,
paid_date=expense.paid_date,
is_verified=expense.is_verified,
verified_date=expense.verified_date,
created_by=expense.created_by,
created_at=expense.created_at,
updated_at=expense.updated_at,
center_name=center_name,
attachments_count=attachments_count or 0,
)
)
return FeeApiResponse(data=items, meta={"total": len(items)})
@router.get(
"/special/{expense_id}",
response_model=FeeApiResponse[SpecialExpenseRead],
dependencies=[Depends(get_current_user)],
)
async def get_special_expense(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[SpecialExpenseRead]:
expense = await special_crud.get_special_expense(db, expense_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=False)
cra_scope = await get_cra_site_scope(db, expense.project_id, current_user)
if cra_scope and expense.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
@router.post(
"/special",
response_model=FeeApiResponse[SpecialExpenseRead],
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def create_special_expense(
expense_in: SpecialExpenseCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[SpecialExpenseRead]:
await _ensure_project_access(db, expense_in.project_id, current_user, write=True)
_validate_category(expense_in.category)
_validate_special_rules(expense_in)
if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense_in.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense_in.project_id, expense_in.center_id)
expense = await special_crud.create_special_expense(db, expense_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=expense.project_id,
entity_type="special_expense",
entity_id=expense.id,
action="CREATE_SPECIAL_EXPENSE",
detail="特殊费用已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
@router.patch(
"/special/{expense_id}",
response_model=FeeApiResponse[SpecialExpenseRead],
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def update_special_expense(
expense_id: uuid.UUID,
expense_in: SpecialExpenseUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FeeApiResponse[SpecialExpenseRead]:
expense = await special_crud.get_special_expense(db, expense_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=True)
_validate_category(expense_in.category)
merged = SpecialExpenseCreate(
project_id=expense.project_id,
center_id=expense_in.center_id if expense_in.center_id is not None else expense.center_id,
category=expense_in.category if expense_in.category is not None else expense.category,
amount=expense_in.amount if expense_in.amount is not None else expense.amount,
happen_date=expense_in.happen_date if expense_in.happen_date is not None else expense.happen_date,
description=expense_in.description if expense_in.description is not None else expense.description,
is_paid=expense_in.is_paid if expense_in.is_paid is not None else expense.is_paid,
paid_date=expense_in.paid_date if expense_in.paid_date is not None else expense.paid_date,
is_verified=expense_in.is_verified if expense_in.is_verified is not None else expense.is_verified,
verified_date=expense_in.verified_date if expense_in.verified_date is not None else expense.verified_date,
)
_validate_special_rules(merged)
if expense_in.center_id:
site = await site_crud.get_site(db, expense_in.center_id)
if not site or site.study_id != expense.project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense.project_id, expense_in.center_id)
else:
await _ensure_center_active(db, expense.project_id, expense.center_id)
expense = await special_crud.update_special_expense(db, expense, expense_in)
await audit_crud.log_action(
db,
study_id=expense.project_id,
entity_type="special_expense",
entity_id=expense_id,
action="UPDATE_SPECIAL_EXPENSE",
detail="特殊费用已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FeeApiResponse(data=SpecialExpenseRead.model_validate(expense))
@router.delete(
"/special/{expense_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(get_current_user), Depends(require_study_not_locked())],
)
async def delete_special_expense(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
expense = await special_crud.get_special_expense(db, expense_id)
if not expense:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
await _ensure_project_access(db, expense.project_id, current_user, write=True)
await _ensure_center_active(db, expense.project_id, expense.center_id)
await special_crud.delete_special_expense(db, expense)
await audit_crud.log_action(
db,
study_id=expense.project_id,
entity_type="special_expense",
entity_id=expense_id,
action="DELETE_SPECIAL_EXPENSE",
detail="特殊费用已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
-177
View File
@@ -1,177 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import finance_special as special_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post(
"/specials",
response_model=FinanceSpecialRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def create_special(
study_id: uuid.UUID,
special_in: FinanceSpecialCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and special_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, special_in.site_name)
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_special",
entity_id=item.id,
action="CREATE_FINANCE_SPECIAL",
detail=f"特殊费用 {item.id} 已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FinanceSpecialRead.model_validate(item)
@router.get(
"/specials",
response_model=list[FinanceSpecialRead],
dependencies=[Depends(require_study_member())],
)
async def list_specials(
study_id: uuid.UUID,
site_name: str | None = None,
fee_type: str | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FinanceSpecialRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
if site_name and site_names is not None and site_name not in site_names:
return []
items = await special_crud.list_specials(
db,
study_id,
site_name=site_name,
site_names=site_names,
fee_type=fee_type,
skip=skip,
limit=limit,
)
return [FinanceSpecialRead.model_validate(item) for item in items]
@router.get(
"/specials/{special_id}",
response_model=FinanceSpecialRead,
dependencies=[Depends(require_study_member())],
)
async def get_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FinanceSpecialRead.model_validate(item)
@router.patch(
"/specials/{special_id}",
response_model=FinanceSpecialRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def update_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
special_in: FinanceSpecialUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
item = await special_crud.update_special(db, item, special_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_special",
entity_id=special_id,
action="UPDATE_FINANCE_SPECIAL",
detail=f"特殊费用 {special_id} 已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FinanceSpecialRead.model_validate(item)
@router.delete(
"/specials/{special_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def delete_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
await special_crud.delete_special(db, item)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_special",
entity_id=special_id,
action="DELETE_FINANCE_SPECIAL",
detail=f"特殊费用 {special_id} 已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
+1 -3
View File
@@ -1,6 +1,6 @@
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, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues
api_router = APIRouter()
@@ -21,9 +21,7 @@ api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{s
api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["aes"])
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
api_router.include_router(finance_contracts.router, prefix="/studies/{study_id}/finance", tags=["finance-contracts"])
api_router.include_router(finance_specials.router, prefix="/studies/{study_id}/finance", tags=["finance-specials"])
api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-contracts"])
api_router.include_router(fees_specials.router, prefix="/fees", tags=["fees-specials"])
api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"])
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
api_router.include_router(material_equipments.router, prefix="/studies/{study_id}/materials", tags=["material-equipments"])
-77
View File
@@ -1,77 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.finance_special import FinanceSpecial
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialUpdate
async def create_special(
db: AsyncSession,
study_id: uuid.UUID,
special_in: FinanceSpecialCreate,
created_by: uuid.UUID | None,
) -> FinanceSpecial:
special = FinanceSpecial(
study_id=study_id,
site_name=special_in.site_name,
fee_type=special_in.fee_type,
amount=special_in.amount,
occur_date=special_in.occur_date,
staff_name=special_in.staff_name,
remark=special_in.remark,
created_by=created_by,
)
db.add(special)
await db.commit()
await db.refresh(special)
return special
async def get_special(db: AsyncSession, special_id: uuid.UUID) -> FinanceSpecial | None:
result = await db.execute(select(FinanceSpecial).where(FinanceSpecial.id == special_id))
return result.scalar_one_or_none()
async def list_specials(
db: AsyncSession,
study_id: uuid.UUID,
site_name: str | None = None,
site_names: set[str] | None = None,
fee_type: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceSpecial]:
stmt = (
select(FinanceSpecial)
.where(FinanceSpecial.study_id == study_id)
)
if site_names is not None:
if not site_names:
return []
stmt = stmt.where(FinanceSpecial.site_name.in_(site_names))
if site_name:
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
if fee_type:
stmt = stmt.where(FinanceSpecial.fee_type == fee_type)
stmt = stmt.order_by(FinanceSpecial.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_special(
db: AsyncSession, special: FinanceSpecial, special_in: FinanceSpecialUpdate
) -> FinanceSpecial:
update_data = special_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(special, key, value)
await db.commit()
await db.refresh(special)
return special
async def delete_special(db: AsyncSession, special: FinanceSpecial) -> None:
await db.delete(special)
await db.commit()
-38
View File
@@ -17,13 +17,11 @@ from app.models.drug_shipment import DrugShipment
from app.models.fee_attachment import FeeAttachment
from app.models.finance import FinanceItem
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.kickoff_meeting import KickoffMeeting
from app.models.knowledge_note import KnowledgeNote
from app.models.milestone import Milestone
from app.models.monitoring_visit_issue import MonitoringVisitIssue
from app.models.site import Site
from app.models.special_expense import SpecialExpense
from app.models.startup_ethics import StartupEthics
from app.models.startup_feasibility import StartupFeasibility
from app.models.study_center_confirm import StudyCenterConfirm
@@ -158,9 +156,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
contract_fee_ids = (
await db.execute(select(ContractFee.id).where(ContractFee.center_id == site_id))
).scalars().all()
special_expense_ids = (
await db.execute(select(SpecialExpense.id).where(SpecialExpense.center_id == site_id))
).scalars().all()
drug_shipment_ids = (
await db.execute(select(DrugShipment.id).where(DrugShipment.center_id == site_id))
).scalars().all()
@@ -189,14 +184,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
)
)
).scalars().all()
finance_special_ids = (
await db.execute(
select(FinanceSpecial.id).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
).scalars().all()
knowledge_note_ids = (
await db.execute(
select(KnowledgeNote.id).where(
@@ -233,7 +220,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
"startup_kickoff_ppt": kickoff_ids,
"training_authorization": training_ids,
"finance_contract": finance_contract_ids,
"finance_special": finance_special_ids,
"knowledge_note": knowledge_note_ids,
"drug_shipment": drug_shipment_ids,
}
@@ -279,23 +265,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
)
)
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
if special_expense_ids:
fee_attachment_paths.extend(
(
await db.execute(
select(FeeAttachment.storage_key).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
).scalars().all()
)
await db.execute(
delete(FeeAttachment).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
if distribution_ids:
await db.execute(delete(Acknowledgement).where(Acknowledgement.distribution_id.in_(distribution_ids)))
@@ -327,7 +296,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
await db.execute(delete(StartupEthics).where(StartupEthics.site_id == site_id))
await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id))
await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id))
await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id))
await db.execute(
update(MonitoringVisitIssue)
@@ -347,12 +315,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
FinanceContract.site_name == site_name,
)
)
await db.execute(
delete(FinanceSpecial).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
await db.execute(
delete(KnowledgeNote).where(
KnowledgeNote.study_id == study_id,
-104
View File
@@ -1,104 +0,0 @@
import uuid
from datetime import date
from typing import Sequence
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.attachment import Attachment
from app.models.special_expense import SpecialExpense
from app.models.site import Site
from app.schemas.special_expense import SpecialExpenseCreate, SpecialExpenseUpdate
async def create_special_expense(
db: AsyncSession,
expense_in: SpecialExpenseCreate,
created_by: uuid.UUID | None,
) -> SpecialExpense:
expense = SpecialExpense(
project_id=expense_in.project_id,
center_id=expense_in.center_id,
category=expense_in.category,
amount=expense_in.amount,
happen_date=expense_in.happen_date,
description=expense_in.description,
is_paid=expense_in.is_paid,
paid_date=expense_in.paid_date,
is_verified=expense_in.is_verified,
verified_date=expense_in.verified_date,
created_by=created_by,
)
db.add(expense)
await db.commit()
await db.refresh(expense)
return expense
async def get_special_expense(db: AsyncSession, expense_id: uuid.UUID) -> SpecialExpense | None:
result = await db.execute(select(SpecialExpense).where(SpecialExpense.id == expense_id))
return result.scalar_one_or_none()
async def list_special_expenses(
db: AsyncSession,
project_id: uuid.UUID,
center_id: uuid.UUID | None = None,
center_ids: set[uuid.UUID] | None = None,
category: str | None = None,
date_from: date | None = None,
date_to: date | None = None,
) -> Sequence[tuple[SpecialExpense, str | None, int]]:
attachment_count = func.count(Attachment.id).label("attachments_count")
attachment_types = ["special_expense_voucher", "special_expense_invoice", "special_expense_other"]
stmt = (
select(
SpecialExpense,
Site.name.label("center_name"),
attachment_count,
)
.outerjoin(Site, Site.id == SpecialExpense.center_id)
.outerjoin(
Attachment,
and_(
Attachment.entity_type.in_(attachment_types),
Attachment.entity_id == SpecialExpense.id,
Attachment.is_deleted.is_(False),
),
)
.where(SpecialExpense.project_id == project_id)
.group_by(SpecialExpense.id, Site.name)
.order_by(SpecialExpense.happen_date.desc().nullslast(), SpecialExpense.created_at.desc())
)
if center_id:
stmt = stmt.where(SpecialExpense.center_id == center_id)
if center_ids is not None:
if not center_ids:
return []
stmt = stmt.where(SpecialExpense.center_id.in_(center_ids))
if category:
stmt = stmt.where(SpecialExpense.category == category)
if date_from:
stmt = stmt.where(SpecialExpense.happen_date >= date_from)
if date_to:
stmt = stmt.where(SpecialExpense.happen_date <= date_to)
result = await db.execute(stmt)
return result.all()
async def update_special_expense(
db: AsyncSession, expense: SpecialExpense, expense_in: SpecialExpenseUpdate
) -> SpecialExpense:
update_data = expense_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(expense, key, value)
await db.commit()
await db.refresh(expense)
return expense
async def delete_special_expense(db: AsyncSession, expense: SpecialExpense) -> None:
await db.delete(expense)
await db.commit()
-4
View File
@@ -100,10 +100,8 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
from app.models.ae import AdverseEvent
from app.models.finance import FinanceItem
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.contract_fee import ContractFee
from app.models.contract_fee_payment import ContractFeePayment
from app.models.special_expense import SpecialExpense
from app.models.milestone import Milestone
from app.models.document import Document
from app.models.attachment import Attachment
@@ -152,7 +150,6 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
await db.execute(sa_delete(Milestone).where(Milestone.study_id == study_id))
# 9. 删除财务相关
await db.execute(sa_delete(SpecialExpense).where(SpecialExpense.project_id == study_id))
await db.execute(
sa_delete(ContractFeePayment).where(
ContractFeePayment.contract_fee_id.in_(
@@ -161,7 +158,6 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
)
)
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
await db.execute(sa_delete(FinanceSpecial).where(FinanceSpecial.study_id == study_id))
await db.execute(sa_delete(FinanceContract).where(FinanceContract.study_id == study_id))
await db.execute(sa_delete(FinanceItem).where(FinanceItem.study_id == study_id))
-2
View File
@@ -18,10 +18,8 @@ from app.models.visit import Visit # noqa: F401
from app.models.ae import AdverseEvent # noqa: F401
from app.models.finance import FinanceItem # noqa: F401
from app.models.finance_contract import FinanceContract # noqa: F401
from app.models.finance_special import FinanceSpecial # noqa: F401
from app.models.contract_fee import ContractFee # noqa: F401
from app.models.contract_fee_payment import ContractFeePayment # noqa: F401
from app.models.special_expense import SpecialExpense # noqa: F401
from app.models.fee_attachment import FeeAttachment # noqa: F401
from app.models.drug_shipment import DrugShipment # noqa: F401
from app.models.material_equipment import MaterialEquipment # noqa: F401
-26
View File
@@ -1,26 +0,0 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class FinanceSpecial(Base):
__tablename__ = "finance_specials"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
fee_type: Mapped[str] = mapped_column(String(50), nullable=False)
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
occur_date: Mapped[date | None] = mapped_column(Date, nullable=True)
staff_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
-30
View File
@@ -1,30 +0,0 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class SpecialExpense(Base):
__tablename__ = "special_expenses"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
center_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False)
amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
happen_date: Mapped[date | None] = mapped_column(Date, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
is_paid: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
paid_date: Mapped[date | None] = mapped_column(Date, nullable=True)
is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
verified_date: Mapped[date | None] = mapped_column(Date, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
-40
View File
@@ -1,40 +0,0 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class FinanceSpecialCreate(BaseModel):
site_name: str
fee_type: str
amount: Decimal = Field(gt=0)
occur_date: Optional[date] = None
staff_name: Optional[str] = None
remark: Optional[str] = None
class FinanceSpecialUpdate(BaseModel):
site_name: Optional[str] = None
fee_type: Optional[str] = None
amount: Optional[Decimal] = Field(default=None, gt=0)
occur_date: Optional[date] = None
staff_name: Optional[str] = None
remark: Optional[str] = None
class FinanceSpecialRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_name: str
fee_type: str
amount: Decimal
occur_date: Optional[date]
staff_name: Optional[str]
remark: Optional[str]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-55
View File
@@ -1,55 +0,0 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class SpecialExpenseCreate(BaseModel):
project_id: uuid.UUID
center_id: Optional[uuid.UUID] = None
category: str
amount: Decimal = Field(ge=0)
happen_date: Optional[date] = None
description: Optional[str] = None
is_paid: bool = False
paid_date: Optional[date] = None
is_verified: bool = False
verified_date: Optional[date] = None
class SpecialExpenseUpdate(BaseModel):
center_id: Optional[uuid.UUID] = None
category: Optional[str] = None
amount: Optional[Decimal] = Field(default=None, ge=0)
happen_date: Optional[date] = None
description: Optional[str] = None
is_paid: Optional[bool] = None
paid_date: Optional[date] = None
is_verified: Optional[bool] = None
verified_date: Optional[date] = None
class SpecialExpenseRead(BaseModel):
id: uuid.UUID
project_id: uuid.UUID
center_id: Optional[uuid.UUID]
category: str
amount: Decimal
happen_date: Optional[date]
description: Optional[str]
is_paid: bool
paid_date: Optional[date]
is_verified: bool
verified_date: Optional[date]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class SpecialExpenseListItem(SpecialExpenseRead):
center_name: Optional[str]
attachments_count: int