refactor(core,ai): 调整模块导入路径并移除废弃文件

- 修复 scheduler.py 中的导入路径错误,将 reports.service
  改为 reports.services
- 移除废弃的 app/core/background/task_queue.py 文件
- 移除废弃的 app/modules/ai_agent/adapters.py 文件
- 修复 ai_memory/service.py 中的导入路径错误,将
  events.service 改为 events.services
```
This commit is contained in:
2026-07-09 18:50:52 +08:00
parent dc8605ce3f
commit bf309ecdf7
83 changed files with 4871 additions and 4153 deletions

View File

@@ -12,7 +12,7 @@ from app.modules.risk.schemas import (
RiskReopenRequest,
RiskResolveRequest,
)
from app.modules.risk.service import RiskService
from app.modules.risk.services import RiskService
router = APIRouter(dependencies=[Depends(require_api_key)])

View File

@@ -1,577 +0,0 @@
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.http.pagination import bounded_limit
from app.core.utils.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

View File

@@ -0,0 +1,4 @@
from app.modules.risk.services.service import RiskService
__all__ = ["RiskService"]

View File

@@ -0,0 +1,238 @@
from typing import Any
from app.core.constants import ActorValue
from app.core.utils.time import utc_now
from app.modules.audit.constants import (
AuditAction,
AuditRiskLevel,
AuditSource,
)
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.constants import (
BusinessDomain,
StatusValue,
)
from app.modules.business.models import (
RiskEvent,
RiskEventAction,
)
from app.modules.business.service import serialize_model
from app.modules.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventType,
)
from app.modules.events.services import EventService
from app.modules.risk.constants import (
RiskEventActionKey,
RiskEventActionValue,
RiskErrorDetail,
)
class RiskActionMixin:
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 _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),
}

View File

@@ -0,0 +1,183 @@
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

View File

@@ -0,0 +1,90 @@
from typing import Any
from sqlalchemy import select
from app.core.constants import ActorValue
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,
)
from app.modules.business.models import (
RiskEvent,
)
from app.modules.business.service import serialize_model
from app.modules.risk.constants import (
RiskGenerationAction,
RiskGenerationResultKey,
RiskEventPayloadKey,
)
class RiskGenerationMixin:
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,
}

View File

@@ -0,0 +1,123 @@
from datetime import date
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from app.core.http.pagination import bounded_limit
from app.modules.business.constants import (
DONE_STATUSES,
GENERATED_RISK_EVENT_TYPES,
PROJECT_CLOSED_STATUSES,
SUPPLIER_RISK_LEVELS,
StatusValue,
)
from app.modules.business.models import (
FundAccount,
Project,
RiskEvent,
RiskEventAction,
Supplier,
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.risk.constants import (
RISK_SCORE_WEIGHTS,
RiskEventPayloadKey,
RiskSummaryKey,
risk_level_for_score,
)
class RiskQueryMixin:
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 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,
}

View File

@@ -0,0 +1,18 @@
from sqlalchemy.orm import Session
from app.modules.risk.services.actions import RiskActionMixin
from app.modules.risk.services.detectors import RiskDetectorMixin
from app.modules.risk.services.generation import RiskGenerationMixin
from app.modules.risk.services.query import RiskQueryMixin
class RiskService(
RiskGenerationMixin,
RiskDetectorMixin,
RiskActionMixin,
RiskQueryMixin,
):
"""Evaluate rule-based business risk signals from internal ledgers."""
def __init__(self, db: Session):
self.db = db