feat: add document type root management

This commit is contained in:
wren
2026-05-06 14:20:28 +08:00
parent 201e3adc18
commit c4694e11f0
8 changed files with 282 additions and 10 deletions
@@ -110,7 +110,6 @@ SQL 里初始化了以下核心菜单:
- `/documents`
- `/documents/list`
- `/rules`
- `/rules/sets`
- `/audit`
- `/audit/runs`
- `/system`
+1 -5
View File
@@ -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'),
@@ -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():
"""获取文档处理队列状态。"""
@@ -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):
"""文档类型创建请求。"""
@@ -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 (
@@ -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:
"""更新一级文档类型(业务大类)。"""
...
@@ -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 = (
+11 -4
View File
@@ -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),