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 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("risk_type") not in GENERATED_RISK_EVENT_TYPES ] risk_score = ( len(overdue_tasks) * 1 + len(delayed_projects) * 3 + len(over_budget_projects) * 4 + len(fund_risks) * 5 + len(supplier_risks) * 3 + len(external_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": utc_now(), "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": utc_now(), "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": utc_now(), "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": utc_now(), "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": utc_now(), "description": "供应商风险等级或黑名单状态需要关注。", "mitigation": ( "请采购负责人复核供应商准入、履约和替代方案。" ), "evidence": serialize_model(supplier), } ) return payloads