费用管理内容优化-初步

This commit is contained in:
Cheng Zhou
2026-01-13 16:49:55 +08:00
parent 0c7c03069a
commit 1db36f40b0
43 changed files with 5229 additions and 26 deletions
@@ -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")
+2
View File
@@ -137,6 +137,7 @@ async def download_attachment(
path=attachment.file_path, path=attachment.file_path,
filename=attachment.filename, filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream", 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, path=attachment.file_path,
filename=attachment.filename, filename=attachment.filename,
media_type=attachment.content_type or "application/octet-stream", media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'},
) )
+255
View File
@@ -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,
)
+368
View File
@@ -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,
)
+224
View File
@@ -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,
)
+4 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter 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 = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"]) 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_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_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(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(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(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"])
api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"]) api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"])
+112
View File
@@ -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()
+74
View File
@@ -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()
+70
View File
@@ -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
+98
View File
@@ -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()
+4
View File
@@ -14,6 +14,10 @@ from app.models.ae import AdverseEvent # noqa: F401
from app.models.finance import FinanceItem # noqa: F401 from app.models.finance import FinanceItem # noqa: F401
from app.models.finance_contract import FinanceContract # noqa: F401 from app.models.finance_contract import FinanceContract # noqa: F401
from app.models.finance_special import FinanceSpecial # 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.drug_shipment import DrugShipment # noqa: F401
from app.models.startup_feasibility import StartupFeasibility # noqa: F401 from app.models.startup_feasibility import StartupFeasibility # noqa: F401
from app.models.startup_ethics import StartupEthics # noqa: F401 from app.models.startup_ethics import StartupEthics # noqa: F401
+2
View File
@@ -10,6 +10,7 @@ from app.core.exceptions import register_exception_handlers
from app.crud.user import ensure_admin_exists from app.crud.user import ensure_admin_exists
from app.db.base import Base from app.db.base import Base
from app.db.session import SessionLocal, engine from app.db.session import SessionLocal, engine
from app.services.fee_seed import seed_demo_fees
@asynccontextmanager @asynccontextmanager
@@ -23,6 +24,7 @@ async def lifespan(_: FastAPI):
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
async with SessionLocal() as session: async with SessionLocal() as session:
await ensure_admin_exists(session) await ensure_admin_exists(session)
await seed_demo_fees(session)
yield yield
+29
View File
@@ -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()
)
@@ -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()
)
+25
View File
@@ -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")
+30
View File
@@ -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()
)
+57
View File
@@ -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]]
@@ -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)
+24
View File
@@ -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)
+11
View File
@@ -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
+55
View File
@@ -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
View File
+175
View File
@@ -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()
+67
View File
@@ -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 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 ( CREATE TABLE IF NOT EXISTS public.finance_items (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
study_id uuid NOT NULL, study_id uuid NOT NULL,
+32
View File
@@ -0,0 +1,32 @@
import { apiDelete, apiGet, apiPost } from "./axios";
import type { FeeApiResponse } from "../types/api";
export const listFeeAttachments = (entityType: string, entityId: string) =>
apiGet<FeeApiResponse<any[]>>("/api/v1/fees/attachments", { params: { entity_type: entityType, entity_id: entityId } });
export const uploadFeeAttachment = (
entityType: string,
entityId: string,
fileType: string,
file: File,
options?: Record<string, any>
) => {
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<FeeApiResponse<any>>("/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`;
};
+50
View File
@@ -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<FeeApiResponse<any[]>>("/api/v1/fees/contracts", { params });
export const getContractFee = (contractId: string) => apiGet<FeeApiResponse<any>>(`/api/v1/fees/contracts/${contractId}`);
export const createContractFee = (payload: ContractFeePayload) =>
apiPost<FeeApiResponse<any>>("/api/v1/fees/contracts", payload);
export const updateContractFee = (contractId: string, payload: ContractFeeUpdatePayload) =>
apiPatch<FeeApiResponse<any>>(`/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<FeeApiResponse<any>>(`/api/v1/fees/contracts/${contractId}/payments`, payload);
export const updateContractPayment = (paymentId: string, payload: ContractPaymentPayload) =>
apiPatch<FeeApiResponse<any>>(`/api/v1/fees/payments/${paymentId}`, payload);
export const deleteContractPayment = (paymentId: string) => apiDelete(`/api/v1/fees/payments/${paymentId}`);
+45
View File
@@ -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<FeeApiResponse<any[]>>("/api/v1/fees/special", { params });
export const getSpecialExpense = (expenseId: string) => apiGet<FeeApiResponse<any>>(`/api/v1/fees/special/${expenseId}`);
export const createSpecialExpense = (payload: SpecialExpensePayload) =>
apiPost<FeeApiResponse<any>>("/api/v1/fees/special", payload);
export const updateSpecialExpense = (expenseId: string, payload: SpecialExpenseUpdatePayload) =>
apiPatch<FeeApiResponse<any>>(`/api/v1/fees/special/${expenseId}`, payload);
export const deleteSpecialExpense = (expenseId: string) => apiDelete(`/api/v1/fees/special/${expenseId}`);
+70 -14
View File
@@ -1,8 +1,13 @@
<template> <template>
<el-card class="kpi-card" shadow="never"> <el-card class="kpi-card" :class="[`type-${type}`]" shadow="hover">
<div class="kpi-header"> <div class="kpi-header">
<span class="kpi-title">{{ title }}</span> <div class="kpi-title-wrapper">
<el-icon v-if="icon" class="kpi-icon"><component :is="icon" /></el-icon> <div class="kpi-icon-wrapper" v-if="icon">
<el-icon class="kpi-icon"><component :is="icon" /></el-icon>
</div>
<span class="kpi-title">{{ title }}</span>
</div>
<slot name="header-suffix"></slot>
</div> </div>
<div class="kpi-content"> <div class="kpi-content">
<div v-if="loading" class="kpi-loading"> <div v-if="loading" class="kpi-loading">
@@ -22,6 +27,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
interface Props { interface Props {
title: string; title: string;
value: string | number; value: string | number;
@@ -29,38 +36,85 @@ interface Props {
loading?: boolean; loading?: boolean;
icon?: any; icon?: any;
unit?: string; unit?: string;
type?: 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
} }
defineProps<Props>(); const props = withDefaults(defineProps<Props>(), {
type: 'default',
});
</script> </script>
<style scoped> <style scoped>
.kpi-card { .kpi-card {
border: 1px solid var(--ctms-border-color); border: 1px solid var(--ctms-border-color);
transition: var(--ctms-transition); transition: all 0.3s ease;
height: 100%; height: 100%;
border-radius: 12px;
overflow: hidden;
position: relative;
--el-card-padding: 12px 16px;
} }
.kpi-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background-color: transparent;
transition: background-color 0.3s ease;
}
.kpi-card.type-primary::before { background-color: var(--el-color-primary); }
.kpi-card.type-success::before { background-color: var(--el-color-success); }
.kpi-card.type-warning::before { background-color: var(--el-color-warning); }
.kpi-card.type-danger::before { background-color: var(--el-color-danger); }
.kpi-card.type-info::before { background-color: var(--el-color-info); }
.kpi-card:hover { .kpi-card:hover {
border-color: var(--ctms-border-color-hover); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
} }
.kpi-header { .kpi-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 12px; margin-bottom: 8px;
} }
.kpi-title-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.kpi-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background-color: var(--ctms-bg-color-page);
color: var(--ctms-text-secondary);
}
.type-primary .kpi-icon-wrapper { color: var(--el-color-primary); background-color: var(--el-color-primary-light-9); }
.type-success .kpi-icon-wrapper { color: var(--el-color-success); background-color: var(--el-color-success-light-9); }
.type-warning .kpi-icon-wrapper { color: var(--el-color-warning); background-color: var(--el-color-warning-light-9); }
.type-danger .kpi-icon-wrapper { color: var(--el-color-danger); background-color: var(--el-color-danger-light-9); }
.type-info .kpi-icon-wrapper { color: var(--el-color-info); background-color: var(--el-color-info-light-9); }
.kpi-title { .kpi-title {
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
font-size: 13px; font-size: 14px;
font-weight: 600; font-weight: 500;
} }
.kpi-icon { .kpi-icon {
font-size: 16px; font-size: 16px;
color: var(--ctms-text-secondary);
} }
.kpi-content { .kpi-content {
@@ -75,24 +129,26 @@ defineProps<Props>();
} }
.kpi-value { .kpi-value {
font-size: 28px; font-size: 32px;
font-weight: 600; font-weight: 700;
color: var(--ctms-text-main); color: var(--ctms-text-main);
line-height: 1.2; line-height: 1.2;
font-family: var(--el-font-family);
} }
.kpi-unit { .kpi-unit {
font-size: 14px; font-size: 14px;
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
font-weight: 500; font-weight: 500;
margin-left: 2px;
} }
.kpi-subtext { .kpi-subtext {
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
font-size: 12px; font-size: 13px;
margin-top: 8px; margin-top: 8px;
padding-top: 8px; padding-top: 8px;
border-top: 1px dashed var(--ctms-border-color); border-top: 1px solid var(--ctms-border-color-lighter);
} }
.kpi-loading { .kpi-loading {
+7 -5
View File
@@ -39,13 +39,13 @@
<el-icon><House /></el-icon> <el-icon><House /></el-icon>
<span>{{ TEXT.menu.projectOverview }}</span> <span>{{ TEXT.menu.projectOverview }}</span>
</el-menu-item> </el-menu-item>
<el-sub-menu index="finance"> <el-sub-menu index="fees">
<template #title> <template #title>
<el-icon><Coin /></el-icon> <el-icon><Coin /></el-icon>
<span>{{ TEXT.menu.finance }}</span> <span>{{ TEXT.menu.finance }}</span>
</template> </template>
<el-menu-item index="/finance/contracts">{{ TEXT.menu.financeContracts }}</el-menu-item> <el-menu-item index="/fees/contracts">{{ TEXT.menu.feeContracts }}</el-menu-item>
<el-menu-item index="/finance/special">{{ TEXT.menu.financeSpecials }}</el-menu-item> <el-menu-item index="/fees/special">{{ TEXT.menu.feeSpecials }}</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="drug"> <el-sub-menu index="drug">
<template #title> <template #title>
@@ -170,8 +170,10 @@ const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
const activeMenu = computed(() => { const activeMenu = computed(() => {
const path = route.path; const path = route.path;
if (path.startsWith("/project/")) return "/project/overview"; if (path.startsWith("/project/")) return "/project/overview";
if (path.startsWith("/finance/contracts")) return "/finance/contracts"; if (path.startsWith("/fees/contracts")) return "/fees/contracts";
if (path.startsWith("/finance/special")) return "/finance/special"; if (path.startsWith("/fees/special")) return "/fees/special";
if (path.startsWith("/finance/contracts")) return "/fees/contracts";
if (path.startsWith("/finance/special")) return "/fees/special";
if (path.startsWith("/drug/shipments")) return "/drug/shipments"; if (path.startsWith("/drug/shipments")) return "/drug/shipments";
if (path.startsWith("/file-versions")) return "/file-versions"; if (path.startsWith("/file-versions")) return "/file-versions";
if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics"; if (path.startsWith("/startup/feasibility") || path.startsWith("/startup/ethics")) return "/startup/feasibility-ethics";
+1 -1
View File
@@ -36,7 +36,7 @@ const actions = [
{ label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", icon: Timer }, { label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", icon: Timer },
{ label: TEXT.menu.subjects, path: "/subjects", icon: UserFilled }, { label: TEXT.menu.subjects, path: "/subjects", icon: UserFilled },
{ label: TEXT.menu.drugShipments, path: "/drug/shipments", icon: Management }, { label: TEXT.menu.drugShipments, path: "/drug/shipments", icon: Management },
{ label: TEXT.menu.finance, path: "/finance/contracts", icon: Money }, { label: TEXT.menu.finance, path: "/fees/contracts", icon: Money },
{ label: TEXT.menu.knowledge, path: "/knowledge/medical-consult", icon: Collection }, { label: TEXT.menu.knowledge, path: "/knowledge/medical-consult", icon: Collection },
]; ];
+3 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="state state-error"> <div class="state state-error">
<el-icon class="state-icon"><CircleCloseFilled /></el-icon> <el-icon class="state-icon"><CircleClose /></el-icon>
<div class="state-title">{{ title }}</div> <div class="state-title">{{ title }}</div>
<div v-if="description" class="state-desc">{{ description }}</div> <div v-if="description" class="state-desc">{{ description }}</div>
<div v-if="$slots.action" class="state-action"> <div v-if="$slots.action" class="state-action">
@@ -10,7 +10,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { CircleCloseFilled } from "@element-plus/icons-vue"; import { CircleClose } from "@element-plus/icons-vue";
import { TEXT } from "../locales"; import { TEXT } from "../locales";
withDefaults( withDefaults(
@@ -32,6 +32,7 @@ withDefaults(
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 40px 12px; padding: 40px 12px;
color: var(--ctms-text-secondary);
text-align: center; text-align: center;
} }
@@ -24,8 +24,11 @@
{{ displayDateTime(scope.row.uploaded_at) }} {{ displayDateTime(scope.row.uploaded_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="180"> <el-table-column :label="TEXT.common.labels.actions" width="220">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="preview(scope.row)">
{{ TEXT.common.actions.view }}
</el-button>
<el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button> <el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
<el-button <el-button
v-if="canDelete(scope.row)" v-if="canDelete(scope.row)"
@@ -40,6 +43,19 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
</el-card> </el-card>
<el-dialog v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
<template v-else>
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf'" :src="previewUrl" class="preview-frame" />
<div v-else class="preview-error">
{{ TEXT.common.messages.previewNotSupported }}
</div>
</template>
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -65,6 +81,11 @@ const loading = ref(false);
const auth = useAuthStore(); const auth = useAuthStore();
const study = useStudyStore(); const study = useStudyStore();
const members = ref<any[]>([]); const members = ref<any[]>([]);
const previewVisible = ref(false);
const previewUrl = ref("");
const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref("");
const load = async () => { const load = async () => {
if (!props.studyId || !props.entityId) return; if (!props.studyId || !props.entityId) return;
@@ -107,6 +128,25 @@ const download = (row: any) => {
window.open(url, "_blank"); window.open(url, "_blank");
}; };
const detectPreviewType = (row: any) => {
const mime = (row?.mime_type || row?.content_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
return "other";
};
const preview = (row: any) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
previewTitle.value = row?.filename || TEXT.common.labels.preview;
const token = localStorage.getItem("ctms_token");
previewUrl.value = row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
if (previewType.value === "other") {
previewError.value = TEXT.common.messages.previewNotSupported;
}
previewVisible.value = true;
};
const canDelete = (row: any) => { const canDelete = (row: any) => {
const userId = auth.user?.id; const userId = auth.user?.id;
const role = auth.user?.role; const role = auth.user?.role;
@@ -141,4 +181,24 @@ onMounted(async () => {
align-items: center; align-items: center;
margin-bottom: 8px; margin-bottom: 8px;
} }
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
display: block;
margin: 0 auto;
}
.preview-error {
color: var(--ctms-text-secondary);
font-size: 13px;
padding: 12px 0;
text-align: center;
}
</style> </style>
@@ -0,0 +1,322 @@
<template>
<div class="fee-attachments">
<el-card v-for="group in groups" :key="group.key" class="group-card">
<div class="group-header">
<div>
<div class="group-title">{{ group.label }}</div>
<div v-if="group.description" class="group-desc">{{ group.description }}</div>
</div>
<el-upload
:http-request="(options: any) => doUpload(group.key, options)"
:show-file-list="false"
:limit="1"
:disabled="readonly || isUploading(group.key)"
:auto-upload="true"
>
<el-button size="small" type="primary" :loading="isUploading(group.key)" :disabled="readonly">
{{ TEXT.common.actions.upload }}
</el-button>
</el-upload>
</div>
<el-progress
v-if="progressMap[group.key] && progressMap[group.key] < 100"
:percentage="progressMap[group.key]"
:stroke-width="6"
/>
<StateLoading v-if="loading" :rows="3" />
<template v-else>
<div v-if="(grouped[group.key] || []).length === 0" class="group-empty">
<StateEmpty :description="group.emptyText" />
</div>
<el-table v-else :data="grouped[group.key]" style="width: 100%">
<el-table-column prop="filename" :label="TEXT.common.labels.filename" min-width="160" />
<el-table-column :label="TEXT.common.labels.size" width="110">
<template #default="scope">{{ formatFileSize(scope.row.size || scope.row.file_size) }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.uploader" width="140">
<template #default="scope">
{{ uploaderLabel(scope.row) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="160">
<template #default="scope">
<el-button link type="primary" size="small" @click="preview(scope.row)">
{{ TEXT.common.actions.view }}
</el-button>
<el-button link type="primary" size="small" @click="download(scope.row)">
{{ TEXT.common.actions.download }}
</el-button>
<el-button
v-if="canDelete(scope.row)"
link
type="danger"
size="small"
@click="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
</template>
</el-card>
</div>
<el-dialog v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
<template v-else>
<img v-if="previewType === 'image'" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf'" :src="previewUrl" class="preview-frame" />
<div v-else class="preview-error">
{{ TEXT.common.messages.previewNotSupported }}
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { listFeeAttachments, uploadFeeAttachment, deleteFeeAttachment, getFeeAttachmentDownloadUrl } from "../../api/feeAttachments";
import { listMembers } from "../../api/members";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { formatFileSize } from "../attachments/attachmentUtils";
import { displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display";
import StateEmpty from "../StateEmpty.vue";
import StateLoading from "../StateLoading.vue";
import { TEXT } from "../../locales";
interface AttachmentGroup {
key: string;
label: string;
description?: string;
emptyText?: string;
}
const props = defineProps<{
entityType: string;
entityId: string;
groups: AttachmentGroup[];
readonly?: boolean;
maxSizeMb?: number;
}>();
const loading = ref(false);
const grouped = reactive<Record<string, any[]>>({});
const progressMap = reactive<Record<string, number>>({});
const auth = useAuthStore();
const study = useStudyStore();
const members = ref<any[]>([]);
const previewVisible = ref(false);
const previewUrl = ref("");
const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref("");
const isUploading = (key: string) => (progressMap[key] || 0) > 0 && (progressMap[key] || 0) < 100;
const loadMembers = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await listMembers(studyId, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const load = async () => {
if (!props.entityType || !props.entityId) return;
loading.value = true;
try {
const { data } = await listFeeAttachments(props.entityType, props.entityId);
const items = Array.isArray(data?.data) ? data?.data : [];
props.groups.forEach((group) => {
grouped[group.key] = items.filter((item: any) => item.file_type === group.key || item.fileType === group.key);
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const uploaderLabel = (row: any) => {
const memberMap = members.value.reduce<Record<string, string>>((acc, cur) => {
const username = getMemberDisplayName(cur);
if (cur?.user_id && username) acc[cur.user_id] = username;
return acc;
}, {});
if (row?.uploaded_by && typeof row.uploaded_by === "object") {
return getUserDisplayName(row.uploaded_by) || row.uploaded_by.id || TEXT.common.fallback;
}
return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap });
};
const doUpload = async (fileType: string, options: any) => {
const file: File = options.file;
if (!file || props.readonly) return;
const maxSize = props.maxSizeMb ?? 50;
if (file.size > maxSize * 1024 * 1024) {
ElMessage.error(`${TEXT.common.messages.fileTooLarge} ${maxSize}MB`);
return;
}
progressMap[fileType] = 0;
try {
await uploadFeeAttachment(props.entityType, props.entityId, fileType, file, {
onUploadProgress: (evt: ProgressEvent) => {
if (evt.total) {
progressMap[fileType] = Math.round((evt.loaded / evt.total) * 100);
}
},
});
ElMessage.success(TEXT.common.messages.uploadSuccess);
await load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.uploadFailed);
} finally {
progressMap[fileType] = 0;
}
};
const detectPreviewType = (row: any) => {
const mime = (row?.mime_type || row?.content_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
return "other";
};
const preview = (row: any) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
previewTitle.value = row?.filename || TEXT.common.labels.preview;
previewUrl.value = row?.url || getFeeAttachmentDownloadUrl(row.id);
if (previewType.value === "other") {
previewError.value = TEXT.common.messages.previewNotSupported;
}
previewVisible.value = true;
};
const download = (row: any) => {
if (row?.url) {
window.open(row.url, "_blank");
return;
}
window.open(getFeeAttachmentDownloadUrl(row.id), "_blank");
};
const canDelete = (row: any) => {
if (props.readonly) return false;
const userId = auth.user?.id;
const role = auth.user?.role;
const projectRole = study.currentStudyRole || (study.currentStudy as any)?.role_in_study;
const ownerId =
row.uploaded_by_id || (row.uploaded_by && typeof row.uploaded_by === "object" ? row.uploaded_by.id : row.uploaded_by);
return userId === ownerId || role === "ADMIN" || projectRole === "PM";
};
const remove = async (row: any) => {
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteFeeAttachment(row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
watch(
() => [props.entityType, props.entityId],
() => {
load();
}
);
onMounted(async () => {
await loadMembers();
load();
});
</script>
<style scoped>
.fee-attachments {
display: flex;
flex-direction: column;
gap: 12px;
}
.group-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
gap: 8px;
}
.group-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.group-desc {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.group-empty {
padding: 0;
}
/* Compact Empty State Override */
.group-empty :deep(.state) {
padding: 16px 0 !important;
min-height: auto !important;
flex-direction: row;
gap: 8px;
}
.group-empty :deep(.state-icon) {
font-size: 20px !important;
margin-bottom: 0 !important;
}
.group-empty :deep(.state-title) {
font-size: 13px !important;
font-weight: normal !important;
margin: 0 !important;
}
.group-empty :deep(.state-desc) {
display: none; /* Hide description in compact mode if desired, or keep it small */
}
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
display: block;
margin: 0 auto;
}
.preview-error {
color: var(--ctms-text-secondary);
font-size: 13px;
padding: 12px 0;
text-align: center;
}
</style>
+109 -2
View File
@@ -23,6 +23,7 @@ export const TEXT = {
submit: "提交", submit: "提交",
view: "查看", view: "查看",
refresh: "刷新", refresh: "刷新",
retry: "重试",
quote: "引用", quote: "引用",
more: "查看更多", more: "查看更多",
exitProject: "退出当前项目", exitProject: "退出当前项目",
@@ -53,6 +54,8 @@ export const TEXT = {
sessionExpired: "登录已过期,请重新登录", sessionExpired: "登录已过期,请重新登录",
requestFailed: "请求失败,请稍后重试", requestFailed: "请求失败,请稍后重试",
invalidStateAction: "当前状态不允许该操作", invalidStateAction: "当前状态不允许该操作",
required: "必填项",
previewNotSupported: "该文件暂不支持内嵌预览,请下载查看",
}, },
confirm: { confirm: {
delete: "确认删除该记录?", delete: "确认删除该记录?",
@@ -68,6 +71,7 @@ export const TEXT = {
uploader: "上传人", uploader: "上传人",
uploadedAt: "上传时间", uploadedAt: "上传时间",
actions: "操作", actions: "操作",
preview: "预览",
filename: "文件名", filename: "文件名",
role: "角色", role: "角色",
quickActions: "通往业务模块的快捷入口", quickActions: "通往业务模块的快捷入口",
@@ -80,6 +84,7 @@ export const TEXT = {
collapseSidebar: "收缩侧边栏", collapseSidebar: "收缩侧边栏",
userInitialFallback: "用", userInitialFallback: "用",
userFallback: "用户", userFallback: "用户",
basicInfo: "基本信息",
}, },
units: { units: {
case: "例", case: "例",
@@ -235,8 +240,10 @@ export const TEXT = {
currentProject: "当前项目", currentProject: "当前项目",
projectOverview: "项目总览", projectOverview: "项目总览",
finance: "费用管理", finance: "费用管理",
financeContracts: "合同费用", financeContracts: "合同管理",
financeSpecials: "特殊费用", financeSpecials: "特殊费用管理",
feeContracts: "合同管理",
feeSpecials: "特殊费用管理",
drug: "药品管理", drug: "药品管理",
drugShipments: "运输/流向", drugShipments: "运输/流向",
fileVersionManagement: "文件版本管理", fileVersionManagement: "文件版本管理",
@@ -320,6 +327,97 @@ export const TEXT = {
detailTitle: "特殊费用详情", detailTitle: "特殊费用详情",
detailSubtitle: "查看费用记录与附件", detailSubtitle: "查看费用记录与附件",
}, },
feeContracts: {
title: "合同管理",
subtitle: "按中心维护合同费用与分期付款",
empty: "暂无合同费用数据,点击创建",
newTitle: "新增合同费用",
editTitle: "编辑合同费用",
formSubtitle: "维护合同费用与分期付款信息",
detailTitle: "合同费用详情",
detailSubtitle: "查看合同费用与分期付款信息",
overviewTitle: "费用总览",
totalContractAmount: "合同金额总计",
totalPaidAmount: "已付金额总计",
totalUnpaidAmount: "未付金额总计",
totalVerifiedAmount: "已核销金额总计",
centerCount: "中心数",
contractAmount: "合同金额",
contractCases: "合同例数",
actualCases: "实际例数",
settlementAmount: "结算费用",
finalPaymentAmount: "尾款费用",
caseProgress: "合同/实际例数",
paidSummary: "已付/未付",
paidTotal: "已付合计",
unpaidBalance: "未付余额",
verifySummary: "核销情况",
verifiedTotal: "已核销合计",
unverifiedBalance: "未核销余额",
recentDates: "最近日期",
lastPaid: "最近打款",
lastVerified: "最近核销",
paymentTitle: "分期付款",
paymentEmpty: "暂无分期付款记录",
paymentSeq: "第",
paymentSeqUnit: "笔",
paidFlag: "打款状态",
verifiedFlag: "核销状态",
isPaid: "已打款",
isVerified: "已核销",
amountInvalid: "金额需大于等于 0",
paidDateRequired: "请填写打款日期",
verifiedDateRequired: "请填写核销日期",
verifyRequiresPaid: "核销前需先打款",
paymentValidationFailed: "请完善分期付款信息",
attachmentTitle: "合同附件",
attachmentContract: "合同",
attachmentContractDesc: "上传合同扫描件或签署页",
attachmentContractEmpty: "暂无合同附件",
attachmentVoucher: "凭证",
attachmentVoucherDesc: "上传打款凭证或收据",
attachmentVoucherEmpty: "暂无凭证附件",
attachmentInvoice: "发票",
attachmentInvoiceDesc: "上传发票或税务凭证",
attachmentInvoiceEmpty: "暂无发票附件",
uploadHint: "保存后可上传合同/凭证/发票附件",
},
feeSpecials: {
title: "特殊费用管理",
subtitle: "记录差旅、餐费、会议、物品等费用",
empty: "暂无特殊费用记录",
newTitle: "新增特殊费用",
editTitle: "编辑特殊费用",
formSubtitle: "维护特殊费用与核销信息",
detailTitle: "特殊费用详情",
detailSubtitle: "查看费用记录与附件",
overviewTitle: "费用总览",
totalAmount: "费用总计",
paidAmount: "已打款金额",
verifiedAmount: "已核销金额",
attachmentTotal: "附件总数",
recordCount: "记录数",
attachmentTitle: "费用附件",
attachmentCount: "附件数",
paidFlag: "打款状态",
verifiedFlag: "核销状态",
isPaid: "已打款",
isVerified: "已核销",
paidDateRequired: "请填写打款日期",
verifiedDateRequired: "请填写核销日期",
verifyRequiresPaid: "核销前需先打款",
validationFailed: "请完善费用状态信息",
attachmentVoucher: "凭证",
attachmentVoucherDesc: "上传报销凭证或收据",
attachmentVoucherEmpty: "暂无凭证附件",
attachmentInvoice: "发票",
attachmentInvoiceDesc: "上传发票或税务凭证",
attachmentInvoiceEmpty: "暂无发票附件",
attachmentOther: "其他",
attachmentOtherDesc: "上传其他支持性材料",
attachmentOtherEmpty: "暂无其他附件",
uploadHint: "保存后可上传凭证/发票/其他附件",
},
drugShipments: { drugShipments: {
title: "药品运输/流向", title: "药品运输/流向",
subtitle: "登记寄送与回收信息", subtitle: "登记寄送与回收信息",
@@ -681,6 +779,8 @@ export const TEXT = {
projectMembersManage: "仅 ADMIN / PM 可调整项目成员", projectMembersManage: "仅 ADMIN / PM 可调整项目成员",
siteManage: "仅 ADMIN / PM 可管理中心", siteManage: "仅 ADMIN / PM 可管理中心",
siteCraBind: "仅 ADMIN / PM 可绑定 CRA", siteCraBind: "仅 ADMIN / PM 可绑定 CRA",
feeContractsWrite: "仅 ADMIN / PM 可维护合同费用",
feeSpecialsWrite: "仅 ADMIN / PM 可维护特殊费用",
default: "当前角色无权执行该操作", default: "当前角色无权执行该操作",
}, },
profile: { profile: {
@@ -772,6 +872,13 @@ export const TEXT = {
TRANSPORT: "交通", TRANSPORT: "交通",
OTHER: "其他", OTHER: "其他",
}, },
feeSpecialCategory: {
travel: "差旅",
meal: "餐费",
meeting: "会议",
supplies: "物品",
other: "其他",
},
projectStatus: { projectStatus: {
DRAFT: "草稿", DRAFT: "草稿",
ACTIVE: "进行中", ACTIVE: "进行中",
+54
View File
@@ -19,6 +19,12 @@ import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue"; import ProjectOverview from "../views/ia/ProjectOverview.vue";
import FinanceContracts from "../views/ia/FinanceContracts.vue"; import FinanceContracts from "../views/ia/FinanceContracts.vue";
import FinanceSpecial from "../views/ia/FinanceSpecial.vue"; import FinanceSpecial from "../views/ia/FinanceSpecial.vue";
import FeeContracts from "../views/fees/ContractFees.vue";
import FeeContractForm from "../views/fees/ContractFeeForm.vue";
import FeeContractDetail from "../views/fees/ContractFeeDetail.vue";
import FeeSpecials from "../views/fees/SpecialExpenses.vue";
import FeeSpecialForm from "../views/fees/SpecialExpenseForm.vue";
import FeeSpecialDetail from "../views/fees/SpecialExpenseDetail.vue";
import DrugShipments from "../views/ia/DrugShipments.vue"; import DrugShipments from "../views/ia/DrugShipments.vue";
import FileVersionManagement from "../views/ia/FileVersionManagement.vue"; import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue"; import StartupFeasibilityEthics from "../views/ia/StartupFeasibilityEthics.vue";
@@ -98,6 +104,30 @@ const routes: RouteRecordRaw[] = [
component: FinanceContracts, component: FinanceContracts,
meta: { title: TEXT.menu.financeContracts, requiresStudy: true }, meta: { title: TEXT.menu.financeContracts, requiresStudy: true },
}, },
{
path: "fees/contracts",
name: "FeeContracts",
component: FeeContracts,
meta: { title: TEXT.menu.feeContracts, requiresStudy: true },
},
{
path: "fees/contracts/new",
name: "FeeContractNew",
component: FeeContractForm,
meta: { title: TEXT.common.actions.add + TEXT.menu.feeContracts, requiresStudy: true },
},
{
path: "fees/contracts/:contractId",
name: "FeeContractDetail",
component: FeeContractDetail,
meta: { title: TEXT.modules.feeContracts.detailTitle, requiresStudy: true },
},
{
path: "fees/contracts/:contractId/edit",
name: "FeeContractEdit",
component: FeeContractForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.feeContracts, requiresStudy: true },
},
{ {
path: "finance/contracts/new", path: "finance/contracts/new",
name: "FinanceContractNew", name: "FinanceContractNew",
@@ -122,6 +152,30 @@ const routes: RouteRecordRaw[] = [
component: FinanceSpecial, component: FinanceSpecial,
meta: { title: TEXT.menu.financeSpecials, requiresStudy: true }, meta: { title: TEXT.menu.financeSpecials, requiresStudy: true },
}, },
{
path: "fees/special",
name: "FeeSpecials",
component: FeeSpecials,
meta: { title: TEXT.menu.feeSpecials, requiresStudy: true },
},
{
path: "fees/special/new",
name: "FeeSpecialNew",
component: FeeSpecialForm,
meta: { title: TEXT.common.actions.add + TEXT.menu.feeSpecials, requiresStudy: true },
},
{
path: "fees/special/:expenseId",
name: "FeeSpecialDetail",
component: FeeSpecialDetail,
meta: { title: TEXT.modules.feeSpecials.detailTitle, requiresStudy: true },
},
{
path: "fees/special/:expenseId/edit",
name: "FeeSpecialEdit",
component: FeeSpecialForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.feeSpecials, requiresStudy: true },
},
{ {
path: "finance/special/new", path: "finance/special/new",
name: "FinanceSpecialNew", name: "FinanceSpecialNew",
+6
View File
@@ -3,6 +3,12 @@ export interface ApiListResponse<T> {
total: number; total: number;
} }
export interface FeeApiResponse<T> {
data?: T | null;
error?: string | Record<string, any> | null;
meta?: Record<string, any> | null;
}
export interface ApiError { export interface ApiError {
code: string; code: string;
message: string; message: string;
+4
View File
@@ -16,6 +16,8 @@ const PERMISSIONS: Record<string, string[]> = {
"project.members.manage": ["ADMIN", "PM"], "project.members.manage": ["ADMIN", "PM"],
"site.manage": ["ADMIN", "PM"], "site.manage": ["ADMIN", "PM"],
"site.cra.bind": ["ADMIN", "PM"], "site.cra.bind": ["ADMIN", "PM"],
"fees.contract.write": ["ADMIN", "PM"],
"fees.special.write": ["ADMIN", "PM"],
}; };
const REASONS: Record<string, string> = { const REASONS: Record<string, string> = {
@@ -29,6 +31,8 @@ const REASONS: Record<string, string> = {
"project.members.manage": TEXT.modules.permissions.projectMembersManage, "project.members.manage": TEXT.modules.permissions.projectMembersManage,
"site.manage": TEXT.modules.permissions.siteManage, "site.manage": TEXT.modules.permissions.siteManage,
"site.cra.bind": TEXT.modules.permissions.siteCraBind, "site.cra.bind": TEXT.modules.permissions.siteCraBind,
"fees.contract.write": TEXT.modules.permissions.feeContractsWrite,
"fees.special.write": TEXT.modules.permissions.feeSpecialsWrite,
}; };
export const usePermission = () => { export const usePermission = () => {
@@ -0,0 +1,305 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeContracts.detailTitle }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeContracts.detailSubtitle }}</p>
</div>
<div class="actions">
<el-button type="primary" :disabled="!canWrite" @click="goEdit" class="header-action-btn copy-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack" class="header-action-btn">
{{ TEXT.common.actions.back }}
</el-button>
</div>
</div>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<el-card class="detail-card" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
</div>
</template>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item :label="TEXT.common.fields.site" label-class-name="desc-label">
{{ centerName || TEXT.common.fallback }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.feeContracts.contractAmount" label-class-name="desc-label">
<span class="amount-text">{{ formatAmountWan(detail.contract_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.feeContracts.contractCases" label-class-name="desc-label">
{{ detail.contract_cases ?? TEXT.common.fallback }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.feeContracts.actualCases" label-class-name="desc-label">
{{ detail.actual_cases ?? TEXT.common.fallback }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.feeContracts.settlementAmount" label-class-name="desc-label">
<span class="amount-text">{{ formatAmountWan(detail.settlement_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.feeContracts.finalPaymentAmount" label-class-name="desc-label">
<span class="amount-text">{{ formatAmountWan(detail.final_payment_amount) }}</span>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="section-card" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
</div>
</template>
<el-table :data="detail.payments" style="width: 100%" :header-cell-style="{ background: '#f8f9fb' }">
<el-table-column :label="TEXT.modules.feeContracts.paymentSeq" width="120">
<template #default="scope">
<span class="seq-tag">{{ TEXT.modules.feeContracts.paymentSeq }}{{ scope.row.seq }}{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" width="160" align="right">
<template #default="scope">
<span class="amount-text">{{ formatAmountWan(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.paidFlag" width="200">
<template #default="scope">
<div v-if="scope.row.is_paid" class="status-cell">
<el-tag type="success" size="small">{{ TEXT.modules.feeContracts.isPaid }}</el-tag>
<span class="status-date">{{ displayDate(scope.row.paid_date) }}</span>
</div>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.verifiedFlag" width="200">
<template #default="scope">
<div v-if="scope.row.is_verified" class="status-cell">
<el-tag type="info" size="small">{{ TEXT.modules.feeContracts.isVerified }}</el-tag>
<span class="status-date">{{ displayDate(scope.row.verified_date) }}</span>
</div>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.remark" min-width="200" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column>
</el-table>
<StateEmpty v-if="detail.payments.length === 0" :description="TEXT.modules.feeContracts.paymentEmpty" />
</el-card>
<el-card class="section-card" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
</div>
</template>
<FeeAttachmentPanel
v-if="contractId"
:entity-type="'contract_fee'"
:entity-id="contractId"
:groups="attachmentGroups"
/>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Edit } from "@element-plus/icons-vue";
import { getContractFee } from "../../api/feeContracts";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const canWrite = computed(() => can("fees.contract.write"));
const contractId = route.params.contractId as string;
const loading = ref(false);
const errorMessage = ref("");
const detail = reactive<any>({
contract_amount: 0,
contract_cases: 0,
actual_cases: null,
settlement_amount: null,
final_payment_amount: null,
payments: [],
});
const sites = ref<any[]>([]);
const attachmentGroups = [
{
key: "contract",
label: TEXT.modules.feeContracts.attachmentContract,
description: TEXT.modules.feeContracts.attachmentContractDesc,
emptyText: TEXT.modules.feeContracts.attachmentContractEmpty,
},
{
key: "voucher",
label: TEXT.modules.feeContracts.attachmentVoucher,
description: TEXT.modules.feeContracts.attachmentVoucherDesc,
emptyText: TEXT.modules.feeContracts.attachmentVoucherEmpty,
},
{
key: "invoice",
label: TEXT.modules.feeContracts.attachmentInvoice,
description: TEXT.modules.feeContracts.attachmentInvoiceDesc,
emptyText: TEXT.modules.feeContracts.attachmentInvoiceEmpty,
},
];
const centerName = computed(() => {
const match = sites.value.find((site) => site.id === detail.center_id);
return match?.name || "";
});
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
if (!contractId) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await getContractFee(contractId);
Object.assign(detail, data?.data || {});
if (!Array.isArray(detail.payments)) detail.payments = [];
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const formatAmountWan = (value: any) => {
const numberValue = Number(value);
if (Number.isNaN(numberValue)) return TEXT.common.fallback;
return (numberValue / 10000).toFixed(2);
};
const goEdit = () => router.push(`/fees/contracts/${contractId}/edit`);
const goBack = () => router.push("/fees/contracts");
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--ctms-text-main);
}
.page-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--ctms-text-secondary);
}
.actions {
display: flex;
gap: 12px;
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
}
.detail-card, .section-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.card-header {
padding: 4px 0;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
}
.custom-descriptions :deep(.desc-label) {
background-color: var(--ctms-bg-color-page) !important;
color: var(--ctms-text-secondary);
width: 150px;
font-weight: 500;
}
.amount-text {
font-family: var(--el-font-family);
font-weight: 600;
}
.seq-tag {
color: var(--ctms-text-secondary);
font-size: 13px;
}
.status-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.status-date {
font-size: 12px;
color: var(--ctms-text-secondary);
font-family: var(--el-font-family);
}
.text-muted {
color: var(--ctms-text-placeholder);
font-size: 13px;
}
</style>
+635
View File
@@ -0,0 +1,635 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ isEdit ? TEXT.modules.feeContracts.editTitle : TEXT.modules.feeContracts.newTitle }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeContracts.formSubtitle }}</p>
</div>
<div class="actions">
<el-button @click="goBack" class="header-action-btn">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="loadDetail">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<el-form ref="formRef" :model="form" :rules="rules" label-width="140px" label-position="top">
<el-card class="form-card" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
</div>
</template>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site" prop="center_id" required>
<el-select v-model="form.center_id" :disabled="isEdit" :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.contractAmount" prop="contract_amount" required>
<el-input-number v-model="form.contract_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.contractCases" prop="contract_cases" required>
<el-input-number v-model="form.contract_cases" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.actualCases" prop="actual_cases">
<el-input-number v-model="form.actual_cases" :min="0" :step="1" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.settlementAmount" prop="settlement_amount">
<el-input-number v-model="form.settlement_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.modules.feeContracts.finalPaymentAmount" prop="final_payment_amount">
<el-input-number v-model="form.final_payment_amount" :min="0" :precision="2" :step="1000" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-card class="form-card section-margin" shadow="never">
<template #header>
<div class="card-header actions-header">
<span class="header-title">{{ TEXT.modules.feeContracts.paymentTitle }}</span>
<el-button type="primary" @click="addPayment">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}
</el-button>
</div>
</template>
<div v-if="payments.length === 0" class="section-empty">
<StateEmpty :description="TEXT.modules.feeContracts.paymentEmpty" />
</div>
<div v-else class="payment-list">
<transition-group name="list">
<div v-for="(payment, index) in payments" :key="payment.tempId" class="payment-item">
<div class="payment-item-header">
<div class="payment-seq">
<span class="seq-circle">{{ index + 1 }}</span>
<span class="seq-text">{{ TEXT.modules.feeContracts.paymentSeqUnit }}</span>
</div>
<el-button link type="danger" @click="removePayment(index)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item :label="TEXT.common.fields.amount" :error="paymentErrors[index]?.amount">
<el-input-number v-model="payment.amount" :min="0" :precision="2" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.paidFlag" :error="paymentErrors[index]?.paid_date">
<div class="status-control">
<el-checkbox v-model="payment.is_paid" @change="() => onPaidToggle(payment)">
{{ TEXT.modules.feeContracts.isPaid }}
</el-checkbox>
<el-date-picker
v-if="payment.is_paid"
v-model="payment.paid_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-picker"
size="small"
/>
</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item :label="TEXT.modules.feeContracts.verifiedFlag" :error="paymentErrors[index]?.verified_date">
<div class="status-control">
<el-checkbox v-model="payment.is_verified" @change="() => onVerifiedToggle(payment)">
{{ TEXT.modules.feeContracts.isVerified }}
</el-checkbox>
<el-date-picker
v-if="payment.is_verified"
v-model="payment.verified_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
class="status-picker"
size="small"
/>
</div>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.remark" class="mb-0">
<el-input v-model="payment.remark" type="textarea" :rows="2" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<div v-if="paymentErrors[index]?.verification" class="payment-error">
{{ paymentErrors[index].verification }}
</div>
</div>
</transition-group>
</div>
</el-card>
<el-card class="form-card section-margin" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeContracts.attachmentTitle }}</span>
</div>
</template>
<FeeAttachmentPanel
v-if="form.id"
:entity-type="'contract_fee'"
:entity-id="form.id"
:groups="attachmentGroups"
/>
<StateEmpty v-else :description="TEXT.modules.feeContracts.uploadHint" class="compact-empty" />
</el-card>
<div class="footer-actions">
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" @click="submit" class="save-btn">
{{ TEXT.common.actions.save }}
</el-button>
</div>
</el-form>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Plus, Delete } from "@element-plus/icons-vue";
import {
createContractFee,
createContractPayment,
deleteContractPayment,
getContractFee,
updateContractFee,
updateContractPayment,
} from "../../api/feeContracts";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import StateEmpty from "../../components/StateEmpty.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const sites = ref<any[]>([]);
const formRef = ref();
const form = reactive({
id: "",
project_id: "",
center_id: "",
contract_amount: 0,
contract_cases: 0,
actual_cases: null as number | null,
settlement_amount: null as number | null,
final_payment_amount: null as number | null,
});
const payments = ref<any[]>([]);
const removedPaymentIds = ref<string[]>([]);
const paymentErrors = ref<Record<string, string>[]>([]);
const contractId = computed(() => route.params.contractId as string | undefined);
const isEdit = computed(() => !!contractId.value);
const attachmentGroups = [
{
key: "contract",
label: TEXT.modules.feeContracts.attachmentContract,
description: TEXT.modules.feeContracts.attachmentContractDesc,
emptyText: TEXT.modules.feeContracts.attachmentContractEmpty,
},
{
key: "voucher",
label: TEXT.modules.feeContracts.attachmentVoucher,
description: TEXT.modules.feeContracts.attachmentVoucherDesc,
emptyText: TEXT.modules.feeContracts.attachmentVoucherEmpty,
},
{
key: "invoice",
label: TEXT.modules.feeContracts.attachmentInvoice,
description: TEXT.modules.feeContracts.attachmentInvoiceDesc,
emptyText: TEXT.modules.feeContracts.attachmentInvoiceEmpty,
},
];
const rules = {
center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
contract_amount: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
contract_cases: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
};
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const loadDetail = async () => {
if (!contractId.value) return;
loading.value = true;
errorMessage.value = "";
removedPaymentIds.value = [];
paymentErrors.value = [];
try {
const { data } = await getContractFee(contractId.value);
const detail = data?.data || {};
Object.assign(form, {
id: detail.id || contractId.value,
project_id: detail.project_id || study.currentStudy?.id || "",
center_id: detail.center_id || "",
contract_amount: Number(detail.contract_amount || 0) / 10000,
contract_cases: Number(detail.contract_cases || 0),
actual_cases: detail.actual_cases ?? null,
settlement_amount: detail.settlement_amount !== null && detail.settlement_amount !== undefined ? Number(detail.settlement_amount) / 10000 : null,
final_payment_amount: detail.final_payment_amount !== null && detail.final_payment_amount !== undefined ? Number(detail.final_payment_amount) / 10000 : null,
});
payments.value = (detail.payments || []).map((payment: any) => ({
id: payment.id,
tempId: payment.id,
amount: Number(payment.amount || 0),
paid_date: payment.paid_date || "",
verified_date: payment.verified_date || "",
is_paid: !!payment.is_paid,
is_verified: !!payment.is_verified,
remark: payment.remark || "",
}));
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const addPayment = () => {
payments.value.push({
tempId: `${Date.now()}-${Math.random()}`,
amount: 0,
paid_date: "",
verified_date: "",
is_paid: false,
is_verified: false,
remark: "",
});
};
const removePayment = (index: number) => {
const payment = payments.value[index];
if (payment?.id) {
removedPaymentIds.value.push(payment.id);
}
payments.value.splice(index, 1);
};
const onPaidToggle = (payment: any) => {
if (!payment.is_paid) {
payment.paid_date = "";
payment.is_verified = false;
payment.verified_date = "";
}
};
const onVerifiedToggle = (payment: any) => {
if (payment.is_verified && !payment.is_paid) {
payment.is_paid = true;
}
if (!payment.is_verified) {
payment.verified_date = "";
}
};
const validatePayments = () => {
const errors: Record<string, string>[] = [];
let ok = true;
payments.value.forEach((payment, index) => {
const entry: Record<string, string> = {};
if (payment.amount === null || payment.amount === undefined || Number(payment.amount) < 0) {
entry.amount = TEXT.modules.feeContracts.amountInvalid;
ok = false;
}
if (payment.is_paid && !payment.paid_date) {
entry.paid_date = TEXT.modules.feeContracts.paidDateRequired;
ok = false;
}
if (payment.is_verified && !payment.verified_date) {
entry.verified_date = TEXT.modules.feeContracts.verifiedDateRequired;
ok = false;
}
if (payment.is_verified && !payment.is_paid) {
entry.verification = TEXT.modules.feeContracts.verifyRequiresPaid;
ok = false;
}
errors[index] = entry;
});
paymentErrors.value = errors;
return ok;
};
const submit = async () => {
if (!study.currentStudy?.id) return;
const formOk = await formRef.value?.validate?.().catch(() => false);
if (!formOk) return;
if (!validatePayments()) {
ElMessage.error(TEXT.modules.feeContracts.paymentValidationFailed);
return;
}
saving.value = true;
try {
const payload = {
project_id: study.currentStudy.id,
center_id: form.center_id,
contract_amount: Number(form.contract_amount || 0) * 10000,
contract_cases: Number(form.contract_cases || 0),
actual_cases: form.actual_cases === null ? null : Number(form.actual_cases),
settlement_amount: form.settlement_amount === null ? null : Number(form.settlement_amount) * 10000,
final_payment_amount: form.final_payment_amount === null ? null : Number(form.final_payment_amount) * 10000,
};
const updatePayload = {
contract_amount: payload.contract_amount,
contract_cases: payload.contract_cases,
actual_cases: payload.actual_cases,
settlement_amount: payload.settlement_amount,
final_payment_amount: payload.final_payment_amount,
};
let savedId = contractId.value || form.id;
if (isEdit.value && savedId) {
await updateContractFee(savedId, updatePayload);
} else {
const { data } = await createContractFee(payload);
savedId = data?.data?.id;
form.id = savedId || "";
}
if (savedId) {
for (const paymentId of removedPaymentIds.value) {
await deleteContractPayment(paymentId);
}
removedPaymentIds.value = [];
for (const payment of payments.value) {
const paymentPayload = {
amount: Number(payment.amount || 0),
paid_date: payment.paid_date || null,
verified_date: payment.verified_date || null,
is_paid: !!payment.is_paid,
is_verified: !!payment.is_verified,
remark: payment.remark || null,
};
if (payment.id) {
await updateContractPayment(payment.id, paymentPayload);
} else {
const { data } = await createContractPayment(savedId, paymentPayload);
payment.id = data?.data?.id;
}
}
}
ElMessage.success(TEXT.common.messages.saveSuccess);
if (savedId) {
router.push(`/fees/contracts/${savedId}`);
} else {
goBack();
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/fees/contracts");
onMounted(async () => {
await loadSites();
if (isEdit.value) {
await loadDetail();
} else {
form.project_id = study.currentStudy?.id || "";
}
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 24px;
max-width: 1000px;
margin: 0 auto;
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--ctms-text-main);
}
.page-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--ctms-text-secondary);
}
.header-action-btn {
height: 36px;
border-radius: 8px;
}
.form-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.section-margin {
margin-top: 24px;
}
.card-header {
padding: 4px 0;
}
.actions-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
}
.full-width {
width: 100%;
}
.section-empty {
padding: 24px 0;
display: flex;
justify-content: center;
}
.payment-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.payment-item {
border: 1px solid var(--ctms-border-color);
border-radius: 8px;
padding: 16px;
background-color: var(--ctms-bg-color-page);
transition: all 0.3s;
}
.payment-item:hover {
border-color: var(--ctms-border-color-hover);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.payment-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.payment-seq {
display: flex;
align-items: center;
gap: 8px;
}
.seq-circle {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: var(--el-color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.seq-text {
font-size: 14px;
font-weight: 500;
color: var(--ctms-text-main);
}
.status-control {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-picker {
width: 100%;
}
.payment-error {
color: var(--ctms-danger);
font-size: 12px;
margin-top: 8px;
}
.mb-0 {
margin-bottom: 0 !important;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-bottom: 32px;
}
.save-btn {
padding: 0 32px;
}
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.compact-empty :deep(.state) {
padding: 24px 0 !important;
flex-direction: row;
gap: 12px;
}
.compact-empty :deep(.state-icon) {
font-size: 24px !important;
margin-bottom: 0 !important;
}
.compact-empty :deep(.state-title) {
font-size: 14px !important;
font-weight: normal !important;
margin: 0 !important;
}
.compact-empty :deep(.state-desc) {
margin: 0 !important;
font-size: 14px !important;
}
</style>
+441
View File
@@ -0,0 +1,441 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeContracts.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeContracts.subtitle }}</p>
</div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeContracts.newTitle }}
</el-button>
</div>
<div class="overview">
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="`${TEXT.modules.feeContracts.totalContractAmount} (${TEXT.modules.feeContracts.centerCount} ${overview.centerCount})`"
:value="formatAmountWan(overview.totalContractAmount)"
unit="万元"
:loading="loading"
:icon="Coin"
type="primary"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeContracts.totalPaidAmount"
:value="formatAmountWan(overview.totalPaidAmount)"
unit="万元"
:loading="loading"
:icon="Wallet"
type="success"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeContracts.totalUnpaidAmount"
:value="formatAmountWan(overview.totalUnpaidAmount)"
unit="万元"
:loading="loading"
:icon="CircleClose"
type="warning"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeContracts.totalVerifiedAmount"
:value="formatAmountWan(overview.totalVerifiedAmount)"
unit="万元"
:loading="loading"
:icon="CircleCheck"
type="info"
/>
</el-col>
</el-row>
</div>
<el-card class="filter-card" shadow="never">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item">
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-input v-model="filters.q" :placeholder="TEXT.common.placeholders.keyword" clearable class="filter-input">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
</el-card>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<el-card v-else class="table-card" shadow="never">
<el-table :data="contracts" style="width: 100%" @row-click="onRowClick" :header-cell-style="{ background: '#f8f9fb' }">
<el-table-column prop="center_name" :label="TEXT.common.fields.site" min-width="180">
<template #default="scope">
<div class="site-cell">
<span class="site-name">{{ scope.row.center_name || TEXT.common.fallback }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.contractAmount" width="160" align="right">
<template #default="scope">
<span class="amount-text">{{ formatAmountWan(scope.row.contract_amount) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.caseProgress" min-width="150" align="center">
<template #default="scope">
<el-tag effect="plain" type="info">
{{ displayCases(scope.row.contract_cases, scope.row.actual_cases) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.paidSummary" min-width="180">
<template #default="scope">
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.paidTotal }}</span>
<span class="amount-highlight success">{{ formatAmountWan(scope.row.paid_total) }}</span>
</div>
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.unpaidBalance }}</span>
<span class="amount-highlight warning" v-if="scope.row.unpaid_balance > 0">{{ formatAmountWan(scope.row.unpaid_balance) }}</span>
<span class="amount-muted" v-else>0.00</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.verifySummary" min-width="180">
<template #default="scope">
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.verifiedTotal }}</span>
<span class="amount-highlight info">{{ formatAmountWan(scope.row.verified_total) }}</span>
</div>
<div class="summary-line">
<span>{{ TEXT.modules.feeContracts.unverifiedBalance }}</span>
<span class="amount-highlight warning" v-if="scope.row.unverified_balance > 0">{{ formatAmountWan(scope.row.unverified_balance) }}</span>
<span class="amount-muted" v-else>0.00</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeContracts.recentDates" min-width="200">
<template #default="scope">
<div class="date-line">
<span class="date-label">{{ TEXT.modules.feeContracts.lastPaid }}</span>
<span class="date-value">{{ displayDate(scope.row.last_paid_date) }}</span>
</div>
<div class="date-line">
<span class="date-label">{{ TEXT.modules.feeContracts.lastVerified }}</span>
<span class="date-value">{{ displayDate(scope.row.last_verified_date) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.remark" min-width="180" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.remark || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.actions.delete" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="!canWrite"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="contracts.length === 0" :description="TEXT.modules.feeContracts.empty" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Coin, Wallet, CircleCheck, CircleClose, Plus, Location, Search } from "@element-plus/icons-vue";
import { deleteContractFee, listContractFees } from "../../api/feeContracts";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { displayDate } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue";
import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue";
import { TEXT } from "../../locales";
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const canWrite = computed(() => can("fees.contract.write"));
const loading = ref(false);
const errorMessage = ref("");
const contracts = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({
centerId: "",
q: "",
});
const toNumber = (value: any) => {
const numberValue = Number(value);
return Number.isNaN(numberValue) ? 0 : numberValue;
};
const overview = computed(() => {
const totals = contracts.value.reduce(
(acc, item) => {
acc.totalContractAmount += toNumber(item.contract_amount);
acc.totalPaidAmount += toNumber(item.paid_total);
acc.totalUnpaidAmount += toNumber(item.unpaid_balance);
acc.totalVerifiedAmount += toNumber(item.verified_total);
return acc;
},
{
totalContractAmount: 0,
totalPaidAmount: 0,
totalUnpaidAmount: 0,
totalVerifiedAmount: 0,
}
);
return {
...totals,
centerCount: contracts.value.length,
};
});
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
const projectId = study.currentStudy?.id;
if (!projectId) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await listContractFees({
projectId,
centerId: filters.centerId || undefined,
q: filters.q || undefined,
});
contracts.value = data?.data || [];
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.centerId = "";
filters.q = "";
load();
};
const goNew = () => router.push("/fees/contracts/new");
const goDetail = (id: string) => router.push(`/fees/contracts/${id}`);
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => {
if (!row?.id || !canWrite.value) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteContractFee(row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const displayCases = (contractCases: any, actualCases: any) => {
const contractValue = Number(contractCases ?? 0);
const actualValue = Number(actualCases ?? 0);
if (Number.isNaN(contractValue) || Number.isNaN(actualValue)) {
return TEXT.common.fallback;
}
return `${actualValue}/${contractValue}`;
};
const formatAmountWan = (value: any) => {
const numberValue = Number(value);
if (Number.isNaN(numberValue)) return TEXT.common.fallback;
return (numberValue / 10000).toFixed(2);
};
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.5px;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--ctms-text-secondary);
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
.overview {
margin-bottom: 4px;
}
.filter-card {
border: none;
background-color: transparent;
padding: 0;
}
.filter-card :deep(.el-card__body) {
padding: 0;
}
.filter-form {
display: flex;
gap: 12px;
align-items: center;
background: white;
padding: 16px;
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
}
.filter-item {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select,
.filter-input {
width: 240px;
}
.filter-actions {
margin-left: auto !important;
margin-bottom: 0 !important;
}
.table-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.site-cell {
font-weight: 500;
color: var(--ctms-text-main);
}
.amount-text {
font-family: var(--el-font-family);
font-weight: 500;
}
.summary-line {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
margin-bottom: 4px;
color: var(--ctms-text-regular);
}
.summary-line:last-child {
margin-bottom: 0;
}
.amount-highlight {
font-weight: 600;
font-family: var(--el-font-family);
}
.amount-highlight.success { color: var(--el-color-success); }
.amount-highlight.warning { color: var(--el-color-warning); }
.amount-highlight.info { color: var(--el-color-info); }
.amount-muted { color: var(--ctms-text-placeholder); }
.date-line {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 2px;
}
.date-label {
color: var(--ctms-text-secondary);
}
.date-value {
color: var(--ctms-text-regular);
font-family: var(--el-font-family);
}
</style>
@@ -0,0 +1,283 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeSpecials.detailTitle }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.detailSubtitle }}</p>
</div>
<div class="actions">
<el-button type="primary" :disabled="!canWrite" @click="goEdit" class="header-action-btn copy-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
<el-button @click="goBack" class="header-action-btn">
{{ TEXT.common.actions.back }}
</el-button>
</div>
</div>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<el-card class="detail-card" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
<div class="status-badges">
<el-tag v-if="detail.is_paid" type="success" effect="dark" size="default">{{ TEXT.modules.feeSpecials.isPaid }}</el-tag>
<el-tag v-else type="info" effect="plain" size="default">{{ TEXT.modules.feeSpecials.unpaid }}</el-tag>
<el-tag v-if="detail.is_verified" type="info" effect="dark" size="default">{{ TEXT.modules.feeSpecials.isVerified }}</el-tag>
<el-tag v-else type="info" effect="plain" size="default">{{ TEXT.modules.feeSpecials.unverified }}</el-tag>
</div>
</div>
</template>
<el-descriptions :column="2" border class="custom-descriptions">
<el-descriptions-item :label="TEXT.common.fields.occurDate" label-class-name="desc-label">
<span class="date-text">{{ displayDate(detail.happen_date) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.site" label-class-name="desc-label">
{{ centerName || TEXT.common.fallback }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.category" label-class-name="desc-label">
<el-tag size="small" :type="getCategoryType(detail.category)" effect="light">
{{ displayEnum(TEXT.enums.feeSpecialCategory, detail.category) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.amount" label-class-name="desc-label">
<span class="amount-text">{{ formatAmount(detail.amount) }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.feeSpecials.paidFlag" label-class-name="desc-label">
<span v-if="detail.is_paid">
{{ displayDate(detail.paid_date) }}
</span>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.feeSpecials.verifiedFlag" label-class-name="desc-label">
<span v-if="detail.is_verified">
{{ displayDate(detail.verified_date) }}
</span>
<span v-else class="text-muted">{{ TEXT.common.fallback }}</span>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.description" :span="2" label-class-name="desc-label">
{{ detail.description || TEXT.common.fallback }}
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="section-card" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeSpecials.attachmentTitle }}</span>
</div>
</template>
<FeeAttachmentPanel
v-if="expenseId"
:entity-type="'special_expense'"
:entity-id="expenseId"
:groups="attachmentGroups"
/>
</el-card>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Edit } from "@element-plus/icons-vue";
import { getSpecialExpense } from "../../api/feeSpecials";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { displayDate, displayEnum } from "../../utils/display";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const canWrite = computed(() => can("fees.special.write"));
const expenseId = route.params.expenseId as string;
const loading = ref(false);
const errorMessage = ref("");
const detail = reactive<any>({
happen_date: "",
center_id: "",
category: "",
amount: 0,
description: "",
is_paid: false,
paid_date: "",
is_verified: false,
verified_date: "",
});
const sites = ref<any[]>([]);
const attachmentGroups = [
{
key: "voucher",
label: TEXT.modules.feeSpecials.attachmentVoucher,
description: TEXT.modules.feeSpecials.attachmentVoucherDesc,
emptyText: TEXT.modules.feeSpecials.attachmentVoucherEmpty,
},
{
key: "invoice",
label: TEXT.modules.feeSpecials.attachmentInvoice,
description: TEXT.modules.feeSpecials.attachmentInvoiceDesc,
emptyText: TEXT.modules.feeSpecials.attachmentInvoiceEmpty,
},
{
key: "other",
label: TEXT.modules.feeSpecials.attachmentOther,
description: TEXT.modules.feeSpecials.attachmentOtherDesc,
emptyText: TEXT.modules.feeSpecials.attachmentOtherEmpty,
},
];
const centerName = computed(() => {
const match = sites.value.find((site) => site.id === detail.center_id);
return match?.name || "";
});
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
if (!expenseId) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await getSpecialExpense(expenseId);
Object.assign(detail, data?.data || {});
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const formatAmount = (value: any) => {
const numberValue = Number(value);
if (Number.isNaN(numberValue)) return TEXT.common.fallback;
return numberValue.toFixed(2);
};
const getCategoryType = (category: string) => {
if (category === 'travel') return 'warning';
if (category === 'meeting') return 'success';
if (category === 'material') return 'primary';
return 'info';
};
const goEdit = () => router.push(`/fees/special/${expenseId}/edit`);
const goBack = () => router.push("/fees/special");
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--ctms-text-main);
}
.page-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--ctms-text-secondary);
}
.actions {
display: flex;
gap: 12px;
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
}
.detail-card, .section-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
}
.status-badges {
display: flex;
gap: 8px;
}
.custom-descriptions :deep(.desc-label) {
background-color: var(--ctms-bg-color-page) !important;
color: var(--ctms-text-secondary);
width: 150px;
font-weight: 500;
}
.amount-text {
font-family: var(--el-font-family);
font-weight: 600;
}
.date-text {
font-family: var(--el-font-family);
}
.text-muted {
color: var(--ctms-text-placeholder);
font-size: 13px;
}
</style>
@@ -0,0 +1,495 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ isEdit ? TEXT.modules.feeSpecials.editTitle : TEXT.modules.feeSpecials.newTitle }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.formSubtitle }}</p>
</div>
<div class="actions">
<el-button @click="goBack" class="header-action-btn">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="loadDetail">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
<el-card class="form-card" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
</div>
</template>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.site">
<el-select v-model="form.center_id" clearable :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.occurDate" prop="happen_date">
<el-date-picker v-model="form.happen_date" type="date" value-format="YYYY-MM-DD" class="full-width" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.category" prop="category" required>
<el-select v-model="form.category" :placeholder="TEXT.common.placeholders.select" class="full-width">
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.amount" prop="amount" required>
<el-input-number v-model="form.amount" :min="0" :precision="2" class="full-width" controls-position="right" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item :label="TEXT.common.fields.description">
<el-input v-model="form.description" type="textarea" :rows="3" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-card class="form-card section-margin" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.common.fields.status }}</span>
</div>
</template>
<div class="status-section">
<el-row :gutter="24">
<el-col :span="12">
<div class="status-box">
<div class="status-header">
<span class="status-title">{{ TEXT.modules.feeSpecials.paidFlag }}</span>
<el-switch v-model="form.is_paid" @change="onPaidToggle" />
</div>
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.paid_date" class="status-form-item">
<el-date-picker
v-model="form.paid_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="!form.is_paid"
class="full-width"
/>
</el-form-item>
</div>
</el-col>
<el-col :span="12">
<div class="status-box">
<div class="status-header">
<span class="status-title">{{ TEXT.modules.feeSpecials.verifiedFlag }}</span>
<el-switch v-model="form.is_verified" @change="onVerifiedToggle" />
</div>
<el-form-item :label="TEXT.common.fields.occurDate" :error="specialErrors.verified_date" class="status-form-item">
<el-date-picker
v-model="form.verified_date"
type="date"
value-format="YYYY-MM-DD"
:placeholder="TEXT.common.placeholders.select"
:disabled="!form.is_verified"
class="full-width"
/>
</el-form-item>
</div>
</el-col>
</el-row>
<div v-if="specialErrors.verification" class="payment-error">
{{ specialErrors.verification }}
</div>
</div>
</el-card>
<el-card class="form-card section-margin" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.modules.feeSpecials.attachmentTitle }}</span>
</div>
</template>
<FeeAttachmentPanel
v-if="form.id"
:entity-type="'special_expense'"
:entity-id="form.id"
:groups="attachmentGroups"
/>
<StateEmpty v-else :description="TEXT.modules.feeSpecials.uploadHint" class="compact-empty" />
</el-card>
<div class="footer-actions">
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="saving" @click="submit" class="save-btn">
{{ TEXT.common.actions.save }}
</el-button>
</div>
</el-form>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { createSpecialExpense, getSpecialExpense, updateSpecialExpense } from "../../api/feeSpecials";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import StateEmpty from "../../components/StateEmpty.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import FeeAttachmentPanel from "../../components/fees/FeeAttachmentPanel.vue";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const saving = ref(false);
const errorMessage = ref("");
const sites = ref<any[]>([]);
const formRef = ref();
const form = reactive({
id: "",
project_id: "",
center_id: "",
category: "travel",
amount: 0,
happen_date: "",
description: "",
is_paid: false,
paid_date: "",
is_verified: false,
verified_date: "",
});
const specialErrors = reactive({
paid_date: "",
verified_date: "",
verification: "",
});
const expenseId = computed(() => route.params.expenseId as string | undefined);
const isEdit = computed(() => !!expenseId.value);
const attachmentGroups = [
{
key: "voucher",
label: TEXT.modules.feeSpecials.attachmentVoucher,
description: TEXT.modules.feeSpecials.attachmentVoucherDesc,
emptyText: TEXT.modules.feeSpecials.attachmentVoucherEmpty,
},
{
key: "invoice",
label: TEXT.modules.feeSpecials.attachmentInvoice,
description: TEXT.modules.feeSpecials.attachmentInvoiceDesc,
emptyText: TEXT.modules.feeSpecials.attachmentInvoiceEmpty,
},
{
key: "other",
label: TEXT.modules.feeSpecials.attachmentOther,
description: TEXT.modules.feeSpecials.attachmentOtherDesc,
emptyText: TEXT.modules.feeSpecials.attachmentOtherEmpty,
},
];
const categoryOptions = Object.keys(TEXT.enums.feeSpecialCategory).map((value) => ({
value,
label: (TEXT.enums.feeSpecialCategory as any)[value],
}));
const rules = {
category: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
amount: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
};
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const loadDetail = async () => {
if (!expenseId.value) return;
loading.value = true;
errorMessage.value = "";
resetSpecialErrors();
try {
const { data } = await getSpecialExpense(expenseId.value);
const detail = data?.data || {};
Object.assign(form, {
id: detail.id || expenseId.value,
project_id: detail.project_id || study.currentStudy?.id || "",
center_id: detail.center_id || "",
category: detail.category || "travel",
amount: Number(detail.amount || 0),
happen_date: detail.happen_date || "",
description: detail.description || "",
is_paid: !!detail.is_paid,
paid_date: detail.paid_date || "",
is_verified: !!detail.is_verified,
verified_date: detail.verified_date || "",
});
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const resetSpecialErrors = () => {
specialErrors.paid_date = "";
specialErrors.verified_date = "";
specialErrors.verification = "";
};
const onPaidToggle = () => {
if (!form.is_paid) {
form.paid_date = "";
form.is_verified = false;
form.verified_date = "";
}
};
const onVerifiedToggle = () => {
if (form.is_verified && !form.is_paid) {
form.is_paid = true;
}
if (!form.is_verified) {
form.verified_date = "";
}
};
const validateSpecial = () => {
resetSpecialErrors();
let ok = true;
if (form.is_paid && !form.paid_date) {
specialErrors.paid_date = TEXT.modules.feeSpecials.paidDateRequired;
ok = false;
}
if (form.is_verified && !form.verified_date) {
specialErrors.verified_date = TEXT.modules.feeSpecials.verifiedDateRequired;
ok = false;
}
if (form.is_verified && !form.is_paid) {
specialErrors.verification = TEXT.modules.feeSpecials.verifyRequiresPaid;
ok = false;
}
return ok;
};
const submit = async () => {
if (!study.currentStudy?.id) return;
const formOk = await formRef.value?.validate?.().catch(() => false);
if (!formOk) return;
if (!validateSpecial()) {
ElMessage.error(TEXT.modules.feeSpecials.validationFailed);
return;
}
saving.value = true;
try {
const payload = {
project_id: study.currentStudy.id,
center_id: form.center_id || null,
category: form.category,
amount: Number(form.amount || 0),
happen_date: form.happen_date || null,
description: form.description || null,
is_paid: !!form.is_paid,
paid_date: form.paid_date || null,
is_verified: !!form.is_verified,
verified_date: form.verified_date || null,
};
const updatePayload = {
center_id: payload.center_id,
category: payload.category,
amount: payload.amount,
happen_date: payload.happen_date,
description: payload.description,
is_paid: payload.is_paid,
paid_date: payload.paid_date,
is_verified: payload.is_verified,
verified_date: payload.verified_date,
};
let savedId = expenseId.value || form.id;
if (isEdit.value && savedId) {
await updateSpecialExpense(savedId, updatePayload);
} else {
const { data } = await createSpecialExpense(payload);
savedId = data?.data?.id;
form.id = savedId || "";
}
ElMessage.success(TEXT.common.messages.saveSuccess);
if (savedId) {
router.push(`/fees/special/${savedId}`);
} else {
goBack();
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/fees/special");
onMounted(async () => {
await loadSites();
if (isEdit.value) {
await loadDetail();
} else {
form.project_id = study.currentStudy?.id || "";
}
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 24px;
max-width: 1000px;
margin: 0 auto;
width: 100%;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--ctms-text-main);
}
.page-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--ctms-text-secondary);
}
.header-action-btn {
height: 36px;
border-radius: 8px;
padding: 0 16px;
}
.form-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.section-margin {
margin-top: 24px;
}
.card-header {
padding: 4px 0;
}
.header-title {
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-main);
border-left: 4px solid var(--el-color-primary);
padding-left: 12px;
}
.full-width {
width: 100%;
}
.status-section {
padding: 8px 0;
}
.status-box {
background-color: var(--ctms-bg-color-page);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--ctms-border-color);
height: 100%;
}
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.status-title {
font-weight: 600;
color: var(--ctms-text-main);
}
.status-form-item {
margin-bottom: 0 !important;
}
.payment-error {
color: var(--ctms-danger);
font-size: 12px;
margin-top: 12px;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-bottom: 32px;
}
.save-btn {
padding: 0 32px;
}
.compact-empty :deep(.state) {
padding: 24px 0 !important;
flex-direction: row;
gap: 12px;
}
.compact-empty :deep(.state-icon) {
font-size: 24px !important;
margin-bottom: 0 !important;
}
.compact-empty :deep(.state-title) {
font-size: 14px !important;
font-weight: normal !important;
margin: 0 !important;
}
.compact-empty :deep(.state-desc) {
margin: 0 !important;
font-size: 14px !important;
}
</style>
+447
View File
@@ -0,0 +1,447 @@
<template>
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">{{ TEXT.modules.feeSpecials.title }}</h1>
<p class="page-subtitle">{{ TEXT.modules.feeSpecials.subtitle }}</p>
</div>
<el-button type="primary" :disabled="!canWrite" @click="goNew" class="header-action-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.modules.feeSpecials.newTitle }}
</el-button>
</div>
<div class="overview">
<div class="overview-header">
<span class="overview-title">{{ TEXT.modules.feeSpecials.overviewTitle }}</span>
<el-tag effect="plain" type="info" size="small">{{ TEXT.modules.feeSpecials.recordCount }} {{ overview.recordCount }}</el-tag>
</div>
<el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeSpecials.totalAmount"
:value="formatAmount(overview.totalAmount)"
unit="元"
:loading="loading"
:icon="Coin"
type="primary"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeSpecials.paidAmount"
:value="formatAmount(overview.paidAmount)"
unit="元"
:loading="loading"
:icon="Wallet"
type="success"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeSpecials.verifiedAmount"
:value="formatAmount(overview.verifiedAmount)"
unit="元"
:loading="loading"
:icon="CircleCheck"
type="info"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<KpiCard
:title="TEXT.modules.feeSpecials.attachmentTotal"
:value="overview.attachmentTotal"
:loading="loading"
:icon="Document"
type="warning"
/>
</el-col>
</el-row>
</div>
<el-card class="filter-card" shadow="never">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item">
<el-select v-model="filters.centerId" clearable :placeholder="TEXT.common.fields.site" class="filter-select">
<template #prefix>
<el-icon><Location /></el-icon>
</template>
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-select v-model="filters.category" clearable :placeholder="TEXT.common.fields.category" class="filter-select">
<template #prefix>
<el-icon><Menu /></el-icon>
</template>
<el-option v-for="option in categoryOptions" :key="option.value" :label="option.label" :value="option.value" />
</el-select>
</el-form-item>
<el-form-item label="" class="filter-item">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
unlink-panels
value-format="YYYY-MM-DD"
:start-placeholder="TEXT.common.placeholders.startDate"
:end-placeholder="TEXT.common.placeholders.endDate"
class="filter-date"
/>
</el-form-item>
<el-form-item class="filter-actions">
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</el-form-item>
</el-form>
</el-card>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<el-card v-else class="table-card" shadow="never">
<el-table :data="expenses" style="width: 100%" @row-click="onRowClick" :header-cell-style="{ background: '#f8f9fb' }">
<el-table-column :label="TEXT.common.fields.occurDate" width="140">
<template #default="scope">
<span class="date-text">{{ displayDate(scope.row.happen_date) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">
<span class="site-text">{{ scope.row.center_name || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.category" width="140">
<template #default="scope">
<el-tag size="small" :type="getCategoryType(scope.row.category)" effect="light" class="category-tag">
{{ displayEnum(TEXT.enums.feeSpecialCategory, scope.row.category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.amount" width="140" align="right">
<template #default="scope">
<span class="amount-text">{{ formatAmount(scope.row.amount) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" :label="TEXT.common.fields.remark" min-width="200" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.description || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column width="140" align="center" :label="TEXT.common.labels.status">
<template #default="scope">
<el-tooltip :content="scope.row.is_paid ? `${TEXT.modules.feeSpecials.isPaid} (${displayDate(scope.row.paid_date)})` : TEXT.modules.feeSpecials.unpaid" placement="top">
<el-icon :class="scope.row.is_paid ? 'status-icon success' : 'status-icon muted'"><Wallet /></el-icon>
</el-tooltip>
<el-tooltip :content="scope.row.is_verified ? `${TEXT.modules.feeSpecials.isVerified} (${displayDate(scope.row.verified_date)})` : TEXT.modules.feeSpecials.unverified" placement="top">
<el-icon :class="scope.row.is_verified ? 'status-icon info' : 'status-icon muted'"><CircleCheck /></el-icon>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.feeSpecials.attachmentCount" width="120" align="center">
<template #default="scope">
<el-tag v-if="scope.row.attachments_count > 0" effect="plain" type="info" round size="small">
{{ scope.row.attachments_count }}
</el-tag>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.actions.delete" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="!canWrite"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
</el-table>
<StateEmpty v-if="expenses.length === 0" :description="TEXT.modules.feeSpecials.empty" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Coin, Wallet, CircleCheck, Document, Plus, Location, Menu } from "@element-plus/icons-vue";
import { deleteSpecialExpense, listSpecialExpenses } from "../../api/feeSpecials";
import { fetchSites } from "../../api/sites";
import { useStudyStore } from "../../store/study";
import { usePermission } from "../../utils/permission";
import { displayDate, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue";
import StateError from "../../components/StateError.vue";
import KpiCard from "../../components/KpiCard.vue";
import { TEXT } from "../../locales";
const router = useRouter();
const study = useStudyStore();
const { can } = usePermission();
const canWrite = computed(() => can("fees.special.write"));
const loading = ref(false);
const errorMessage = ref("");
const expenses = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({
centerId: "",
category: "",
dateRange: [] as string[],
});
const categoryOptions = Object.keys(TEXT.enums.feeSpecialCategory).map((value) => ({
value,
label: TEXT.enums.feeSpecialCategory[value],
}));
const toNumber = (value: any) => {
const numberValue = Number(value);
return Number.isNaN(numberValue) ? 0 : numberValue;
};
const overview = computed(() => {
const totals = expenses.value.reduce(
(acc, item) => {
acc.totalAmount += toNumber(item.amount);
if (item.is_paid) acc.paidAmount += toNumber(item.amount);
if (item.is_verified) acc.verifiedAmount += toNumber(item.amount);
acc.attachmentTotal += toNumber(item.attachments_count);
return acc;
},
{
totalAmount: 0,
paidAmount: 0,
verifiedAmount: 0,
attachmentTotal: 0,
}
);
return {
...totals,
recordCount: expenses.value.length,
};
});
const loadSites = async () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
const projectId = study.currentStudy?.id;
if (!projectId) return;
loading.value = true;
errorMessage.value = "";
try {
const [dateFrom, dateTo] = filters.dateRange || [];
const { data } = await listSpecialExpenses({
projectId,
centerId: filters.centerId || undefined,
category: filters.category || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
});
expenses.value = data?.data || [];
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.centerId = "";
filters.category = "";
filters.dateRange = [];
load();
};
const goNew = () => router.push("/fees/special/new");
const goDetail = (id: string) => router.push(`/fees/special/${id}`);
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => {
if (!row?.id || !canWrite.value) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteSpecialExpense(row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
const formatAmount = (value: any) => {
const numberValue = Number(value);
if (Number.isNaN(numberValue)) return TEXT.common.fallback;
return numberValue.toFixed(2);
};
const getCategoryType = (category: string) => {
if (category === 'travel') return 'warning';
if (category === 'meeting') return 'success';
if (category === 'material') return 'primary';
return 'info';
};
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.page-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--ctms-text-main);
letter-spacing: -0.5px;
}
.page-subtitle {
margin: 6px 0 0;
font-size: 14px;
color: var(--ctms-text-secondary);
}
.header-action-btn {
height: 36px;
padding: 0 20px;
border-radius: 8px;
}
.overview {
margin-bottom: 4px;
}
.overview-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px 8px;
}
.overview-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.filter-card {
border: none;
background-color: transparent;
padding: 0;
}
.filter-card :deep(.el-card__body) {
padding: 0;
}
.filter-form {
display: flex;
gap: 12px;
align-items: center;
background: white;
padding: 16px;
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
}
.filter-item {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select {
width: 200px;
}
.filter-date {
width: 280px;
}
.filter-actions {
margin-left: auto !important;
margin-bottom: 0 !important;
}
.table-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.date-text {
font-family: var(--el-font-family);
color: var(--ctms-text-regular);
}
.site-text {
font-weight: 500;
color: var(--ctms-text-main);
}
.amount-text {
font-family: var(--el-font-family);
font-weight: 500;
}
.category-tag {
font-weight: 500;
}
.status-icon {
font-size: 18px;
margin: 0 6px;
outline: none;
}
.status-icon.success { color: var(--el-color-success); }
.status-icon.info { color: var(--el-color-info); }
.status-icon.muted { color: var(--ctms-border-color); opacity: 0.6; }
.text-muted {
color: var(--ctms-text-placeholder);
}
</style>