费用管理内容优化-初步
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import aiofiles
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status, Request, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.deps import get_current_user, get_db_session
|
||||
from app.core.security import decode_token
|
||||
from app.crud import audit as audit_crud
|
||||
from app.crud import contract_fee as contract_fee_crud
|
||||
from app.crud import contract_fee_payment as payment_crud
|
||||
from app.crud import fee_attachment as fee_attachment_crud
|
||||
from app.crud import member as member_crud
|
||||
from app.crud import special_expense as special_crud
|
||||
from app.crud import user as user_crud
|
||||
from app.schemas.fee_attachment import FeeAttachmentRead
|
||||
from app.schemas.fee_common import FeeApiResponse
|
||||
from app.schemas.user import UserDisplay
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
UPLOAD_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "fees"
|
||||
|
||||
ALLOWED_ENTITY_TYPES = {"contract_fee", "contract_payment", "special_expense"}
|
||||
ALLOWED_FILE_TYPES = {
|
||||
"contract_fee": {"contract", "voucher", "invoice"},
|
||||
"contract_payment": {"voucher", "invoice"},
|
||||
"special_expense": {"voucher", "invoice", "other"},
|
||||
}
|
||||
|
||||
|
||||
async def _resolve_project_id(db: AsyncSession, entity_type: str, entity_id: uuid.UUID) -> uuid.UUID:
|
||||
if entity_type == "contract_fee":
|
||||
contract = await contract_fee_crud.get_contract_fee(db, entity_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
return contract.project_id
|
||||
if entity_type == "contract_payment":
|
||||
payment = await payment_crud.get_payment(db, entity_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分期记录不存在")
|
||||
contract = await contract_fee_crud.get_contract_fee(db, payment.contract_fee_id)
|
||||
if not contract:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合同费用不存在")
|
||||
return contract.project_id
|
||||
if entity_type == "special_expense":
|
||||
expense = await special_crud.get_special_expense(db, entity_id)
|
||||
if not expense:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="特殊费用不存在")
|
||||
return expense.project_id
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的附件类型")
|
||||
|
||||
|
||||
async def _ensure_project_access(db: AsyncSession, project_id: uuid.UUID, current_user, write: bool = False):
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
if role_value == "ADMIN":
|
||||
return None
|
||||
membership = await member_crud.get_member(db, project_id, current_user.id)
|
||||
if not membership or not membership.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不是项目成员")
|
||||
if write and membership.role_in_study != "PM":
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="项目权限不足")
|
||||
return membership
|
||||
|
||||
|
||||
async def _authorize_download_user(request: Request, db: AsyncSession):
|
||||
token = None
|
||||
auth_header = request.headers.get("Authorization")
|
||||
if auth_header and auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1]
|
||||
if not token:
|
||||
token = request.query_params.get("token")
|
||||
if not token:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录")
|
||||
payload = decode_token(token)
|
||||
user = await user_crud.get_by_id(db, uuid.UUID(str(payload.get("sub"))))
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
|
||||
return user
|
||||
|
||||
|
||||
@router.post(
|
||||
"/attachments",
|
||||
response_model=FeeApiResponse[FeeAttachmentRead],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def upload_fee_attachment(
|
||||
entity_type: str = Form(...),
|
||||
entity_id: uuid.UUID = Form(...),
|
||||
file_type: str = Form(...),
|
||||
file: UploadFile = File(...),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[FeeAttachmentRead]:
|
||||
if entity_type not in ALLOWED_ENTITY_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
|
||||
if file_type not in ALLOWED_FILE_TYPES.get(entity_type, set()):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件文件类型无效")
|
||||
project_id = await _resolve_project_id(db, entity_type, entity_id)
|
||||
await _ensure_project_access(db, project_id, current_user, write=True)
|
||||
|
||||
dest_dir = UPLOAD_ROOT / f"{entity_type}_{entity_id}"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
|
||||
dest_path = dest_dir / unique_name
|
||||
|
||||
content = await file.read()
|
||||
async with aiofiles.open(dest_path, "wb") as out_file:
|
||||
await out_file.write(content)
|
||||
|
||||
attachment = await fee_attachment_crud.create_attachment(
|
||||
db,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
file_type=file_type,
|
||||
filename=file.filename,
|
||||
mime_type=file.content_type,
|
||||
size=len(content),
|
||||
storage_key=str(dest_path),
|
||||
url=None,
|
||||
uploaded_by=current_user.id,
|
||||
)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=project_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
action="UPLOAD_FEE_ATTACHMENT",
|
||||
detail=f"附件已上传:{file.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
return FeeApiResponse(
|
||||
data=FeeAttachmentRead(
|
||||
id=attachment.id,
|
||||
entity_type=attachment.entity_type,
|
||||
entity_id=attachment.entity_id,
|
||||
file_type=attachment.file_type,
|
||||
filename=attachment.filename,
|
||||
mime_type=attachment.mime_type,
|
||||
size=attachment.size,
|
||||
storage_key=attachment.storage_key,
|
||||
url=attachment.url,
|
||||
uploaded_by=UserDisplay.model_validate(current_user),
|
||||
uploaded_by_id=attachment.uploaded_by,
|
||||
uploaded_at=attachment.uploaded_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attachments",
|
||||
response_model=FeeApiResponse[list[FeeAttachmentRead]],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def list_fee_attachments(
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> FeeApiResponse[list[FeeAttachmentRead]]:
|
||||
if entity_type not in ALLOWED_ENTITY_TYPES:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="附件类型无效")
|
||||
project_id = await _resolve_project_id(db, entity_type, entity_id)
|
||||
await _ensure_project_access(db, project_id, current_user, write=False)
|
||||
attachments = await fee_attachment_crud.list_attachments(db, entity_type=entity_type, entity_id=entity_id)
|
||||
user_ids = {a.uploaded_by for a in attachments if a.uploaded_by}
|
||||
users_map = await user_crud.get_users_by_ids(db, user_ids)
|
||||
items: list[FeeAttachmentRead] = []
|
||||
for attachment in attachments:
|
||||
user = users_map.get(attachment.uploaded_by)
|
||||
items.append(
|
||||
FeeAttachmentRead(
|
||||
id=attachment.id,
|
||||
entity_type=attachment.entity_type,
|
||||
entity_id=attachment.entity_id,
|
||||
file_type=attachment.file_type,
|
||||
filename=attachment.filename,
|
||||
mime_type=attachment.mime_type,
|
||||
size=attachment.size,
|
||||
storage_key=attachment.storage_key,
|
||||
url=attachment.url,
|
||||
uploaded_by=UserDisplay.model_validate(user) if user else None,
|
||||
uploaded_by_id=attachment.uploaded_by,
|
||||
uploaded_at=attachment.uploaded_at,
|
||||
)
|
||||
)
|
||||
return FeeApiResponse(data=items, meta={"total": len(items)})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/attachments/{attachment_id}/download",
|
||||
response_class=FileResponse,
|
||||
)
|
||||
async def download_fee_attachment(
|
||||
attachment_id: uuid.UUID,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
):
|
||||
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
current_user = await _authorize_download_user(request, db)
|
||||
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
|
||||
await _ensure_project_access(db, project_id, current_user, write=False)
|
||||
if not attachment.storage_key or not os.path.exists(attachment.storage_key):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="服务器未找到文件")
|
||||
return FileResponse(
|
||||
path=attachment.storage_key,
|
||||
filename=attachment.filename,
|
||||
media_type=attachment.mime_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": f'inline; filename="{attachment.filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/attachments/{attachment_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
async def delete_fee_attachment(
|
||||
attachment_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
current_user=Depends(get_current_user),
|
||||
) -> None:
|
||||
attachment = await fee_attachment_crud.get_attachment(db, attachment_id)
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
|
||||
project_id = await _resolve_project_id(db, attachment.entity_type, attachment.entity_id)
|
||||
membership = await _ensure_project_access(db, project_id, current_user, write=True)
|
||||
|
||||
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||
can_delete = (
|
||||
role_value == "ADMIN"
|
||||
or attachment.uploaded_by == current_user.id
|
||||
or (membership and getattr(membership, "role_in_study", None) == "PM")
|
||||
)
|
||||
if not can_delete:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
|
||||
|
||||
await fee_attachment_crud.soft_delete_attachment(db, attachment)
|
||||
await audit_crud.log_action(
|
||||
db,
|
||||
study_id=project_id,
|
||||
entity_type=attachment.entity_type,
|
||||
entity_id=attachment.entity_id,
|
||||
action="DELETE_FEE_ATTACHMENT",
|
||||
detail=f"附件已删除:{attachment.filename}",
|
||||
operator_id=current_user.id,
|
||||
operator_role=current_user.role,
|
||||
)
|
||||
Reference in New Issue
Block a user