feat: add backend rule group and permission support
This commit is contained in:
@@ -0,0 +1,814 @@
|
||||
"""评查点分组服务实现(新链路:文档类型 -> 子类型 -> 规则集)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import bindparam, text
|
||||
|
||||
from fastapi_common.fastapi_common_sqlalchemy.database import GetAsyncSession
|
||||
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.evaluationPointGroupDto import (
|
||||
EvaluationPointGroupBatchDeleteDTO,
|
||||
EvaluationPointGroupBatchStatusDTO,
|
||||
EvaluationPointGroupBindingCreateDTO,
|
||||
EvaluationPointGroupBindingUpdateDTO,
|
||||
EvaluationPointGroupCreateDTO,
|
||||
EvaluationPointGroupRebindDTO,
|
||||
EvaluationPointGroupUpdateDTO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointGroupVo import (
|
||||
EvaluationPointGroupBatchDeleteVO,
|
||||
EvaluationPointGroupBatchStatusVO,
|
||||
EvaluationPointGroupDeleteVO,
|
||||
EvaluationPointGroupListVO,
|
||||
EvaluationPointGroupRebindVO,
|
||||
EvaluationPointGroupVO,
|
||||
RuleGroupBindingVO,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.services.evaluationPointGroupService import IEvaluationPointGroupService
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.ruleGroupSupport import (
|
||||
bootstrap_rule_groups,
|
||||
ensure_rule_group_schema,
|
||||
sync_doc_type_bindings_from_group,
|
||||
)
|
||||
from fastapi_modules.fastapi_leaudit.services.impl.ruleServiceImpl import RuleServiceImpl
|
||||
|
||||
|
||||
class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService):
|
||||
"""评查点分组服务实现。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.RuleService = RuleServiceImpl()
|
||||
|
||||
async def ListGroups(
|
||||
self,
|
||||
Name: str | None,
|
||||
Code: str | None,
|
||||
IsEnabled: bool | None,
|
||||
Pid: int | None,
|
||||
Page: int,
|
||||
PageSize: int,
|
||||
) -> EvaluationPointGroupListVO:
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
offset = max(Page - 1, 0) * PageSize
|
||||
filters = ["g.deleted_at IS NULL", "(COALESCE(g.pid, 0) <> 0 OR g.document_type_id IS NOT NULL OR g.entry_module_id IS NOT NULL)"]
|
||||
params: dict[str, Any] = {"limit": PageSize, "offset": offset}
|
||||
|
||||
if Name:
|
||||
filters.append("g.name ILIKE :name")
|
||||
params["name"] = f"%{Name.strip()}%"
|
||||
if Code:
|
||||
filters.append("g.code ILIKE :code")
|
||||
params["code"] = f"%{Code.strip()}%"
|
||||
if IsEnabled is not None:
|
||||
filters.append("g.is_enabled = :is_enabled")
|
||||
params["is_enabled"] = IsEnabled
|
||||
if Pid is not None:
|
||||
filters.append("COALESCE(g.pid, 0) = :pid")
|
||||
params["pid"] = self._normalize_pid(Pid)
|
||||
|
||||
where_clause = " AND ".join(filters)
|
||||
total = int(
|
||||
(
|
||||
await session.execute(
|
||||
text(f"SELECT COUNT(*) FROM leaudit_evaluation_point_groups g WHERE {where_clause}"),
|
||||
params,
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
g.id,
|
||||
g.pid,
|
||||
g.name,
|
||||
g.code,
|
||||
g.description,
|
||||
g.document_type_id,
|
||||
dt.name AS document_type_name,
|
||||
COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS entry_module_id,
|
||||
em.name AS entry_module_name,
|
||||
g.sort_order,
|
||||
g.is_enabled,
|
||||
g.created_at,
|
||||
g.updated_at,
|
||||
COALESCE(bg.binding_count, 0) AS rule_count
|
||||
FROM leaudit_evaluation_point_groups g
|
||||
LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id
|
||||
LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = g.pid AND parent.deleted_at IS NULL
|
||||
LEFT JOIN leaudit_entry_modules em ON em.id = COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id)
|
||||
LEFT JOIN (
|
||||
SELECT group_id, COUNT(*)::int AS binding_count
|
||||
FROM leaudit_rule_group_bindings
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY group_id
|
||||
) bg ON bg.group_id = g.id
|
||||
WHERE {where_clause}
|
||||
ORDER BY COALESCE(g.sort_order, 0) ASC, g.id ASC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
).mappings().all()
|
||||
binding_map = await self._load_binding_map(session, [int(row["id"]) for row in rows])
|
||||
return EvaluationPointGroupListVO(
|
||||
data=[self._to_group_vo(row, binding_map.get(int(row["id"]), []), include_rule_count=True) for row in rows],
|
||||
total=total,
|
||||
page=Page,
|
||||
page_size=PageSize,
|
||||
)
|
||||
|
||||
async def ListAllGroups(self, IncludeDisabled: bool, WithRuleCount: bool) -> list[EvaluationPointGroupVO]:
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
filters = ["g.deleted_at IS NULL", "(COALESCE(g.pid, 0) <> 0 OR g.document_type_id IS NOT NULL OR g.entry_module_id IS NOT NULL)"]
|
||||
if not IncludeDisabled:
|
||||
filters.append("g.is_enabled = TRUE")
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
g.id,
|
||||
g.pid,
|
||||
g.name,
|
||||
g.code,
|
||||
g.description,
|
||||
g.document_type_id,
|
||||
dt.name AS document_type_name,
|
||||
COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS entry_module_id,
|
||||
em.name AS entry_module_name,
|
||||
g.sort_order,
|
||||
g.is_enabled,
|
||||
g.created_at,
|
||||
g.updated_at,
|
||||
COALESCE(bg.binding_count, 0) AS rule_count
|
||||
FROM leaudit_evaluation_point_groups g
|
||||
LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id
|
||||
LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = g.pid AND parent.deleted_at IS NULL
|
||||
LEFT JOIN leaudit_entry_modules em ON em.id = COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id)
|
||||
LEFT JOIN (
|
||||
SELECT group_id, COUNT(*)::int AS binding_count
|
||||
FROM leaudit_rule_group_bindings
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY group_id
|
||||
) bg ON bg.group_id = g.id
|
||||
WHERE {' AND '.join(filters)}
|
||||
ORDER BY COALESCE(g.sort_order, 0) ASC, g.id ASC
|
||||
"""
|
||||
)
|
||||
)
|
||||
).mappings().all()
|
||||
binding_map = await self._load_binding_map(session, [int(row["id"]) for row in rows])
|
||||
|
||||
groups = [self._to_group_vo(row, binding_map.get(int(row["id"]), []), include_rule_count=WithRuleCount) for row in rows]
|
||||
by_parent: dict[int, list[EvaluationPointGroupVO]] = {}
|
||||
roots: list[EvaluationPointGroupVO] = []
|
||||
for group in groups:
|
||||
parent_id = self._normalize_pid(group.pid)
|
||||
if parent_id == 0:
|
||||
roots.append(group)
|
||||
else:
|
||||
by_parent.setdefault(parent_id, []).append(group)
|
||||
for group in groups:
|
||||
children = by_parent.get(group.id)
|
||||
if children:
|
||||
group.children = children
|
||||
return roots
|
||||
|
||||
async def ListGroupsByDocumentTypes(
|
||||
self,
|
||||
DocumentTypeIds: list[int],
|
||||
IncludeDisabled: bool,
|
||||
WithRuleCount: bool,
|
||||
) -> list[EvaluationPointGroupVO]:
|
||||
normalized_ids = sorted({int(item) for item in DocumentTypeIds if item})
|
||||
if not normalized_ids:
|
||||
return []
|
||||
roots = await self.ListAllGroups(IncludeDisabled=IncludeDisabled, WithRuleCount=WithRuleCount)
|
||||
result: list[EvaluationPointGroupVO] = []
|
||||
for root in roots:
|
||||
if root.document_type_id in normalized_ids:
|
||||
result.append(root)
|
||||
continue
|
||||
children = root.children or []
|
||||
if any(child.document_type_id in normalized_ids for child in children):
|
||||
result.append(root)
|
||||
return result
|
||||
|
||||
async def GetGroup(self, GroupId: int, WithRuleCount: bool) -> EvaluationPointGroupVO:
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
row = await self._get_group_row(session, GroupId)
|
||||
binding_map = await self._load_binding_map(session, [GroupId])
|
||||
return self._to_group_vo(row, binding_map.get(GroupId, []), include_rule_count=WithRuleCount)
|
||||
|
||||
async def GetChildren(self, GroupId: int, IsEnabled: bool | None, Page: int, PageSize: int) -> EvaluationPointGroupListVO:
|
||||
await self.GetGroup(GroupId, WithRuleCount=False)
|
||||
return await self.ListGroups(Name=None, Code=None, IsEnabled=IsEnabled, Pid=GroupId, Page=Page, PageSize=PageSize)
|
||||
|
||||
async def CreateGroup(self, Body: EvaluationPointGroupCreateDTO) -> EvaluationPointGroupVO:
|
||||
payload = self._normalize_create_payload(Body)
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
await self._ensure_code_unique(session, payload["code"], None)
|
||||
parent = await self._ensure_parent_valid(session, payload["pid"])
|
||||
payload["entry_module_id"] = await self._ensure_entry_module_valid(
|
||||
session, payload["pid"], payload["entry_module_id"], parent
|
||||
)
|
||||
payload["document_type_id"] = await self._ensure_document_type_valid(
|
||||
session, payload["pid"], payload["document_type_id"], payload["entry_module_id"], None, parent
|
||||
)
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO leaudit_evaluation_point_groups (
|
||||
pid, code, name, description, document_type_id, entry_module_id, sort_order, is_enabled, created_at, updated_at
|
||||
) VALUES (
|
||||
:pid, :code, :name, :description, :document_type_id, :entry_module_id, :sort_order, :is_enabled, NOW(), NOW()
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
payload,
|
||||
)
|
||||
).mappings().one()
|
||||
await session.commit()
|
||||
group_id = int(row["id"])
|
||||
return await self.GetGroup(group_id, WithRuleCount=True)
|
||||
|
||||
async def UpdateGroup(self, GroupId: int, Body: EvaluationPointGroupUpdateDTO) -> EvaluationPointGroupVO:
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
current = await self._get_group_row(session, GroupId)
|
||||
provided_fields = set(getattr(Body, "model_fields_set", set()))
|
||||
next_pid = self._normalize_pid(Body.pid) if Body.pid is not None else self._normalize_pid(current["pid"])
|
||||
name = (Body.name.strip() if Body.name is not None else str(current.get("name") or "")).strip()
|
||||
code = (Body.code.strip() if Body.code is not None else str(current.get("code") or "")).strip()
|
||||
description = Body.description.strip() if Body.description is not None and Body.description else current.get("description")
|
||||
document_type_id = Body.document_type_id if "document_type_id" in provided_fields else current.get("document_type_id")
|
||||
entry_module_id = Body.entry_module_id if "entry_module_id" in provided_fields else current.get("entry_module_id")
|
||||
sort_order = Body.sort_order if Body.sort_order is not None else int(current.get("sort_order") or 0)
|
||||
is_enabled = Body.is_enabled if Body.is_enabled is not None else bool(current.get("is_enabled", True))
|
||||
|
||||
if not name:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组名称不能为空")
|
||||
if not code:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组编码不能为空")
|
||||
|
||||
await self._ensure_code_unique(session, code, GroupId)
|
||||
parent = await self._ensure_parent_valid(session, next_pid)
|
||||
entry_module_id = await self._ensure_entry_module_valid(session, next_pid, entry_module_id, parent)
|
||||
document_type_id = await self._ensure_document_type_valid(
|
||||
session, next_pid, document_type_id, entry_module_id, GroupId, parent
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE leaudit_evaluation_point_groups
|
||||
SET pid = :pid,
|
||||
code = :code,
|
||||
name = :name,
|
||||
description = :description,
|
||||
document_type_id = :document_type_id,
|
||||
entry_module_id = :entry_module_id,
|
||||
sort_order = :sort_order,
|
||||
is_enabled = :is_enabled,
|
||||
updated_at = NOW()
|
||||
WHERE id = :group_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"group_id": GroupId,
|
||||
"pid": next_pid,
|
||||
"code": code,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"document_type_id": document_type_id,
|
||||
"entry_module_id": entry_module_id,
|
||||
"sort_order": sort_order,
|
||||
"is_enabled": is_enabled,
|
||||
},
|
||||
)
|
||||
await sync_doc_type_bindings_from_group(session, GroupId)
|
||||
await session.commit()
|
||||
return await self.GetGroup(GroupId, WithRuleCount=True)
|
||||
|
||||
async def DeleteGroup(self, GroupId: int) -> EvaluationPointGroupDeleteVO:
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
current = await self._get_group_row(session, GroupId)
|
||||
is_root = self._normalize_pid(current["pid"]) == 0
|
||||
if is_root:
|
||||
child_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM leaudit_evaluation_point_groups WHERE pid = :group_id AND deleted_at IS NULL"
|
||||
),
|
||||
{"group_id": GroupId},
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
if child_count > 0:
|
||||
return EvaluationPointGroupDeleteVO(
|
||||
success=False,
|
||||
message="当前一级分组下仍存在二级分组,请先迁移或删除二级分组",
|
||||
deleted_groups=0,
|
||||
deleted_points=0,
|
||||
)
|
||||
|
||||
binding_count = int(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT COUNT(*) FROM leaudit_rule_group_bindings WHERE group_id = :group_id AND deleted_at IS NULL"
|
||||
),
|
||||
{"group_id": GroupId},
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE group_id = :group_id AND deleted_at IS NULL"
|
||||
),
|
||||
{"group_id": GroupId},
|
||||
)
|
||||
await sync_doc_type_bindings_from_group(session, GroupId)
|
||||
result = await session.execute(
|
||||
text(
|
||||
"UPDATE leaudit_evaluation_point_groups SET deleted_at = NOW(), updated_at = NOW() WHERE id = :group_id AND deleted_at IS NULL"
|
||||
),
|
||||
{"group_id": GroupId},
|
||||
)
|
||||
await session.commit()
|
||||
deleted_groups = int(result.rowcount or 0)
|
||||
return EvaluationPointGroupDeleteVO(
|
||||
success=True,
|
||||
message="规则分组删除成功",
|
||||
deleted_count=deleted_groups,
|
||||
deleted_groups=deleted_groups,
|
||||
deleted_points=binding_count,
|
||||
)
|
||||
|
||||
async def RebindGroup(self, GroupId: int, Body: EvaluationPointGroupRebindDTO) -> EvaluationPointGroupRebindVO:
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
current = await self._get_group_row(session, GroupId)
|
||||
target = await self._get_group_row(session, Body.new_parent_id)
|
||||
if self._normalize_pid(current["pid"]) != 0 or self._normalize_pid(target["pid"]) != 0:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "仅支持一级分组之间迁移二级分组")
|
||||
if GroupId == Body.new_parent_id:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "迁移目标不能与原分组相同")
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE leaudit_evaluation_point_groups
|
||||
SET pid = :new_parent_id,
|
||||
updated_at = NOW()
|
||||
WHERE pid = :old_group_id AND deleted_at IS NULL
|
||||
"""
|
||||
),
|
||||
{"old_group_id": GroupId, "new_parent_id": Body.new_parent_id},
|
||||
)
|
||||
await session.commit()
|
||||
moved = int(result.rowcount or 0)
|
||||
return EvaluationPointGroupRebindVO(success=True, message="二级分组迁移成功", rebind_count=moved, doc_types_updated=moved)
|
||||
|
||||
async def BatchUpdateStatus(self, Body: EvaluationPointGroupBatchStatusDTO) -> EvaluationPointGroupBatchStatusVO:
|
||||
ids = sorted({int(item) for item in Body.ids if item})
|
||||
if not ids:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请选择至少一个分组")
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
result = await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE leaudit_evaluation_point_groups
|
||||
SET is_enabled = :is_enabled,
|
||||
updated_at = NOW()
|
||||
WHERE id IN :ids AND deleted_at IS NULL
|
||||
"""
|
||||
).bindparams(bindparam("ids", expanding=True)),
|
||||
{"ids": ids, "is_enabled": Body.is_enabled},
|
||||
)
|
||||
await session.commit()
|
||||
updated_count = int(result.rowcount or 0)
|
||||
return EvaluationPointGroupBatchStatusVO(success=True, updated_count=updated_count, message=f"成功更新 {updated_count} 个分组状态")
|
||||
|
||||
async def BatchDelete(self, Body: EvaluationPointGroupBatchDeleteDTO) -> EvaluationPointGroupBatchDeleteVO:
|
||||
ids = sorted({int(item) for item in Body.ids if item})
|
||||
if not ids:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请选择至少一个分组")
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT id, pid, document_type_id FROM leaudit_evaluation_point_groups WHERE id IN :ids AND deleted_at IS NULL"
|
||||
).bindparams(bindparam("ids", expanding=True)),
|
||||
{"ids": ids},
|
||||
)
|
||||
).mappings().all()
|
||||
if len(rows) != len(ids):
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "部分分组不存在")
|
||||
if any(self._normalize_pid(row["pid"]) == 0 for row in rows):
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "批量删除仅支持二级分组")
|
||||
|
||||
deleted_bindings = 0
|
||||
for row in rows:
|
||||
deleted_bindings += int(
|
||||
(
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE group_id = :group_id AND deleted_at IS NULL"
|
||||
),
|
||||
{"group_id": int(row["id"])},
|
||||
)
|
||||
).rowcount
|
||||
or 0
|
||||
)
|
||||
await sync_doc_type_bindings_from_group(session, int(row["id"]))
|
||||
result = await session.execute(
|
||||
text(
|
||||
"UPDATE leaudit_evaluation_point_groups SET deleted_at = NOW(), updated_at = NOW() WHERE id IN :ids AND deleted_at IS NULL"
|
||||
).bindparams(bindparam("ids", expanding=True)),
|
||||
{"ids": ids},
|
||||
)
|
||||
await session.commit()
|
||||
deleted_groups = int(result.rowcount or 0)
|
||||
return EvaluationPointGroupBatchDeleteVO(success=True, deleted_groups=deleted_groups, deleted_points=deleted_bindings, message=f"成功删除 {deleted_groups} 个分组")
|
||||
|
||||
async def CreateBinding(self, GroupId: int, Body: EvaluationPointGroupBindingCreateDTO) -> RuleGroupBindingVO:
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
group = await self._get_group_row(session, GroupId)
|
||||
if self._normalize_pid(group["pid"]) == 0:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "一级分组不能直接绑定规则集,请先选择二级分组")
|
||||
await self._ensure_rule_set_valid(session, Body.rule_set_id)
|
||||
existing = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT id FROM leaudit_rule_group_bindings WHERE group_id = :group_id AND rule_set_id = :rule_set_id AND deleted_at IS NULL LIMIT 1"
|
||||
),
|
||||
{"group_id": GroupId, "rule_set_id": Body.rule_set_id},
|
||||
)
|
||||
).mappings().first()
|
||||
if existing:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "该规则集已绑定到当前二级分组")
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO leaudit_rule_group_bindings (
|
||||
group_id, rule_set_id, priority, is_active, note, created_at, updated_at
|
||||
) VALUES (
|
||||
:group_id, :rule_set_id, :priority, :is_active, :note, NOW(), NOW()
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"group_id": GroupId,
|
||||
"rule_set_id": Body.rule_set_id,
|
||||
"priority": Body.priority,
|
||||
"is_active": Body.is_active,
|
||||
"note": Body.note.strip() if Body.note else None,
|
||||
},
|
||||
)
|
||||
).mappings().one()
|
||||
await sync_doc_type_bindings_from_group(session, GroupId)
|
||||
await session.commit()
|
||||
binding_id = int(row["id"])
|
||||
binding_row = await self._get_binding_row(session, binding_id)
|
||||
binding_vo = await self._build_binding_vo(binding_row)
|
||||
return binding_vo
|
||||
|
||||
async def UpdateBinding(self, BindingId: int, Body: EvaluationPointGroupBindingUpdateDTO) -> RuleGroupBindingVO:
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
current = await self._get_binding_row(session, BindingId)
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE leaudit_rule_group_bindings
|
||||
SET priority = :priority,
|
||||
is_active = :is_active,
|
||||
note = :note,
|
||||
updated_at = NOW()
|
||||
WHERE id = :binding_id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"binding_id": BindingId,
|
||||
"priority": Body.priority if Body.priority is not None else int(current.get("priority") or 0),
|
||||
"is_active": Body.is_active if Body.is_active is not None else bool(current.get("is_active", True)),
|
||||
"note": Body.note.strip() if Body.note else (current.get("note") if Body.note is None else None),
|
||||
},
|
||||
)
|
||||
await sync_doc_type_bindings_from_group(session, int(current["group_id"]))
|
||||
await session.commit()
|
||||
binding_row = await self._get_binding_row(session, BindingId)
|
||||
binding_vo = await self._build_binding_vo(binding_row)
|
||||
return binding_vo
|
||||
|
||||
async def DeleteBinding(self, BindingId: int) -> None:
|
||||
async with GetAsyncSession() as session:
|
||||
await self._ensure_ready(session)
|
||||
current = await self._get_binding_row(session, BindingId)
|
||||
await session.execute(
|
||||
text(
|
||||
"UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE id = :binding_id"
|
||||
),
|
||||
{"binding_id": BindingId},
|
||||
)
|
||||
await sync_doc_type_bindings_from_group(session, int(current["group_id"]))
|
||||
await session.commit()
|
||||
|
||||
async def _ensure_ready(self, session) -> None:
|
||||
self._rule_set_meta_cache = None
|
||||
await ensure_rule_group_schema(session)
|
||||
await bootstrap_rule_groups(session)
|
||||
|
||||
async def _get_group_row(self, session, group_id: int):
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
g.id,
|
||||
g.pid,
|
||||
g.name,
|
||||
g.code,
|
||||
g.description,
|
||||
g.document_type_id,
|
||||
dt.name AS document_type_name,
|
||||
COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id) AS entry_module_id,
|
||||
em.name AS entry_module_name,
|
||||
g.sort_order,
|
||||
g.is_enabled,
|
||||
g.created_at,
|
||||
g.updated_at,
|
||||
COALESCE(bg.binding_count, 0) AS rule_count
|
||||
FROM leaudit_evaluation_point_groups g
|
||||
LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id
|
||||
LEFT JOIN leaudit_evaluation_point_groups parent ON parent.id = g.pid AND parent.deleted_at IS NULL
|
||||
LEFT JOIN leaudit_entry_modules em ON em.id = COALESCE(g.entry_module_id, parent.entry_module_id, dt.entry_module_id)
|
||||
LEFT JOIN (
|
||||
SELECT group_id, COUNT(*)::int AS binding_count
|
||||
FROM leaudit_rule_group_bindings
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY group_id
|
||||
) bg ON bg.group_id = g.id
|
||||
WHERE g.id = :group_id AND g.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"group_id": group_id},
|
||||
)
|
||||
).mappings().first()
|
||||
if not row:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则分组不存在")
|
||||
return row
|
||||
|
||||
async def _get_binding_row(self, session, binding_id: int):
|
||||
row = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, group_id, rule_set_id, rule_type_binding_id, priority, is_active, note, created_at, updated_at
|
||||
FROM leaudit_rule_group_bindings
|
||||
WHERE id = :binding_id AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"binding_id": binding_id},
|
||||
)
|
||||
).mappings().first()
|
||||
if not row:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_404_NOT_FOUND, "规则组绑定不存在")
|
||||
return row
|
||||
|
||||
async def _load_binding_map(self, session, group_ids: list[int]) -> dict[int, list[RuleGroupBindingVO]]:
|
||||
group_ids = [int(item) for item in group_ids if item]
|
||||
if not group_ids:
|
||||
return {}
|
||||
rows = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, group_id, rule_set_id, rule_type_binding_id, priority, is_active, note, created_at, updated_at
|
||||
FROM leaudit_rule_group_bindings
|
||||
WHERE group_id IN :group_ids AND deleted_at IS NULL
|
||||
ORDER BY priority DESC, id ASC
|
||||
"""
|
||||
).bindparams(bindparam("group_ids", expanding=True)),
|
||||
{"group_ids": group_ids},
|
||||
)
|
||||
).mappings().all()
|
||||
result: dict[int, list[RuleGroupBindingVO]] = {}
|
||||
for row in rows:
|
||||
result.setdefault(int(row["group_id"]), []).append(await self._build_binding_vo(row))
|
||||
return result
|
||||
|
||||
async def _build_binding_vo(self, row) -> RuleGroupBindingVO:
|
||||
rule_set_meta = await self._get_rule_set_meta(int(row["rule_set_id"]))
|
||||
return RuleGroupBindingVO(
|
||||
id=int(row["id"]),
|
||||
group_id=int(row["group_id"]),
|
||||
rule_set_id=int(row["rule_set_id"]),
|
||||
rule_type_binding_id=int(row["rule_type_binding_id"]) if row.get("rule_type_binding_id") else None,
|
||||
priority=int(row.get("priority") or 0),
|
||||
is_active=bool(row.get("is_active", True)),
|
||||
note=row.get("note"),
|
||||
rule_type=rule_set_meta.get("rule_type"),
|
||||
rule_name=rule_set_meta.get("rule_name"),
|
||||
current_version_id=rule_set_meta.get("current_version_id"),
|
||||
fallback_version_id=rule_set_meta.get("fallback_version_id"),
|
||||
has_usable_version=bool(rule_set_meta.get("has_usable_version", False)),
|
||||
usable_rule_count=int(rule_set_meta.get("usable_rule_count") or 0),
|
||||
)
|
||||
|
||||
async def _get_rule_set_meta(self, rule_set_id: int) -> dict[str, Any]:
|
||||
if not getattr(self, "_rule_set_meta_cache", None):
|
||||
rule_sets = await self.RuleService.ListSets()
|
||||
self._rule_set_meta_cache = {
|
||||
int(item.id): {
|
||||
"rule_type": item.ruleType,
|
||||
"rule_name": item.ruleName,
|
||||
"current_version_id": item.currentVersionId,
|
||||
"fallback_version_id": item.fallbackVersionId,
|
||||
"has_usable_version": item.hasUsableVersion,
|
||||
"usable_rule_count": item.usableRuleCount,
|
||||
}
|
||||
for item in rule_sets
|
||||
}
|
||||
return self._rule_set_meta_cache.get(rule_set_id, {})
|
||||
|
||||
async def _ensure_parent_valid(self, session, pid: int):
|
||||
if pid == 0:
|
||||
return None
|
||||
parent = await self._get_group_row(session, pid)
|
||||
if self._normalize_pid(parent["pid"]) != 0:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "上级分组必须是一级分组")
|
||||
return parent
|
||||
|
||||
async def _ensure_entry_module_valid(
|
||||
self,
|
||||
session,
|
||||
pid: int,
|
||||
entry_module_id: int | None,
|
||||
parent: Any | None,
|
||||
) -> int | None:
|
||||
if pid != 0:
|
||||
if parent is not None and parent.get("entry_module_id") is not None:
|
||||
return int(parent["entry_module_id"])
|
||||
return None if entry_module_id in (None, 0, "0", "") else int(entry_module_id)
|
||||
|
||||
if entry_module_id in (None, 0, "0", ""):
|
||||
return None
|
||||
|
||||
exists = (
|
||||
await session.execute(
|
||||
text("SELECT id FROM leaudit_entry_modules WHERE id = :entry_module_id AND deleted_at IS NULL LIMIT 1"),
|
||||
{"entry_module_id": int(entry_module_id)},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is None:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "关联入口模块不存在")
|
||||
return int(entry_module_id)
|
||||
|
||||
async def _ensure_document_type_valid(
|
||||
self,
|
||||
session,
|
||||
pid: int,
|
||||
document_type_id: int | None,
|
||||
entry_module_id: int | None,
|
||||
group_id: int | None,
|
||||
parent: Any | None,
|
||||
) -> int | None:
|
||||
if pid == 0:
|
||||
if document_type_id is None and entry_module_id is None:
|
||||
return None
|
||||
else:
|
||||
if parent is None:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "二级分组必须挂到一级分组下")
|
||||
if parent.get("document_type_id") is not None:
|
||||
parent_doc_type_id = int(parent["document_type_id"])
|
||||
if document_type_id is None:
|
||||
document_type_id = parent_doc_type_id
|
||||
elif int(document_type_id) != parent_doc_type_id:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "二级分组的文档类型必须与所属一级分组一致")
|
||||
elif document_type_id is None:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "二级分组必须明确绑定具体文档类型")
|
||||
|
||||
if document_type_id is None:
|
||||
return None
|
||||
exists = (
|
||||
await session.execute(
|
||||
text(
|
||||
"SELECT id FROM leaudit_document_types WHERE id = :doc_type_id AND deleted_at IS NULL LIMIT 1"
|
||||
),
|
||||
{"doc_type_id": int(document_type_id)},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is None:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "关联文档类型不存在")
|
||||
if pid == 0:
|
||||
duplicated_root = (
|
||||
await session.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id
|
||||
FROM leaudit_evaluation_point_groups
|
||||
WHERE deleted_at IS NULL
|
||||
AND COALESCE(pid, 0) = 0
|
||||
AND document_type_id = :doc_type_id
|
||||
AND (:group_id IS NULL OR id <> :group_id)
|
||||
LIMIT 1
|
||||
"""
|
||||
),
|
||||
{"doc_type_id": int(document_type_id), "group_id": group_id},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if duplicated_root is not None:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "该文档类型已存在一级分组")
|
||||
return int(document_type_id)
|
||||
|
||||
async def _ensure_code_unique(self, session, code: str, group_id: int | None) -> None:
|
||||
sql = "SELECT id FROM leaudit_evaluation_point_groups WHERE LOWER(code) = LOWER(:code) AND deleted_at IS NULL"
|
||||
params: dict[str, Any] = {"code": code}
|
||||
if group_id is not None:
|
||||
sql += " AND id <> :group_id"
|
||||
params["group_id"] = group_id
|
||||
duplicated = (await session.execute(text(sql + " LIMIT 1"), params)).scalar_one_or_none()
|
||||
if duplicated is not None:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组编码已存在")
|
||||
|
||||
async def _ensure_rule_set_valid(self, session, rule_set_id: int) -> None:
|
||||
exists = (
|
||||
await session.execute(
|
||||
text("SELECT id FROM leaudit_rule_sets WHERE id = :rule_set_id AND deleted_at IS NULL LIMIT 1"),
|
||||
{"rule_set_id": rule_set_id},
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if exists is None:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "规则集不存在")
|
||||
|
||||
def _normalize_create_payload(self, body: EvaluationPointGroupCreateDTO) -> dict[str, Any]:
|
||||
name = body.name.strip()
|
||||
code = body.code.strip()
|
||||
if not name:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组名称不能为空")
|
||||
if not code:
|
||||
raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "分组编码不能为空")
|
||||
return {
|
||||
"name": name,
|
||||
"code": code,
|
||||
"pid": self._normalize_pid(body.pid),
|
||||
"description": body.description.strip() if body.description else None,
|
||||
"document_type_id": body.document_type_id,
|
||||
"entry_module_id": body.entry_module_id,
|
||||
"sort_order": int(body.sort_order or 0),
|
||||
"is_enabled": body.is_enabled,
|
||||
}
|
||||
|
||||
def _to_group_vo(self, row, bindings: list[RuleGroupBindingVO], include_rule_count: bool) -> EvaluationPointGroupVO:
|
||||
return EvaluationPointGroupVO(
|
||||
id=int(row["id"]),
|
||||
pid=self._normalize_pid(row.get("pid")),
|
||||
name=str(row.get("name") or ""),
|
||||
code=str(row.get("code") or ""),
|
||||
description=row.get("description"),
|
||||
document_type_id=int(row["document_type_id"]) if row.get("document_type_id") is not None else None,
|
||||
document_type_name=row.get("document_type_name"),
|
||||
entry_module_id=int(row["entry_module_id"]) if row.get("entry_module_id") is not None else None,
|
||||
entry_module_name=row.get("entry_module_name"),
|
||||
sort_order=int(row.get("sort_order") or 0),
|
||||
is_enabled=bool(row.get("is_enabled", True)),
|
||||
created_at=self._to_iso(row.get("created_at")),
|
||||
updated_at=self._to_iso(row.get("updated_at")),
|
||||
rule_count=len(bindings) if include_rule_count else None,
|
||||
bindings=bindings,
|
||||
children=None,
|
||||
)
|
||||
|
||||
def _normalize_pid(self, value: Any) -> int:
|
||||
if value in (None, "", "0", 0):
|
||||
return 0
|
||||
return int(value)
|
||||
|
||||
def _to_iso(self, value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
Reference in New Issue
Block a user