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, Supplier, WorkTask from app.modules.business.service import serialize_model from app.modules.risk.constants import ( RISK_SCORE_WEIGHTS, 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 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 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