feat: support contract template replace and delete

This commit is contained in:
wren
2026-05-22 18:14:44 +08:00
parent 0309df1cf7
commit 14d1199675
5 changed files with 351 additions and 17 deletions
@@ -9,6 +9,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
ContractTemplateCreateDTO,
ContractTemplateListQueryDTO,
ContractTemplateSearchQueryDTO,
ContractTemplateUpdateDTO,
)
from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService
from fastapi_modules.fastapi_leaudit.services.impl.contractTemplateServiceImpl import ContractTemplateServiceImpl
@@ -82,7 +83,7 @@ class ContractTemplateController(BaseController):
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:create:write"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前仅允许租户管理员上传合同模板", "data": None})
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前没有上传合同模板权限", "data": None})
body = ContractTemplateCreateDTO(
title=title,
template_code=template_code,
@@ -129,13 +130,41 @@ class ContractTemplateController(BaseController):
TemplateId: int,
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:detail:read", "contract_template:list:read"]):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:detail:read"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前用户没有查看合同模板详情权限", "data": None})
data = await self.ContractTemplateService.GetTemplateDetail(TemplateId, int(payload["user_id"]))
if not data:
return JSONResponse(status_code=404, content={"code": 404, "msg": "合同模板不存在", "data": None})
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.put("/{TemplateId}")
async def UpdateContractTemplate(
TemplateId: int,
title: str = Form(...),
template_code: str = Form(...),
category_id: int = Form(...),
region: str | None = Form(default=None),
tenant_code: str | None = Form(default=None),
description: str | None = Form(default=None),
is_featured: bool = Form(default=False),
file: UploadFile = File(...),
pdf_file: UploadFile | None = File(default=None),
payload: dict = Depends(verify_access_token),
):
if not await self._check_permission(int(payload["user_id"]), ["contract_template:update:write"]):
return JSONResponse(status_code=403, content={"code": 403, "msg": "当前没有更新合同模板权限", "data": None})
body = ContractTemplateUpdateDTO(
title=title,
template_code=template_code,
category_id=category_id,
region=region,
tenant_code=tenant_code,
description=description,
is_featured=is_featured,
)
data = await self.ContractTemplateService.UpdateTemplate(TemplateId, body, file, pdf_file, int(payload["user_id"]))
return JSONResponse(status_code=200, content={"code": 200, "message": "ok", "data": data.model_dump()})
@self.router.delete("/{TemplateId}")
async def DeleteContractTemplate(
TemplateId: int,
@@ -41,3 +41,7 @@ class ContractTemplateCreateDTO(BaseModel):
tenant_code: str | None = Field(None, description="所属租户编码")
description: str | None = Field(None, description="模板简介")
is_featured: bool = Field(False, description="是否推荐")
class ContractTemplateUpdateDTO(ContractTemplateCreateDTO):
"""合同模板替换上传参数。"""
@@ -5,6 +5,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
ContractTemplateCreateDTO,
ContractTemplateListQueryDTO,
ContractTemplateSearchQueryDTO,
ContractTemplateUpdateDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
ContractTemplateCategoryVO,
@@ -44,6 +45,17 @@ class IContractTemplateService(ABC):
) -> ContractTemplateCreateVO:
...
@abstractmethod
async def UpdateTemplate(
self,
TemplateId: int,
Body: ContractTemplateUpdateDTO,
File: UploadFile,
PdfFile: UploadFile | None,
CurrentUserId: int,
) -> ContractTemplateCreateVO:
...
@abstractmethod
async def DeleteTemplate(self, TemplateId: int, CurrentUserId: int) -> None:
...
@@ -15,6 +15,7 @@ from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import (
ContractTemplateCreateDTO,
ContractTemplateListQueryDTO,
ContractTemplateSearchQueryDTO,
ContractTemplateUpdateDTO,
)
from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import (
ContractTemplateCategoryVO,
@@ -225,6 +226,7 @@ class ContractTemplateServiceImpl(IContractTemplateService):
LIMIT 1
"""
)
(sql,) = self._bind_expanding(sql, params)
row = (await session.execute(sql, params)).mappings().first()
if not row:
@@ -423,15 +425,54 @@ class ContractTemplateServiceImpl(IContractTemplateService):
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板创建成功但详情读取失败")
return ContractTemplateCreateVO(**detail.model_dump())
async def DeleteTemplate(self, TemplateId: int, CurrentUserId: int) -> None:
async def UpdateTemplate(
self,
TemplateId: int,
Body: ContractTemplateUpdateDTO,
File: UploadFile,
PdfFile: UploadFile | None,
CurrentUserId: int,
) -> ContractTemplateCreateVO:
if File is None or not File.filename:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "替换模板主文件不能为空")
fileContent = await File.read()
if not fileContent:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "替换模板主文件内容不能为空")
normalizedCode = (Body.template_code or "").strip()
normalizedTitle = (Body.title or "").strip()
if not normalizedCode:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板编码不能为空")
if not normalizedTitle:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "模板标题不能为空")
fileExt = Path(File.filename).suffix.lstrip(".").lower()
if fileExt not in {"doc", "docx", "pdf"}:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前仅支持上传 DOC、DOCX、PDF 模板")
mimeType = File.content_type or mimetypes.guess_type(File.filename)[0] or "application/octet-stream"
pdfContent: bytes | None = None
pdfMimeType: str | None = None
pdfFileName: str | None = None
if PdfFile and PdfFile.filename:
pdfContent = await PdfFile.read()
if not pdfContent:
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "预览 PDF 文件内容不能为空")
pdfExt = Path(PdfFile.filename).suffix.lstrip(".").lower()
if pdfExt != "pdf":
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "预览文件仅支持 PDF")
pdfMimeType = PdfFile.content_type or "application/pdf"
pdfFileName = PdfFile.filename
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
resolvedTenantCode, resolvedTenantName, resolvedRegion = self._resolve_upload_scope(currentUser, Body.region, Body.tenant_code)
params: dict[str, Any] = {"template_id": TemplateId}
scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=None, writable=True)
row = (
await session.execute(
text(
existing_sql = text(
f"""
SELECT id
FROM contract_templates t
@@ -440,9 +481,159 @@ class ContractTemplateServiceImpl(IContractTemplateService):
AND {' AND '.join(scope_filters)}
LIMIT 1
"""
),
params,
)
(existing_sql,) = self._bind_expanding(existing_sql, params)
existingRow = (await session.execute(existing_sql, params)).mappings().first()
if not existingRow:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板不存在或无权更新")
categoryRow = (
await session.execute(
text(
"""
SELECT id, name
FROM contract_categories
WHERE id = :category_id
AND deleted_at IS NULL
LIMIT 1
"""
),
{"category_id": Body.category_id},
)
).mappings().first()
if not categoryRow:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板分类不存在")
duplicateRow = (
await session.execute(
text(
"""
SELECT id
FROM contract_templates
WHERE (
tenant_code = :tenant_code
OR (
(tenant_code IS NULL OR BTRIM(tenant_code) = '')
AND region = :region
)
)
AND template_code = :template_code
AND id <> :template_id
AND deleted_at IS NULL
LIMIT 1
"""
),
{
"tenant_code": resolvedTenantCode,
"region": resolvedRegion,
"template_code": normalizedCode,
"template_id": TemplateId,
},
)
).mappings().first()
if duplicateRow:
raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"当前租户已存在模板编码 {normalizedCode}")
categoryName = str(categoryRow["name"] or "未分类")
objectKey = OssPathUtils.BuildContractTemplateKey(
Region=resolvedRegion,
CategoryName=categoryName,
TemplateCode=normalizedCode,
FileRole="source",
FileName=File.filename,
)
filePath = await self.OssService.UploadBytes(
ObjectKey=objectKey,
Content=fileContent,
ContentType=mimeType,
)
pdfPath: str | None = None
if pdfContent is not None and pdfFileName:
pdfObjectKey = OssPathUtils.BuildContractTemplateKey(
Region=resolvedRegion,
CategoryName=categoryName,
TemplateCode=normalizedCode,
FileRole="preview",
FileName=pdfFileName,
)
pdfPath = await self.OssService.UploadBytes(
ObjectKey=pdfObjectKey,
Content=pdfContent,
ContentType=pdfMimeType or "application/pdf",
)
await session.execute(
text(
"""
UPDATE contract_templates
SET template_code = :template_code,
title = :title,
category_id = :category_id,
tenant_code = :tenant_code,
tenant_name = :tenant_name,
region = :region,
description = :description,
file_path = :file_path,
pdf_file_path = :pdf_file_path,
file_format = :file_format,
original_file_name = :original_file_name,
mime_type = :mime_type,
file_size = :file_size,
pdf_file_size = :pdf_file_size,
is_featured = :is_featured,
updated_by = :updated_by,
updated_at = NOW()
WHERE id = :template_id
AND deleted_at IS NULL
"""
),
{
"template_id": TemplateId,
"template_code": normalizedCode,
"title": normalizedTitle,
"category_id": Body.category_id,
"tenant_code": resolvedTenantCode,
"tenant_name": resolvedTenantName,
"region": resolvedRegion,
"description": (Body.description or "").strip() or None,
"file_path": filePath,
"pdf_file_path": pdfPath,
"file_format": fileExt,
"original_file_name": File.filename,
"mime_type": mimeType,
"file_size": len(fileContent),
"pdf_file_size": len(pdfContent) if pdfContent is not None else None,
"is_featured": Body.is_featured,
"updated_by": CurrentUserId,
},
)
await session.commit()
detail = await self.GetTemplateDetail(TemplateId, CurrentUserId)
if not detail:
raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板更新成功但详情读取失败")
return ContractTemplateCreateVO(**detail.model_dump())
async def DeleteTemplate(self, TemplateId: int, CurrentUserId: int) -> None:
async with GetAsyncSession() as session:
await self._ensureContractTemplateSchema(session)
currentUser = await self._getCurrentUserContext(CurrentUserId, session)
params: dict[str, Any] = {"template_id": TemplateId}
scope_filters = self._build_template_scope_filters(currentUser, params, requestedRegion=None, writable=True)
sql = text(
f"""
SELECT id
FROM contract_templates t
WHERE t.id = :template_id
AND t.deleted_at IS NULL
AND {' AND '.join(scope_filters)}
LIMIT 1
"""
)
(sql,) = self._bind_expanding(sql, params)
row = (
await session.execute(sql, params)
).mappings().first()
if not row:
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "合同模板不存在或无权删除")
@@ -859,8 +1050,8 @@ class ContractTemplateServiceImpl(IContractTemplateService):
current_tenant_code = str(currentUser.get("tenant_code") or "").strip() or None
current_tenant_name = str(currentUser.get("tenant_name") or "").strip() or None
current_region = str(currentUser["tenant_scope_value"] or currentUser["area"] or "").strip()
if not currentUser.get("is_area_admin"):
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅支持租户管理员上传合同模板")
if not currentUser.get("can_manage"):
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许有权限的管理员上传合同模板")
if not current_region:
raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前租户管理员账号未配置所属租户,无法上传合同模板")
if requested_tenant_code and not current_tenant_code:
+98
View File
@@ -1,6 +1,9 @@
import asyncio
from unittest.mock import patch
import pytest
from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException
from fastapi_modules.fastapi_leaudit.services.impl.contractTemplateServiceImpl import ContractTemplateServiceImpl
@@ -11,6 +14,9 @@ class _EmptyMappingResult:
def all(self):
return []
def first(self):
return None
class _FakeSession:
def __init__(self):
@@ -66,3 +72,95 @@ def test_contract_template_search_category_stats_binds_expanding_scope_params():
assert fake_session.executed_sql._bindparams["visible_regions"].expanding is True
assert fake_session.executed_params["visible_tenant_codes"] == ["PROVINCIAL", "PUBLIC", "MZ"]
assert fake_session.executed_params["visible_regions"] == ["省级", "公共", "梅州"]
def test_contract_template_upload_scope_allows_global_manager_with_tenant():
service = ContractTemplateServiceImpl()
tenant_code, tenant_name, region = service._resolve_upload_scope(
{
"id": 1,
"area": "梅州",
"tenant_code": "MZ",
"tenant_name": "梅州",
"tenant_scope_value": "梅州",
"is_global": True,
"can_manage": True,
"is_area_admin": False,
},
"梅州",
"MZ",
)
assert tenant_code == "MZ"
assert tenant_name == "梅州"
assert region == "梅州"
def test_contract_template_upload_scope_rejects_non_manager():
service = ContractTemplateServiceImpl()
with pytest.raises(LeauditException):
service._resolve_upload_scope(
{
"id": 2,
"area": "梅州",
"tenant_code": "MZ",
"tenant_name": "梅州",
"tenant_scope_value": "梅州",
"is_global": False,
"can_manage": False,
"is_area_admin": False,
},
"梅州",
"MZ",
)
def test_contract_template_detail_binds_expanding_scope_params():
service = ContractTemplateServiceImpl()
fake_session = _FakeSession()
async def noop_ensure_schema(session):
return None
async def fake_user_context(current_user_id, session):
return {
"id": current_user_id,
"area": "梅州",
"tenant_code": "MZ",
"tenant_name": "梅州",
"tenant_scope_value": "梅州",
"is_global": False,
"can_manage": True,
"is_area_admin": True,
}
service._ensureContractTemplateSchema = noop_ensure_schema
service._getCurrentUserContext = fake_user_context
with patch(
"fastapi_modules.fastapi_leaudit.services.impl.contractTemplateServiceImpl.GetAsyncSession",
return_value=_FakeSessionContext(fake_session),
):
result = asyncio.run(service.GetTemplateDetail(32, 5))
assert result is None
assert fake_session.executed_sql._bindparams["visible_tenant_codes"].expanding is True
assert fake_session.executed_sql._bindparams["visible_regions"].expanding is True
assert fake_session.executed_params["visible_tenant_codes"] == ["PROVINCIAL", "PUBLIC", "MZ"]
assert fake_session.executed_params["visible_regions"] == ["省级", "公共", "梅州"]
def test_contract_template_detail_permission_does_not_fallback_to_list_permission():
from fastapi_modules.fastapi_leaudit.controllers.contractTemplateController import ContractTemplateController
controller = ContractTemplateController()
class FakePermissionService:
async def CheckPermission(self, user_id, permission_key):
return permission_key == "contract_template:list:read"
controller.PermissionService = FakePermissionService()
assert asyncio.run(controller._check_permission(5, ["contract_template:detail:read"])) is False