feat(ai_agent): 完善AI适配器和服务功能 - 添加OpenClaw和Hermes健康检查接口 - 实现OpenClaw工具调用功能 - 重构AI适配器使用常量定义 - 增加AI技能系统支持 - 更新配置文件中的默认模型提供者设置 refactor(scheduler): 使用常量替换硬编码值 - 将硬编码的actor值替换为ActorValue常量 - 将receive_id_type替换为FeishuReceiveIdType枚举 refactor(audit): 统一审计日志常量使用 - 将硬编码的actor、source、risk_level等值替换为对应常量 - 更新审核服务中的状态和操作常量引用 refactor(approvals): 标准化审批模块常量使用 - 将applicant默认值替换为ActorValue.API常量 - 使用ApprovalStatus常量替代硬编码状态值 - 更新审核操作常量引用 ```
321 lines
13 KiB
Python
321 lines
13 KiB
Python
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.constants import ActorValue
|
|
from app.modules.audit.constants import (
|
|
AuditAction,
|
|
AuditRiskLevel,
|
|
AuditSource,
|
|
AuditTargetType,
|
|
)
|
|
from app.modules.audit.schemas import AuditLogCreate
|
|
from app.modules.audit.service import AuditService
|
|
from app.modules.business.constants import (
|
|
CLOSED_RISK_STATUSES,
|
|
DONE_STATUSES,
|
|
PROJECT_CLOSED_STATUSES,
|
|
SUPPLIER_RISK_LEVELS,
|
|
BusinessDomain,
|
|
RiskEventType,
|
|
RiskLevel,
|
|
StatusValue,
|
|
)
|
|
from app.modules.business.models import FundAccount, Project, RiskEvent, Supplier, WorkTask
|
|
from app.modules.business.service import serialize_model
|
|
|
|
|
|
class RiskService:
|
|
"""Evaluate rule-based business risk signals from internal ledgers."""
|
|
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
def overdue_tasks(self) -> list[dict[str, Any]]:
|
|
stmt = select(WorkTask).where(
|
|
WorkTask.due_date.is_not(None),
|
|
WorkTask.due_date < date.today(),
|
|
WorkTask.status.notin_(DONE_STATUSES),
|
|
)
|
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
|
|
|
def delayed_projects(self) -> list[dict[str, Any]]:
|
|
stmt = select(Project).where(
|
|
Project.due_date.is_not(None),
|
|
Project.due_date < date.today(),
|
|
Project.status.notin_(PROJECT_CLOSED_STATUSES),
|
|
)
|
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
|
|
|
def over_budget_projects(self) -> list[dict[str, Any]]:
|
|
stmt = select(Project).where(
|
|
Project.budget_amount > 0,
|
|
Project.actual_amount > Project.budget_amount,
|
|
)
|
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
|
|
|
def fund_risks(self) -> list[dict[str, Any]]:
|
|
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
|
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
|
|
|
def supplier_risks(self) -> list[dict[str, Any]]:
|
|
stmt = select(Supplier).where(
|
|
(Supplier.blacklist_status != StatusValue.NORMAL)
|
|
| Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS)
|
|
)
|
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
|
|
|
def list_events(
|
|
self,
|
|
limit: int = 100,
|
|
status_filter: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(min(limit, 500))
|
|
if status_filter:
|
|
stmt = (
|
|
select(RiskEvent)
|
|
.where(RiskEvent.status == status_filter)
|
|
.order_by(RiskEvent.id.desc())
|
|
.limit(min(limit, 500))
|
|
)
|
|
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
|
|
|
|
def summary(self) -> dict[str, Any]:
|
|
overdue_tasks = self.overdue_tasks()
|
|
delayed_projects = self.delayed_projects()
|
|
over_budget_projects = self.over_budget_projects()
|
|
fund_risks = self.fund_risks()
|
|
supplier_risks = self.supplier_risks()
|
|
open_events = self.list_events(status_filter=StatusValue.OPEN)
|
|
risk_score = (
|
|
len(overdue_tasks) * 1
|
|
+ len(delayed_projects) * 3
|
|
+ len(over_budget_projects) * 4
|
|
+ len(fund_risks) * 5
|
|
+ len(supplier_risks) * 3
|
|
+ len(open_events) * 2
|
|
)
|
|
if risk_score >= 15:
|
|
level = RiskLevel.HIGH
|
|
elif risk_score >= 5:
|
|
level = RiskLevel.MEDIUM
|
|
else:
|
|
level = RiskLevel.LOW
|
|
return {
|
|
"risk_level": level,
|
|
"risk_score": Decimal(risk_score),
|
|
"overdue_tasks": overdue_tasks,
|
|
"delayed_projects": delayed_projects,
|
|
"over_budget_projects": over_budget_projects,
|
|
"fund_risks": fund_risks,
|
|
"supplier_risks": supplier_risks,
|
|
"open_events": open_events,
|
|
}
|
|
|
|
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
|
|
"""Generate or refresh risk-event ledger entries from current signals."""
|
|
|
|
payloads = self._build_event_payloads()
|
|
created = 0
|
|
updated = 0
|
|
skipped = 0
|
|
items: list[dict[str, Any]] = []
|
|
|
|
for payload in payloads:
|
|
record = self.db.execute(
|
|
select(RiskEvent).where(RiskEvent.code == payload["code"])
|
|
).scalar_one_or_none()
|
|
if record is None:
|
|
record = RiskEvent(**payload)
|
|
self.db.add(record)
|
|
self.db.flush()
|
|
created += 1
|
|
action = "created"
|
|
elif record.status in CLOSED_RISK_STATUSES:
|
|
skipped += 1
|
|
items.append({"action": "skipped", "risk_event": serialize_model(record)})
|
|
continue
|
|
else:
|
|
for key, value in payload.items():
|
|
if key != "code":
|
|
setattr(record, key, value)
|
|
updated += 1
|
|
action = "updated"
|
|
items.append({"action": action, "risk_event": serialize_model(record)})
|
|
|
|
self.db.commit()
|
|
AuditService(self.db).log(
|
|
AuditLogCreate(
|
|
actor=actor,
|
|
source=AuditSource.RISK,
|
|
action=AuditAction.GENERATE_EVENTS,
|
|
target_type=AuditTargetType.RISK_EVENTS,
|
|
risk_level=AuditRiskLevel.MEDIUM,
|
|
response_payload={
|
|
"created": created,
|
|
"updated": updated,
|
|
"skipped": skipped,
|
|
},
|
|
)
|
|
)
|
|
return {"created": created, "updated": updated, "skipped": skipped, "items": items}
|
|
|
|
def _build_event_payloads(self) -> list[dict[str, Any]]:
|
|
payloads: list[dict[str, Any]] = []
|
|
payloads.extend(self._overdue_task_payloads())
|
|
payloads.extend(self._delayed_project_payloads())
|
|
payloads.extend(self._over_budget_project_payloads())
|
|
payloads.extend(self._fund_risk_payloads())
|
|
payloads.extend(self._supplier_risk_payloads())
|
|
return payloads
|
|
|
|
def _overdue_task_payloads(self) -> list[dict[str, Any]]:
|
|
stmt = select(WorkTask).where(
|
|
WorkTask.due_date.is_not(None),
|
|
WorkTask.due_date < date.today(),
|
|
WorkTask.status.notin_(DONE_STATUSES),
|
|
)
|
|
payloads = []
|
|
for task in self.db.execute(stmt).scalars():
|
|
payloads.append(
|
|
{
|
|
"code": f"RISK-TASK-OVERDUE-{task.id}",
|
|
"title": f"任务逾期:{task.title}",
|
|
"risk_type": RiskEventType.OVERDUE_TASK,
|
|
"risk_level": RiskLevel.MEDIUM,
|
|
"status": StatusValue.OPEN,
|
|
"source_domain": BusinessDomain.TASKS,
|
|
"source_record_id": str(task.id),
|
|
"project_code": task.project_code,
|
|
"owner": task.owner,
|
|
"due_date": task.due_date,
|
|
"detected_at": datetime.utcnow(),
|
|
"description": "任务已超过截止日期且未完成。",
|
|
"mitigation": (
|
|
"请负责人更新进度、明确阻塞项并给出新的完成时间。"
|
|
),
|
|
"evidence": serialize_model(task),
|
|
}
|
|
)
|
|
return payloads
|
|
|
|
def _delayed_project_payloads(self) -> list[dict[str, Any]]:
|
|
stmt = select(Project).where(
|
|
Project.due_date.is_not(None),
|
|
Project.due_date < date.today(),
|
|
Project.status.notin_(PROJECT_CLOSED_STATUSES),
|
|
)
|
|
payloads = []
|
|
for project in self.db.execute(stmt).scalars():
|
|
level = RiskLevel.HIGH if project.progress_percent < 80 else RiskLevel.MEDIUM
|
|
payloads.append(
|
|
{
|
|
"code": f"RISK-PROJECT-DELAY-{project.id}",
|
|
"title": f"项目延期:{project.name}",
|
|
"risk_type": RiskEventType.DELAYED_PROJECT,
|
|
"risk_level": level,
|
|
"status": StatusValue.OPEN,
|
|
"source_domain": BusinessDomain.PROJECTS,
|
|
"source_record_id": str(project.id),
|
|
"project_code": project.code,
|
|
"owner": project.owner,
|
|
"due_date": project.due_date,
|
|
"detected_at": datetime.utcnow(),
|
|
"description": "项目已超过计划截止日期且未进入完成状态。",
|
|
"mitigation": (
|
|
"请项目负责人提交延期原因、资源需求和纠偏计划。"
|
|
),
|
|
"evidence": serialize_model(project),
|
|
}
|
|
)
|
|
return payloads
|
|
|
|
def _over_budget_project_payloads(self) -> list[dict[str, Any]]:
|
|
stmt = select(Project).where(
|
|
Project.budget_amount > 0,
|
|
Project.actual_amount > Project.budget_amount,
|
|
)
|
|
payloads = []
|
|
for project in self.db.execute(stmt).scalars():
|
|
payloads.append(
|
|
{
|
|
"code": f"RISK-PROJECT-BUDGET-{project.id}",
|
|
"title": f"项目超预算:{project.name}",
|
|
"risk_type": RiskEventType.OVER_BUDGET_PROJECT,
|
|
"risk_level": RiskLevel.HIGH,
|
|
"status": StatusValue.OPEN,
|
|
"source_domain": BusinessDomain.PROJECTS,
|
|
"source_record_id": str(project.id),
|
|
"project_code": project.code,
|
|
"owner": project.owner,
|
|
"due_date": project.due_date,
|
|
"detected_at": datetime.utcnow(),
|
|
"description": "项目实际成本已超过预算。",
|
|
"mitigation": (
|
|
"请复核预算科目、冻结非必要采购并补充审批依据。"
|
|
),
|
|
"evidence": serialize_model(project),
|
|
}
|
|
)
|
|
return payloads
|
|
|
|
def _fund_risk_payloads(self) -> list[dict[str, Any]]:
|
|
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
|
|
payloads = []
|
|
for account in self.db.execute(stmt).scalars():
|
|
payloads.append(
|
|
{
|
|
"code": f"RISK-FUND-{account.id}",
|
|
"title": f"资金低于安全线:{account.name}",
|
|
"risk_type": RiskEventType.FUND_SAFETY_LINE,
|
|
"risk_level": RiskLevel.HIGH,
|
|
"status": StatusValue.OPEN,
|
|
"source_domain": BusinessDomain.FUND_ACCOUNTS,
|
|
"source_record_id": str(account.id),
|
|
"owner": None,
|
|
"detected_at": datetime.utcnow(),
|
|
"description": "账户当前余额低于设置的安全线。",
|
|
"mitigation": (
|
|
"请财务确认收付款计划,"
|
|
"并优先处理关键项目资金安排。"
|
|
),
|
|
"evidence": serialize_model(account),
|
|
}
|
|
)
|
|
return payloads
|
|
|
|
def _supplier_risk_payloads(self) -> list[dict[str, Any]]:
|
|
stmt = select(Supplier).where(
|
|
(Supplier.blacklist_status != StatusValue.NORMAL)
|
|
| Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS)
|
|
)
|
|
payloads = []
|
|
for supplier in self.db.execute(stmt).scalars():
|
|
level = (
|
|
RiskLevel.HIGH
|
|
if supplier.blacklist_status != StatusValue.NORMAL
|
|
else supplier.risk_level
|
|
)
|
|
payloads.append(
|
|
{
|
|
"code": f"RISK-SUPPLIER-{supplier.id}",
|
|
"title": f"供应商风险:{supplier.name}",
|
|
"risk_type": RiskEventType.SUPPLIER_RISK,
|
|
"risk_level": level,
|
|
"status": StatusValue.OPEN,
|
|
"source_domain": BusinessDomain.SUPPLIERS,
|
|
"source_record_id": str(supplier.id),
|
|
"owner": supplier.contact,
|
|
"detected_at": datetime.utcnow(),
|
|
"description": "供应商风险等级或黑名单状态需要关注。",
|
|
"mitigation": (
|
|
"请采购负责人复核供应商准入、履约和替代方案。"
|
|
),
|
|
"evidence": serialize_model(supplier),
|
|
}
|
|
)
|
|
return payloads
|