Step 10:费用管理
This commit is contained in:
@@ -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))
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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.
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.
Reference in New Issue
Block a user