From 1db36f40b03191d93f481b43000193e1dd4e91be Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Tue, 13 Jan 2026 16:49:55 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B9=E7=94=A8=E7=AE=A1=E7=90=86=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E4=BC=98=E5=8C=96-=E5=88=9D=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20240501_000006_create_fee_tables.py | 104 +++ backend/app/api/v1/attachments.py | 2 + backend/app/api/v1/fees_attachments.py | 255 +++++++ backend/app/api/v1/fees_contracts.py | 368 ++++++++++ backend/app/api/v1/fees_specials.py | 224 ++++++ backend/app/api/v1/router.py | 5 +- backend/app/crud/contract_fee.py | 112 +++ backend/app/crud/contract_fee_payment.py | 74 ++ backend/app/crud/fee_attachment.py | 70 ++ backend/app/crud/special_expense.py | 98 +++ backend/app/db/base.py | 4 + backend/app/main.py | 2 + backend/app/models/contract_fee.py | 29 + backend/app/models/contract_fee_payment.py | 29 + backend/app/models/fee_attachment.py | 25 + backend/app/models/special_expense.py | 30 + backend/app/schemas/contract_fee.py | 57 ++ backend/app/schemas/contract_fee_payment.py | 40 ++ backend/app/schemas/fee_attachment.py | 24 + backend/app/schemas/fee_common.py | 11 + backend/app/schemas/special_expense.py | 55 ++ backend/app/services/__init__.py | 0 backend/app/services/fee_seed.py | 175 +++++ database/init.sql | 67 ++ frontend/src/api/feeAttachments.ts | 32 + frontend/src/api/feeContracts.ts | 50 ++ frontend/src/api/feeSpecials.ts | 45 ++ frontend/src/components/KpiCard.vue | 84 ++- frontend/src/components/Layout.vue | 12 +- frontend/src/components/QuickActions.vue | 2 +- frontend/src/components/StateError.vue | 5 +- .../components/attachments/AttachmentList.vue | 62 +- .../components/fees/FeeAttachmentPanel.vue | 322 +++++++++ frontend/src/locales/zh-CN.ts | 111 ++- frontend/src/router/index.ts | 54 ++ frontend/src/types/api.ts | 6 + frontend/src/utils/permission.ts | 4 + frontend/src/views/fees/ContractFeeDetail.vue | 305 +++++++++ frontend/src/views/fees/ContractFeeForm.vue | 635 ++++++++++++++++++ frontend/src/views/fees/ContractFees.vue | 441 ++++++++++++ .../src/views/fees/SpecialExpenseDetail.vue | 283 ++++++++ .../src/views/fees/SpecialExpenseForm.vue | 495 ++++++++++++++ frontend/src/views/fees/SpecialExpenses.vue | 447 ++++++++++++ 43 files changed, 5229 insertions(+), 26 deletions(-) create mode 100644 backend/alembic/versions/20240501_000006_create_fee_tables.py create mode 100644 backend/app/api/v1/fees_attachments.py create mode 100644 backend/app/api/v1/fees_contracts.py create mode 100644 backend/app/api/v1/fees_specials.py create mode 100644 backend/app/crud/contract_fee.py create mode 100644 backend/app/crud/contract_fee_payment.py create mode 100644 backend/app/crud/fee_attachment.py create mode 100644 backend/app/crud/special_expense.py create mode 100644 backend/app/models/contract_fee.py create mode 100644 backend/app/models/contract_fee_payment.py create mode 100644 backend/app/models/fee_attachment.py create mode 100644 backend/app/models/special_expense.py create mode 100644 backend/app/schemas/contract_fee.py create mode 100644 backend/app/schemas/contract_fee_payment.py create mode 100644 backend/app/schemas/fee_attachment.py create mode 100644 backend/app/schemas/fee_common.py create mode 100644 backend/app/schemas/special_expense.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/fee_seed.py create mode 100644 frontend/src/api/feeAttachments.ts create mode 100644 frontend/src/api/feeContracts.ts create mode 100644 frontend/src/api/feeSpecials.ts create mode 100644 frontend/src/components/fees/FeeAttachmentPanel.vue create mode 100644 frontend/src/views/fees/ContractFeeDetail.vue create mode 100644 frontend/src/views/fees/ContractFeeForm.vue create mode 100644 frontend/src/views/fees/ContractFees.vue create mode 100644 frontend/src/views/fees/SpecialExpenseDetail.vue create mode 100644 frontend/src/views/fees/SpecialExpenseForm.vue create mode 100644 frontend/src/views/fees/SpecialExpenses.vue diff --git a/backend/alembic/versions/20240501_000006_create_fee_tables.py b/backend/alembic/versions/20240501_000006_create_fee_tables.py new file mode 100644 index 00000000..f45f53a4 --- /dev/null +++ b/backend/alembic/versions/20240501_000006_create_fee_tables.py @@ -0,0 +1,104 @@ +"""create fee tables + +Revision ID: 20240501_000006 +Revises: 20240501_000005 +Create Date: 2025-02-14 00:00:00 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "20240501_000006" +down_revision = "20240501_000005" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "contract_fees", + sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("project_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("center_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("contract_amount", sa.Numeric(12, 2), nullable=False), + sa.Column("contract_cases", sa.Integer(), nullable=False), + sa.Column("actual_cases", sa.Integer(), nullable=True), + sa.Column("settlement_amount", sa.Numeric(12, 2), nullable=True), + sa.Column("final_payment_amount", sa.Numeric(12, 2), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["project_id"], ["studies.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["center_id"], ["sites.id"], ondelete="RESTRICT"), + sa.UniqueConstraint("project_id", "center_id", name="uq_contract_fees_project_center"), + ) + op.create_index("ix_contract_fees_project_id", "contract_fees", ["project_id"]) + op.create_index("ix_contract_fees_center_id", "contract_fees", ["center_id"]) + + op.create_table( + "contract_fee_payments", + sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("contract_fee_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("seq", sa.Integer(), nullable=False), + sa.Column("amount", sa.Numeric(12, 2), nullable=False), + sa.Column("paid_date", sa.Date(), nullable=True), + sa.Column("verified_date", sa.Date(), nullable=True), + sa.Column("is_paid", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("is_verified", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("remark", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["contract_fee_id"], ["contract_fees.id"], ondelete="CASCADE"), + ) + op.create_index("ix_contract_fee_payments_contract_fee_id", "contract_fee_payments", ["contract_fee_id"]) + + op.create_table( + "special_expenses", + sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("project_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("center_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("category", sa.String(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=sa.text("false"), nullable=False), + sa.Column("paid_date", sa.Date(), nullable=True), + sa.Column("is_verified", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("verified_date", sa.Date(), nullable=True), + sa.Column("created_by", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["project_id"], ["studies.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["center_id"], ["sites.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="RESTRICT"), + ) + op.create_index("ix_special_expenses_project_id", "special_expenses", ["project_id"]) + op.create_index("ix_special_expenses_center_id", "special_expenses", ["center_id"]) + + op.create_table( + "fee_attachments", + sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True), + sa.Column("entity_type", sa.String(50), nullable=False), + sa.Column("entity_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("file_type", sa.String(30), nullable=False), + sa.Column("filename", sa.String(255), nullable=False), + sa.Column("mime_type", sa.String(100), nullable=True), + sa.Column("size", sa.Integer(), nullable=False), + sa.Column("storage_key", sa.String(500), nullable=True), + sa.Column("url", sa.Text(), nullable=True), + sa.Column("uploaded_by", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("uploaded_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("is_deleted", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.ForeignKeyConstraint(["uploaded_by"], ["users.id"], ondelete="RESTRICT"), + ) + + +def downgrade() -> None: + op.drop_table("fee_attachments") + op.drop_index("ix_special_expenses_center_id", table_name="special_expenses") + op.drop_index("ix_special_expenses_project_id", table_name="special_expenses") + op.drop_table("special_expenses") + op.drop_index("ix_contract_fee_payments_contract_fee_id", table_name="contract_fee_payments") + op.drop_table("contract_fee_payments") + op.drop_index("ix_contract_fees_center_id", table_name="contract_fees") + op.drop_index("ix_contract_fees_project_id", table_name="contract_fees") + op.drop_table("contract_fees") diff --git a/backend/app/api/v1/attachments.py b/backend/app/api/v1/attachments.py index 730cf5b9..f44c7d73 100644 --- a/backend/app/api/v1/attachments.py +++ b/backend/app/api/v1/attachments.py @@ -137,6 +137,7 @@ async def download_attachment( path=attachment.file_path, filename=attachment.filename, media_type=attachment.content_type or "application/octet-stream", + headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'}, ) @@ -181,6 +182,7 @@ async def global_download_attachment( path=attachment.file_path, filename=attachment.filename, media_type=attachment.content_type or "application/octet-stream", + headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'}, ) diff --git a/backend/app/api/v1/fees_attachments.py b/backend/app/api/v1/fees_attachments.py new file mode 100644 index 00000000..5b3951b2 --- /dev/null +++ b/backend/app/api/v1/fees_attachments.py @@ -0,0 +1,255 @@ +import os +import uuid +from pathlib import Path + +import aiofiles +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request, Form +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session +from app.core.security import decode_token +from app.crud import audit as audit_crud +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 +from app.schemas.user import UserDisplay + +router = APIRouter() + +UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "fees" + +ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment", "special_expense"} +ALLOWED_FILE_TYPES = { + "contract_fee": {"contract", "voucher", "invoice"}, + "contract_payment": {"voucher", "invoice"}, + "special_expense": {"voucher", "invoice", "other"}, +} + + +async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> uuid.UUID: + if entity_type == "contract_fee": + contract = await contract_fee_crud.get_contract_fee(db, entity_id) + if not contract: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") + return contract.project_id + if entity_type == "contract_payment": + payment = await payment_crud.get_payment(db, entity_id) + if not payment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在") + contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id) + 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="不支持的附件类型") + + +async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False): + 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 + + +async def _authorize_download_user(request: Request, db: AsyncSession): + token = None + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1] + if not token: + token = request.query_params.get("token") + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录") + payload = decode_token(token) + user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub")))) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用") + return user + + +@router.post( + "/attachments", + response_model=FeeApiResponse[FeeAttachmentRead], + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(get_current_user)], +) +async def upload_fee_attachment( + entity_type: str = Form(...), + entity_id: uuid.UUID = Form(...), + file_type: str = Form(...), + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FeeApiResponse[FeeAttachmentRead]: + if entity_type not in ALLOWED_ENTITY_TYPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效") + if file_type not in ALLOWED_FILE_TYPES.get(entity_type, set()): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效") + project_id = await _resolve_project_id(db, entity_type, entity_id) + await _ensure_project_access(db, project_id, current_user, write=True) + + dest_dir = UPLOAD_ROOT / f"{entity_type}_{entity_id}" + dest_dir.mkdir(parents=True, exist_ok=True) + unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}" + dest_path = dest_dir / unique_name + + content = await file.read() + async with aiofiles.open(dest_path, "wb") as out_file: + await out_file.write(content) + + attachment = await fee_attachment_crud.create_attachment( + db, + entity_type=entity_type, + entity_id=entity_id, + file_type=file_type, + filename=file.filename, + mime_type=file.content_type, + size=len(content), + storage_key=str(dest_path), + url=None, + uploaded_by=current_user.id, + ) + await audit_crud.log_action( + db, + study_id=project_id, + entity_type=entity_type, + entity_id=entity_id, + action="UPLOAD_FEE_ATTACHMENT", + detail=f"附件已上传:{file.filename}", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return FeeApiResponse( + data=FeeAttachmentRead( + id=attachment.id, + entity_type=attachment.entity_type, + entity_id=attachment.entity_id, + file_type=attachment.file_type, + filename=attachment.filename, + mime_type=attachment.mime_type, + size=attachment.size, + storage_key=attachment.storage_key, + url=attachment.url, + uploaded_by=UserDisplay.model_validate(current_user), + uploaded_by_id=attachment.uploaded_by, + uploaded_at=attachment.uploaded_at, + ) + ) + + +@router.get( + "/attachments", + response_model=FeeApiResponse[list[FeeAttachmentRead]], + dependencies=[Depends(get_current_user)], +) +async def list_fee_attachments( + entity_type: str, + entity_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FeeApiResponse[list[FeeAttachmentRead]]: + if entity_type not in ALLOWED_ENTITY_TYPES: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效") + project_id = await _resolve_project_id(db, entity_type, entity_id) + await _ensure_project_access(db, project_id, current_user, write=False) + attachments = await fee_attachment_crud.list_attachments(db, entity_type=entity_type, entity_id=entity_id) + user_ids = {a.uploaded_by for a in attachments if a.uploaded_by} + users_map = await user_crud.get_users_by_ids(db, user_ids) + items: list[FeeAttachmentRead] = [] + for attachment in attachments: + user = users_map.get(attachment.uploaded_by) + items.append( + FeeAttachmentRead( + id=attachment.id, + entity_type=attachment.entity_type, + entity_id=attachment.entity_id, + file_type=attachment.file_type, + filename=attachment.filename, + mime_type=attachment.mime_type, + size=attachment.size, + storage_key=attachment.storage_key, + url=attachment.url, + uploaded_by=UserDisplay.model_validate(user) if user else None, + uploaded_by_id=attachment.uploaded_by, + uploaded_at=attachment.uploaded_at, + ) + ) + return FeeApiResponse(data=items, meta={"total": len(items)}) + + +@router.get( + "/attachments/{attachment_id}/download", + response_class=FileResponse, +) +async def download_fee_attachment( + attachment_id: uuid.UUID, + request: Request, + db: AsyncSession = Depends(get_db_session), +): + attachment = await fee_attachment_crud.get_attachment(db, attachment_id) + if not attachment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") + current_user = await _authorize_download_user(request, db) + project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id) + await _ensure_project_access(db, project_id, current_user, write=False) + if not attachment.storage_key or not os.path.exists(attachment.storage_key): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件") + return FileResponse( + path=attachment.storage_key, + filename=attachment.filename, + media_type=attachment.mime_type or "application/octet-stream", + headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'}, + ) + + +@router.delete( + "/attachments/{attachment_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(get_current_user)], +) +async def delete_fee_attachment( + attachment_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + attachment = await fee_attachment_crud.get_attachment(db, attachment_id) + if not attachment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在") + project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id) + membership = await _ensure_project_access(db, project_id, current_user, write=True) + + role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + can_delete = ( + role_value == "ADMIN" + or attachment.uploaded_by == current_user.id + or (membership and getattr(membership, "role_in_study", None) == "PM") + ) + if not can_delete: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件") + + await fee_attachment_crud.soft_delete_attachment(db, attachment) + await audit_crud.log_action( + db, + study_id=project_id, + entity_type=attachment.entity_type, + entity_id=attachment.entity_id, + action="DELETE_FEE_ATTACHMENT", + detail=f"附件已删除:{attachment.filename}", + operator_id=current_user.id, + operator_role=current_user.role, + ) diff --git a/backend/app/api/v1/fees_contracts.py b/backend/app/api/v1/fees_contracts.py new file mode 100644 index 00000000..f7973769 --- /dev/null +++ b/backend/app/api/v1/fees_contracts.py @@ -0,0 +1,368 @@ +import uuid +from decimal import Decimal +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session +from app.crud import audit as audit_crud +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 site as site_crud +from app.crud import user as user_crud +from app.crud import study as study_crud +from app.schemas.contract_fee import ContractFeeCreate, ContractFeeDetail, ContractFeeListItem, ContractFeeRead, ContractFeeUpdate +from app.schemas.contract_fee_payment import ( + ContractFeePaymentCreate, + ContractFeePaymentRead, + ContractFeePaymentUpdate, +) +from app.schemas.fee_common import FeeApiResponse +from app.schemas.fee_attachment import FeeAttachmentRead +from app.schemas.user import UserDisplay + +router = APIRouter() + + +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 _to_decimal(value: Any) -> Decimal: + if isinstance(value, Decimal): + return value + if value is None: + return Decimal("0") + try: + return Decimal(str(value)) + except Exception: + return Decimal("0") + + +def _validate_payment_rules(data: ContractFeePaymentCreate | ContractFeePaymentUpdate): + if data.is_verified is True and data.is_paid is False: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="核销需先打款") + if data.is_paid and not data.paid_date: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已打款需填写打款日期") + if data.is_verified and not data.verified_date: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="已核销需填写核销日期") + + +@router.get( + "/contracts", + response_model=FeeApiResponse[list[ContractFeeListItem]], + dependencies=[Depends(get_current_user)], +) +async def list_contract_fees( + project_id: uuid.UUID = Query(..., alias="projectId"), + center_id: uuid.UUID | None = Query(None, alias="centerId"), + q: str | None = None, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FeeApiResponse[list[ContractFeeListItem]]: + await _ensure_project_access(db, project_id, current_user, write=False) + rows = await contract_fee_crud.list_contract_fees(db, project_id, center_id=center_id, q=q) + items: list[ContractFeeListItem] = [] + for contract, center_name, paid_total, verified_total, last_paid_date, last_verified_date in rows: + paid_total_decimal = _to_decimal(paid_total) + verified_total_decimal = _to_decimal(verified_total) + contract_amount_decimal = _to_decimal(contract.contract_amount) + unpaid_balance = contract_amount_decimal - paid_total_decimal + unverified_balance = paid_total_decimal - verified_total_decimal + items.append( + ContractFeeListItem( + id=contract.id, + project_id=contract.project_id, + center_id=contract.center_id, + contract_amount=contract_amount_decimal, + contract_cases=contract.contract_cases, + actual_cases=contract.actual_cases, + settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None, + final_payment_amount=_to_decimal(contract.final_payment_amount) if contract.final_payment_amount else None, + created_at=contract.created_at, + updated_at=contract.updated_at, + center_name=center_name or "", + paid_total=paid_total_decimal, + verified_total=verified_total_decimal, + unpaid_balance=unpaid_balance, + unverified_balance=unverified_balance, + last_paid_date=last_paid_date, + last_verified_date=last_verified_date, + ) + ) + return FeeApiResponse(data=items, meta={"total": len(items)}) + + +@router.get( + "/contracts/{contract_id}", + response_model=FeeApiResponse[ContractFeeDetail], + dependencies=[Depends(get_current_user)], +) +async def get_contract_fee( + contract_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FeeApiResponse[ContractFeeDetail]: + contract = await contract_fee_crud.get_contract_fee(db, contract_id) + if not contract: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") + await _ensure_project_access(db, contract.project_id, current_user, write=False) + + payments = await payment_crud.list_payments(db, contract.id) + attachments = await fee_attachment_crud.list_attachments(db, entity_type="contract_fee", entity_id=contract.id) + user_ids = {a.uploaded_by for a in attachments if a.uploaded_by} + users_map = await user_crud.get_users_by_ids(db, user_ids) + + attachments_map: dict[str, list[FeeAttachmentRead]] = { + "contract": [], + "voucher": [], + "invoice": [], + } + for attachment in attachments: + key = attachment.file_type + attachments_map.setdefault(key, []) + user = users_map.get(attachment.uploaded_by) + attachments_map[key].append( + FeeAttachmentRead( + id=attachment.id, + entity_type=attachment.entity_type, + entity_id=attachment.entity_id, + file_type=attachment.file_type, + filename=attachment.filename, + mime_type=attachment.mime_type, + size=attachment.size, + storage_key=attachment.storage_key, + url=attachment.url, + uploaded_by_id=attachment.uploaded_by, + uploaded_by=UserDisplay.model_validate(user) if user else None, + uploaded_at=attachment.uploaded_at, + ) + ) + + payment_reads = [ContractFeePaymentRead.model_validate(payment) for payment in payments] + + detail = ContractFeeDetail( + id=contract.id, + project_id=contract.project_id, + center_id=contract.center_id, + contract_amount=_to_decimal(contract.contract_amount), + contract_cases=contract.contract_cases, + actual_cases=contract.actual_cases, + settlement_amount=_to_decimal(contract.settlement_amount) if contract.settlement_amount else None, + final_payment_amount=_to_decimal(contract.final_payment_amount) if contract.final_payment_amount else None, + created_at=contract.created_at, + updated_at=contract.updated_at, + payments=payment_reads, + attachments=attachments_map, + ) + + return FeeApiResponse(data=detail) + + +@router.post( + "/contracts", + response_model=FeeApiResponse[ContractFeeRead], + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(get_current_user)], +) +async def create_contract_fee( + contract_in: ContractFeeCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FeeApiResponse[ContractFeeRead]: + await _ensure_project_access(db, contract_in.project_id, current_user, write=True) + existing = await contract_fee_crud.get_contract_fee_by_project_center( + db, contract_in.project_id, contract_in.center_id + ) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该中心已存在合同费用") + + site = await site_crud.get_site(db, contract_in.center_id) + if not site or site.study_id != contract_in.project_id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目") + + contract = await contract_fee_crud.create_contract_fee(db, contract_in) + await audit_crud.log_action( + db, + study_id=contract.project_id, + entity_type="contract_fee", + entity_id=contract.id, + action="CREATE_CONTRACT_FEE", + detail="合同费用已创建", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) + + +@router.patch( + "/contracts/{contract_id}", + response_model=FeeApiResponse[ContractFeeRead], + dependencies=[Depends(get_current_user)], +) +async def update_contract_fee( + contract_id: uuid.UUID, + contract_in: ContractFeeUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), + ) -> FeeApiResponse[ContractFeeRead]: + contract = await contract_fee_crud.get_contract_fee(db, contract_id) + if not contract: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") + await _ensure_project_access(db, contract.project_id, current_user, write=True) + contract = await contract_fee_crud.update_contract_fee(db, contract, contract_in) + await audit_crud.log_action( + db, + study_id=contract.project_id, + entity_type="contract_fee", + entity_id=contract_id, + action="UPDATE_CONTRACT_FEE", + detail="合同费用已更新", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return FeeApiResponse(data=ContractFeeRead.model_validate(contract)) + + +@router.delete( + "/contracts/{contract_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(get_current_user)], +) +async def delete_contract_fee( + contract_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + contract = await contract_fee_crud.get_contract_fee(db, contract_id) + if not contract: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") + await _ensure_project_access(db, contract.project_id, current_user, write=True) + await contract_fee_crud.delete_contract_fee(db, contract) + await audit_crud.log_action( + db, + study_id=contract.project_id, + entity_type="contract_fee", + entity_id=contract_id, + action="DELETE_CONTRACT_FEE", + detail="合同费用已删除", + operator_id=current_user.id, + operator_role=current_user.role, + ) + + +@router.post( + "/contracts/{contract_id}/payments", + response_model=FeeApiResponse[ContractFeePaymentRead], + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(get_current_user)], +) +async def create_contract_payment( + contract_id: uuid.UUID, + payment_in: ContractFeePaymentCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FeeApiResponse[ContractFeePaymentRead]: + contract = await contract_fee_crud.get_contract_fee(db, contract_id) + if not contract: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") + await _ensure_project_access(db, contract.project_id, current_user, write=True) + _validate_payment_rules(payment_in) + payment = await payment_crud.create_payment(db, contract_id, payment_in) + await audit_crud.log_action( + db, + study_id=contract.project_id, + entity_type="contract_fee_payment", + entity_id=payment.id, + action="CREATE_CONTRACT_FEE_PAYMENT", + detail="合同费用分期已创建", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) + + +@router.patch( + "/payments/{payment_id}", + response_model=FeeApiResponse[ContractFeePaymentRead], + dependencies=[Depends(get_current_user)], +) +async def update_contract_payment( + payment_id: uuid.UUID, + payment_in: ContractFeePaymentUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> FeeApiResponse[ContractFeePaymentRead]: + payment = await payment_crud.get_payment(db, payment_id) + if not payment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在") + contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id) + if not contract: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") + await _ensure_project_access(db, contract.project_id, current_user, write=True) + merged_payment = ContractFeePaymentCreate( + amount=payment_in.amount if payment_in.amount is not None else payment.amount, + paid_date=payment_in.paid_date if payment_in.paid_date is not None else payment.paid_date, + verified_date=payment_in.verified_date if payment_in.verified_date is not None else payment.verified_date, + is_paid=payment_in.is_paid if payment_in.is_paid is not None else payment.is_paid, + is_verified=payment_in.is_verified if payment_in.is_verified is not None else payment.is_verified, + remark=payment_in.remark if payment_in.remark is not None else payment.remark, + ) + _validate_payment_rules(merged_payment) + payment = await payment_crud.update_payment(db, payment, payment_in) + await audit_crud.log_action( + db, + study_id=contract.project_id, + entity_type="contract_fee_payment", + entity_id=payment_id, + action="UPDATE_CONTRACT_FEE_PAYMENT", + detail="合同费用分期已更新", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return FeeApiResponse(data=ContractFeePaymentRead.model_validate(payment)) + + +@router.delete( + "/payments/{payment_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(get_current_user)], +) +async def delete_contract_payment( + payment_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + payment = await payment_crud.get_payment(db, payment_id) + if not payment: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在") + contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id) + if not contract: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在") + await _ensure_project_access(db, contract.project_id, current_user, write=True) + await payment_crud.delete_payment(db, payment) + await payment_crud.resequence_payments(db, contract.id) + await audit_crud.log_action( + db, + study_id=contract.project_id, + entity_type="contract_fee_payment", + entity_id=payment_id, + action="DELETE_CONTRACT_FEE_PAYMENT", + detail="合同费用分期已删除", + operator_id=current_user.id, + operator_role=current_user.role, + ) diff --git a/backend/app/api/v1/fees_specials.py b/backend/app/api/v1/fees_specials.py new file mode 100644 index 00000000..9f7caad2 --- /dev/null +++ b/backend/app/api/v1/fees_specials.py @@ -0,0 +1,224 @@ +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_current_user, get_db_session +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="费用类别无效") + + +@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) + rows = await special_crud.list_special_expenses( + db, + project_id, + center_id=center_id, + 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) + 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)], +) +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: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目") + 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)], +) +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: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或不属于该项目") + 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)], +) +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 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, + ) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 1e28401a..7f50eccb 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs +from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs api_router = APIRouter() api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) @@ -19,6 +19,9 @@ api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["a 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(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"]) api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"]) diff --git a/backend/app/crud/contract_fee.py b/backend/app/crud/contract_fee.py new file mode 100644 index 00000000..4e666c6e --- /dev/null +++ b/backend/app/crud/contract_fee.py @@ -0,0 +1,112 @@ +import uuid +from datetime import date +from typing import Sequence + +from sqlalchemy import case, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.contract_fee import ContractFee +from app.models.contract_fee_payment import ContractFeePayment +from app.models.site import Site +from app.schemas.contract_fee import ContractFeeCreate, ContractFeeUpdate + + +async def create_contract_fee( + db: AsyncSession, + contract_in: ContractFeeCreate, +) -> ContractFee: + contract = ContractFee( + project_id=contract_in.project_id, + center_id=contract_in.center_id, + contract_amount=contract_in.contract_amount, + contract_cases=contract_in.contract_cases, + actual_cases=contract_in.actual_cases, + settlement_amount=contract_in.settlement_amount, + final_payment_amount=contract_in.final_payment_amount, + ) + db.add(contract) + await db.commit() + await db.refresh(contract) + return contract + + +async def get_contract_fee(db: AsyncSession, contract_id: uuid.UUID) -> ContractFee | None: + result = await db.execute(select(ContractFee).where(ContractFee.id == contract_id)) + return result.scalar_one_or_none() + + +async def get_contract_fee_by_project_center( + db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID +) -> ContractFee | None: + result = await db.execute( + select(ContractFee).where(ContractFee.project_id == project_id, ContractFee.center_id == center_id) + ) + return result.scalar_one_or_none() + + +async def list_contract_fees( + db: AsyncSession, + project_id: uuid.UUID, + center_id: uuid.UUID | None = None, + q: str | None = None, +) -> Sequence[tuple[ContractFee, str, float, float, date | None, date | None]]: + paid_total = func.coalesce( + func.sum(case((ContractFeePayment.is_paid.is_(True), ContractFeePayment.amount), else_=0)), + 0, + ).label("paid_total") + verified_total = func.coalesce( + func.sum(case((ContractFeePayment.is_verified.is_(True), ContractFeePayment.amount), else_=0)), + 0, + ).label("verified_total") + last_paid_date = func.max( + case((ContractFeePayment.is_paid.is_(True), ContractFeePayment.paid_date), else_=None) + ).label("last_paid_date") + last_verified_date = func.max( + case((ContractFeePayment.is_verified.is_(True), ContractFeePayment.verified_date), else_=None) + ).label("last_verified_date") + + stmt = ( + select( + ContractFee, + Site.name.label("center_name"), + paid_total, + verified_total, + last_paid_date, + last_verified_date, + ) + .join(Site, Site.id == ContractFee.center_id) + .outerjoin(ContractFeePayment, ContractFeePayment.contract_fee_id == ContractFee.id) + .where(ContractFee.project_id == project_id) + .group_by(ContractFee.id, Site.name) + ) + + if center_id: + stmt = stmt.where(ContractFee.center_id == center_id) + + if q: + conditions = [Site.name.ilike(f"%{q}%")] + try: + conditions.append(Site.id == uuid.UUID(q)) + except (ValueError, TypeError): + pass + stmt = stmt.where(or_(*conditions)) + + stmt = stmt.order_by(Site.name.asc()) + result = await db.execute(stmt) + return result.all() + + +async def update_contract_fee( + db: AsyncSession, contract: ContractFee, contract_in: ContractFeeUpdate +) -> ContractFee: + update_data = contract_in.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(contract, key, value) + await db.commit() + await db.refresh(contract) + return contract + + +async def delete_contract_fee(db: AsyncSession, contract: ContractFee) -> None: + await db.delete(contract) + await db.commit() diff --git a/backend/app/crud/contract_fee_payment.py b/backend/app/crud/contract_fee_payment.py new file mode 100644 index 00000000..1d801195 --- /dev/null +++ b/backend/app/crud/contract_fee_payment.py @@ -0,0 +1,74 @@ +import uuid +from typing import Sequence + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.contract_fee_payment import ContractFeePayment +from app.schemas.contract_fee_payment import ContractFeePaymentCreate, ContractFeePaymentUpdate + + +async def create_payment( + db: AsyncSession, + contract_fee_id: uuid.UUID, + payment_in: ContractFeePaymentCreate, +) -> ContractFeePayment: + result = await db.execute( + select(func.coalesce(func.max(ContractFeePayment.seq), 0)).where( + ContractFeePayment.contract_fee_id == contract_fee_id + ) + ) + next_seq = (result.scalar_one_or_none() or 0) + 1 + payment = ContractFeePayment( + contract_fee_id=contract_fee_id, + seq=next_seq, + amount=payment_in.amount, + paid_date=payment_in.paid_date, + verified_date=payment_in.verified_date, + is_paid=payment_in.is_paid, + is_verified=payment_in.is_verified, + remark=payment_in.remark, + ) + db.add(payment) + await db.commit() + await db.refresh(payment) + return payment + + +async def get_payment(db: AsyncSession, payment_id: uuid.UUID) -> ContractFeePayment | None: + result = await db.execute(select(ContractFeePayment).where(ContractFeePayment.id == payment_id)) + return result.scalar_one_or_none() + + +async def list_payments(db: AsyncSession, contract_fee_id: uuid.UUID) -> Sequence[ContractFeePayment]: + result = await db.execute( + select(ContractFeePayment) + .where(ContractFeePayment.contract_fee_id == contract_fee_id) + .order_by(ContractFeePayment.seq.asc(), ContractFeePayment.created_at.asc()) + ) + return result.scalars().all() + + +async def update_payment( + db: AsyncSession, payment: ContractFeePayment, payment_in: ContractFeePaymentUpdate +) -> ContractFeePayment: + update_data = payment_in.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(payment, key, value) + await db.commit() + await db.refresh(payment) + return payment + + +async def delete_payment(db: AsyncSession, payment: ContractFeePayment) -> None: + await db.delete(payment) + await db.commit() + + +async def resequence_payments(db: AsyncSession, contract_fee_id: uuid.UUID) -> None: + payments = await list_payments(db, contract_fee_id) + for idx, payment in enumerate(payments, start=1): + if payment.seq != idx: + payment.seq = idx + db.add(payment) + await db.commit() diff --git a/backend/app/crud/fee_attachment.py b/backend/app/crud/fee_attachment.py new file mode 100644 index 00000000..403f3d55 --- /dev/null +++ b/backend/app/crud/fee_attachment.py @@ -0,0 +1,70 @@ +import uuid +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.fee_attachment import FeeAttachment + + +async def create_attachment( + db: AsyncSession, + *, + entity_type: str, + entity_id: uuid.UUID, + file_type: str, + filename: str, + mime_type: str | None, + size: int, + storage_key: str | None, + url: str | None, + uploaded_by: uuid.UUID, +) -> FeeAttachment: + attachment = FeeAttachment( + entity_type=entity_type, + entity_id=entity_id, + file_type=file_type, + filename=filename, + mime_type=mime_type, + size=size, + storage_key=storage_key, + url=url, + uploaded_by=uploaded_by, + ) + db.add(attachment) + await db.commit() + await db.refresh(attachment) + return attachment + + +async def list_attachments( + db: AsyncSession, + *, + entity_type: str, + entity_id: uuid.UUID, +) -> Sequence[FeeAttachment]: + result = await db.execute( + select(FeeAttachment) + .where( + FeeAttachment.entity_type == entity_type, + FeeAttachment.entity_id == entity_id, + FeeAttachment.is_deleted.is_(False), + ) + .order_by(FeeAttachment.uploaded_at.desc()) + ) + return result.scalars().all() + + +async def get_attachment(db: AsyncSession, attachment_id: uuid.UUID) -> FeeAttachment | None: + result = await db.execute( + select(FeeAttachment).where(FeeAttachment.id == attachment_id, FeeAttachment.is_deleted.is_(False)) + ) + return result.scalar_one_or_none() + + +async def soft_delete_attachment(db: AsyncSession, attachment: FeeAttachment) -> FeeAttachment: + attachment.is_deleted = True + db.add(attachment) + await db.commit() + await db.refresh(attachment) + return attachment diff --git a/backend/app/crud/special_expense.py b/backend/app/crud/special_expense.py new file mode 100644 index 00000000..a148da81 --- /dev/null +++ b/backend/app/crud/special_expense.py @@ -0,0 +1,98 @@ +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.fee_attachment import FeeAttachment +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, + category: str | None = None, + date_from: date | None = None, + date_to: date | None = None, +) -> Sequence[tuple[SpecialExpense, str | None, int]]: + attachment_count = func.count(FeeAttachment.id).label("attachments_count") + stmt = ( + select( + SpecialExpense, + Site.name.label("center_name"), + attachment_count, + ) + .outerjoin(Site, Site.id == SpecialExpense.center_id) + .outerjoin( + FeeAttachment, + and_( + FeeAttachment.entity_type == "special_expense", + FeeAttachment.entity_id == SpecialExpense.id, + FeeAttachment.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 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() diff --git a/backend/app/db/base.py b/backend/app/db/base.py index d77cb6dd..bea62225 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -14,6 +14,10 @@ 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.startup_feasibility import StartupFeasibility # noqa: F401 from app.models.startup_ethics import StartupEthics # noqa: F401 diff --git a/backend/app/main.py b/backend/app/main.py index 09a1f9c9..3ba5a67f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from app.core.exceptions import register_exception_handlers from app.crud.user import ensure_admin_exists from app.db.base import Base from app.db.session import SessionLocal, engine +from app.services.fee_seed import seed_demo_fees @asynccontextmanager @@ -23,6 +24,7 @@ async def lifespan(_: FastAPI): await conn.run_sync(Base.metadata.create_all) async with SessionLocal() as session: await ensure_admin_exists(session) + await seed_demo_fees(session) yield diff --git a/backend/app/models/contract_fee.py b/backend/app/models/contract_fee.py new file mode 100644 index 00000000..0f445da3 --- /dev/null +++ b/backend/app/models/contract_fee.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, UniqueConstraint, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class ContractFee(Base): + __tablename__ = "contract_fees" + __table_args__ = ( + UniqueConstraint("project_id", "center_id", name="uq_contract_fees_project_center"), + ) + + 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] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=False) + contract_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + contract_cases: Mapped[int] = mapped_column(Integer, nullable=False) + actual_cases: Mapped[int | None] = mapped_column(Integer, nullable=True) + settlement_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), nullable=True) + final_payment_amount: Mapped[Decimal | None] = mapped_column(Numeric(12, 2), 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() + ) diff --git a/backend/app/models/contract_fee_payment.py b/backend/app/models/contract_fee_payment.py new file mode 100644 index 00000000..5fe8ff38 --- /dev/null +++ b/backend/app/models/contract_fee_payment.py @@ -0,0 +1,29 @@ +import uuid +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class ContractFeePayment(Base): + __tablename__ = "contract_fee_payments" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + contract_fee_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), ForeignKey("contract_fees.id"), index=True, nullable=False + ) + seq: Mapped[int] = mapped_column(Integer, nullable=False) + amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False) + paid_date: Mapped[date | None] = mapped_column(Date, nullable=True) + verified_date: Mapped[date | None] = mapped_column(Date, nullable=True) + is_paid: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + is_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + remark: Mapped[str | None] = mapped_column(Text, 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() + ) diff --git a/backend/app/models/fee_attachment.py b/backend/app/models/fee_attachment.py new file mode 100644 index 00000000..d444c017 --- /dev/null +++ b/backend/app/models/fee_attachment.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class FeeAttachment(Base): + __tablename__ = "fee_attachments" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + entity_type: Mapped[str] = mapped_column(String(50), nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False) + file_type: Mapped[str] = mapped_column(String(30), nullable=False) + filename: Mapped[str] = mapped_column(String(255), nullable=False) + mime_type: Mapped[str | None] = mapped_column(String(100), nullable=True) + size: Mapped[int] = mapped_column(Integer, nullable=False) + storage_key: Mapped[str | None] = mapped_column(String(500), nullable=True) + url: Mapped[str | None] = mapped_column(Text, nullable=True) + uploaded_by: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") diff --git a/backend/app/models/special_expense.py b/backend/app/models/special_expense.py new file mode 100644 index 00000000..1e66a96d --- /dev/null +++ b/backend/app/models/special_expense.py @@ -0,0 +1,30 @@ +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() + ) diff --git a/backend/app/schemas/contract_fee.py b/backend/app/schemas/contract_fee.py new file mode 100644 index 00000000..72ce92dd --- /dev/null +++ b/backend/app/schemas/contract_fee.py @@ -0,0 +1,57 @@ +import uuid +from datetime import date, datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.contract_fee_payment import ContractFeePaymentRead +from app.schemas.fee_attachment import FeeAttachmentRead + + +class ContractFeeCreate(BaseModel): + project_id: uuid.UUID + center_id: uuid.UUID + contract_amount: Decimal = Field(ge=0) + contract_cases: int = Field(ge=0) + actual_cases: Optional[int] = Field(default=None, ge=0) + settlement_amount: Optional[Decimal] = Field(default=None, ge=0) + final_payment_amount: Optional[Decimal] = Field(default=None, ge=0) + + +class ContractFeeUpdate(BaseModel): + contract_amount: Optional[Decimal] = Field(default=None, ge=0) + contract_cases: Optional[int] = Field(default=None, ge=0) + actual_cases: Optional[int] = Field(default=None, ge=0) + settlement_amount: Optional[Decimal] = Field(default=None, ge=0) + final_payment_amount: Optional[Decimal] = Field(default=None, ge=0) + + +class ContractFeeRead(BaseModel): + id: uuid.UUID + project_id: uuid.UUID + center_id: uuid.UUID + contract_amount: Decimal + contract_cases: int + actual_cases: Optional[int] + settlement_amount: Optional[Decimal] + final_payment_amount: Optional[Decimal] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ContractFeeListItem(ContractFeeRead): + center_name: str + paid_total: Decimal + verified_total: Decimal + unpaid_balance: Decimal + unverified_balance: Decimal + last_paid_date: Optional[date] + last_verified_date: Optional[date] + + +class ContractFeeDetail(ContractFeeRead): + payments: list[ContractFeePaymentRead] + attachments: dict[str, list[FeeAttachmentRead]] diff --git a/backend/app/schemas/contract_fee_payment.py b/backend/app/schemas/contract_fee_payment.py new file mode 100644 index 00000000..cb11b84a --- /dev/null +++ b/backend/app/schemas/contract_fee_payment.py @@ -0,0 +1,40 @@ +import uuid +from datetime import date, datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class ContractFeePaymentCreate(BaseModel): + amount: Decimal = Field(ge=0) + paid_date: Optional[date] = None + verified_date: Optional[date] = None + is_paid: bool = False + is_verified: bool = False + remark: Optional[str] = None + + +class ContractFeePaymentUpdate(BaseModel): + amount: Optional[Decimal] = Field(default=None, ge=0) + paid_date: Optional[date] = None + verified_date: Optional[date] = None + is_paid: Optional[bool] = None + is_verified: Optional[bool] = None + remark: Optional[str] = None + + +class ContractFeePaymentRead(BaseModel): + id: uuid.UUID + contract_fee_id: uuid.UUID + seq: int + amount: Decimal + paid_date: Optional[date] + verified_date: Optional[date] + is_paid: bool + is_verified: bool + remark: Optional[str] + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/fee_attachment.py b/backend/app/schemas/fee_attachment.py new file mode 100644 index 00000000..df344509 --- /dev/null +++ b/backend/app/schemas/fee_attachment.py @@ -0,0 +1,24 @@ +import uuid +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, ConfigDict + +from app.schemas.user import UserDisplay + + +class FeeAttachmentRead(BaseModel): + id: uuid.UUID + entity_type: str + entity_id: uuid.UUID + file_type: str + filename: str + mime_type: Optional[str] + size: int + storage_key: Optional[str] + url: Optional[str] + uploaded_by: Optional[UserDisplay] + uploaded_by_id: Optional[uuid.UUID] = None + uploaded_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/fee_common.py b/backend/app/schemas/fee_common.py new file mode 100644 index 00000000..9ae39ebd --- /dev/null +++ b/backend/app/schemas/fee_common.py @@ -0,0 +1,11 @@ +from typing import Generic, TypeVar + +from pydantic.generics import GenericModel + +T = TypeVar("T") + + +class FeeApiResponse(GenericModel, Generic[T]): + data: T | None = None + error: str | dict | None = None + meta: dict | None = None diff --git a/backend/app/schemas/special_expense.py b/backend/app/schemas/special_expense.py new file mode 100644 index 00000000..b55ea6ff --- /dev/null +++ b/backend/app/schemas/special_expense.py @@ -0,0 +1,55 @@ +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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/app/services/fee_seed.py b/backend/app/services/fee_seed.py new file mode 100644 index 00000000..81fdd1aa --- /dev/null +++ b/backend/app/services/fee_seed.py @@ -0,0 +1,175 @@ +import uuid +from datetime import date, timedelta +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.contract_fee import ContractFee +from app.models.contract_fee_payment import ContractFeePayment +from app.models.fee_attachment import FeeAttachment +from app.models.site import Site +from app.models.special_expense import SpecialExpense +from app.models.study import Study +from app.models.user import User + + +async def seed_demo_fees(db: AsyncSession) -> None: + existing_contract = await db.execute(select(ContractFee.id).limit(1)) + existing_special = await db.execute(select(SpecialExpense.id).limit(1)) + if existing_contract.scalar_one_or_none() or existing_special.scalar_one_or_none(): + return + + result = await db.execute(select(Study).order_by(Study.created_at.asc())) + study = result.scalars().first() + if not study: + study = Study( + id=uuid.uuid4(), + code="DEMO-CTMS", + name="示例临床试验项目", + sponsor="示例申办方", + status="ACTIVE", + ) + db.add(study) + await db.commit() + await db.refresh(study) + + result = await db.execute(select(Site).where(Site.study_id == study.id)) + sites = result.scalars().all() + if len(sites) < 3: + for idx in range(3 - len(sites)): + site = Site( + id=uuid.uuid4(), + study_id=study.id, + name=f"示例中心 {len(sites) + idx + 1}", + city="上海", + pi_name="张医生", + contact="010-88888888", + ) + db.add(site) + await db.commit() + result = await db.execute(select(Site).where(Site.study_id == study.id)) + sites = result.scalars().all() + + user_result = await db.execute(select(User).order_by(User.created_at.asc())) + uploader = user_result.scalars().first() + uploader_id = uploader.id if uploader else None + + today = date.today() + for idx, site in enumerate(sites[:3], start=1): + contract = ContractFee( + id=uuid.uuid4(), + project_id=study.id, + center_id=site.id, + contract_amount=Decimal("120000.00") + Decimal(idx * 10000), + contract_cases=50 + idx * 5, + actual_cases=40 + idx * 4, + settlement_amount=Decimal("80000.00") + Decimal(idx * 5000), + final_payment_amount=Decimal("20000.00") + Decimal(idx * 2000), + ) + db.add(contract) + await db.commit() + await db.refresh(contract) + + payments = [ + ContractFeePayment( + id=uuid.uuid4(), + contract_fee_id=contract.id, + seq=1, + amount=Decimal("40000.00") + Decimal(idx * 2000), + is_paid=True, + paid_date=today - timedelta(days=30), + is_verified=True, + verified_date=today - timedelta(days=20), + remark="首付款已核销", + ), + ContractFeePayment( + id=uuid.uuid4(), + contract_fee_id=contract.id, + seq=2, + amount=Decimal("30000.00") + Decimal(idx * 1000), + is_paid=True, + paid_date=today - timedelta(days=10), + is_verified=False, + remark="等待核销", + ), + ContractFeePayment( + id=uuid.uuid4(), + contract_fee_id=contract.id, + seq=3, + amount=Decimal("20000.00") + Decimal(idx * 1000), + is_paid=False, + remark="未打款", + ), + ] + db.add_all(payments) + await db.commit() + + if uploader_id: + attachments = [ + FeeAttachment( + id=uuid.uuid4(), + entity_type="contract_fee", + entity_id=contract.id, + file_type="contract", + filename=f"合同-{site.name}.pdf", + mime_type="application/pdf", + size=102400, + storage_key=None, + url="https://example.com/contract-demo.pdf", + uploaded_by=uploader_id, + ), + FeeAttachment( + id=uuid.uuid4(), + entity_type="contract_fee", + entity_id=contract.id, + file_type="voucher", + filename=f"凭证-{site.name}.pdf", + mime_type="application/pdf", + size=20480, + storage_key=None, + url="https://example.com/voucher-demo.pdf", + uploaded_by=uploader_id, + ), + ] + db.add_all(attachments) + await db.commit() + + categories = ["travel", "meal", "meeting", "supplies", "other"] + for idx, category in enumerate(categories): + for seq in range(2): + expense = SpecialExpense( + id=uuid.uuid4(), + project_id=study.id, + center_id=sites[seq % len(sites)].id if sites else None, + category=category, + amount=Decimal("800.00") + Decimal(seq * 150), + happen_date=today - timedelta(days=5 * (seq + 1)), + description=f"{category} 费用示例", + is_paid=seq % 2 == 0, + paid_date=today - timedelta(days=3 * (seq + 1)) if seq % 2 == 0 else None, + is_verified=seq == 0, + verified_date=today - timedelta(days=2 * (seq + 1)) if seq == 0 else None, + created_by=uploader_id, + ) + db.add(expense) + await db.commit() + + result = await db.execute(select(SpecialExpense).where(SpecialExpense.project_id == study.id)) + expenses = result.scalars().all() + if uploader_id: + for expense in expenses[:3]: + attachment = FeeAttachment( + id=uuid.uuid4(), + entity_type="special_expense", + entity_id=expense.id, + file_type="invoice", + filename=f"发票-{expense.id}.pdf", + mime_type="application/pdf", + size=40960, + storage_key=None, + url="https://example.com/invoice-demo.pdf", + uploaded_by=uploader_id, + ) + db.add(attachment) + await db.commit() diff --git a/database/init.sql b/database/init.sql index f2c3c2ab..0f6cdc99 100644 --- a/database/init.sql +++ b/database/init.sql @@ -198,6 +198,73 @@ CREATE TABLE IF NOT EXISTS public.finance_specials ( CONSTRAINT fk_finance_specials_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); +CREATE TABLE IF NOT EXISTS public.contract_fees ( + id uuid PRIMARY KEY, + project_id uuid NOT NULL, + center_id uuid NOT NULL, + contract_amount numeric(12, 2) NOT NULL, + contract_cases integer NOT NULL, + actual_cases integer, + settlement_amount numeric(12, 2), + final_payment_amount numeric(12, 2), + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT uq_contract_fees_project_center UNIQUE (project_id, center_id), + CONSTRAINT fk_contract_fees_project_id FOREIGN KEY (project_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_contract_fees_center_id FOREIGN KEY (center_id) REFERENCES public.sites(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS public.contract_fee_payments ( + id uuid PRIMARY KEY, + contract_fee_id uuid NOT NULL, + seq integer NOT NULL, + amount numeric(12, 2) NOT NULL, + paid_date date, + verified_date date, + is_paid boolean NOT NULL DEFAULT false, + is_verified boolean NOT NULL DEFAULT false, + remark text, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_contract_fee_payments_contract_fee_id FOREIGN KEY (contract_fee_id) REFERENCES public.contract_fees(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.special_expenses ( + id uuid PRIMARY KEY, + project_id uuid NOT NULL, + center_id uuid, + category character varying(50) NOT NULL, + amount numeric(12, 2) NOT NULL, + happen_date date, + description text, + is_paid boolean NOT NULL DEFAULT false, + paid_date date, + is_verified boolean NOT NULL DEFAULT false, + verified_date date, + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_special_expenses_project_id FOREIGN KEY (project_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_special_expenses_center_id FOREIGN KEY (center_id) REFERENCES public.sites(id) ON DELETE RESTRICT, + CONSTRAINT fk_special_expenses_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS public.fee_attachments ( + id uuid PRIMARY KEY, + entity_type character varying(50) NOT NULL, + entity_id uuid NOT NULL, + file_type character varying(30) NOT NULL, + filename character varying(255) NOT NULL, + mime_type character varying(100), + size integer NOT NULL, + storage_key character varying(500), + url text, + uploaded_by uuid NOT NULL, + uploaded_at timestamp with time zone NOT NULL DEFAULT now(), + is_deleted boolean NOT NULL DEFAULT false, + CONSTRAINT fk_fee_attachments_uploaded_by FOREIGN KEY (uploaded_by) REFERENCES public.users(id) ON DELETE RESTRICT +); + CREATE TABLE IF NOT EXISTS public.finance_items ( id uuid PRIMARY KEY, study_id uuid NOT NULL, diff --git a/frontend/src/api/feeAttachments.ts b/frontend/src/api/feeAttachments.ts new file mode 100644 index 00000000..1c301e84 --- /dev/null +++ b/frontend/src/api/feeAttachments.ts @@ -0,0 +1,32 @@ +import { apiDelete, apiGet, apiPost } from "./axios"; +import type { FeeApiResponse } from "../types/api"; + +export const listFeeAttachments = (entityType: string, entityId: string) => + apiGet>("/api/v1/fees/attachments", { params: { entity_type: entityType, entity_id: entityId } }); + +export const uploadFeeAttachment = ( + entityType: string, + entityId: string, + fileType: string, + file: File, + options?: Record +) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("entity_type", entityType); + formData.append("entity_id", entityId); + formData.append("file_type", fileType); + return apiPost>("/api/v1/fees/attachments", formData, { + headers: { "Content-Type": "multipart/form-data" }, + ...options, + }); +}; + +export const deleteFeeAttachment = (attachmentId: string) => apiDelete(`/api/v1/fees/attachments/${attachmentId}`); + +export const getFeeAttachmentDownloadUrl = (attachmentId: string) => { + const token = localStorage.getItem("ctms_token"); + return token + ? `/api/v1/fees/attachments/${attachmentId}/download?token=${token}` + : `/api/v1/fees/attachments/${attachmentId}/download`; +}; diff --git a/frontend/src/api/feeContracts.ts b/frontend/src/api/feeContracts.ts new file mode 100644 index 00000000..8826ce6e --- /dev/null +++ b/frontend/src/api/feeContracts.ts @@ -0,0 +1,50 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; +import type { FeeApiResponse } from "../types/api"; + +export interface ContractFeePayload { + project_id: string; + center_id: string; + contract_amount: number; + contract_cases: number; + actual_cases?: number | null; + settlement_amount?: number | null; + final_payment_amount?: number | null; +} + +export interface ContractFeeUpdatePayload { + contract_amount?: number; + contract_cases?: number; + actual_cases?: number | null; + settlement_amount?: number | null; + final_payment_amount?: number | null; +} + +export interface ContractPaymentPayload { + amount: number; + paid_date?: string | null; + verified_date?: string | null; + is_paid?: boolean; + is_verified?: boolean; + remark?: string | null; +} + +export const listContractFees = (params: { projectId: string; centerId?: string; q?: string }) => + apiGet>("/api/v1/fees/contracts", { params }); + +export const getContractFee = (contractId: string) => apiGet>(`/api/v1/fees/contracts/${contractId}`); + +export const createContractFee = (payload: ContractFeePayload) => + apiPost>("/api/v1/fees/contracts", payload); + +export const updateContractFee = (contractId: string, payload: ContractFeeUpdatePayload) => + apiPatch>(`/api/v1/fees/contracts/${contractId}`, payload); + +export const deleteContractFee = (contractId: string) => apiDelete(`/api/v1/fees/contracts/${contractId}`); + +export const createContractPayment = (contractId: string, payload: ContractPaymentPayload) => + apiPost>(`/api/v1/fees/contracts/${contractId}/payments`, payload); + +export const updateContractPayment = (paymentId: string, payload: ContractPaymentPayload) => + apiPatch>(`/api/v1/fees/payments/${paymentId}`, payload); + +export const deleteContractPayment = (paymentId: string) => apiDelete(`/api/v1/fees/payments/${paymentId}`); diff --git a/frontend/src/api/feeSpecials.ts b/frontend/src/api/feeSpecials.ts new file mode 100644 index 00000000..f517e1c4 --- /dev/null +++ b/frontend/src/api/feeSpecials.ts @@ -0,0 +1,45 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; +import type { FeeApiResponse } from "../types/api"; + +export interface SpecialExpensePayload { + project_id: string; + center_id?: string | null; + category: string; + amount: number; + happen_date?: string | null; + description?: string | null; + is_paid?: boolean; + paid_date?: string | null; + is_verified?: boolean; + verified_date?: string | null; +} + +export interface SpecialExpenseUpdatePayload { + center_id?: string | null; + category?: string; + amount?: number; + happen_date?: string | null; + description?: string | null; + is_paid?: boolean; + paid_date?: string | null; + is_verified?: boolean; + verified_date?: string | null; +} + +export const listSpecialExpenses = (params: { + projectId: string; + centerId?: string; + category?: string; + dateFrom?: string; + dateTo?: string; +}) => apiGet>("/api/v1/fees/special", { params }); + +export const getSpecialExpense = (expenseId: string) => apiGet>(`/api/v1/fees/special/${expenseId}`); + +export const createSpecialExpense = (payload: SpecialExpensePayload) => + apiPost>("/api/v1/fees/special", payload); + +export const updateSpecialExpense = (expenseId: string, payload: SpecialExpenseUpdatePayload) => + apiPatch>(`/api/v1/fees/special/${expenseId}`, payload); + +export const deleteSpecialExpense = (expenseId: string) => apiDelete(`/api/v1/fees/special/${expenseId}`); diff --git a/frontend/src/components/KpiCard.vue b/frontend/src/components/KpiCard.vue index b1809d2a..dbc31a79 100644 --- a/frontend/src/components/KpiCard.vue +++ b/frontend/src/components/KpiCard.vue @@ -1,8 +1,13 @@ diff --git a/frontend/src/components/fees/FeeAttachmentPanel.vue b/frontend/src/components/fees/FeeAttachmentPanel.vue new file mode 100644 index 00000000..66e43d29 --- /dev/null +++ b/frontend/src/components/fees/FeeAttachmentPanel.vue @@ -0,0 +1,322 @@ +