from __future__ import annotations import mimetypes from pathlib import Path from typing import Any from fastapi import UploadFile from sqlalchemy import bindparam, text from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils from fastapi_common.fastapi_common_web.domain.responses import StatusCodeEnum from fastapi_common.fastapi_common_web.exception.LeauditException import LeauditException from fastapi_modules.fastapi_leaudit.domian.Dto.contractTemplateDto import ( ContractTemplateCreateDTO, ContractTemplateListQueryDTO, ContractTemplateSearchQueryDTO, ContractTemplateUpdateDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.contractTemplateVo import ( ContractTemplateCategoryVO, ContractTemplateCreateVO, ContractTemplateDetailVO, ContractTemplateListItemVO, ContractTemplatePageVO, ContractTemplateSearchCategoryVO, ContractTemplateSearchResultVO, ) from fastapi_modules.fastapi_leaudit.services.contractTemplateService import IContractTemplateService from fastapi_modules.fastapi_leaudit.services.impl.ssoUserCompat import SsoUserCompat from fastapi_modules.fastapi_leaudit.services.impl.tenantResolver import TenantResolver from fastapi_modules.fastapi_leaudit.services.ossService import IOssService from fastapi_modules.fastapi_leaudit.services.impl.ossServiceImpl import OssServiceImpl _ALLOWED_SORT_FIELDS = { "id": "t.id", "title": "t.title", "created_at": "t.created_at", "updated_at": "t.updated_at", } class ContractTemplateServiceImpl(IContractTemplateService): """合同模板服务实现。""" def __init__(self, OssService: IOssService | None = None, TenantResolverService: TenantResolver | None = None) -> None: self.OssService = OssService or OssServiceImpl() self.TenantResolver = TenantResolverService or TenantResolver() async def ListCategories(self, IncludeDisabled: bool, WithTemplateCount: bool) -> list[ContractTemplateCategoryVO]: count_select = "COUNT(t.id)::int AS template_count" if WithTemplateCount else "0::int AS template_count" filters = ["c.deleted_at IS NULL"] if not IncludeDisabled: filters.append("1=1") sql = text( f""" SELECT c.id, c.name, c.icon, c.description, COALESCE(c.sort_order, 0) AS sort_order, {count_select}, TRUE AS is_enabled FROM contract_categories c LEFT JOIN contract_templates t ON t.category_id = c.id AND t.deleted_at IS NULL WHERE {' AND '.join(filters)} GROUP BY c.id, c.name, c.icon, c.description, c.sort_order ORDER BY COALESCE(c.sort_order, 0) ASC, c.name ASC """ ) async with GetAsyncSession() as session: await self._ensureContractTemplateSchema(session) rows = (await session.execute(sql)).mappings().all() return [self._to_category_vo(row) for row in rows] async def ListTemplates(self, Query: ContractTemplateListQueryDTO, CurrentUserId: int) -> ContractTemplatePageVO: async with GetAsyncSession() as session: await self._ensureContractTemplateSchema(session) currentUser = await self._getCurrentUserContext(CurrentUserId, session) where_clause, params, needs_category_name_filter = self._build_template_filters( keyword=Query.keyword, category_id=Query.category_id, category_name=Query.category_name, region=Query.region, tenant_code=Query.tenant_code, file_format=Query.file_format, is_featured=Query.is_featured, currentUser=currentUser, ) order_sql = self._build_order_clause(Query.sort_by, Query.sort_order, default_field="updated_at", default_order="desc") offset = max(Query.page - 1, 0) * Query.page_size params.update({"limit": Query.page_size, "offset": offset}) from_sql = self._build_template_from_sql(needs_category_name_filter) count_sql = text( f""" SELECT COUNT(*) {from_sql} WHERE {where_clause} """ ) list_sql = text( f""" SELECT t.id, t.template_code, t.title, t.category_id, c.name AS category_name, c.icon AS category_icon, c.description AS category_description, t.region, COALESCE(NULLIF(BTRIM(t.tenant_code), ''), NULL) AS tenant_code, CASE WHEN t.tenant_name IS NOT NULL AND BTRIM(t.tenant_name) <> '' THEN t.tenant_name WHEN t.region IS NOT NULL AND BTRIM(t.region) <> '' THEN t.region WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PUBLIC' THEN '公共' WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PROVINCIAL' THEN '省级' ELSE NULL END AS tenant_name, t.description, t.file_path, t.pdf_file_path, t.file_format, t.original_file_name, t.mime_type, COALESCE(t.file_size, 0) AS file_size, t.pdf_file_size, COALESCE(t.is_featured, FALSE) AS is_featured, t.created_by, t.updated_by, t.created_at, t.updated_at {from_sql} WHERE {where_clause} ORDER BY {order_sql} LIMIT :limit OFFSET :offset """ ) count_sql, list_sql = self._bind_expanding(count_sql, list_sql, params) total = int((await session.execute(count_sql, params)).scalar_one()) rows = (await session.execute(list_sql, params)).mappings().all() return ContractTemplatePageVO( total=total, page=Query.page, page_size=Query.page_size, total_pages=max((total + Query.page_size - 1) // Query.page_size, 1) if total else 0, templates=[self._to_list_item_vo(row) for row in rows], ) async def SearchTemplates(self, Query: ContractTemplateSearchQueryDTO, CurrentUserId: int) -> ContractTemplateSearchResultVO: list_query = ContractTemplateListQueryDTO( keyword=Query.q, category_id=Query.category_id, category_name=Query.category_name, region=Query.region, tenant_code=Query.tenant_code, page=Query.page, page_size=Query.page_size, sort_by=Query.sort_by, sort_order=Query.sort_order, ) page_result = await self.ListTemplates(list_query, CurrentUserId) category_stats = await self._load_search_category_stats(Query.q, Query.region, Query.tenant_code, CurrentUserId) return ContractTemplateSearchResultVO( total=page_result.total, page=page_result.page, page_size=page_result.page_size, total_pages=page_result.total_pages, templates=page_result.templates, category_stats=category_stats, ) async def GetTemplateDetail(self, TemplateId: int, CurrentUserId: int) -> ContractTemplateDetailVO | 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) sql = text( f""" SELECT t.id, t.template_code, t.title, t.category_id, c.name AS category_name, c.icon AS category_icon, c.description AS category_description, t.region, COALESCE(NULLIF(BTRIM(t.tenant_code), ''), NULL) AS tenant_code, CASE WHEN t.tenant_name IS NOT NULL AND BTRIM(t.tenant_name) <> '' THEN t.tenant_name WHEN t.region IS NOT NULL AND BTRIM(t.region) <> '' THEN t.region WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PUBLIC' THEN '公共' WHEN NULLIF(BTRIM(t.tenant_code), '') = 'PROVINCIAL' THEN '省级' ELSE NULL END AS tenant_name, t.description, t.file_path, t.pdf_file_path, t.file_format, t.original_file_name, t.mime_type, COALESCE(t.file_size, 0) AS file_size, t.pdf_file_size, COALESCE(t.is_featured, FALSE) AS is_featured, t.created_by, t.updated_by, t.created_at, t.updated_at FROM contract_templates t LEFT JOIN contract_categories c ON c.id = t.category_id WHERE t.id = :template_id AND t.deleted_at IS NULL AND c.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: return None return self._to_detail_vo(row) async def CreateTemplate( self, Body: ContractTemplateCreateDTO, 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) 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 deleted_at IS NULL LIMIT 1 """ ), {"tenant_code": resolvedTenantCode, "region": resolvedRegion, "template_code": normalizedCode}, ) ).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", ) createdRow = ( await session.execute( text( """ INSERT INTO contract_templates ( template_code, title, category_id, tenant_code, tenant_name, region, description, file_path, pdf_file_path, file_format, original_file_name, mime_type, file_size, pdf_file_size, is_featured, created_by, updated_by, created_at, updated_at ) VALUES ( :template_code, :title, :category_id, :tenant_code, :tenant_name, :region, :description, :file_path, :pdf_file_path, :file_format, :original_file_name, :mime_type, :file_size, :pdf_file_size, :is_featured, :created_by, :updated_by, NOW(), NOW() ) RETURNING id """ ), { "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, "created_by": CurrentUserId, "updated_by": CurrentUserId, }, ) ).mappings().first() await session.commit() if not createdRow: raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板创建失败") detail = await self.GetTemplateDetail(int(createdRow["id"]), CurrentUserId) if not detail: raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "合同模板创建成功但详情读取失败") return ContractTemplateCreateVO(**detail.model_dump()) 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) existing_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 """ ) (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, "合同模板不存在或无权删除") await session.execute( text( """ UPDATE contract_templates SET deleted_at = NOW(), updated_at = NOW(), updated_by = :updated_by WHERE id = :template_id AND deleted_at IS NULL """ ), {"template_id": TemplateId, "updated_by": CurrentUserId}, ) await session.commit() async def _load_search_category_stats( self, keyword: str, requestedRegion: str | None, requestedTenantCode: str | None, CurrentUserId: int, ) -> list[ContractTemplateSearchCategoryVO]: clean_keyword = (keyword or "").strip() if not clean_keyword: return [] async with GetAsyncSession() as session: await self._ensureContractTemplateSchema(session) currentUser = await self._getCurrentUserContext(CurrentUserId, session) params: dict[str, Any] = {"keyword": f"%{clean_keyword}%"} scope_filters = self._build_template_scope_filters( currentUser, params, requestedRegion=requestedRegion, requestedTenantCode=requestedTenantCode, ) filters = [ "c.deleted_at IS NULL", "t.deleted_at IS NULL", "(" "t.title ILIKE :keyword " "OR COALESCE(t.description, '') ILIKE :keyword " "OR COALESCE(t.template_code, '') ILIKE :keyword " "OR COALESCE(c.name, '') ILIKE :keyword" ")", *scope_filters, ] sql = text( f""" SELECT c.id, c.name, COUNT(t.id)::int AS search_count FROM contract_categories c LEFT JOIN contract_templates t ON t.category_id = c.id WHERE {' AND '.join(filters)} GROUP BY c.id, c.name ORDER BY c.name ASC """ ) (sql,) = self._bind_expanding(sql, params) rows = (await session.execute(sql, params)).mappings().all() return [ ContractTemplateSearchCategoryVO( id=int(row["id"]), name=str(row["name"] or ""), search_count=int(row["search_count"] or 0), ) for row in rows if row.get("id") is not None ] def _build_template_filters( self, keyword: str | None, category_id: int | None, category_name: str | None, region: str | None, tenant_code: str | None, file_format: str | None, is_featured: bool | None, currentUser: dict[str, Any], ) -> tuple[str, dict[str, Any], bool]: filters = ["t.deleted_at IS NULL", "c.deleted_at IS NULL"] params: dict[str, Any] = {} needs_category_name_filter = False filters.extend(self._build_template_scope_filters(currentUser, params, region, tenant_code)) if category_id is not None: filters.append("t.category_id = :category_id") params["category_id"] = category_id elif category_name: filters.append("c.name = :category_name") params["category_name"] = category_name.strip() needs_category_name_filter = True if file_format: filters.append("LOWER(t.file_format) = :file_format") params["file_format"] = file_format.strip().lower() if is_featured is not None: filters.append("COALESCE(t.is_featured, FALSE) = :is_featured") params["is_featured"] = is_featured clean_keyword = (keyword or "").strip() if clean_keyword: filters.append( "(" "t.title ILIKE :keyword " "OR COALESCE(t.description, '') ILIKE :keyword " "OR COALESCE(t.template_code, '') ILIKE :keyword " "OR COALESCE(c.name, '') ILIKE :keyword" ")" ) params["keyword"] = f"%{clean_keyword}%" needs_category_name_filter = True return " AND ".join(filters), params, needs_category_name_filter def _build_template_from_sql(self, needs_category_name_filter: bool) -> str: _ = needs_category_name_filter return """ FROM contract_templates t LEFT JOIN contract_categories c ON c.id = t.category_id """ def _build_order_clause(self, sort_by: str | None, sort_order: str | None, default_field: str, default_order: str) -> str: field = _ALLOWED_SORT_FIELDS.get(str(sort_by or "").strip().lower(), _ALLOWED_SORT_FIELDS[default_field]) direction = "DESC" if str(sort_order or default_order).strip().lower() == "desc" else "ASC" return f"{field} {direction}, t.id ASC" def _bind_expanding(self, *sql_objects_and_params: Any): sql_objects = list(sql_objects_and_params[:-1]) params = sql_objects_and_params[-1] if "visible_tenant_codes" in params: sql_objects = [sql.bindparams(bindparam("visible_tenant_codes", expanding=True)) for sql in sql_objects] if "visible_regions" in params: sql_objects = [sql.bindparams(bindparam("visible_regions", expanding=True)) for sql in sql_objects] return tuple(sql_objects) def _to_category_vo(self, row: Any) -> ContractTemplateCategoryVO: return ContractTemplateCategoryVO( id=int(row["id"]), name=str(row["name"] or ""), icon=row.get("icon"), description=row.get("description"), sort_order=int(row.get("sort_order") or 0), template_count=int(row.get("template_count") or 0), is_enabled=bool(row.get("is_enabled", True)), ) def _to_list_item_vo(self, row: Any) -> ContractTemplateListItemVO: tenant_name = row.get("tenant_name") or row.get("region") or "省级" return ContractTemplateListItemVO( id=int(row["id"]), template_code=str(row.get("template_code") or ""), title=str(row.get("title") or ""), category_id=int(row.get("category_id") or 0), category_name=row.get("category_name"), category_icon=row.get("category_icon"), description=row.get("description"), region=str(row.get("region") or tenant_name), tenant_code=row.get("tenant_code"), tenant_name=tenant_name, file_path=row.get("file_path"), pdf_file_path=row.get("pdf_file_path"), file_format=str(row.get("file_format") or ""), original_file_name=row.get("original_file_name"), mime_type=row.get("mime_type"), file_size=int(row.get("file_size") or 0), pdf_file_size=int(row["pdf_file_size"]) if row.get("pdf_file_size") is not None else None, is_featured=bool(row.get("is_featured", False)), created_by=int(row["created_by"]) if row.get("created_by") is not None else None, updated_by=int(row["updated_by"]) if row.get("updated_by") is not None else None, created_at=self._stringify_time(row.get("created_at")), updated_at=self._stringify_time(row.get("updated_at")), ) def _to_detail_vo(self, row: Any) -> ContractTemplateDetailVO: base = self._to_list_item_vo(row) return ContractTemplateDetailVO( **base.model_dump(), category_description=row.get("category_description"), placeholder_schema=None, ) def _stringify_time(self, value: Any) -> str | None: if value is None: return None return str(value) def _build_template_scope_filters( self, currentUser: dict[str, Any], params: dict[str, Any], requestedRegion: str | None, requestedTenantCode: str | None = None, writable: bool = False, ) -> list[str]: requested_tenant_code, requested_region = self._normalize_scope_value(requestedRegion, requestedTenantCode, currentUser) current_tenant_code = str(currentUser.get("tenant_code") or "").strip() current_region = str(currentUser["tenant_scope_value"] or currentUser["area"] or "").strip() if currentUser["is_global"]: if requested_tenant_code: return self._tenant_filter_sql( params, tenant_code=requested_tenant_code, region=requested_region, prefix="requested", ) if requested_region: if requested_region in {"省级", "公共"}: return self._tenant_filter_sql( params, tenant_code="PROVINCIAL" if requested_region == "省级" else "PUBLIC", region=requested_region, prefix="requested", ) params["requested_region"] = requested_region return ["t.region = :requested_region"] return ["1=1"] if writable: if not current_tenant_code and not current_region: return ["1=0"] if requested_tenant_code and current_tenant_code and requested_tenant_code != current_tenant_code: return ["1=0"] if requested_tenant_code and not current_tenant_code: return ["1=0"] if requested_region and requested_region != current_region: return ["1=0"] return self._tenant_filter_sql( params, tenant_code=current_tenant_code or requested_tenant_code, region=current_region or requested_region, prefix="scope", ) if currentUser["can_manage"]: if not current_tenant_code and not current_region: return ["1=0"] if requested_tenant_code: if requested_tenant_code in {"PUBLIC", "PROVINCIAL"}: return self._tenant_filter_sql( params, tenant_code=requested_tenant_code, region=requested_region, prefix="requested", ) if current_tenant_code and requested_tenant_code != current_tenant_code: return ["1=0"] if not current_tenant_code and requested_region != current_region: return ["1=0"] return self._tenant_filter_sql( params, tenant_code=requested_tenant_code, region=requested_region, prefix="requested", ) if requested_region: if requested_region in {"省级", "公共"}: return self._tenant_filter_sql( params, tenant_code="PROVINCIAL" if requested_region == "省级" else "PUBLIC", region=requested_region, prefix="requested", ) if requested_region != current_region: return ["1=0"] return self._tenant_filter_sql( params, tenant_code=current_tenant_code or None, region=current_region or requested_region, prefix="requested", ) return self._visible_tenant_filters( params, tenant_codes=["PROVINCIAL", "PUBLIC", current_tenant_code], legacy_regions=["省级", "公共", current_region], ) if requested_tenant_code: if requested_tenant_code in {"PUBLIC", "PROVINCIAL"}: return self._tenant_filter_sql( params, tenant_code=requested_tenant_code, region=requested_region, prefix="requested", ) if current_tenant_code and requested_tenant_code == current_tenant_code: return self._tenant_filter_sql( params, tenant_code=requested_tenant_code, region=requested_region, prefix="requested", ) if not current_tenant_code and current_region and requested_region == current_region: return self._tenant_filter_sql( params, tenant_code=requested_tenant_code, region=requested_region, prefix="requested", ) return ["1=0"] if requested_region: if requested_region in {"省级", "公共"}: return self._tenant_filter_sql( params, tenant_code="PROVINCIAL" if requested_region == "省级" else "PUBLIC", region=requested_region, prefix="requested", ) if current_region and requested_region == current_region: return self._tenant_filter_sql( params, tenant_code=current_tenant_code or None, region=current_region or requested_region, prefix="requested", ) return ["1=0"] if current_tenant_code or current_region: return self._visible_tenant_filters( params, tenant_codes=["PROVINCIAL", "PUBLIC", current_tenant_code], legacy_regions=["省级", "公共", current_region], ) return self._tenant_filter_sql( params, tenant_code="PROVINCIAL", region="省级", prefix="requested", ) async def _getCurrentUserContext(self, CurrentUserId: int, session=None) -> dict[str, Any]: own_session = False if session is None: own_session = True session_cm = GetAsyncSession() session = await session_cm.__aenter__() try: sso_user_columns = await SsoUserCompat.get_columns(session) tenant_code_select = SsoUserCompat.optional_coalesce_as( sso_user_columns, alias="u", column="tenant_code", fallback_sql="''", ) tenant_name_select = SsoUserCompat.optional_coalesce_as( sso_user_columns, alias="u", column="tenant_name", fallback_sql="''", ) row = ( await session.execute( text( f""" SELECT u.id, COALESCE(u.area, '') AS area, {tenant_code_select}, {tenant_name_select}, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin')), FALSE) AS is_global, COALESCE(bool_or(r.role_key IN ('super_admin', 'provincial_admin', 'admin')), FALSE) AS can_manage FROM sso_users u LEFT JOIN user_role ur ON ur.user_id = u.id LEFT JOIN roles r ON r.id = ur.role_id WHERE u.id = :user_id GROUP BY u.id, u.area """ ), {"user_id": CurrentUserId}, ) ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "当前用户不存在") tenant = await self.TenantResolver.ResolveUserContext( Area=str(row["area"] or ""), TenantCode=str(row.get("tenant_code") or "") or None, TenantName=str(row.get("tenant_name") or "") or None, Source="contract_template_user_context", ) return { "id": int(row["id"]), "area": str(row["area"] or ""), "tenant_code": tenant.tenant_code or (str(row.get("tenant_code") or "") or None), "tenant_name": tenant.tenant_name or (str(row.get("tenant_name") or "") or None), "tenant_scope_value": tenant.tenant_name or tenant.normalized_value or str(row["area"] or ""), "is_global": bool(row["is_global"]), "can_manage": bool(row["can_manage"]), "is_area_admin": bool(row["can_manage"]) and not bool(row["is_global"]), } finally: if own_session: await session_cm.__aexit__(None, None, None) def _resolve_upload_scope( self, currentUser: dict[str, Any], requestedRegion: str | None, requestedTenantCode: str | None = None, ) -> tuple[str | None, str | None, str]: requested_tenant_code, requested_region = self._normalize_scope_value(requestedRegion, requestedTenantCode, currentUser) 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("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: raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前账号未配置标准租户编码,不能显式指定模板租户") if requested_tenant_code and current_tenant_code and requested_tenant_code != current_tenant_code: raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许上传到本人所属租户") if requested_region and requested_region != current_region: raise LeauditException(StatusCodeEnum.HTTP_403_FORBIDDEN, "当前仅允许上传到本人所属租户") return current_tenant_code, current_tenant_name or current_region, current_region def _normalize_scope_value( self, requestedRegion: str | None, requestedTenantCode: str | None, currentUser: dict[str, Any], ) -> tuple[str | None, str]: tenant_code = str(requestedTenantCode or "").strip() if tenant_code: if tenant_code == str(currentUser.get("tenant_code") or "").strip(): return ( str(currentUser.get("tenant_code") or "").strip() or None, str(currentUser.get("tenant_scope_value") or currentUser.get("tenant_name") or currentUser.get("area") or "").strip(), ) if tenant_code == "PUBLIC": return "PUBLIC", "公共" if tenant_code == "PROVINCIAL": return "PROVINCIAL", "省级" return tenant_code, str(requestedRegion or "").strip() return None, str(requestedRegion or "").strip() def _tenant_filter_sql( self, params: dict[str, Any], *, tenant_code: str | None, region: str | None, prefix: str, ) -> list[str]: normalized_region = str(region or "").strip() normalized_tenant_code = str(tenant_code or "").strip() if normalized_tenant_code: params[f"{prefix}_tenant_code"] = normalized_tenant_code if normalized_region: params[f"{prefix}_region"] = normalized_region return [ "(" f"t.tenant_code = :{prefix}_tenant_code " "OR (" "(t.tenant_code IS NULL OR BTRIM(t.tenant_code) = '') " f"AND t.region = :{prefix}_region" ")" ")" ] return [f"t.tenant_code = :{prefix}_tenant_code"] if normalized_region: params[f"{prefix}_region"] = normalized_region return [f"t.region = :{prefix}_region"] return ["1=0"] def _visible_tenant_filters( self, params: dict[str, Any], *, tenant_codes: list[str | None], legacy_regions: list[str | None], ) -> list[str]: normalized_tenant_codes = [code.strip() for code in tenant_codes if code and str(code).strip()] normalized_regions = [region.strip() for region in legacy_regions if region and str(region).strip()] if normalized_tenant_codes: params["visible_tenant_codes"] = normalized_tenant_codes if normalized_regions: params["visible_regions"] = normalized_regions return [ "(" "t.tenant_code IN :visible_tenant_codes " "OR (" "(t.tenant_code IS NULL OR BTRIM(t.tenant_code) = '') " "AND t.region IN :visible_regions" ")" ")" ] return ["t.tenant_code IN :visible_tenant_codes"] if normalized_regions: params["visible_regions"] = normalized_regions return ["t.region IN :visible_regions"] return ["1=0"] async def _ensureContractTemplateSchema(self, session) -> None: statements = [ """ ALTER TABLE contract_categories ADD COLUMN IF NOT EXISTS created_by BIGINT """, """ ALTER TABLE contract_categories ADD COLUMN IF NOT EXISTS updated_by BIGINT """, """ ALTER TABLE contract_categories ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS region VARCHAR(50) NOT NULL DEFAULT '省级' """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS tenant_code VARCHAR(64) """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS tenant_name VARCHAR(128) """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS pdf_file_path VARCHAR(500) """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS original_file_name VARCHAR(500) NOT NULL DEFAULT '' """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS mime_type VARCHAR(200) """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS file_size BIGINT NOT NULL DEFAULT 0 """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS pdf_file_size BIGINT """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS created_by BIGINT """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS updated_by BIGINT """, """ ALTER TABLE contract_templates ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ """, ] for statement in statements: await session.execute(text(statement)) await session.execute( text( """ UPDATE contract_templates SET region = '省级' WHERE region IS NULL OR BTRIM(region) = '' """ ) ) await session.execute( text( """ UPDATE contract_templates SET tenant_name = region WHERE (tenant_name IS NULL OR BTRIM(tenant_name) = '') AND region IS NOT NULL AND BTRIM(region) <> '' """ ) ) await session.execute( text( """ UPDATE contract_templates SET original_file_name = COALESCE(NULLIF(original_file_name, ''), title || CASE WHEN file_format IS NOT NULL AND BTRIM(file_format) <> '' THEN '.' || LOWER(file_format) ELSE '' END) WHERE original_file_name IS NULL OR BTRIM(original_file_name) = '' """ ) ) await session.commit()