from datetime import date from typing import Any from sqlalchemy import select from app.core.utils.time import utc_now from app.modules.business.constants import ( DONE_STATUSES, PROJECT_CLOSED_STATUSES, SUPPLIER_RISK_LEVELS, BusinessDomain, RiskEventType, RiskLevel, StatusValue, ) from app.modules.business.models import ( FundAccount, Project, Supplier, WorkTask, ) from app.modules.business.service import serialize_model from app.modules.risk.constants import ( RiskEventPayloadKey, ) class RiskDetectorMixin: 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