feat: support contract template replace and delete
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user