from datetime import date 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.core.pagination import bounded_limit from app.core.time import utc_now 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, GENERATED_RISK_EVENT_TYPES, PROJECT_CLOSED_STATUSES, SUPPLIER_RISK_LEVELS, BusinessDomain, RiskEventType, RiskLevel, StatusValue, ) from app.modules.business.models import ( FundAccount, Project, RiskEvent, RiskEventAction, Supplier, WorkTask, ) from app.modules.business.service import serialize_model from app.modules.events.constants import ( EventAggregateType, EventPayloadKey, EventSource, EventType, ) from app.modules.events.service import EventService from app.modules.risk.constants import ( RISK_SCORE_WEIGHTS, RiskEventActionKey, RiskEventActionValue, RiskErrorDetail, RiskGenerationAction, RiskGenerationResultKey, RiskEventPayloadKey, RiskSummaryKey, risk_level_for_score, ) 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]]: limit_value = bounded_limit(limit) stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(limit_value) if status_filter: stmt = ( select(RiskEvent) .where(RiskEvent.status == status_filter) .order_by(RiskEvent.id.desc()) .limit(limit_value) ) return [serialize_model(item) for item in self.db.execute(stmt).scalars()] def list_actions(self, risk_event_id: int, limit: int = 100) -> list[dict[str, Any]]: self._get_event(risk_event_id) stmt = ( select(RiskEventAction) .where(RiskEventAction.risk_event_id == risk_event_id) .order_by(RiskEventAction.id.desc()) .limit(bounded_limit(limit)) ) return [serialize_model(item) for item in self.db.execute(stmt).scalars()] def assign_event( self, risk_event_id: int, assigned_to: str, comment: str | None = None, actor: str = ActorValue.API, ) -> dict[str, Any]: record = self._get_event(risk_event_id) from_status = record.status record.assigned_to = assigned_to action = self._record_action( record, RiskEventActionValue.ASSIGN, actor, from_status, record.status, comment, {RiskEventActionKey.ASSIGNED_TO: assigned_to}, ) self.db.commit() self.db.refresh(record) self.db.refresh(action) return self._action_response(record, action) def comment_event( self, risk_event_id: int, comment: str, payload: dict[str, Any] | None = None, actor: str = ActorValue.API, ) -> dict[str, Any]: record = self._get_event(risk_event_id) action = self._record_action( record, RiskEventActionValue.COMMENT, actor, record.status, record.status, comment, payload or {}, ) self.db.commit() self.db.refresh(record) self.db.refresh(action) return self._action_response(record, action) def resolve_event( self, risk_event_id: int, comment: str | None = None, payload: dict[str, Any] | None = None, actor: str = ActorValue.API, ) -> dict[str, Any]: record = self._get_event(risk_event_id) from_status = record.status record.status = StatusValue.RESOLVED record.resolved_at = utc_now() action = self._record_action( record, RiskEventActionValue.RESOLVE, actor, from_status, record.status, comment, payload or {}, ) self.db.commit() self.db.refresh(record) self.db.refresh(action) return self._action_response(record, action) def close_event( self, risk_event_id: int, closed_reason: str, review_summary: str | None = None, actor: str = ActorValue.API, ) -> dict[str, Any]: record = self._get_event(risk_event_id) from_status = record.status now = utc_now() record.status = StatusValue.CLOSED record.closed_reason = closed_reason record.review_summary = review_summary record.closed_at = now if record.resolved_at is None: record.resolved_at = now action = self._record_action( record, RiskEventActionValue.CLOSE, actor, from_status, record.status, closed_reason, {RiskEventActionKey.REVIEW_SUMMARY: review_summary}, ) self.db.commit() self.db.refresh(record) self.db.refresh(action) return self._action_response(record, action) def reopen_event( self, risk_event_id: int, comment: str | None = None, actor: str = ActorValue.API, ) -> dict[str, Any]: record = self._get_event(risk_event_id) from_status = record.status record.status = StatusValue.OPEN record.resolved_at = None record.closed_at = None action = self._record_action( record, RiskEventActionValue.REOPEN, actor, from_status, record.status, comment, {}, ) self.db.commit() self.db.refresh(record) self.db.refresh(action) return self._action_response(record, action) 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) external_open_events = [ item for item in open_events if item.get(RiskEventPayloadKey.RISK_TYPE) not in GENERATED_RISK_EVENT_TYPES ] risk_score = ( len(overdue_tasks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OVERDUE_TASKS] + len(delayed_projects) * RISK_SCORE_WEIGHTS[RiskSummaryKey.DELAYED_PROJECTS] + len(over_budget_projects) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OVER_BUDGET_PROJECTS] + len(fund_risks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.FUND_RISKS] + len(supplier_risks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.SUPPLIER_RISKS] + len(external_open_events) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OPEN_EVENTS] ) return { RiskSummaryKey.RISK_LEVEL: risk_level_for_score(risk_score), RiskSummaryKey.RISK_SCORE: Decimal(risk_score), RiskSummaryKey.OVERDUE_TASKS: overdue_tasks, RiskSummaryKey.DELAYED_PROJECTS: delayed_projects, RiskSummaryKey.OVER_BUDGET_PROJECTS: over_budget_projects, RiskSummaryKey.FUND_RISKS: fund_risks, RiskSummaryKey.SUPPLIER_RISKS: supplier_risks, RiskSummaryKey.OPEN_EVENTS: open_events, } def _get_event(self, risk_event_id: int) -> RiskEvent: record = self.db.get(RiskEvent, risk_event_id) if record is None: from fastapi import HTTPException, status raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=RiskErrorDetail.RISK_EVENT_NOT_FOUND, ) return record def _record_action( self, record: RiskEvent, action: str, actor: str, from_status: str | None, to_status: str | None, comment: str | None, payload: dict[str, Any], ) -> RiskEventAction: action_record = RiskEventAction( code=f"RISK-ACTION-{utc_now():%Y%m%d%H%M%S%f}", risk_event_id=record.id, action=action, actor=actor, from_status=from_status, to_status=to_status, assigned_to=record.assigned_to, comment=comment, payload=payload, ) self.db.add(action_record) AuditService(self.db).log( AuditLogCreate( actor=actor, source=AuditSource.RISK, action=AuditAction.RISK_EVENT_ACTION, target_type=BusinessDomain.RISK_EVENTS, target_id=str(record.id), risk_level=AuditRiskLevel.MEDIUM, request_payload={ RiskEventActionKey.ACTION: action, RiskEventActionKey.FROM_STATUS: from_status, RiskEventActionKey.TO_STATUS: to_status, RiskEventActionKey.COMMENT: comment, RiskEventActionKey.PAYLOAD: payload, }, ) ) EventService(self.db).emit( event_type=EventType.RISK_ACTION_RECORDED, source=EventSource.RISK, aggregate_type=EventAggregateType.RISK_EVENT, aggregate_id=record.id, actor=actor, payload={ EventPayloadKey.ACTION: action, EventPayloadKey.STATUS: to_status, EventPayloadKey.RECORD_ID: str(record.id), RiskEventActionKey.FROM_STATUS: from_status, RiskEventActionKey.TO_STATUS: to_status, RiskEventActionKey.COMMENT: comment, RiskEventActionKey.PAYLOAD: payload, }, idempotency_key=f"risk:{record.id}:{action_record.code}", dispatch=True, ) return action_record @staticmethod def _action_response(record: RiskEvent, action: RiskEventAction) -> dict[str, Any]: return { RiskEventActionKey.RISK_EVENT: serialize_model(record), RiskEventActionKey.ACTION_RECORD: serialize_model(action), } 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[RiskEventPayloadKey.CODE]) ).scalar_one_or_none() if record is None: record = RiskEvent(**payload) self.db.add(record) self.db.flush() created += 1 action = RiskGenerationAction.CREATED elif record.status in CLOSED_RISK_STATUSES: skipped += 1 items.append( { RiskGenerationResultKey.ACTION: RiskGenerationAction.SKIPPED, RiskGenerationResultKey.RISK_EVENT: serialize_model(record), } ) continue else: for key, value in payload.items(): if key != RiskEventPayloadKey.CODE: setattr(record, key, value) updated += 1 action = RiskGenerationAction.UPDATED items.append( { RiskGenerationResultKey.ACTION: action, RiskGenerationResultKey.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={ RiskGenerationResultKey.CREATED: created, RiskGenerationResultKey.UPDATED: updated, RiskGenerationResultKey.SKIPPED: skipped, }, ) ) return { RiskGenerationResultKey.CREATED: created, RiskGenerationResultKey.UPDATED: updated, RiskGenerationResultKey.SKIPPED: skipped, RiskGenerationResultKey.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( { RiskEventPayloadKey.CODE: f"RISK-TASK-OVERDUE-{task.id}", RiskEventPayloadKey.TITLE: f"任务逾期:{task.title}", RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVERDUE_TASK, RiskEventPayloadKey.RISK_LEVEL: RiskLevel.MEDIUM, RiskEventPayloadKey.STATUS: StatusValue.OPEN, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.TASKS, RiskEventPayloadKey.SOURCE_RECORD_ID: str(task.id), RiskEventPayloadKey.PROJECT_CODE: task.project_code, RiskEventPayloadKey.OWNER: task.owner, RiskEventPayloadKey.DUE_DATE: task.due_date, RiskEventPayloadKey.DETECTED_AT: utc_now(), RiskEventPayloadKey.DESCRIPTION: "任务已超过截止日期且未完成。", RiskEventPayloadKey.MITIGATION: ( "请负责人更新进度、明确阻塞项并给出新的完成时间。" ), RiskEventPayloadKey.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( { RiskEventPayloadKey.CODE: f"RISK-PROJECT-DELAY-{project.id}", RiskEventPayloadKey.TITLE: f"项目延期:{project.name}", RiskEventPayloadKey.RISK_TYPE: RiskEventType.DELAYED_PROJECT, RiskEventPayloadKey.RISK_LEVEL: level, RiskEventPayloadKey.STATUS: StatusValue.OPEN, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS, RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id), RiskEventPayloadKey.PROJECT_CODE: project.code, RiskEventPayloadKey.OWNER: project.owner, RiskEventPayloadKey.DUE_DATE: project.due_date, RiskEventPayloadKey.DETECTED_AT: utc_now(), RiskEventPayloadKey.DESCRIPTION: "项目已超过计划截止日期且未进入完成状态。", RiskEventPayloadKey.MITIGATION: ( "请项目负责人提交延期原因、资源需求和纠偏计划。" ), RiskEventPayloadKey.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( { RiskEventPayloadKey.CODE: f"RISK-PROJECT-BUDGET-{project.id}", RiskEventPayloadKey.TITLE: f"项目超预算:{project.name}", RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVER_BUDGET_PROJECT, RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH, RiskEventPayloadKey.STATUS: StatusValue.OPEN, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS, RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id), RiskEventPayloadKey.PROJECT_CODE: project.code, RiskEventPayloadKey.OWNER: project.owner, RiskEventPayloadKey.DUE_DATE: project.due_date, RiskEventPayloadKey.DETECTED_AT: utc_now(), RiskEventPayloadKey.DESCRIPTION: "项目实际成本已超过预算。", RiskEventPayloadKey.MITIGATION: ( "请复核预算科目、冻结非必要采购并补充审批依据。" ), RiskEventPayloadKey.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( { RiskEventPayloadKey.CODE: f"RISK-FUND-{account.id}", RiskEventPayloadKey.TITLE: f"资金低于安全线:{account.name}", RiskEventPayloadKey.RISK_TYPE: RiskEventType.FUND_SAFETY_LINE, RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH, RiskEventPayloadKey.STATUS: StatusValue.OPEN, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.FUND_ACCOUNTS, RiskEventPayloadKey.SOURCE_RECORD_ID: str(account.id), RiskEventPayloadKey.OWNER: None, RiskEventPayloadKey.DETECTED_AT: utc_now(), RiskEventPayloadKey.DESCRIPTION: "账户当前余额低于设置的安全线。", RiskEventPayloadKey.MITIGATION: ( "请财务确认收付款计划," "并优先处理关键项目资金安排。" ), RiskEventPayloadKey.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( { RiskEventPayloadKey.CODE: f"RISK-SUPPLIER-{supplier.id}", RiskEventPayloadKey.TITLE: f"供应商风险:{supplier.name}", RiskEventPayloadKey.RISK_TYPE: RiskEventType.SUPPLIER_RISK, RiskEventPayloadKey.RISK_LEVEL: level, RiskEventPayloadKey.STATUS: StatusValue.OPEN, RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.SUPPLIERS, RiskEventPayloadKey.SOURCE_RECORD_ID: str(supplier.id), RiskEventPayloadKey.OWNER: supplier.contact, RiskEventPayloadKey.DETECTED_AT: utc_now(), RiskEventPayloadKey.DESCRIPTION: "供应商风险等级或黑名单状态需要关注。", RiskEventPayloadKey.MITIGATION: ( "请采购负责人复核供应商准入、履约和替代方案。" ), RiskEventPayloadKey.EVIDENCE: serialize_model(supplier), } ) return payloads