From c4694e11f0439c5593fdd66f71584bc5bc12a393 Mon Sep 17 00:00:00 2001 From: wren <“porlong@qq.com”> Date: Wed, 6 May 2026 14:20:28 +0800 Subject: [PATCH] feat: add document type root management --- docs/接口/用户权限与权限点清单.md | 1 - docs/接口/用户权限初始化SQL.sql | 6 +- .../controllers/documentController.py | 29 +++ .../fastapi_leaudit/domian/vo/documentVo.py | 36 ++++ .../fastapi_leaudit/leaudit_bridge/tasks.py | 1 + .../services/documentService.py | 23 +++ .../services/impl/documentServiceImpl.py | 181 ++++++++++++++++++ scripts/user_rbac_seed.sql | 15 +- 8 files changed, 282 insertions(+), 10 deletions(-) diff --git a/docs/接口/用户权限与权限点清单.md b/docs/接口/用户权限与权限点清单.md index 48c728e..62056a5 100644 --- a/docs/接口/用户权限与权限点清单.md +++ b/docs/接口/用户权限与权限点清单.md @@ -110,7 +110,6 @@ SQL 里初始化了以下核心菜单: - `/documents` - `/documents/list` - `/rules` -- `/rules/sets` - `/audit` - `/audit/runs` - `/system` diff --git a/docs/接口/用户权限初始化SQL.sql b/docs/接口/用户权限初始化SQL.sql index cc61c29..bc4fec1 100644 --- a/docs/接口/用户权限初始化SQL.sql +++ b/docs/接口/用户权限初始化SQL.sql @@ -33,7 +33,6 @@ WITH upsert_routes AS ( ('/documents', '文档管理', 'Layout', NULL, 10, TRUE, TRUE, '{"icon":"files"}'::jsonb, NOW(), NOW()), ('/documents/list', '文档列表', 'documents/list', NULL, 11, TRUE, TRUE, '{"icon":"table"}'::jsonb, NOW(), NOW()), ('/rules', '规则管理', 'Layout', NULL, 20, TRUE, TRUE, '{"icon":"rule"}'::jsonb, NOW(), NOW()), - ('/rules/sets', '规则管理', 'rules/sets', NULL, 21, TRUE, TRUE, '{"icon":"yaml"}'::jsonb, NOW(), NOW()), ('/audit', '评查任务', 'Layout', NULL, 30, TRUE, TRUE, '{"icon":"audit"}'::jsonb, NOW(), NOW()), ('/audit/runs', '评查运行', 'audit/runs', NULL, 31, TRUE, TRUE, '{"icon":"history"}'::jsonb, NOW(), NOW()), ('/system', '系统管理', 'Layout', NULL, 90, TRUE, TRUE, '{"icon":"setting"}'::jsonb, NOW(), NOW()), @@ -109,7 +108,7 @@ WITH role_map AS ( route_map AS ( SELECT id, path FROM sys_routes WHERE path IN ( '/documents', '/documents/list', - '/rules', '/rules/sets', + '/rules', '/audit', '/audit/runs', '/system', '/system/users', '/system/roles' ) @@ -119,7 +118,6 @@ seed(role_name, path) AS ( ('super_admin', '/documents'), ('super_admin', '/documents/list'), ('super_admin', '/rules'), - ('super_admin', '/rules/sets'), ('super_admin', '/audit'), ('super_admin', '/audit/runs'), ('super_admin', '/system'), @@ -129,7 +127,6 @@ seed(role_name, path) AS ( ('provincial_admin', '/documents'), ('provincial_admin', '/documents/list'), ('provincial_admin', '/rules'), - ('provincial_admin', '/rules/sets'), ('provincial_admin', '/audit'), ('provincial_admin', '/audit/runs'), ('provincial_admin', '/system'), @@ -139,7 +136,6 @@ seed(role_name, path) AS ( ('admin', '/documents'), ('admin', '/documents/list'), ('admin', '/rules'), - ('admin', '/rules/sets'), ('admin', '/audit'), ('admin', '/audit/runs'), ('admin', '/system'), diff --git a/fastapi_modules/fastapi_leaudit/controllers/documentController.py b/fastapi_modules/fastapi_leaudit/controllers/documentController.py index 6506067..d5ddc03 100644 --- a/fastapi_modules/fastapi_leaudit/controllers/documentController.py +++ b/fastapi_modules/fastapi_leaudit/controllers/documentController.py @@ -18,6 +18,9 @@ from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( DocumentUpdateDTO, DocumentTypeCreateDTO, DocumentTypeItemVO, + DocumentTypeRootCreateDTO, + DocumentTypeRootItemVO, + DocumentTypeRootUpdateDTO, DocumentTypeUpdateDTO, DocumentUploadVO, ) @@ -261,6 +264,32 @@ class DocumentController(BaseController): await self.DocumentService.DeleteDocumentType(Id=TypeId) return Result.success(message="文档类型已删除") + @self.router.get("/v3/document-type-roots", response_model=Result[list[DocumentTypeRootItemVO]]) + async def ListDocumentTypeRoots( + entry_module_id: int | None = Query(None, description="按入口模块过滤一级大类"), + ): + """获取一级文档类型(业务大类)列表。""" + Data = await self.DocumentService.ListDocumentTypeRoots(EntryModuleId=entry_module_id) + return Result.success(data=Data) + + @self.router.get("/v3/document-type-roots/{RootId}", response_model=Result[DocumentTypeRootItemVO]) + async def GetDocumentTypeRoot(RootId: int): + """获取一级文档类型(业务大类)详情。""" + Data = await self.DocumentService.GetDocumentTypeRoot(Id=RootId) + return Result.success(data=Data) + + @self.router.post("/v3/document-type-roots", response_model=Result[DocumentTypeRootItemVO]) + async def CreateDocumentTypeRoot(Body: DocumentTypeRootCreateDTO): + """创建一级文档类型(业务大类)。""" + Data = await self.DocumentService.CreateDocumentTypeRoot(Body=Body) + return Result.success(data=Data, message="一级文档类型创建成功") + + @self.router.put("/v3/document-type-roots/{RootId}", response_model=Result[DocumentTypeRootItemVO]) + async def UpdateDocumentTypeRoot(RootId: int, Body: DocumentTypeRootUpdateDTO): + """更新一级文档类型(业务大类)。""" + Data = await self.DocumentService.UpdateDocumentTypeRoot(Id=RootId, Body=Body) + return Result.success(data=Data, message="一级文档类型更新成功") + @self.router.get("/v2/system/queue/status", response_model=Result[QueueStatusVO]) async def GetQueueStatus(): """获取文档处理队列状态。""" diff --git a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py index 19a46f6..ece8546 100644 --- a/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py +++ b/fastapi_modules/fastapi_leaudit/domian/vo/documentVo.py @@ -125,6 +125,42 @@ class DocumentTypeItemVO(BaseModel): ruleSetIds: list[int] = Field(default_factory=list, description="关联的规则集ID") +class DocumentTypeRootItemVO(BaseModel): + """一级文档类型(业务大类)列表项。""" + + id: int = Field(..., description="一级分组ID") + name: str = Field(..., description="一级分组名称") + code: str = Field(..., description="一级分组编码") + description: str | None = Field(None, description="描述") + entryModuleId: int | None = Field(None, description="入口模块ID") + entryModuleName: str | None = Field(None, description="入口模块名称") + isEnabled: bool = Field(True, description="是否启用") + childGroupCount: int = Field(0, description="下属二级分组数量") + ruleSetCount: int = Field(0, description="汇总规则集数量") + ruleSetIds: list[int] = Field(default_factory=list, description="汇总规则集ID") + + +class DocumentTypeRootCreateDTO(BaseModel): + """创建一级文档类型(业务大类)。""" + + code: str = Field(..., description="一级分组编码") + name: str = Field(..., description="一级分组名称") + description: str = Field("", description="描述") + entryModuleId: int | None = Field(None, description="入口模块ID") + isEnabled: bool = Field(True, description="是否启用") + sortOrder: int = Field(0, description="排序") + + +class DocumentTypeRootUpdateDTO(BaseModel): + """更新一级文档类型(业务大类)。""" + + name: str | None = Field(None, description="一级分组名称") + description: str | None = Field(None, description="描述") + entryModuleId: int | None = Field(None, description="入口模块ID") + isEnabled: bool | None = Field(None, description="是否启用") + sortOrder: int | None = Field(None, description="排序") + + class DocumentTypeCreateDTO(BaseModel): """文档类型创建请求。""" diff --git a/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py b/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py index 9bb8c70..f5a16d6 100644 --- a/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py +++ b/fastapi_modules/fastapi_leaudit/leaudit_bridge/tasks.py @@ -12,6 +12,7 @@ from typing import Any, Dict, Optional import fitz from fastapi_common.fastapi_common_logger import logger from leaudit.converters import doc2pdf +from sqlalchemy import select from fastapi_admin.celery_app import celery_app from fastapi_admin.config import ( diff --git a/fastapi_modules/fastapi_leaudit/services/documentService.py b/fastapi_modules/fastapi_leaudit/services/documentService.py index 218f6b7..e3d3f92 100644 --- a/fastapi_modules/fastapi_leaudit/services/documentService.py +++ b/fastapi_modules/fastapi_leaudit/services/documentService.py @@ -6,6 +6,9 @@ from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( DocumentDetailVO, DocumentListPageVO, DocumentStatusItemVO, + DocumentTypeRootCreateDTO, + DocumentTypeRootItemVO, + DocumentTypeRootUpdateDTO, DocumentUpdateDTO, DocumentTypeCreateDTO, DocumentTypeItemVO, @@ -138,3 +141,23 @@ class IDocumentService(ABC): async def DeleteDocumentType(self, Id: int) -> None: """删除文档类型(软删除)。""" ... + + @abstractmethod + async def ListDocumentTypeRoots(self, EntryModuleId: int | None = None) -> list[DocumentTypeRootItemVO]: + """获取一级文档类型(业务大类)列表。""" + ... + + @abstractmethod + async def GetDocumentTypeRoot(self, Id: int) -> DocumentTypeRootItemVO: + """获取单个一级文档类型(业务大类)。""" + ... + + @abstractmethod + async def CreateDocumentTypeRoot(self, Body: DocumentTypeRootCreateDTO) -> DocumentTypeRootItemVO: + """创建一级文档类型(业务大类)。""" + ... + + @abstractmethod + async def UpdateDocumentTypeRoot(self, Id: int, Body: DocumentTypeRootUpdateDTO) -> DocumentTypeRootItemVO: + """更新一级文档类型(业务大类)。""" + ... diff --git a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py index 61c5c8b..d387657 100644 --- a/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py +++ b/fastapi_modules/fastapi_leaudit/services/impl/documentServiceImpl.py @@ -26,6 +26,9 @@ from fastapi_modules.fastapi_leaudit.domian.vo.documentVo import ( DocumentListItemVO, DocumentListPageVO, DocumentStatusItemVO, + DocumentTypeRootCreateDTO, + DocumentTypeRootItemVO, + DocumentTypeRootUpdateDTO, DocumentUpdateDTO, DocumentTypeCreateDTO, DocumentTypeItemVO, @@ -1185,6 +1188,97 @@ class DocumentServiceImpl(IDocumentService): await Session.execute(text("UPDATE leaudit_rule_type_bindings SET deleted_at = NOW() WHERE doc_type_id = :id AND deleted_at IS NULL"), {"id": Id}) await Session.commit() + async def ListDocumentTypeRoots(self, EntryModuleId: int | None = None) -> list[DocumentTypeRootItemVO]: + """获取一级文档类型(业务大类)列表。""" + async with GetAsyncSession() as Session: + rows = await self._queryDocumentTypeRoots(Session, EntryModuleId=EntryModuleId) + return [self._toDocumentTypeRootVo(row) for row in rows] + + async def GetDocumentTypeRoot(self, Id: int) -> DocumentTypeRootItemVO: + """获取一级文档类型(业务大类)详情。""" + async with GetAsyncSession() as Session: + rows = await self._queryDocumentTypeRoots(Session, Ids=[Id]) + if not rows: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "一级文档类型不存在") + return self._toDocumentTypeRootVo(rows[0]) + + async def CreateDocumentTypeRoot(self, Body: DocumentTypeRootCreateDTO) -> DocumentTypeRootItemVO: + """创建一级文档类型(业务大类)。""" + async with GetAsyncSession() as Session: + await self._ensureDocumentTypeRootCodeUnique(Session, Body.code.strip(), None) + root_id = ( + await Session.execute( + text( + """ + INSERT INTO leaudit_evaluation_point_groups + (pid, name, code, description, document_type_id, entry_module_id, sort_order, is_enabled, created_at, updated_at) + VALUES + (0, :name, :code, :description, NULL, :entry_module_id, :sort_order, :is_enabled, NOW(), NOW()) + RETURNING id + """ + ), + { + "name": Body.name.strip(), + "code": Body.code.strip(), + "description": Body.description.strip() or None, + "entry_module_id": Body.entryModuleId, + "sort_order": Body.sortOrder, + "is_enabled": Body.isEnabled, + }, + ) + ).scalar_one() + await Session.commit() + return await self.GetDocumentTypeRoot(int(root_id)) + + async def UpdateDocumentTypeRoot(self, Id: int, Body: DocumentTypeRootUpdateDTO) -> DocumentTypeRootItemVO: + """更新一级文档类型(业务大类)。""" + async with GetAsyncSession() as Session: + current = ( + await Session.execute( + text( + """ + SELECT 1 + FROM leaudit_evaluation_point_groups + WHERE id = :id + AND deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + """ + ), + {"id": Id}, + ) + ).first() + if not current: + raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "一级文档类型不存在") + + providedFields = set(getattr(Body, "model_fields_set", set())) + sets: list[str] = [] + params: dict[str, object] = {"id": Id} + if "name" in providedFields and Body.name is not None: + sets.append("name = :name") + params["name"] = Body.name.strip() + if "description" in providedFields: + sets.append("description = :description") + params["description"] = Body.description.strip() if Body.description is not None else None + if params["description"] == "": + params["description"] = None + if "entryModuleId" in providedFields: + sets.append("entry_module_id = :entry_module_id") + params["entry_module_id"] = Body.entryModuleId + if "isEnabled" in providedFields and Body.isEnabled is not None: + sets.append("is_enabled = :is_enabled") + params["is_enabled"] = Body.isEnabled + if "sortOrder" in providedFields and Body.sortOrder is not None: + sets.append("sort_order = :sort_order") + params["sort_order"] = Body.sortOrder + if sets: + sets.append("updated_at = NOW()") + await Session.execute( + text(f"UPDATE leaudit_evaluation_point_groups SET {', '.join(sets)} WHERE id = :id"), + params, + ) + await Session.commit() + return await self.GetDocumentTypeRoot(Id) + async def _queryDocumentTypes(self, Session, Ids: list[int] | None = None, EntryModuleId: int | None = None): """查询文档类型及其规则绑定。""" if Ids: @@ -1250,6 +1344,93 @@ class DocumentServiceImpl(IDocumentService): bindingsMap.setdefault(int(b[0]), []).append(int(b[1])) return rows, bindingsMap + async def _queryDocumentTypeRoots(self, Session, Ids: list[int] | None = None, EntryModuleId: int | None = None): + filters = ["g.deleted_at IS NULL", "COALESCE(g.pid, 0) = 0"] + params: dict[str, object] = {} + if Ids: + filters.append("g.id = ANY(:ids)") + params["ids"] = Ids + if EntryModuleId is not None: + filters.append("g.entry_module_id = :entry_module_id") + params["entry_module_id"] = EntryModuleId + + where_clause = " AND ".join(filters) + rows = ( + await Session.execute( + text( + f""" + SELECT + g.id, + g.name, + g.code, + g.description, + g.entry_module_id, + em.name AS entry_module_name, + g.is_enabled, + COALESCE(child_stats.child_group_count, 0) AS child_group_count, + COALESCE(rule_stats.rule_set_count, 0) AS rule_set_count, + COALESCE(rule_stats.rule_set_ids, ARRAY[]::bigint[]) AS rule_set_ids + FROM leaudit_evaluation_point_groups g + LEFT JOIN leaudit_entry_modules em ON em.id = g.entry_module_id + LEFT JOIN ( + SELECT pid, COUNT(*)::int AS child_group_count + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL AND COALESCE(pid, 0) <> 0 + GROUP BY pid + ) child_stats ON child_stats.pid = g.id + LEFT JOIN ( + SELECT + child.pid, + COUNT(DISTINCT rgb.rule_set_id)::int AS rule_set_count, + ARRAY_AGG(DISTINCT rgb.rule_set_id) FILTER (WHERE rgb.rule_set_id IS NOT NULL) AS rule_set_ids + FROM leaudit_evaluation_point_groups child + LEFT JOIN leaudit_rule_group_bindings rgb + ON rgb.group_id = child.id + AND rgb.deleted_at IS NULL + WHERE child.deleted_at IS NULL + AND COALESCE(child.pid, 0) <> 0 + GROUP BY child.pid + ) rule_stats ON rule_stats.pid = g.id + WHERE {where_clause} + ORDER BY COALESCE(g.sort_order, 0) ASC, g.id ASC + """ + ), + params, + ) + ).mappings().all() + return rows + + def _toDocumentTypeRootVo(self, row) -> DocumentTypeRootItemVO: + rule_set_ids = [int(item) for item in (row.get("rule_set_ids") or []) if item is not None] + return DocumentTypeRootItemVO( + id=int(row["id"]), + name=str(row["name"] or ""), + code=str(row["code"] or ""), + description=row.get("description"), + entryModuleId=int(row["entry_module_id"]) if row.get("entry_module_id") is not None else None, + entryModuleName=row.get("entry_module_name"), + isEnabled=bool(row.get("is_enabled", True)), + childGroupCount=int(row.get("child_group_count") or 0), + ruleSetCount=int(row.get("rule_set_count") or 0), + ruleSetIds=rule_set_ids, + ) + + async def _ensureDocumentTypeRootCodeUnique(self, Session, Code: str, CurrentId: int | None) -> None: + sql = """ + SELECT 1 + FROM leaudit_evaluation_point_groups + WHERE deleted_at IS NULL + AND COALESCE(pid, 0) = 0 + AND code = :code + """ + params: dict[str, object] = {"code": Code} + if CurrentId is not None: + sql += " AND id <> :current_id" + params["current_id"] = CurrentId + exists = (await Session.execute(text(sql), params)).first() + if exists: + raise LeauditException(StatusCodeEnum.HTTP_409_CONFLICT, f"一级文档类型编码 {Code} 已存在") + async def _loadDocumentColumns(self, Session) -> set[str]: """读取 leaudit_documents 当前真实列,兼容不同环境的渐进式字段上线。""" rows = ( diff --git a/scripts/user_rbac_seed.sql b/scripts/user_rbac_seed.sql index 97f578d..7ff4006 100644 --- a/scripts/user_rbac_seed.sql +++ b/scripts/user_rbac_seed.sql @@ -35,7 +35,6 @@ VALUES ('/audit', 'audit', 'Layout', NULL, '评查任务', 'audit', 20, FALSE, TRUE, '{"group":"audit"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), ('/audit/runs', 'audit.runs', 'audit/runs', NULL, '评查运行', 'history', 21, FALSE, TRUE, '{"group":"audit"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), ('/rules', 'rules', 'Layout', NULL, '规则管理', 'rule', 30, FALSE, TRUE, '{"group":"rules"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), - ('/rules/sets', 'rules.sets', 'rules/sets', NULL, '规则集管理', 'yaml', 31, FALSE, TRUE, '{"group":"rules"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), ('/system', 'system', 'Layout', NULL, '系统管理', 'setting', 90, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), ('/system/users', 'system.users', 'system/users', NULL, '用户管理', 'user', 91, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), ('/system/roles', 'system.roles', 'system/roles', NULL, '角色权限', 'shield', 92, FALSE, TRUE, '{"group":"system"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL), @@ -48,6 +47,17 @@ VALUES ('/cross-checking/result', 'cross-checking.result', 'cross-checking/result', NULL, '评查结果', 'table', 62, FALSE, TRUE, '{"group":"cross-review"}'::jsonb, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, NULL) ON CONFLICT DO NOTHING; +UPDATE role_route +SET deleted_at = NOW(), updated_at = NOW() +WHERE deleted_at IS NULL + AND route_id IN ( + SELECT id FROM sys_routes WHERE route_path = '/rules/sets' AND deleted_at IS NULL + ); + +UPDATE sys_routes +SET deleted_at = NOW(), updated_at = NOW() +WHERE route_path = '/rules/sets' AND deleted_at IS NULL; + -- -------------------------------------------------------------------------- -- 3. 权限点初始化 -- -------------------------------------------------------------------------- @@ -126,7 +136,6 @@ seed(role_key, route_path, permission, status) AS ( ('super_admin', '/audit', 'RW', 1), ('super_admin', '/audit/runs', 'RW', 1), ('super_admin', '/rules', 'RW', 1), - ('super_admin', '/rules/sets', 'RW', 1), ('super_admin', '/chat-with-llm', 'RW', 1), ('super_admin', '/contract-template', 'RW', 1), ('super_admin', '/contract-template/search', 'RW', 1), @@ -143,7 +152,6 @@ seed(role_key, route_path, permission, status) AS ( ('provincial_admin', '/audit', 'RW', 1), ('provincial_admin', '/audit/runs', 'RW', 1), ('provincial_admin', '/rules', 'RW', 1), - ('provincial_admin', '/rules/sets', 'RW', 1), ('provincial_admin', '/chat-with-llm', 'RW', 1), ('provincial_admin', '/contract-template', 'RW', 1), ('provincial_admin', '/contract-template/search', 'RW', 1), @@ -160,7 +168,6 @@ seed(role_key, route_path, permission, status) AS ( ('admin', '/audit', 'RW', 1), ('admin', '/audit/runs', 'RW', 1), ('admin', '/rules', 'RW', 1), - ('admin', '/rules/sets', 'RW', 1), ('admin', '/chat-with-llm', 'RW', 1), ('admin', '/contract-template', 'RW', 1), ('admin', '/contract-template/search', 'RW', 1),