Step 10:费用管理

This commit is contained in:
Cheng Zhou
2025-12-16 19:35:26 +08:00
parent 882fcb0963
commit 156e02ff3a
97 changed files with 509 additions and 1 deletions
+190
View File
@@ -0,0 +1,190 @@
import uuid
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import finance as finance_crud
from app.crud import study as study_crud
from app.schemas.finance import FinanceCreate, FinanceRead, FinanceStatusUpdate, FinanceUpdate
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="Study not found")
return study
def _with_month(item: FinanceRead) -> FinanceRead:
item.month = item.occur_date.strftime("%Y-%m")
return item
@router.post(
"/items",
response_model=FinanceRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def create_finance_item(
study_id: uuid.UUID,
item_in: FinanceCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceRead:
await _ensure_study_exists(db, study_id)
try:
item = await finance_crud.create_item(db, study_id, item_in, created_by=current_user.id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance",
entity_id=item.id,
action="CREATE_FINANCE_ITEM",
detail=f"Finance {item.id} created",
operator_id=current_user.id,
operator_role=current_user.role,
)
return _with_month(FinanceRead.model_validate(item))
@router.get(
"/items",
response_model=list[FinanceRead],
dependencies=[Depends(require_study_member())],
)
async def list_finance_items(
study_id: uuid.UUID,
status_filter: str | None = None,
category: str | None = None,
site_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
date_from: date | None = None,
date_to: date | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
) -> list[FinanceRead]:
await _ensure_study_exists(db, study_id)
items = await finance_crud.list_items(
db,
study_id,
status=status_filter,
category=category,
site_id=site_id,
subject_id=subject_id,
date_from=date_from,
date_to=date_to,
skip=skip,
limit=limit,
)
return [_with_month(FinanceRead.model_validate(i)) for i in items]
@router.get(
"/items/{item_id}",
response_model=FinanceRead,
dependencies=[Depends(require_study_member())],
)
async def get_finance_item(
study_id: uuid.UUID,
item_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
) -> FinanceRead:
await _ensure_study_exists(db, study_id)
item = await finance_crud.get_item(db, item_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finance item not found")
return _with_month(FinanceRead.model_validate(item))
@router.patch(
"/items/{item_id}",
response_model=FinanceRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def update_finance_item(
study_id: uuid.UUID,
item_id: uuid.UUID,
item_in: FinanceUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceRead:
await _ensure_study_exists(db, study_id)
item = await finance_crud.get_item(db, item_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finance item not found")
try:
updated = await finance_crud.update_item_draft(db, item, item_in)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance",
entity_id=item_id,
action="UPDATE_FINANCE_ITEM",
detail=f"Finance {item_id} updated",
operator_id=current_user.id,
operator_role=current_user.role,
)
return _with_month(FinanceRead.model_validate(updated))
@router.post(
"/items/{item_id}/status",
response_model=FinanceRead,
dependencies=[Depends(require_study_member())],
)
async def change_finance_status(
study_id: uuid.UUID,
item_id: uuid.UUID,
status_in: FinanceStatusUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceRead:
await _ensure_study_exists(db, study_id)
item = await finance_crud.get_item(db, item_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Finance item not found")
# permission by status target
from app.crud import member as member_crud
member = await member_crud.get_member(db, study_id, current_user.id)
member_role = member.role_in_study if member else None
target = status_in.status
if target in {"SUBMITTED"}:
if current_user.role != "ADMIN" and member_role not in {"PM", "CRA"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
elif target in {"APPROVED", "REJECTED", "PAID"}:
if current_user.role != "ADMIN" and member_role not in {"PM"}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unsupported status")
old_status = item.status
try:
updated = await finance_crud.change_status(db, item, status_in, operator_id=current_user.id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
detail = f"Finance {item_id} {old_status} -> {status_in.status}"
action = "FINANCE_STATUS_CHANGE"
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance",
entity_id=item_id,
action=action,
detail=detail,
operator_id=current_user.id,
operator_role=current_user.role,
)
return _with_month(FinanceRead.model_validate(updated))
+32
View File
@@ -0,0 +1,32 @@
import uuid
from datetime import date
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 finance as finance_crud
from app.crud import study as study_crud
from app.schemas.finance import FinanceSummaryRead
router = APIRouter()
@router.get("/summary", response_model=FinanceSummaryRead, dependencies=[Depends(require_study_member())])
async def finance_summary(
study_id: uuid.UUID,
date_from: date | None = None,
date_to: date | None = None,
db: AsyncSession = Depends(get_db_session),
) -> FinanceSummaryRead:
# ensure study exists
await study_crud.get(db, study_id)
row = await finance_crud.summary(db, study_id, date_from=date_from, date_to=date_to)
return FinanceSummaryRead(
total_amount=row.total_amount,
paid_amount=row.paid_amount,
approved_amount=row.approved_amount,
submitted_amount=row.submitted_amount,
count_total=row.count_total,
count_paid=row.count_paid,
)
+3 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions from app.api.v1 import auth, users, studies, sites, members, comments, attachments, audit_logs, milestones, tasks, dashboard, subjects, visits, aes, issues, data_queries, verifications, imp_products, imp_batches, imp_inventory, imp_transactions, finance, finance_dashboard
api_router = APIRouter() api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
@@ -24,3 +24,5 @@ api_router.include_router(imp_products.router, prefix="/studies/{study_id}/imp/p
api_router.include_router(imp_batches.router, prefix="/studies/{study_id}/imp/batches", tags=["imp-batches"]) api_router.include_router(imp_batches.router, prefix="/studies/{study_id}/imp/batches", tags=["imp-batches"])
api_router.include_router(imp_inventory.router, prefix="/studies/{study_id}/imp/inventory", tags=["imp-inventory"]) api_router.include_router(imp_inventory.router, prefix="/studies/{study_id}/imp/inventory", tags=["imp-inventory"])
api_router.include_router(imp_transactions.router, prefix="/studies/{study_id}/imp/transactions", tags=["imp-transactions"]) api_router.include_router(imp_transactions.router, prefix="/studies/{study_id}/imp/transactions", tags=["imp-transactions"])
api_router.include_router(finance.router, prefix="/studies/{study_id}/finance", tags=["finance"])
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
+174
View File
@@ -0,0 +1,174 @@
import uuid
from datetime import date, datetime, timezone
from decimal import Decimal
from typing import Sequence
from sqlalchemy import Numeric, func, select, update as sa_update, case
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.finance import FinanceItem
from app.models.site import Site
from app.models.subject import Subject
from app.schemas.finance import FinanceCreate, FinanceStatusUpdate, FinanceUpdate
VALID_TRANSITIONS = {
"DRAFT": {"SUBMITTED"},
"SUBMITTED": {"APPROVED", "REJECTED"},
"APPROVED": {"PAID"},
"REJECTED": set(),
"PAID": set(),
}
async def _validate_site_subject(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID | None, subject_id: uuid.UUID | None):
subj_site = None
if site_id:
result = await db.execute(select(Site).where(Site.id == site_id))
site = result.scalar_one_or_none()
if not site or site.study_id != study_id:
raise ValueError("Site not found in study")
if subject_id:
result = await db.execute(select(Subject).where(Subject.id == subject_id))
subj = result.scalar_one_or_none()
if not subj or subj.study_id != study_id:
raise ValueError("Subject not found in study")
subj_site = subj.site_id
if site_id and subj_site and site_id != subj_site:
raise ValueError("Site and subject mismatch")
async def create_item(db: AsyncSession, study_id: uuid.UUID, item_in: FinanceCreate, *, created_by: uuid.UUID) -> FinanceItem:
await _validate_site_subject(db, study_id, item_in.site_id, item_in.subject_id)
item = FinanceItem(
study_id=study_id,
site_id=item_in.site_id,
subject_id=item_in.subject_id,
visit_id=item_in.visit_id,
category=item_in.category,
title=item_in.title,
description=item_in.description,
currency=item_in.currency,
amount=item_in.amount,
occur_date=item_in.occur_date,
status="DRAFT",
created_by=created_by,
)
db.add(item)
await db.commit()
await db.refresh(item)
return item
async def get_item(db: AsyncSession, item_id: uuid.UUID) -> FinanceItem | None:
result = await db.execute(select(FinanceItem).where(FinanceItem.id == item_id))
return result.scalar_one_or_none()
async def list_items(
db: AsyncSession,
study_id: uuid.UUID,
*,
status: str | None = None,
category: str | None = None,
site_id: uuid.UUID | None = None,
subject_id: uuid.UUID | None = None,
date_from: date | None = None,
date_to: date | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceItem]:
stmt = select(FinanceItem).where(FinanceItem.study_id == study_id)
if status:
stmt = stmt.where(FinanceItem.status == status)
if category:
stmt = stmt.where(FinanceItem.category == category)
if site_id:
stmt = stmt.where(FinanceItem.site_id == site_id)
if subject_id:
stmt = stmt.where(FinanceItem.subject_id == subject_id)
if date_from:
stmt = stmt.where(FinanceItem.occur_date >= date_from)
if date_to:
stmt = stmt.where(FinanceItem.occur_date <= date_to)
stmt = stmt.offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_item_draft(db: AsyncSession, item: FinanceItem, item_in: FinanceUpdate) -> FinanceItem:
if item.status != "DRAFT":
raise ValueError("Only DRAFT items can be edited")
update_data = item_in.model_dump(exclude_unset=True)
if update_data:
await db.execute(
sa_update(FinanceItem)
.where(FinanceItem.id == item.id)
.values(**update_data)
)
await db.commit()
await db.refresh(item)
return item
async def change_status(db: AsyncSession, item: FinanceItem, status_in: FinanceStatusUpdate, *, operator_id: uuid.UUID) -> FinanceItem:
target = status_in.status
allowed = VALID_TRANSITIONS.get(item.status, set())
if target not in allowed:
raise ValueError(f"Invalid status transition {item.status} -> {target}")
update_data = {"status": target}
now = datetime.now(timezone.utc)
if target == "SUBMITTED":
update_data["submitted_at"] = now
if target == "APPROVED":
update_data["approved_at"] = now
update_data["approver_id"] = operator_id
update_data["reject_reason"] = None
if target == "REJECTED":
if not status_in.reject_reason:
raise ValueError("reject_reason required")
update_data["rejected_at"] = now
update_data["approver_id"] = operator_id
update_data["reject_reason"] = status_in.reject_reason
if target == "PAID":
if item.status != "APPROVED":
raise ValueError("Only APPROVED can transition to PAID")
update_data["paid_at"] = now
update_data["payer_id"] = operator_id
await db.execute(
sa_update(FinanceItem)
.where(FinanceItem.id == item.id)
.values(**update_data)
)
await db.commit()
await db.refresh(item)
return item
async def summary(
db: AsyncSession,
study_id: uuid.UUID,
*,
date_from: date | None = None,
date_to: date | None = None,
):
stmt = select(
func.coalesce(func.sum(FinanceItem.amount), 0).label("total_amount"),
func.coalesce(func.sum(case((FinanceItem.status == "PAID", FinanceItem.amount), else_=0)), 0).label(
"paid_amount"
),
func.coalesce(func.sum(case((FinanceItem.status == "APPROVED", FinanceItem.amount), else_=0)), 0).label(
"approved_amount"
),
func.coalesce(func.sum(case((FinanceItem.status == "SUBMITTED", FinanceItem.amount), else_=0)), 0).label(
"submitted_amount"
),
func.count().label("count_total"),
func.coalesce(func.sum(case((FinanceItem.status == "PAID", 1), else_=0)), 0).label("count_paid"),
).where(FinanceItem.study_id == study_id)
if date_from:
stmt = stmt.where(FinanceItem.occur_date >= date_from)
if date_to:
stmt = stmt.where(FinanceItem.occur_date <= date_to)
result = await db.execute(stmt)
return result.one()
+1
View File
@@ -20,3 +20,4 @@ from app.models.imp_product import ImpProduct # noqa: F401
from app.models.imp_batch import ImpBatch # noqa: F401 from app.models.imp_batch import ImpBatch # noqa: F401
from app.models.imp_inventory import ImpInventory # noqa: F401 from app.models.imp_inventory import ImpInventory # noqa: F401
from app.models.imp_transaction import ImpTransaction # noqa: F401 from app.models.imp_transaction import ImpTransaction # noqa: F401
from app.models.finance import FinanceItem # noqa: F401
+37
View File
@@ -0,0 +1,37 @@
import uuid
from datetime import datetime, date
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 FinanceItem(Base):
__tablename__ = "finance_items"
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_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
subject_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("subjects.id"), index=True, nullable=True)
visit_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False)
title: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
currency: Mapped[str] = mapped_column(String(10), nullable=False, default="CNY")
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
occur_date: Mapped[date] = mapped_column(Date, nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT")
submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
rejected_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
paid_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approver_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
payer_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
reject_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
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()
)
+72
View File
@@ -0,0 +1,72 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class FinanceCreate(BaseModel):
site_id: Optional[uuid.UUID] = None
subject_id: Optional[uuid.UUID] = None
visit_id: Optional[uuid.UUID] = None
category: str
title: str
description: Optional[str] = None
currency: str = "CNY"
amount: Decimal = Field(gt=0)
occur_date: date
class FinanceUpdate(BaseModel):
site_id: Optional[uuid.UUID] = None
subject_id: Optional[uuid.UUID] = None
visit_id: Optional[uuid.UUID] = None
category: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
currency: Optional[str] = None
amount: Optional[Decimal] = Field(default=None, gt=0)
occur_date: Optional[date] = None
class FinanceStatusUpdate(BaseModel):
status: str
reject_reason: Optional[str] = None
class FinanceRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_id: Optional[uuid.UUID]
subject_id: Optional[uuid.UUID]
visit_id: Optional[uuid.UUID]
category: str
title: str
description: Optional[str]
currency: str
amount: Decimal
occur_date: date
status: str
submitted_at: Optional[datetime]
approved_at: Optional[datetime]
rejected_at: Optional[datetime]
paid_at: Optional[datetime]
approver_id: Optional[uuid.UUID]
payer_id: Optional[uuid.UUID]
reject_reason: Optional[str]
created_by: uuid.UUID
created_at: datetime
updated_at: datetime
month: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class FinanceSummaryRead(BaseModel):
total_amount: Decimal
paid_amount: Decimal
approved_amount: Decimal
submitted_amount: Decimal
count_total: int
count_paid: int
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.