"""评查点分组服务实现(新链路:文档类型 -> 子类型 -> 规则集)。""" from __future__ import annotations from datetime import datetime from typing import Any from sqlalchemy import bindparam, text from fastapi_common.fastapi_common_storage.oss_path_utils import OssPathUtils 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, EvaluationPointGroupRuleDraftCreateDTO, EvaluationPointGroupRebindDTO, EvaluationPointGroupUpdateDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointGroupVo import ( EvaluationPointGroupBatchDeleteVO, EvaluationPointGroupBatchStatusVO, EvaluationPointGroupDeleteVO, EvaluationPointGroupListVO, EvaluationPointGroupRuleDraftVO, EvaluationPointGroupRuleTemplateContextVO, EvaluationPointGroupRuleTemplateVO, 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 GetRuleServiceSingleton class EvaluationPointGroupServiceImpl(IEvaluationPointGroupService): """评查点分组服务实现。""" def __init__(self) -> None: self.RuleService = GetRuleServiceSingleton() 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 GetRuleTemplate(self, GroupId: int) -> EvaluationPointGroupRuleTemplateVO: async with GetAsyncSession() as session: await self._ensure_ready(session) context = await self._load_rule_context(session, GroupId) template = self._build_rule_yaml_template(context) existing_binding_id = await self._get_group_binding_id_for_rule_type(session, GroupId, context["rule_type"]) return EvaluationPointGroupRuleTemplateVO( context=EvaluationPointGroupRuleTemplateContextVO( group_id=context["group_id"], group_code=context["group_code"], group_name=context["group_name"], parent_group_id=context["parent_group_id"], parent_group_code=context["parent_group_code"], parent_group_name=context["parent_group_name"], document_type_id=context["document_type_id"], document_type_code=context["document_type_code"], document_type_name=context["document_type_name"], entry_module_id=context["entry_module_id"], entry_module_name=context["entry_module_name"], ), ruleType=context["rule_type"], ruleName=context["rule_name"], nextVersionNo=context["next_version_no"], ossPreviewKey=context["oss_preview_key"], yamlTemplate=template, existingRuleSetId=context["existing_rule_set_id"], existingBindingId=existing_binding_id, ) async def CreateRuleDraft(self, GroupId: int, Body: EvaluationPointGroupRuleDraftCreateDTO) -> EvaluationPointGroupRuleDraftVO: async with GetAsyncSession() as session: await self._ensure_ready(session) context = await self._load_rule_context(session, GroupId) yaml_text = Body.yaml_text.strip() if not yaml_text: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "YAML 内容不能为空") created_version = await self.RuleService.CreateVersion( RuleType=context["rule_type"], YamlText=yaml_text, ChangeNote=Body.change_note.strip() if Body.change_note else None, EditorUserId=Body.editor_user_id, ) async with GetAsyncSession() as session: await self._ensure_ready(session) rule_set_id = await self._get_rule_set_id_by_type(session, context["rule_type"]) if rule_set_id is None: raise LeauditException(StatusCodeEnum.HTTP_500_INTERNAL_SERVER_ERROR, "规则版本已创建,但未找到对应规则集") binding_id, auto_bound = await self._ensure_group_binding_for_rule_set(session, GroupId, rule_set_id) await sync_doc_type_bindings_from_group(session, GroupId) await session.commit() binding_row = await self._get_binding_row(session, binding_id) binding_vo = await self._build_binding_vo(binding_row) return EvaluationPointGroupRuleDraftVO( packId=GroupId, groupId=GroupId, ruleName=context["rule_name"], ruleType=context["rule_type"], ruleSetId=rule_set_id, ossKey=OssPathUtils.BuildRuleYamlKey(context["rule_type"], created_version.versionNo), version=created_version, binding=binding_vo, autoBound=auto_bound, ) 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.code AS document_type_code, dt.name AS document_type_name, parent.id AS root_group_id, parent.name AS root_group_name, parent.code AS root_group_code, 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, "规则集不存在") async def _load_rule_context(self, session, group_id: int) -> dict[str, Any]: row = await self._get_group_row(session, group_id) if self._normalize_pid(row["pid"]) == 0: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "请先选择二级分组,再创建规则 YAML") rule_type = self._resolve_rule_type(row) if not rule_type: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前二级分组缺少可推导的规则类型编码,请先补充分组编码或文档类型编码") rule_name = str(row.get("document_type_name") or row.get("name") or "").strip() if not rule_name: raise LeauditException(StatusCodeEnum.HTTP_400_BAD_REQUEST, "当前二级分组缺少规则名称,请先补充分组名称") next_version_no = await self._get_next_rule_version_no(session, rule_type) return { "group_id": int(row["id"]), "group_code": str(row.get("code") or "").strip(), "group_name": str(row.get("name") or ""), "parent_group_id": int(row.get("root_group_id") or 0), "parent_group_code": str(row.get("root_group_code") or "").strip(), "parent_group_name": str(row.get("root_group_name") or row.get("entry_module_name") or ""), "document_type_id": int(row["document_type_id"]) if row.get("document_type_id") is not None else None, "document_type_code": str(row.get("document_type_code") or "").strip() or None, "document_type_name": str(row.get("document_type_name") or "").strip() or None, "entry_module_id": int(row["entry_module_id"]) if row.get("entry_module_id") is not None else None, "entry_module_name": str(row.get("entry_module_name") or "").strip() or None, "rule_type": rule_type, "rule_name": rule_name, "next_version_no": next_version_no, "oss_preview_key": OssPathUtils.BuildRuleYamlKey(rule_type, next_version_no), "existing_rule_set_id": await self._get_rule_set_id_by_type(session, rule_type), } def _resolve_rule_type(self, row: Any) -> str: doc_type_code = str(row.get("document_type_code") or "").strip() if doc_type_code: return self._normalize_rule_code(doc_type_code) child_code = str(row.get("code") or "").strip() root_code = str(row.get("root_group_code") or "").strip() if child_code and root_code and child_code != root_code and not child_code.startswith(f"{root_code}."): return self._normalize_rule_code(f"{root_code}.{child_code}") if child_code: return self._normalize_rule_code(child_code) group_name = str(row.get("name") or "").strip() if root_code and group_name: return self._normalize_rule_code(f"{root_code}.{group_name}") if group_name: return self._normalize_rule_code(group_name) return "" def _normalize_rule_code(self, value: str) -> str: normalized = ".".join(segment.strip() for segment in value.strip().split(".") if segment.strip()) if not normalized: return "" return normalized.replace("/", "_").replace("\\", "_").replace(" ", "_") async def _get_next_rule_version_no(self, session, rule_type: str) -> str: row = ( await session.execute( text( """ SELECT COALESCE(MAX(rv.version_seq), 0) AS max_seq FROM leaudit_rule_versions rv JOIN leaudit_rule_sets rs ON rs.id = rv.rule_set_id WHERE rs.rule_type = :rule_type AND rs.deleted_at IS NULL """ ), {"rule_type": rule_type}, ) ).mappings().first() next_seq = int((row or {}).get("max_seq") or 0) + 1 return f"v{next_seq}" def _build_rule_yaml_template(self, context: dict[str, Any]) -> str: keywords = [ item for item in dict.fromkeys( [ context.get("parent_group_name"), context.get("group_name"), context.get("document_type_name"), context.get("entry_module_name"), ] ) if item ] keyword_lines = "\n".join(f" - {item}" for item in keywords) or " - 待补充" description = ( f"{context['rule_name']} 规则模板。" f" 入口模块:{context.get('entry_module_name') or '未配置'};" f" 一级分组:{context.get('parent_group_name') or '未配置'};" f" 二级分组:{context.get('group_name') or '未配置'}。" ) parent_type = context.get("parent_group_name") or context.get("group_name") or context["rule_type"] return ( "metadata:\n" f" type_id: {context['rule_type']}\n" f" name: {context['rule_name']}\n" f" version: {context['next_version_no']}\n" f" last_updated: '{datetime.now().date().isoformat()}'\n" f" parent: {parent_type}\n" " classification_keywords:\n" f"{keyword_lines}\n" " description: >\n" f" {description}\n\n" "sub_documents: []\n" "rules: []\n" ) async def _get_group_binding_id_for_rule_type(self, session, group_id: int, rule_type: str) -> int | None: rule_set_id = await self._get_rule_set_id_by_type(session, rule_type) if rule_set_id is None: return None row = ( 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": group_id, "rule_set_id": rule_set_id}, ) ).mappings().first() return int(row["id"]) if row else None async def _get_rule_set_id_by_type(self, session, rule_type: str) -> int | None: row = ( await session.execute( text("SELECT id FROM leaudit_rule_sets WHERE rule_type = :rule_type AND deleted_at IS NULL LIMIT 1"), {"rule_type": rule_type}, ) ).mappings().first() return int(row["id"]) if row else None async def _ensure_group_binding_for_rule_set(self, session, group_id: int, rule_set_id: int) -> tuple[int, bool]: 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": group_id, "rule_set_id": rule_set_id}, ) ).mappings().first() if existing: return int(existing["id"]), False 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, 100, TRUE, :note, NOW(), NOW() ) RETURNING id """ ), { "group_id": group_id, "rule_set_id": rule_set_id, "note": "由二级分组新建规则 YAML 时自动补绑", }, ) ).mappings().one() return int(row["id"]), True 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)