from datetime import date from decimal import Decimal from typing import Any from fastapi import HTTPException, status 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.approvals.constants import ApprovalActionValue, approval_action from app.modules.approvals.service import ApprovalService from app.modules.business.constants import ( CLOSED_RISK_STATUSES, DONE_STATUSES, GENERATED_RISK_EVENT_TYPES, PROJECT_CLOSED_STATUSES, SUPPLIER_RISK_LEVELS, BusinessErrorDetail, 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, approval_ticket_id: str | None = None, ) -> dict[str, Any]: record = self._get_event(risk_event_id) approval_payload = { RiskEventPayloadKey.STATUS: StatusValue.CLOSED, "closed_reason": closed_reason, RiskEventActionKey.REVIEW_SUMMARY: review_summary, } self._consume_risk_approval( approval_ticket_id, risk_event_id, approval_payload, actor, ) 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, approval_ticket_id: str | None = None, ) -> dict[str, Any]: record = self._get_event(risk_event_id) approval_payload = { RiskEventPayloadKey.STATUS: StatusValue.OPEN, RiskEventActionKey.COMMENT: comment, } self._consume_risk_approval( approval_ticket_id, risk_event_id, approval_payload, actor, ) 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 _consume_risk_approval( self, approval_ticket_id: str | None, risk_event_id: int, payload: dict[str, Any], actor: str, ) -> None: if not approval_ticket_id: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=BusinessErrorDetail.HIGH_RISK_APPROVAL_REQUIRED, ) ApprovalService(self.db).consume_for( approval_ticket_id, BusinessDomain.RISK_EVENTS, risk_event_id, approval_action(ApprovalActionValue.UPDATE, BusinessDomain.RISK_EVENTS), payload, actor, ) 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