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 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.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_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(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_inventory import ImpInventory # 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
|
||||
Reference in New Issue
Block a user