移除特殊费用模块

This commit is contained in:
Cheng Zhou
2026-05-12 10:15:18 +08:00
parent cd7e12ce1f
commit 6e90370a5f
30 changed files with 117 additions and 2819 deletions
@@ -0,0 +1,114 @@
"""remove special fee modules
Revision ID: 20260512_02
Revises: 20260512_01
Create Date: 2026-05-12 10:15:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260512_02"
down_revision: Union[str, None] = "20260512_01"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _table_exists(inspector: sa.Inspector, table_name: str) -> bool:
return table_name in inspector.get_table_names()
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if _table_exists(inspector, "attachments"):
op.execute(
"""
DELETE FROM attachments
WHERE entity_type IN (
'finance_special',
'special_expense',
'special_expense_voucher',
'special_expense_invoice',
'special_expense_other'
)
"""
)
if _table_exists(inspector, "fee_attachments"):
op.execute(
"""
DELETE FROM fee_attachments
WHERE entity_type = 'special_expense'
"""
)
if _table_exists(inspector, "audit_logs"):
op.execute(
"""
DELETE FROM audit_logs
WHERE entity_type IN ('finance_special', 'special_expense')
"""
)
if _table_exists(inspector, "finance_specials"):
op.drop_table("finance_specials")
if _table_exists(inspector, "special_expenses"):
op.drop_table("special_expenses")
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not _table_exists(inspector, "special_expenses"):
op.create_table(
"special_expenses",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("project_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("center_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("category", sa.String(length=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="false", nullable=False),
sa.Column("paid_date", sa.Date(), nullable=True),
sa.Column("is_verified", sa.Boolean(), server_default="false", nullable=False),
sa.Column("verified_date", sa.Date(), nullable=True),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["center_id"], ["sites.id"]),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["project_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_special_expenses_project_id", "special_expenses", ["project_id"])
op.create_index("ix_special_expenses_center_id", "special_expenses", ["center_id"])
if not _table_exists(inspector, "finance_specials"):
op.create_table(
"finance_specials",
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("site_name", sa.String(length=255), nullable=False),
sa.Column("fee_type", sa.String(length=50), nullable=False),
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
sa.Column("occur_date", sa.Date(), nullable=True),
sa.Column("staff_name", sa.String(length=100), nullable=True),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["created_by"], ["users.id"]),
sa.ForeignKeyConstraint(["study_id"], ["studies.id"]),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_finance_specials_study_id", "finance_specials", ["study_id"])
+1 -8
View File
@@ -14,7 +14,6 @@ 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
@@ -24,11 +23,10 @@ router = APIRouter()
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "fees"
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment", "special_expense"}
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment"}
ALLOWED_FILE_TYPES = {
"contract_fee": {"contract", "voucher", "invoice"},
"contract_payment": {"voucher", "invoice"},
"special_expense": {"voucher", "invoice", "other"},
}
@@ -46,11 +44,6 @@ async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uui
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="不支持的附件类型")
-245
View File
@@ -1,245 +0,0 @@
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_cra_site_scope, get_current_user, get_db_session, require_study_not_locked
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="费用类别无效")
async def _ensure_center_active(db: AsyncSession, project_id: uuid.UUID, center_id: uuid.UUID | None):
if not center_id:
return
site = await site_crud.get_site(db, center_id)
if not site or site.study_id != project_id or not site.is_active:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, 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)
cra_scope = await get_cra_site_scope(db, project_id, current_user)
center_ids = cra_scope[0] if cra_scope else None
if center_id and center_ids is not None and center_id not in center_ids:
return FeeApiResponse(data=[], meta={"total": 0})
rows = await special_crud.list_special_expenses(
db,
project_id,
center_id=center_id,
center_ids=center_ids,
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)
cra_scope = await get_cra_site_scope(db, expense.project_id, current_user)
if cra_scope and expense.center_id not in cra_scope[0]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
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), Depends(require_study_not_locked())],
)
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 or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense_in.project_id, expense_in.center_id)
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), Depends(require_study_not_locked())],
)
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 or not site.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="中心不存在或已停用")
await _ensure_center_active(db, expense.project_id, expense_in.center_id)
else:
await _ensure_center_active(db, expense.project_id, expense.center_id)
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), Depends(require_study_not_locked())],
)
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 _ensure_center_active(db, expense.project_id, expense.center_id)
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,
)
-177
View File
@@ -1,177 +0,0 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked
from app.crud import audit as audit_crud
from app.crud import finance_special as special_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialRead, FinanceSpecialUpdate
router = APIRouter()
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
study = await study_crud.get(db, study_id)
if not study:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
return study
async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_name: str | None):
if not site_name:
return
active_names = await site_crud.list_active_names(db, study_id)
if site_name not in active_names:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
@router.post(
"/specials",
response_model=FinanceSpecialRead,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def create_special(
study_id: uuid.UUID,
special_in: FinanceSpecialCreate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and special_in.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, special_in.site_name)
item = await special_crud.create_special(db, study_id, special_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_special",
entity_id=item.id,
action="CREATE_FINANCE_SPECIAL",
detail=f"特殊费用 {item.id} 已创建",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FinanceSpecialRead.model_validate(item)
@router.get(
"/specials",
response_model=list[FinanceSpecialRead],
dependencies=[Depends(require_study_member())],
)
async def list_specials(
study_id: uuid.UUID,
site_name: str | None = None,
fee_type: str | None = None,
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[FinanceSpecialRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
if site_name and site_names is not None and site_name not in site_names:
return []
items = await special_crud.list_specials(
db,
study_id,
site_name=site_name,
site_names=site_names,
fee_type=fee_type,
skip=skip,
limit=limit,
)
return [FinanceSpecialRead.model_validate(item) for item in items]
@router.get(
"/specials/{special_id}",
response_model=FinanceSpecialRead,
dependencies=[Depends(require_study_member())],
)
async def get_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
return FinanceSpecialRead.model_validate(item)
@router.patch(
"/specials/{special_id}",
response_model=FinanceSpecialRead,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def update_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
special_in: FinanceSpecialUpdate,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> FinanceSpecialRead:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
item = await special_crud.update_special(db, item, special_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_special",
entity_id=special_id,
action="UPDATE_FINANCE_SPECIAL",
detail=f"特殊费用 {special_id} 已更新",
operator_id=current_user.id,
operator_role=current_user.role,
)
return FinanceSpecialRead.model_validate(item)
@router.delete(
"/specials/{special_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(require_study_roles(["PM", "CRA"]))],
)
async def delete_special(
study_id: uuid.UUID,
special_id: uuid.UUID,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> None:
await _ensure_study_exists(db, study_id)
item = await special_crud.get_special(db, special_id)
if not item or item.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
cra_scope = await get_cra_site_scope(db, study_id, current_user)
if cra_scope and item.site_name not in cra_scope[1]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
await _ensure_site_name_active(db, study_id, item.site_name)
await special_crud.delete_special(db, item)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="finance_special",
entity_id=special_id,
action="DELETE_FINANCE_SPECIAL",
detail=f"特殊费用 {special_id} 已删除",
operator_id=current_user.id,
operator_role=current_user.role,
)
+1 -3
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues
from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, fees_contracts, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, subject_pds, study_subject_pds, faq_categories, faqs, documents, overview, notifications, monitoring_visit_issues
api_router = APIRouter()
@@ -21,9 +21,7 @@ api_router.include_router(visits.router, prefix="/studies/{study_id}/subjects/{s
api_router.include_router(aes.router, prefix="/studies/{study_id}/aes", tags=["aes"])
api_router.include_router(finance_dashboard.router, prefix="/studies/{study_id}/finance", tags=["finance"])
api_router.include_router(finance_contracts.router, prefix="/studies/{study_id}/finance", tags=["finance-contracts"])
api_router.include_router(finance_specials.router, prefix="/studies/{study_id}/finance", tags=["finance-specials"])
api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-contracts"])
api_router.include_router(fees_specials.router, prefix="/fees", tags=["fees-specials"])
api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"])
api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"])
api_router.include_router(material_equipments.router, prefix="/studies/{study_id}/materials", tags=["material-equipments"])
-77
View File
@@ -1,77 +0,0 @@
import uuid
from typing import Sequence
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.finance_special import FinanceSpecial
from app.schemas.finance_special import FinanceSpecialCreate, FinanceSpecialUpdate
async def create_special(
db: AsyncSession,
study_id: uuid.UUID,
special_in: FinanceSpecialCreate,
created_by: uuid.UUID | None,
) -> FinanceSpecial:
special = FinanceSpecial(
study_id=study_id,
site_name=special_in.site_name,
fee_type=special_in.fee_type,
amount=special_in.amount,
occur_date=special_in.occur_date,
staff_name=special_in.staff_name,
remark=special_in.remark,
created_by=created_by,
)
db.add(special)
await db.commit()
await db.refresh(special)
return special
async def get_special(db: AsyncSession, special_id: uuid.UUID) -> FinanceSpecial | None:
result = await db.execute(select(FinanceSpecial).where(FinanceSpecial.id == special_id))
return result.scalar_one_or_none()
async def list_specials(
db: AsyncSession,
study_id: uuid.UUID,
site_name: str | None = None,
site_names: set[str] | None = None,
fee_type: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[FinanceSpecial]:
stmt = (
select(FinanceSpecial)
.where(FinanceSpecial.study_id == study_id)
)
if site_names is not None:
if not site_names:
return []
stmt = stmt.where(FinanceSpecial.site_name.in_(site_names))
if site_name:
stmt = stmt.where(FinanceSpecial.site_name.ilike(f"%{site_name}%"))
if fee_type:
stmt = stmt.where(FinanceSpecial.fee_type == fee_type)
stmt = stmt.order_by(FinanceSpecial.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
async def update_special(
db: AsyncSession, special: FinanceSpecial, special_in: FinanceSpecialUpdate
) -> FinanceSpecial:
update_data = special_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(special, key, value)
await db.commit()
await db.refresh(special)
return special
async def delete_special(db: AsyncSession, special: FinanceSpecial) -> None:
await db.delete(special)
await db.commit()
-38
View File
@@ -17,13 +17,11 @@ from app.models.drug_shipment import DrugShipment
from app.models.fee_attachment import FeeAttachment
from app.models.finance import FinanceItem
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.kickoff_meeting import KickoffMeeting
from app.models.knowledge_note import KnowledgeNote
from app.models.milestone import Milestone
from app.models.monitoring_visit_issue import MonitoringVisitIssue
from app.models.site import Site
from app.models.special_expense import SpecialExpense
from app.models.startup_ethics import StartupEthics
from app.models.startup_feasibility import StartupFeasibility
from app.models.study_center_confirm import StudyCenterConfirm
@@ -158,9 +156,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
contract_fee_ids = (
await db.execute(select(ContractFee.id).where(ContractFee.center_id == site_id))
).scalars().all()
special_expense_ids = (
await db.execute(select(SpecialExpense.id).where(SpecialExpense.center_id == site_id))
).scalars().all()
drug_shipment_ids = (
await db.execute(select(DrugShipment.id).where(DrugShipment.center_id == site_id))
).scalars().all()
@@ -189,14 +184,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
)
)
).scalars().all()
finance_special_ids = (
await db.execute(
select(FinanceSpecial.id).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
).scalars().all()
knowledge_note_ids = (
await db.execute(
select(KnowledgeNote.id).where(
@@ -233,7 +220,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
"startup_kickoff_ppt": kickoff_ids,
"training_authorization": training_ids,
"finance_contract": finance_contract_ids,
"finance_special": finance_special_ids,
"knowledge_note": knowledge_note_ids,
"drug_shipment": drug_shipment_ids,
}
@@ -279,23 +265,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
)
)
await db.execute(delete(ContractFeePayment).where(ContractFeePayment.contract_fee_id.in_(contract_fee_ids)))
if special_expense_ids:
fee_attachment_paths.extend(
(
await db.execute(
select(FeeAttachment.storage_key).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
).scalars().all()
)
await db.execute(
delete(FeeAttachment).where(
FeeAttachment.entity_type == "special_expense",
FeeAttachment.entity_id.in_(special_expense_ids),
)
)
if distribution_ids:
await db.execute(delete(Acknowledgement).where(Acknowledgement.distribution_id.in_(distribution_ids)))
@@ -327,7 +296,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
await db.execute(delete(StartupEthics).where(StartupEthics.site_id == site_id))
await db.execute(delete(ContractFee).where(ContractFee.center_id == site_id))
await db.execute(delete(SpecialExpense).where(SpecialExpense.center_id == site_id))
await db.execute(delete(DrugShipment).where(DrugShipment.center_id == site_id))
await db.execute(
update(MonitoringVisitIssue)
@@ -347,12 +315,6 @@ async def delete_site_and_related(db: AsyncSession, site: Site) -> None:
FinanceContract.site_name == site_name,
)
)
await db.execute(
delete(FinanceSpecial).where(
FinanceSpecial.study_id == study_id,
FinanceSpecial.site_name == site_name,
)
)
await db.execute(
delete(KnowledgeNote).where(
KnowledgeNote.study_id == study_id,
-104
View File
@@ -1,104 +0,0 @@
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.attachment import Attachment
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,
center_ids: set[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(Attachment.id).label("attachments_count")
attachment_types = ["special_expense_voucher", "special_expense_invoice", "special_expense_other"]
stmt = (
select(
SpecialExpense,
Site.name.label("center_name"),
attachment_count,
)
.outerjoin(Site, Site.id == SpecialExpense.center_id)
.outerjoin(
Attachment,
and_(
Attachment.entity_type.in_(attachment_types),
Attachment.entity_id == SpecialExpense.id,
Attachment.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 center_ids is not None:
if not center_ids:
return []
stmt = stmt.where(SpecialExpense.center_id.in_(center_ids))
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
@@ -100,10 +100,8 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
from app.models.ae import AdverseEvent
from app.models.finance import FinanceItem
from app.models.finance_contract import FinanceContract
from app.models.finance_special import FinanceSpecial
from app.models.contract_fee import ContractFee
from app.models.contract_fee_payment import ContractFeePayment
from app.models.special_expense import SpecialExpense
from app.models.milestone import Milestone
from app.models.document import Document
from app.models.attachment import Attachment
@@ -152,7 +150,6 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
await db.execute(sa_delete(Milestone).where(Milestone.study_id == study_id))
# 9. 删除财务相关
await db.execute(sa_delete(SpecialExpense).where(SpecialExpense.project_id == study_id))
await db.execute(
sa_delete(ContractFeePayment).where(
ContractFeePayment.contract_fee_id.in_(
@@ -161,7 +158,6 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None:
)
)
await db.execute(sa_delete(ContractFee).where(ContractFee.project_id == study_id))
await db.execute(sa_delete(FinanceSpecial).where(FinanceSpecial.study_id == study_id))
await db.execute(sa_delete(FinanceContract).where(FinanceContract.study_id == study_id))
await db.execute(sa_delete(FinanceItem).where(FinanceItem.study_id == study_id))
-2
View File
@@ -18,10 +18,8 @@ from app.models.visit import Visit # noqa: F401
from app.models.ae import AdverseEvent # noqa: F401
from app.models.finance import FinanceItem # noqa: F401
from app.models.finance_contract import FinanceContract # noqa: F401
from app.models.finance_special import FinanceSpecial # noqa: F401
from app.models.contract_fee import ContractFee # noqa: F401
from app.models.contract_fee_payment import ContractFeePayment # noqa: F401
from app.models.special_expense import SpecialExpense # noqa: F401
from app.models.fee_attachment import FeeAttachment # noqa: F401
from app.models.drug_shipment import DrugShipment # noqa: F401
from app.models.material_equipment import MaterialEquipment # noqa: F401
-26
View File
@@ -1,26 +0,0 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base_class import Base
class FinanceSpecial(Base):
__tablename__ = "finance_specials"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
fee_type: Mapped[str] = mapped_column(String(50), nullable=False)
amount: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
occur_date: Mapped[date | None] = mapped_column(Date, nullable=True)
staff_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
remark: Mapped[str | None] = mapped_column(Text, 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()
)
-30
View File
@@ -1,30 +0,0 @@
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()
)
-40
View File
@@ -1,40 +0,0 @@
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
from pydantic import BaseModel, ConfigDict, Field
class FinanceSpecialCreate(BaseModel):
site_name: str
fee_type: str
amount: Decimal = Field(gt=0)
occur_date: Optional[date] = None
staff_name: Optional[str] = None
remark: Optional[str] = None
class FinanceSpecialUpdate(BaseModel):
site_name: Optional[str] = None
fee_type: Optional[str] = None
amount: Optional[Decimal] = Field(default=None, gt=0)
occur_date: Optional[date] = None
staff_name: Optional[str] = None
remark: Optional[str] = None
class FinanceSpecialRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
site_name: str
fee_type: str
amount: Decimal
occur_date: Optional[date]
staff_name: Optional[str]
remark: Optional[str]
created_by: Optional[uuid.UUID]
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
-55
View File
@@ -1,55 +0,0 @@
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
-45
View File
@@ -214,22 +214,6 @@ CREATE TABLE IF NOT EXISTS public.finance_contracts (
CONSTRAINT fk_finance_contracts_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS public.finance_specials (
id uuid PRIMARY KEY,
study_id uuid NOT NULL,
site_name character varying(255) NOT NULL,
fee_type character varying(50) NOT NULL,
amount numeric(12, 2) NOT NULL,
occur_date date,
staff_name character varying(100),
remark text,
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_finance_specials_study_id FOREIGN KEY (study_id) REFERENCES public.studies(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,
@@ -261,26 +245,6 @@ CREATE TABLE IF NOT EXISTS public.contract_fee_payments (
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,
@@ -549,7 +513,6 @@ CREATE INDEX IF NOT EXISTS ix_adverse_events_study_id ON public.adverse_events (
CREATE INDEX IF NOT EXISTS ix_adverse_events_site_id ON public.adverse_events (site_id);
CREATE INDEX IF NOT EXISTS ix_adverse_events_subject_id ON public.adverse_events (subject_id);
CREATE INDEX IF NOT EXISTS ix_finance_contracts_study_id ON public.finance_contracts (study_id);
CREATE INDEX IF NOT EXISTS ix_finance_specials_study_id ON public.finance_specials (study_id);
CREATE INDEX IF NOT EXISTS ix_finance_items_study_id ON public.finance_items (study_id);
CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (site_id);
CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id);
@@ -650,14 +613,6 @@ INSERT INTO public.finance_contracts (
('77777777-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '上海瑞金医院', 'CON-002', '2025-01-10', 980000.00, 'CNY', '分中心合同签署', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-10 09:00:00+00', '2025-01-10 09:00:00+00')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.finance_specials (
id, study_id, site_name, fee_type, amount, occur_date, staff_name, remark, created_by, created_at, updated_at
) VALUES
('88888888-aaaa-bbbb-cccc-111111111111', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', '差旅', 5200.00, '2025-01-15', '赵敏', 'SIV 差旅费', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-15 10:00:00+00', '2025-01-15 10:00:00+00'),
('88888888-aaaa-bbbb-cccc-222222222222', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '上海瑞金医院', '住宿', 3200.00, '2025-01-18', '李雷', '监查住宿费', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-18 10:00:00+00', '2025-01-18 10:00:00+00'),
('88888888-aaaa-bbbb-cccc-333333333333', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), '北京协和医院', '交通', 860.00, '2025-01-20', '韩梅梅', '市内交通费', (SELECT id FROM public.users WHERE email = 'pm@example.com'), '2025-01-20 10:00:00+00', '2025-01-20 10:00:00+00')
ON CONFLICT (id) DO NOTHING;
INSERT INTO public.finance_items (
id, study_id, site_id, subject_id, visit_id, category, title, description, currency, amount, occur_date, status, submitted_at, approved_at, rejected_at, paid_at, approver_id, payer_id, reject_reason, created_by, created_at, updated_at
) VALUES
-1
View File
@@ -69,7 +69,6 @@ for (const className of requiredOverviewClasses) {
const financeAndFilePages = [
"src/views/fees/ContractFees.vue",
"src/views/fees/SpecialExpenses.vue",
"src/views/ia/FileVersionManagement.vue"
];
-45
View File
@@ -1,45 +0,0 @@
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}`);
-16
View File
@@ -1,16 +0,0 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
export const listFinanceSpecials = (studyId: string, params?: Record<string, any>) =>
apiGet(`/api/v1/studies/${studyId}/finance/specials`, { params });
export const getFinanceSpecial = (studyId: string, specialId: string) =>
apiGet(`/api/v1/studies/${studyId}/finance/specials/${specialId}`);
export const createFinanceSpecial = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/finance/specials`, payload);
export const updateFinanceSpecial = (studyId: string, specialId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/finance/specials/${specialId}`, payload);
export const deleteFinanceSpecial = (studyId: string, specialId: string) =>
apiDelete(`/api/v1/studies/${studyId}/finance/specials/${specialId}`);
-2
View File
@@ -30,10 +30,8 @@ const entityTypeLabelMap: Record<string, string> = {
visit: "访视",
ae: "不良事件",
finance_contract: "费用合同",
finance_special: "费用特殊费用",
contract_fee: "合同费用条目",
contract_fee_payment: "合同费用回款",
special_expense: "特殊费用条目",
drug_shipment: "药品流向",
startup_feasibility: "立项可行性",
startup_ethics: "立项伦理",
-4
View File
@@ -53,7 +53,6 @@
<span>{{ TEXT.menu.finance }}</span>
</template>
<el-menu-item index="/fees/contracts">{{ TEXT.menu.feeContracts }}</el-menu-item>
<el-menu-item index="/fees/special">{{ TEXT.menu.feeSpecials }}</el-menu-item>
</el-sub-menu>
<el-sub-menu index="materials">
<template #title>
@@ -254,9 +253,7 @@ const activeMenu = computed(() => {
if (path.startsWith("/project/milestones")) return "/project/milestones";
if (path.startsWith("/project/")) return "/project/overview";
if (path.startsWith("/fees/contracts")) return "/fees/contracts";
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("/materials/equipment")) return "/materials/equipment";
if (path.startsWith("/file-versions") || path.startsWith("/trial/") || path.startsWith("/documents/")) return "/file-versions";
@@ -330,7 +327,6 @@ const breadcrumbs = computed(() => {
// 3. ()
const moduleMap: Record<string, { label: string, path: string }> = {
"/fees/contracts": { label: TEXT.menu.feeContracts, path: "/fees/contracts" },
"/fees/special": { label: TEXT.menu.feeSpecials, path: "/fees/special" },
"/project/milestones": { label: TEXT.menu.projectMilestones, path: "/project/milestones" },
"/drug/shipments": { label: TEXT.menu.drugShipments, path: "/drug/shipments" },
"/materials/equipment": { label: TEXT.menu.materialEquipment, path: "/materials/equipment" },
-68
View File
@@ -262,9 +262,7 @@ export const TEXT = {
projectMilestones: "项目里程碑",
finance: "费用管理",
financeContracts: "合同管理",
financeSpecials: "特殊费用管理",
feeContracts: "合同管理",
feeSpecials: "特殊费用管理",
drug: "药品管理",
materialManagement: "物资管理",
drugShipments: "药品流向管理",
@@ -371,16 +369,6 @@ export const TEXT = {
detailTitle: "合同费用详情",
detailSubtitle: "查看合同费用与附件",
},
financeSpecials: {
title: "特殊费用",
subtitle: "记录差旅、住宿等特殊费用",
empty: "暂无特殊费用记录",
newTitle: "新增特殊费用",
editTitle: "编辑特殊费用",
uploadHint: "保存后可上传凭证附件",
detailTitle: "特殊费用详情",
detailSubtitle: "查看费用记录与附件",
},
feeContracts: {
title: "合同管理",
subtitle: "按中心维护合同费用与分期付款",
@@ -436,44 +424,6 @@ export const TEXT = {
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: "暂无其他附件",
unpaid: "未打款",
unverified: "未核销",
uploadHint: "保存后可上传凭证/发票/其他附件",
},
drugShipments: {
title: "药品流向管理",
subtitle: "登记寄送与回收信息",
@@ -1011,7 +961,6 @@ export const TEXT = {
siteManage: "仅 ADMIN / PM 可管理中心",
siteCraBind: "仅 ADMIN / PM 可绑定 CRA",
feeContractsWrite: "仅 ADMIN / PM 可维护合同费用",
feeSpecialsWrite: "仅 ADMIN / PM 可维护特殊费用",
default: "当前角色无权执行该操作",
},
profile: {
@@ -1118,23 +1067,6 @@ export const TEXT = {
RETURNED: "已回收",
EXCEPTION: "异常",
},
financeSpecialType: {
: "差旅",
宿: "住宿",
: "交通",
: "其他",
TRAVEL: "差旅",
HOTEL: "住宿",
TRANSPORT: "交通",
OTHER: "其他",
},
feeSpecialCategory: {
travel: "差旅",
meal: "餐费",
meeting: "会议",
supplies: "物品",
other: "其他",
},
projectStatus: {
DRAFT: "草稿",
ACTIVE: "进行中",
-54
View File
@@ -19,13 +19,9 @@ import ProfileSettings from "../views/ProfileSettings.vue";
import ProjectOverview from "../views/ia/ProjectOverview.vue";
import ProjectMilestones from "../views/ia/ProjectMilestones.vue";
import FinanceContracts from "../views/ia/FinanceContracts.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 MaterialEquipment from "../views/ia/MaterialEquipment.vue";
import FileVersionManagement from "../views/ia/FileVersionManagement.vue";
@@ -45,8 +41,6 @@ import KnowledgeSupportFiles from "../views/ia/KnowledgeSupportFiles.vue";
import KnowledgeInstructionFiles from "../views/ia/KnowledgeInstructionFiles.vue";
import ContractForm from "../views/finance/ContractForm.vue";
import ContractDetail from "../views/finance/ContractDetail.vue";
import SpecialForm from "../views/finance/SpecialForm.vue";
import SpecialDetail from "../views/finance/SpecialDetail.vue";
import ShipmentForm from "../views/drug/ShipmentForm.vue";
import ShipmentDetail from "../views/drug/ShipmentDetail.vue";
import FeasibilityForm from "../views/startup/FeasibilityForm.vue";
@@ -159,54 +153,6 @@ const routes: RouteRecordRaw[] = [
component: ContractForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.financeContracts, requiresStudy: true },
},
{
path: "finance/special",
name: "FinanceSpecial",
component: FinanceSpecial,
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",
name: "FinanceSpecialNew",
component: SpecialForm,
meta: { title: TEXT.common.actions.add + TEXT.menu.financeSpecials, requiresStudy: true },
},
{
path: "finance/special/:specialId",
name: "FinanceSpecialDetail",
component: SpecialDetail,
meta: { title: TEXT.modules.financeSpecials.detailTitle, requiresStudy: true },
},
{
path: "finance/special/:specialId/edit",
name: "FinanceSpecialEdit",
component: SpecialForm,
meta: { title: TEXT.common.actions.edit + TEXT.menu.financeSpecials, requiresStudy: true },
},
{
path: "drug/shipments",
name: "DrugShipments",
-2
View File
@@ -17,7 +17,6 @@ const PERMISSIONS: Record<string, string[]> = {
"site.manage": ["ADMIN", "PM"],
"site.cra.bind": ["ADMIN", "PM"],
"fees.contract.write": ["ADMIN", "PM"],
"fees.special.write": ["ADMIN", "PM"],
};
const REASONS: Record<string, string> = {
@@ -32,7 +31,6 @@ const REASONS: Record<string, string> = {
"site.manage": TEXT.modules.permissions.siteManage,
"site.cra.bind": TEXT.modules.permissions.siteCraBind,
"fees.contract.write": TEXT.modules.permissions.feeContractsWrite,
"fees.special.write": TEXT.modules.permissions.feeSpecialsWrite,
};
export const usePermission = () => {
+1 -1
View File
@@ -179,7 +179,7 @@ const protocolSections = [
{
title: "3. 临床试验数据与合规",
paragraphs: [
"用户在系统中录入、上传、维护或导出的项目、中心、受试者、访视、AE/SAE、PD、伦理、培训、合同费用、特殊费用、药品流向、设备台账、文件版本和知识库资料,应确保来源合法、内容真实、准确、完整、及时,并符合适用的 GCP、研究方案、伦理批件、SOP、数据保护和保密要求。",
"用户在系统中录入、上传、维护或导出的项目、中心、受试者、访视、AE/SAE、PD、伦理、培训、合同费用、药品流向、设备台账、文件版本和知识库资料,应确保来源合法、内容真实、准确、完整、及时,并符合适用的 GCP、研究方案、伦理批件、SOP、数据保护和保密要求。",
"涉及受试者、研究者、中心联系人、医学事件、财务附件、合同文件或其他敏感信息时,用户应遵循最小必要原则,不得上传与当前项目管理无关的数据,不得通过截图、导出、复制或外部工具进行未授权传播。",
],
},
@@ -1,280 +0,0 @@
<template>
<div class="page">
<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 unified-shell" shadow="never">
<template #header>
<div class="card-header">
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
<div class="status-badges actions">
<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>
<el-button type="primary" :disabled="!canWrite || isReadOnly" @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>
</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 unified-shell" 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"
:readonly="isReadOnly"
/>
</el-card>
</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 { 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 siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.center_id && siteActiveMap.value[detail.center_id] === false);
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 || {});
//
if (centerName.value) {
study.setViewContext({ siteName: centerName.value });
}
} 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 = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
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: 0;
}
.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;
}
.section-card :deep(.el-card__body) {
padding: 0;
}
.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>
@@ -1,487 +0,0 @@
<template>
<div class="page">
<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 unified-shell" shadow="never">
<template #header>
<div class="card-header header-actions-row">
<span class="header-title">{{ TEXT.common.labels.basicInfo }}</span>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</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 :disabled="isReadOnly" :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"
:disabled="!site.is_active"
/>
</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" :disabled="isReadOnly" 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" :disabled="isReadOnly" :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" :disabled="isReadOnly" :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" :disabled="isReadOnly" type="textarea" :rows="3" />
</el-form-item>
</el-col>
</el-row>
</el-card>
<el-card class="form-card section-margin attachment-card unified-shell" 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" :disabled="isReadOnly" @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="isReadOnly || !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" :disabled="isReadOnly" @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="isReadOnly || !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 unified-shell" 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"
:readonly="isReadOnly"
/>
<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" :disabled="isReadOnly" @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 siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.center_id && siteActiveMap.value[form.center_id] === false);
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;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (form.center_id && siteActiveMap.value[form.center_id] === false) {
ElMessage.warning("中心已停用");
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: 0;
width: 100%;
}
.form-card {
border-radius: 12px;
border: 1px solid var(--ctms-border-color);
overflow: hidden;
}
.section-margin {
margin-top: 24px;
}
.attachment-card :deep(.el-card__body) {
padding: 0;
}
.card-header {
padding: 4px 0;
}
.header-actions-row {
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%;
}
.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>
-450
View File
@@ -1,450 +0,0 @@
<template>
<div class="page ctms-page-shell page--flush">
<div class="overview">
<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>
<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" />
<div class="main-content-flat unified-shell" v-else>
<div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div 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>
</div>
<div 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>
</div>
<div 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"
/>
</div>
<div class="filter-actions">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></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>
</el-form>
</div>
<section class="unified-section special-expense-table-section section--flush-x section--flush-top section--flush-bottom">
<el-table
:data="sortedExpenses"
style="width: 100%"
@row-click="onRowClick"
:row-class-name="expenseRowClass"
class="special-expense-table"
table-layout="fixed"
>
<el-table-column :label="TEXT.common.fields.occurDate">
<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" show-overflow-tooltip>
<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">
<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" 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" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.description || TEXT.common.fallback }}
</template>
</el-table-column>
<el-table-column width="120" align="center" :label="TEXT.common.fields.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" 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.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="!canWrite || isInactiveSite(scope.row.center_id)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div class="table-empty">{{ TEXT.modules.feeSpecials.empty }}</div>
</template>
</el-table>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } 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 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 siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isInactiveSite = (siteId?: string) => !!siteId && siteActiveMap.value[siteId] === false;
const expenseRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.center_id) ? " row-inactive" : ""}`.trim();
const filteredExpenses = computed(() => {
const [dateFrom, dateTo] = filters.dateRange || [];
return expenses.value.filter((item) => {
if (filters.centerId && item?.center_id !== filters.centerId) return false;
if (filters.category && item?.category !== filters.category) return false;
if (dateFrom || dateTo) {
const value = item?.happen_date;
if (!value) return false;
if (dateFrom && value < dateFrom) return false;
if (dateTo && value > dateTo) return false;
}
return true;
});
});
const sortedExpenses = computed(() =>
[...filteredExpenses.value].sort((a, b) => Number(isInactiveSite(a?.center_id)) - Number(isInactiveSite(b?.center_id)))
);
const filters = reactive({
centerId: study.currentSite?.id || "",
category: "",
dateRange: [] as string[],
});
const categoryOptions = Object.keys(TEXT.enums.feeSpecialCategory).map((value) => ({
value,
label: (TEXT.enums.feeSpecialCategory as any)[value],
}));
const toNumber = (value: any) => {
const numberValue = Number(value);
return Number.isNaN(numberValue) ? 0 : numberValue;
};
const overview = computed(() => {
const totals = filteredExpenses.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,
};
});
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 listSpecialExpenses({ projectId });
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 = [];
};
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;
if (isInactiveSite(row?.center_id)) {
ElMessage.warning("中心已停用");
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';
};
watch(() => filters.centerId, (val: string) => {
if (val) {
const matched = sites.value.find(s => s.id === val);
if (matched) study.setCurrentSite(matched);
} else {
study.setCurrentSite(null);
}
});
watch(() => study.currentSite, (newSite: any) => {
filters.centerId = newSite?.id || "";
});
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.overview {
margin: 0;
}
.filter-form {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
}
.filter-item {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-select {
width: 140px;
}
.filter-date {
width: 220px;
}
.filter-actions {
margin-bottom: 0 !important;
}
.filter-spacer {
flex: 1;
}
.special-expense-table :deep(.el-table__inner-wrapper::before) {
display: none;
}
.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);
}
.table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style>
@@ -1,119 +0,0 @@
<template>
<div class="page">
<div class="unified-shell">
<section class="unified-section" v-loading="loading">
<div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.financeSpecials.detailTitle }}</div>
<div class="ctms-section-actions">
<el-button type="primary" :disabled="isReadOnly" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.type">{{ displayEnum(TEXT.enums.financeSpecialType, detail.fee_type) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.occurDate">{{ displayDate(detail.occur_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.staff">{{ detail.staff_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.amount">{{ detail.amount || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions>
</section>
<section class="unified-section attachment-section">
<AttachmentList
v-if="specialId"
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
:readonly="isReadOnly"
/>
</section>
</div>
</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 { useStudyStore } from "../../store/study";
import { getFinanceSpecial } from "../../api/financeSpecials";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const specialId = route.params.specialId as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({
site_name: "",
fee_type: "",
occur_date: "",
staff_name: "",
amount: "",
remark: "",
});
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const loadSites = async () => {
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 (!studyId || !specialId) return;
loading.value = true;
try {
const { data } = await getFinanceSpecial(studyId, specialId);
Object.assign(detail, data);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const goEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
router.push(`/finance/special/${specialId}/edit`);
};
const goBack = () => router.push("/finance/special");
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
.attachment-section {
padding: 0;
}
</style>
-175
View File
@@ -1,175 +0,0 @@
<template>
<div class="page">
<div class="unified-shell">
<section class="unified-section">
<div class="unified-section-header">
<div class="ctms-section-title">{{ isEdit ? TEXT.modules.financeSpecials.editTitle : TEXT.modules.financeSpecials.newTitle }}</div>
<div class="ctms-section-actions">
<el-button @click="goBack">{{ TEXT.common.actions.back }}</el-button>
</div>
</div>
<el-form :model="form" label-width="110px">
<el-form-item :label="TEXT.common.fields.site" required>
<el-input v-model="form.site_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.type" required>
<el-select v-model="form.fee_type" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.select">
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
<el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.common.fields.amount" required>
<el-input-number v-model="form.amount" :disabled="isReadOnly" :min="0" :precision="2" :step="100" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.occurDate">
<el-date-picker v-model="form.occur_date" :disabled="isReadOnly" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.staff">
<el-input v-model="form.staff_name" :disabled="isReadOnly" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.staff" />
</el-form-item>
<el-form-item :label="TEXT.common.fields.remark">
<el-input v-model="form.remark" :disabled="isReadOnly" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" :disabled="isReadOnly" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</el-form-item>
</el-form>
</section>
<section class="unified-section">
<AttachmentList
v-if="isEdit && specialId"
:study-id="studyId"
entity-type="finance_special"
:entity-id="specialId"
:readonly="isReadOnly"
/>
<StateEmpty v-else :description="TEXT.modules.financeSpecials.uploadHint" />
</section>
</div>
</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 { useStudyStore } from "../../store/study";
import { createFinanceSpecial, getFinanceSpecial, updateFinanceSpecial } from "../../api/financeSpecials";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
const route = useRoute();
const router = useRouter();
const study = useStudyStore();
const saving = ref(false);
const sites = ref<any[]>([]);
const specialId = computed(() => route.params.specialId as string | undefined);
const isEdit = computed(() => !!specialId.value);
const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({
site_name: "",
fee_type: "TRAVEL",
amount: 0,
occur_date: "",
staff_name: "",
remark: "",
});
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => isEdit.value && !!form.site_name && siteActiveMap.value[form.site_name] === false);
const loadSites = async () => {
if (!studyId.value) return;
try {
const { data } = await fetchSites(studyId.value, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const load = async () => {
if (!isEdit.value || !studyId.value || !specialId.value) return;
try {
const { data } = await getFinanceSpecial(studyId.value, specialId.value);
Object.assign(form, {
site_name: data.site_name || "",
fee_type: data.fee_type || "TRAVEL",
amount: Number(data.amount || 0),
occur_date: data.occur_date || "",
staff_name: data.staff_name || "",
remark: data.remark || "",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
};
const submit = async () => {
if (!studyId.value) return;
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!form.site_name) {
ElMessage.warning(TEXT.common.messages.required);
return;
}
if (siteActiveMap.value[form.site_name] === false) {
ElMessage.warning("中心已停用");
return;
}
saving.value = true;
try {
const payload = {
site_name: form.site_name,
fee_type: form.fee_type,
amount: form.amount,
occur_date: form.occur_date || null,
staff_name: form.staff_name || null,
remark: form.remark || null,
};
if (isEdit.value && specialId.value) {
await updateFinanceSpecial(studyId.value, specialId.value, payload);
ElMessage.success(TEXT.common.messages.saveSuccess);
router.push(`/finance/special/${specialId.value}`);
} else {
const { data } = await createFinanceSpecial(studyId.value, payload);
ElMessage.success(TEXT.common.messages.createSuccess);
router.push(`/finance/special/${data.id}`);
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
} finally {
saving.value = false;
}
};
const goBack = () => router.push("/finance/special");
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
}
</style>
-261
View File
@@ -1,261 +0,0 @@
<template>
<div class="page">
<div class="main-content-flat unified-shell">
<div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form">
<div class="filter-item-form">
<el-input
v-model="filters.site_name"
:placeholder="TEXT.common.fields.site"
clearable
class="filter-input-comp"
/>
</div>
<div class="filter-item-form">
<el-select
v-model="filters.fee_type"
:placeholder="TEXT.common.fields.type"
clearable
class="filter-select-comp"
>
<el-option :label="TEXT.enums.financeSpecialType.TRAVEL" value="TRAVEL" />
<el-option :label="TEXT.enums.financeSpecialType.HOTEL" value="HOTEL" />
<el-option :label="TEXT.enums.financeSpecialType.TRANSPORT" value="TRANSPORT" />
<el-option :label="TEXT.enums.financeSpecialType.OTHER" value="OTHER" />
</el-select>
</div>
<div class="filter-item-form">
<el-button @click="resetFilters">{{ TEXT.common.actions.reset }}</el-button>
</div>
<div class="filter-spacer"></div>
<el-button type="primary" @click="goNew">
{{ TEXT.modules.financeSpecials.newTitle }}
</el-button>
</el-form>
</div>
<div class="unified-section table-section">
<el-table
:data="sortedItems"
v-loading="loading"
style="width: 100%"
class="ctms-table"
@row-click="onRowClick"
:row-class-name="specialRowClass"
table-layout="fixed"
>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" show-overflow-tooltip />
<el-table-column prop="fee_type" :label="TEXT.common.fields.type">
<template #default="scope">
{{ displayEnum(TEXT.enums.financeSpecialType, scope.row.fee_type) }}
</template>
</el-table-column>
<el-table-column prop="occur_date" :label="TEXT.common.fields.occurDate">
<template #default="scope">
{{ displayDate(scope.row.occur_date) }}
</template>
</el-table-column>
<el-table-column prop="staff_name" :label="TEXT.common.fields.staff" show-overflow-tooltip />
<el-table-column :label="TEXT.common.fields.amount">
<template #default="scope">
{{ scope.row.amount }}
</template>
</el-table-column>
<el-table-column prop="updated_at" :label="TEXT.common.labels.updatedAt" show-overflow-tooltip>
<template #default="scope">
{{ displayDateTime(scope.row.updated_at) }}
</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope">
<el-button
link
type="danger"
size="small"
:disabled="isInactiveSite(scope.row.site_name)"
@click.stop="remove(scope.row)"
>
{{ TEXT.common.actions.delete }}
</el-button>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">{{ TEXT.modules.financeSpecials.empty }}</div>
</template>
</el-table>
</div>
</div>
</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 { useStudyStore } from "../../store/study";
import { listFinanceSpecials, deleteFinanceSpecial } from "../../api/financeSpecials";
import { fetchSites } from "../../api/sites";
import { displayDate, displayDateTime, displayEnum } from "../../utils/display";
import { TEXT } from "../../locales";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({
site_name: "",
fee_type: "",
});
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
return map;
});
const isInactiveSite = (siteName?: string) => !!siteName && siteActiveMap.value[siteName] === false;
const specialRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_name) ? " row-inactive" : ""}`.trim();
const filteredItems = computed(() => {
const keyword = filters.site_name.trim().toLowerCase();
return items.value.filter((item) => {
if (keyword && !String(item?.site_name || "").toLowerCase().includes(keyword)) return false;
if (filters.fee_type && item?.fee_type !== filters.fee_type) return false;
return true;
});
});
const sortedItems = computed(() =>
[...filteredItems.value].sort((a, b) => Number(isInactiveSite(a?.site_name)) - Number(isInactiveSite(b?.site_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 () => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
loading.value = true;
try {
const { data } = await listFinanceSpecials(studyId, {}) as any;
items.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
};
const resetFilters = () => {
filters.site_name = "";
filters.fee_type = "";
};
const goNew = () => router.push("/finance/special/new");
const goDetail = (id: string) => router.push(`/finance/special/${id}`);
const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const remove = async (row: any) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (isInactiveSite(row?.site_name)) {
ElMessage.warning("中心已停用");
return;
}
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteFinanceSpecial(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
};
onMounted(async () => {
await loadSites();
load();
});
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
}
.main-content-flat {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0;
}
.filter-form {
display: flex;
width: 100%;
gap: 8px;
align-items: center;
}
.filter-item-form {
margin-bottom: 0 !important;
margin-right: 0 !important;
}
.filter-input-comp {
width: 160px;
}
.filter-select-comp {
width: 140px;
}
.filter-spacer {
flex: 1;
}
.table-section {
padding-top: 0;
padding-bottom: 0;
}
:deep(.ctms-table .el-table__inner-wrapper::before) {
display: none;
}
.table-empty {
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
}
</style>
<style>
.row-inactive td {
color: var(--ctms-text-secondary);
background-color: #fff7d6 !important;
}
.row-inactive .el-tag {
opacity: 0.6;
}
</style>