from __future__ import annotations import json import os from datetime import datetime from decimal import Decimal from typing import Any from urllib.parse import quote_plus from sqlalchemy import text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from fastapi_admin.config import DB_HOST, DB_PASSWORD, DB_PORT, DB_USER 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.evaluationPointDto import ( EvaluationPointCreateDTO, EvaluationPointUpdateDTO, ) from fastapi_modules.fastapi_leaudit.domian.vo.evaluationPointVo import ( AttributeTypeListVO, AttributeTypeVO, EvaluationPointDeleteVO, EvaluationPointListVO, EvaluationPointVO, ) from fastapi_modules.fastapi_leaudit.services.evaluationPointService import IEvaluationPointService _LEGACY_DB_NAME = os.getenv("LEGACY_RULE_DB_NAME", "docauditai") _LEGACY_DB_URL = ( f"postgresql+asyncpg://{quote_plus(str(DB_USER))}:{quote_plus(str(DB_PASSWORD))}" f"@{DB_HOST}:{DB_PORT}/{quote_plus(_LEGACY_DB_NAME)}" ) _LEGACY_ENGINE = create_async_engine(_LEGACY_DB_URL, pool_pre_ping=True) _LegacySession = async_sessionmaker(_LEGACY_ENGINE, expire_on_commit=False) class EvaluationPointServiceImpl(IEvaluationPointService): """评查点服务实现。""" async def ListPoints( self, Name: str | None, Code: str | None, Risk: str | None, IsEnabled: bool | None, GroupPid: int | None, GroupId: int | None, DocumentAttributeType: str | None, Area: str | None, Page: int, PageSize: int, ) -> EvaluationPointListVO: offset = max(Page - 1, 0) * PageSize where_clause, params = self._build_list_filters( Name=Name, Code=Code, Risk=Risk, IsEnabled=IsEnabled, GroupPid=GroupPid, GroupId=GroupId, DocumentAttributeType=DocumentAttributeType, Area=Area, ) params.update({"limit": PageSize, "offset": offset}) async with _LegacySession() as session: total = int( ( await session.execute( text( f""" SELECT COUNT(*) FROM evaluation_points ep LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) WHERE {where_clause} """ ), params, ) ).scalar_one() ) rows = ( await session.execute( text( f""" SELECT ep.id, ep.code, ep.name, ep.evaluation_point_groups_id, ep.evaluation_point_groups_pid, ep.risk, ep.description, ep.is_enabled, ep.document_attribute_type, ep.references_laws, ep.extraction_config, ep.evaluation_config, ep.pass_message, ep.fail_message, ep.suggestion_message, ep.suggestion_message_type, ep.post_action, ep.action_config, ep.score, ep.area, ep.created_at, ep.updated_at, child_group.name AS group_name, parent_group.name AS rule_type FROM evaluation_points ep LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) WHERE {where_clause} ORDER BY COALESCE(ep.sort, 0) ASC, ep.updated_at DESC, ep.id DESC LIMIT :limit OFFSET :offset """ ), params, ) ).mappings().all() return EvaluationPointListVO( data=[self._to_point_vo(row) for row in rows], total=total, page=Page, page_size=PageSize, ) async def GetPoint(self, PointId: int) -> EvaluationPointVO: async with _LegacySession() as session: row = ( await session.execute( text( """ SELECT ep.id, ep.code, ep.name, ep.evaluation_point_groups_id, ep.evaluation_point_groups_pid, ep.risk, ep.description, ep.is_enabled, ep.document_attribute_type, ep.references_laws, ep.extraction_config, ep.evaluation_config, ep.pass_message, ep.fail_message, ep.suggestion_message, ep.suggestion_message_type, ep.post_action, ep.action_config, ep.score, ep.area, ep.created_at, ep.updated_at, child_group.name AS group_name, parent_group.name AS rule_type FROM evaluation_points ep LEFT JOIN evaluation_point_groups child_group ON child_group.id = ep.evaluation_point_groups_id LEFT JOIN evaluation_point_groups parent_group ON parent_group.id = COALESCE(ep.evaluation_point_groups_pid, child_group.pid) WHERE ep.id = :point_id """ ), {"point_id": PointId}, ) ).mappings().first() if not row: raise LeauditException(StatusCodeEnum.NOT_FOUND, "评查点不存在") return self._to_point_vo(row) async def CreatePoint(self, Body: EvaluationPointCreateDTO) -> EvaluationPointVO: await self._validate_group_relation(Body.evaluation_point_groups_pid, Body.evaluation_point_groups_id) await self._ensure_code_unique(str(Body.code).strip()) now = datetime.utcnow() insert_params = self._build_write_params(Body, now) insert_params["created_at"] = now insert_params["updated_at"] = now async with _LegacySession() as session: async with session.begin(): new_id = await session.scalar( text( """ INSERT INTO evaluation_points ( code, name, evaluation_point_groups_id, evaluation_point_groups_pid, risk, description, is_enabled, document_attribute_type, references_laws, extraction_config, evaluation_config, pass_message, fail_message, suggestion_message, suggestion_message_type, post_action, action_config, score, area, created_at, updated_at ) VALUES ( :code, :name, :evaluation_point_groups_id, :evaluation_point_groups_pid, :risk, :description, :is_enabled, :document_attribute_type, CAST(:references_laws AS jsonb), CAST(:extraction_config AS jsonb), CAST(:evaluation_config AS jsonb), :pass_message, :fail_message, :suggestion_message, :suggestion_message_type, :post_action, :action_config, :score, :area, :created_at, :updated_at ) RETURNING id """ ), insert_params, ) await session.commit() return await self.GetPoint(int(new_id)) async def UpdatePoint(self, PointId: int, Body: EvaluationPointUpdateDTO) -> EvaluationPointVO: await self.GetPoint(PointId) payload = Body.model_dump(exclude_unset=True) if not payload: return await self.GetPoint(PointId) group_pid = payload.get("evaluation_point_groups_pid") group_id = payload.get("evaluation_point_groups_id") if group_pid is not None or group_id is not None: current = await self.GetPoint(PointId) await self._validate_group_relation( group_pid if group_pid is not None else current.evaluation_point_groups_pid, group_id if group_id is not None else current.evaluation_point_groups_id, ) if "code" in payload and payload["code"]: await self._ensure_code_unique(str(payload["code"]).strip(), PointId) updates: list[str] = [] params: dict[str, Any] = {"point_id": PointId, "updated_at": datetime.utcnow()} simple_fields = [ "code", "name", "evaluation_point_groups_id", "evaluation_point_groups_pid", "risk", "description", "is_enabled", "document_attribute_type", "pass_message", "fail_message", "suggestion_message", "suggestion_message_type", "post_action", "action_config", "score", "area", ] json_fields = ["references_laws", "extraction_config", "evaluation_config"] for field in simple_fields: if field not in payload: continue params[field] = self._normalize_scalar_field(field, payload[field]) updates.append(f"{field} = :{field}") for field in json_fields: if field not in payload: continue params[field] = json.dumps(payload[field] if payload[field] is not None else self._default_json(field), ensure_ascii=False) updates.append(f"{field} = CAST(:{field} AS jsonb)") updates.append("updated_at = :updated_at") async with _LegacySession() as session: async with session.begin(): await session.execute( text(f"UPDATE evaluation_points SET {', '.join(updates)} WHERE id = :point_id"), params, ) await session.commit() return await self.GetPoint(PointId) async def DeletePoint(self, PointId: int) -> EvaluationPointDeleteVO: await self.GetPoint(PointId) async with _LegacySession() as session: async with session.begin(): await session.execute(text("DELETE FROM evaluation_points WHERE id = :point_id"), {"point_id": PointId}) await session.commit() return EvaluationPointDeleteVO(success=True, message="评查点删除成功") async def GetAttributeTypes(self) -> AttributeTypeListVO: async with _LegacySession() as session: rows = ( await session.execute( text( """ SELECT DISTINCT TRIM(document_attribute_type) AS code FROM evaluation_points WHERE document_attribute_type IS NOT NULL AND TRIM(document_attribute_type) <> '' ORDER BY TRIM(document_attribute_type) ASC """ ) ) ).scalars().all() types = [AttributeTypeVO(code=str(item), label=str(item)) for item in rows if item] if not any(item.code == "ALL" for item in types): types.insert(0, AttributeTypeVO(code="ALL", label="通用")) return AttributeTypeListVO(types=types) def _build_list_filters( self, Name: str | None, Code: str | None, Risk: str | None, IsEnabled: bool | None, GroupPid: int | None, GroupId: int | None, DocumentAttributeType: str | None, Area: str | None, ) -> tuple[str, dict[str, Any]]: filters = ["1=1"] params: dict[str, Any] = {} name = (Name or "").strip() code = (Code or "").strip() if name and code: if name == code: filters.append("(ep.name ILIKE :keyword OR ep.code ILIKE :keyword)") params["keyword"] = f"%{name}%" else: filters.append("ep.name ILIKE :name") filters.append("ep.code ILIKE :code") params["name"] = f"%{name}%" params["code"] = f"%{code}%" elif name: filters.append("ep.name ILIKE :name") params["name"] = f"%{name}%" elif code: filters.append("ep.code ILIKE :code") params["code"] = f"%{code}%" if Risk: filters.append("ep.risk = :risk") params["risk"] = Risk if IsEnabled is not None: filters.append("ep.is_enabled = :is_enabled") params["is_enabled"] = IsEnabled if GroupPid is not None: filters.append("ep.evaluation_point_groups_pid = :group_pid") params["group_pid"] = GroupPid if GroupId is not None: filters.append("ep.evaluation_point_groups_id = :group_id") params["group_id"] = GroupId if DocumentAttributeType: if DocumentAttributeType == "ALL": filters.append("(ep.document_attribute_type = 'ALL' OR ep.document_attribute_type = '通用' OR ep.document_attribute_type IS NULL OR TRIM(ep.document_attribute_type) = '')") else: filters.append("ep.document_attribute_type = :document_attribute_type") params["document_attribute_type"] = DocumentAttributeType if Area: filters.append("ep.area = :area") params["area"] = Area return " AND ".join(filters), params async def _validate_group_relation(self, GroupPid: int | None, GroupId: int | None) -> None: if not GroupPid: raise LeauditException(StatusCodeEnum.BAD_REQUEST, "评查点类型不能为空") if not GroupId: raise LeauditException(StatusCodeEnum.BAD_REQUEST, "所属规则组不能为空") async with _LegacySession() as session: parent = ( await session.execute( text("SELECT id, pid, is_enabled FROM evaluation_point_groups WHERE id = :group_pid"), {"group_pid": GroupPid}, ) ).mappings().first() child = ( await session.execute( text("SELECT id, pid, is_enabled FROM evaluation_point_groups WHERE id = :group_id"), {"group_id": GroupId}, ) ).mappings().first() if not parent: raise LeauditException(StatusCodeEnum.BAD_REQUEST, "评查点类型不存在") if not child: raise LeauditException(StatusCodeEnum.BAD_REQUEST, "所属规则组不存在") if int(child.get("pid") or 0) != int(GroupPid): raise LeauditException(StatusCodeEnum.BAD_REQUEST, "所属规则组与评查点类型不匹配") async def _ensure_code_unique(self, Code: str, PointId: int | None = None) -> None: params: dict[str, Any] = {"code": Code} sql = "SELECT id FROM evaluation_points WHERE LOWER(code) = LOWER(:code)" if PointId is not None: sql += " AND id <> :point_id" params["point_id"] = PointId async with _LegacySession() as session: exists = ( await session.execute(text(sql), params) ).scalar_one_or_none() if exists: raise LeauditException(StatusCodeEnum.BAD_REQUEST, "评查点编码已存在") def _build_write_params(self, Body: EvaluationPointCreateDTO, Now: datetime) -> dict[str, Any]: return { "code": str(Body.code).strip(), "name": str(Body.name).strip(), "evaluation_point_groups_id": Body.evaluation_point_groups_id, "evaluation_point_groups_pid": Body.evaluation_point_groups_pid, "risk": Body.risk, "description": Body.description or "", "is_enabled": bool(Body.is_enabled), "document_attribute_type": self._normalize_document_attribute_type(Body.document_attribute_type), "references_laws": json.dumps(Body.references_laws or self._default_json("references_laws"), ensure_ascii=False), "extraction_config": json.dumps(Body.extraction_config or self._default_json("extraction_config"), ensure_ascii=False), "evaluation_config": json.dumps(Body.evaluation_config or self._default_json("evaluation_config"), ensure_ascii=False), "pass_message": Body.pass_message or "", "fail_message": Body.fail_message or "", "suggestion_message": Body.suggestion_message or "", "suggestion_message_type": Body.suggestion_message_type or "warning", "post_action": Body.post_action or "none", "action_config": Body.action_config or "", "score": float(Body.score or 0), "area": (Body.area or "").strip() or None, "created_at": Now, "updated_at": Now, } def _to_point_vo(self, row: dict[str, Any]) -> EvaluationPointVO: group_id = row.get("evaluation_point_groups_id") return EvaluationPointVO( id=int(row["id"]), code=str(row.get("code") or ""), name=str(row.get("name") or ""), evaluation_point_groups_id=int(group_id) if group_id is not None else None, evaluation_point_groups_pid=int(row["evaluation_point_groups_pid"]) if row.get("evaluation_point_groups_pid") is not None else None, ruleType=str(row.get("rule_type") or ""), groupName=str(row.get("group_name") or ""), groupId=str(group_id) if group_id is not None else "", risk=str(row.get("risk") or ""), description=str(row.get("description") or ""), is_enabled=bool(row.get("is_enabled")), document_attribute_type=self._normalize_document_attribute_type(row.get("document_attribute_type")), references_laws=self._parse_json(row.get("references_laws"), self._default_json("references_laws")), extraction_config=self._parse_json(row.get("extraction_config"), self._default_json("extraction_config")), evaluation_config=self._parse_json(row.get("evaluation_config"), self._default_json("evaluation_config")), pass_message=str(row.get("pass_message") or ""), fail_message=str(row.get("fail_message") or ""), suggestion_message=str(row.get("suggestion_message") or ""), suggestion_message_type=str(row.get("suggestion_message_type") or "warning"), post_action=str(row.get("post_action") or "none"), action_config=str(row.get("action_config") or ""), score=self._normalize_score(row.get("score")), area=str(row.get("area") or ""), created_at=self._format_datetime(row.get("created_at")), updated_at=self._format_datetime(row.get("updated_at")), ) def _parse_json(self, value: Any, default: Any) -> Any: if value is None: return default if isinstance(value, (dict, list)): return value if isinstance(value, str): try: return json.loads(value) except json.JSONDecodeError: return default return value def _format_datetime(self, value: Any) -> str | None: if value is None: return None if isinstance(value, datetime): return value.isoformat() return str(value) def _normalize_document_attribute_type(self, value: Any) -> str: normalized = str(value or "").strip() if not normalized: return "通用" if normalized == "ALL": return "通用" return normalized def _normalize_score(self, value: Any) -> float: if value is None: return 0 if isinstance(value, Decimal): return float(value) if isinstance(value, (int, float)): return float(value) try: return float(value) except (TypeError, ValueError): return 0 def _normalize_scalar_field(self, field: str, value: Any) -> Any: if field in {"code", "name"}: return str(value or "").strip() if field == "document_attribute_type": return self._normalize_document_attribute_type(value) if field == "area": area = str(value or "").strip() return area or None if field == "score": return self._normalize_score(value) return value def _default_json(self, field: str) -> dict[str, Any]: if field == "references_laws": return {"name": "", "content": "", "articles": []} if field == "evaluation_config": return {"logicType": "and", "customLogic": "", "rules": []} return { "llm": {"fields": [], "prompt_setting": {"type": "system", "template": ""}}, "vlm": {"fields": [], "prompt_setting": {"type": "system", "template": ""}}, "regex": {"fields": []}, }