diff --git a/app/core/background/scheduler.py b/app/core/background/scheduler.py index bff71bd..c6412c7 100644 --- a/app/core/background/scheduler.py +++ b/app/core/background/scheduler.py @@ -41,7 +41,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any: enqueue_project_weekly_push, ) from app.modules.observability.service import ObservabilityService - from app.modules.reports.service import ReportService + from app.modules.reports.services import ReportService settings = get_settings() scheduler = BackgroundScheduler(timezone="Asia/Shanghai") diff --git a/app/core/background/task_queue.py b/app/core/background/task_queue.py deleted file mode 100644 index a391a02..0000000 --- a/app/core/background/task_queue.py +++ /dev/null @@ -1,295 +0,0 @@ -from collections.abc import Callable -from typing import Any - -from app.core.config import get_settings -from app.modules.feishu.constants import FeishuReceiveIdType - -TASK_PUSH_DAILY_BRIEF = "reports.push_daily_brief" -TASK_PUSH_PROJECT_WEEKLY = "reports.push_project_weekly" -TASK_GENERATE_RISK_EVENTS = "risks.generate_events" -TASK_SYNC_LEGACY_PROJECTS = "legacy.sync_projects" -TASK_SYNC_LEGACY_TASKS = "legacy.sync_tasks" -TASK_DISPATCH_PENDING_EVENTS = "events.dispatch_pending" - - -def dispatch_task( - task_name: str, - kwargs: dict[str, Any], - inline: Callable[[], Any], -) -> dict[str, Any]: - """Dispatch a task through Celery when enabled, otherwise run inline.""" - - settings = get_settings() - if settings.task_queue_enabled: - from app.tasks import celery_app - - async_result = celery_app.signature(task_name, kwargs=kwargs).apply_async() - return { - "queued": True, - "mode": "celery", - "task_name": task_name, - "task_id": async_result.id, - } - return { - "queued": False, - "mode": "inline", - "task_name": task_name, - "result": inline(), - } - - -def enqueue_daily_brief_push( - receive_id: str | None = None, - receive_id_type: str = FeishuReceiveIdType.CHAT_ID, - actor: str = "scheduler", -) -> dict[str, Any]: - settings = get_settings() - if settings.task_queue_enabled: - from app.core.database import SessionLocal - from app.modules.reports.constants import ReportPushStatus, ReportTitle, ReportType - from app.modules.reports.service import ReportService - from app.tasks import celery_app - - db = SessionLocal() - try: - push_run = ReportService(db).create_push_run( - report_type=ReportType.DAILY, - title=ReportTitle.DAILY_BRIEF, - receive_id=receive_id, - receive_id_type=receive_id_type, - actor=actor, - status=ReportPushStatus.QUEUED, - ) - async_result = celery_app.signature( - TASK_PUSH_DAILY_BRIEF, - kwargs={ - "receive_id": receive_id, - "receive_id_type": receive_id_type, - "actor": actor, - "push_run_code": push_run.code, - }, - ).apply_async() - ReportService(db).update_push_run( - push_run.code, - ReportPushStatus.QUEUED, - task_id=async_result.id, - ) - finally: - db.close() - return { - "queued": True, - "mode": "celery", - "task_name": TASK_PUSH_DAILY_BRIEF, - "task_id": async_result.id, - "push_run_code": push_run.code, - } - - def inline() -> Any: - from app.core.database import SessionLocal - from app.modules.reports.service import ReportService - - db = SessionLocal() - try: - report = ReportService(db).daily_brief() - return ReportService(db).push_report(report, receive_id, receive_id_type, actor) - finally: - db.close() - - return dispatch_task( - TASK_PUSH_DAILY_BRIEF, - { - "receive_id": receive_id, - "receive_id_type": receive_id_type, - "actor": actor, - }, - inline, - ) - - -def enqueue_project_weekly_push( - receive_id: str | None = None, - receive_id_type: str = FeishuReceiveIdType.CHAT_ID, - actor: str = "scheduler", -) -> dict[str, Any]: - settings = get_settings() - if settings.task_queue_enabled: - from app.core.database import SessionLocal - from app.modules.reports.constants import ReportPushStatus, ReportTitle, ReportType - from app.modules.reports.service import ReportService - from app.tasks import celery_app - - db = SessionLocal() - try: - push_run = ReportService(db).create_push_run( - report_type=ReportType.WEEKLY, - title=ReportTitle.PROJECT_WEEKLY, - receive_id=receive_id, - receive_id_type=receive_id_type, - actor=actor, - status=ReportPushStatus.QUEUED, - ) - async_result = celery_app.signature( - TASK_PUSH_PROJECT_WEEKLY, - kwargs={ - "receive_id": receive_id, - "receive_id_type": receive_id_type, - "actor": actor, - "push_run_code": push_run.code, - }, - ).apply_async() - ReportService(db).update_push_run( - push_run.code, - ReportPushStatus.QUEUED, - task_id=async_result.id, - ) - finally: - db.close() - return { - "queued": True, - "mode": "celery", - "task_name": TASK_PUSH_PROJECT_WEEKLY, - "task_id": async_result.id, - "push_run_code": push_run.code, - } - - def inline() -> Any: - from app.core.database import SessionLocal - from app.modules.reports.service import ReportService - - db = SessionLocal() - try: - report = ReportService(db).project_weekly() - return ReportService(db).push_report(report, receive_id, receive_id_type, actor) - finally: - db.close() - - return dispatch_task( - TASK_PUSH_PROJECT_WEEKLY, - { - "receive_id": receive_id, - "receive_id_type": receive_id_type, - "actor": actor, - }, - inline, - ) - - -def enqueue_risk_event_generation(actor: str = "scheduler") -> dict[str, Any]: - def inline() -> Any: - from app.core.database import SessionLocal - from app.modules.risk.service import RiskService - - db = SessionLocal() - try: - return RiskService(db).generate_events(actor=actor) - finally: - db.close() - - return dispatch_task( - TASK_GENERATE_RISK_EVENTS, - {"actor": actor}, - inline, - ) - - -def enqueue_event_dispatch( - limit: int | None = None, - actor: str = "scheduler", -) -> dict[str, Any]: - def inline() -> Any: - from app.core.config import get_settings - from app.core.database import SessionLocal - from app.modules.events.service import EventService - - db = SessionLocal() - try: - return EventService(db).dispatch_pending( - limit=limit or get_settings().event_dispatch_batch_size, - worker_id=actor, - ) - finally: - db.close() - - return dispatch_task( - TASK_DISPATCH_PENDING_EVENTS, - {"limit": limit, "actor": actor}, - inline, - ) - - -def enqueue_legacy_project_sync( - source_query: str | None = None, - source_query_name: str | None = None, - field_map: dict[str, str] | None = None, - limit: int = 100, - dry_run: bool = False, - actor: str = "scheduler", -) -> dict[str, Any]: - def inline() -> Any: - from app.core.database import SessionLocal - from app.modules.legacy_mysql.service import LegacyMySQLService - - db = SessionLocal() - try: - return LegacyMySQLService(db).sync_projects( - source_query=source_query, - source_query_name=source_query_name, - field_map=field_map or {}, - limit=limit, - dry_run=dry_run, - actor=actor, - ) - finally: - db.close() - - return dispatch_task( - TASK_SYNC_LEGACY_PROJECTS, - { - "source_query": source_query, - "source_query_name": source_query_name, - "field_map": field_map or {}, - "limit": limit, - "dry_run": dry_run, - "actor": actor, - }, - inline, - ) - - -def enqueue_legacy_task_sync( - source_query: str | None = None, - source_query_name: str | None = None, - field_map: dict[str, str] | None = None, - limit: int = 100, - dry_run: bool = False, - actor: str = "scheduler", -) -> dict[str, Any]: - def inline() -> Any: - from app.core.database import SessionLocal - from app.modules.legacy_mysql.service import LegacyMySQLService - - db = SessionLocal() - try: - return LegacyMySQLService(db).sync_tasks( - source_query=source_query, - source_query_name=source_query_name, - field_map=field_map or {}, - limit=limit, - dry_run=dry_run, - actor=actor, - ) - finally: - db.close() - - return dispatch_task( - TASK_SYNC_LEGACY_TASKS, - { - "source_query": source_query, - "source_query_name": source_query_name, - "field_map": field_map or {}, - "limit": limit, - "dry_run": dry_run, - "actor": actor, - }, - inline, - ) diff --git a/app/core/background/task_queue/__init__.py b/app/core/background/task_queue/__init__.py new file mode 100644 index 0000000..ca0ae98 --- /dev/null +++ b/app/core/background/task_queue/__init__.py @@ -0,0 +1,30 @@ +from app.core.background.task_queue.constants import ( + TASK_DISPATCH_PENDING_EVENTS, + TASK_GENERATE_RISK_EVENTS, + TASK_PUSH_DAILY_BRIEF, + TASK_PUSH_PROJECT_WEEKLY, + TASK_SYNC_LEGACY_PROJECTS, + TASK_SYNC_LEGACY_TASKS, +) +from app.core.background.task_queue.dispatcher import dispatch_task +from app.core.background.task_queue.events import enqueue_event_dispatch +from app.core.background.task_queue.legacy import enqueue_legacy_project_sync, enqueue_legacy_task_sync +from app.core.background.task_queue.reports import enqueue_daily_brief_push, enqueue_project_weekly_push +from app.core.background.task_queue.risk import enqueue_risk_event_generation + + +__all__ = [ + "TASK_DISPATCH_PENDING_EVENTS", + "TASK_GENERATE_RISK_EVENTS", + "TASK_PUSH_DAILY_BRIEF", + "TASK_PUSH_PROJECT_WEEKLY", + "TASK_SYNC_LEGACY_PROJECTS", + "TASK_SYNC_LEGACY_TASKS", + "dispatch_task", + "enqueue_daily_brief_push", + "enqueue_event_dispatch", + "enqueue_legacy_project_sync", + "enqueue_legacy_task_sync", + "enqueue_project_weekly_push", + "enqueue_risk_event_generation", +] diff --git a/app/core/background/task_queue/constants.py b/app/core/background/task_queue/constants.py new file mode 100644 index 0000000..119836c --- /dev/null +++ b/app/core/background/task_queue/constants.py @@ -0,0 +1,6 @@ +TASK_PUSH_DAILY_BRIEF = "reports.push_daily_brief" +TASK_PUSH_PROJECT_WEEKLY = "reports.push_project_weekly" +TASK_GENERATE_RISK_EVENTS = "risks.generate_events" +TASK_SYNC_LEGACY_PROJECTS = "legacy.sync_projects" +TASK_SYNC_LEGACY_TASKS = "legacy.sync_tasks" +TASK_DISPATCH_PENDING_EVENTS = "events.dispatch_pending" diff --git a/app/core/background/task_queue/dispatcher.py b/app/core/background/task_queue/dispatcher.py new file mode 100644 index 0000000..b48df10 --- /dev/null +++ b/app/core/background/task_queue/dispatcher.py @@ -0,0 +1,30 @@ +from collections.abc import Callable +from typing import Any + +from app.core.config import get_settings + + +def dispatch_task( + task_name: str, + kwargs: dict[str, Any], + inline: Callable[[], Any], +) -> dict[str, Any]: + """Dispatch a task through Celery when enabled, otherwise run inline.""" + + settings = get_settings() + if settings.task_queue_enabled: + from app.tasks import celery_app + + async_result = celery_app.signature(task_name, kwargs=kwargs).apply_async() + return { + "queued": True, + "mode": "celery", + "task_name": task_name, + "task_id": async_result.id, + } + return { + "queued": False, + "mode": "inline", + "task_name": task_name, + "result": inline(), + } diff --git a/app/core/background/task_queue/events.py b/app/core/background/task_queue/events.py new file mode 100644 index 0000000..93adb1a --- /dev/null +++ b/app/core/background/task_queue/events.py @@ -0,0 +1,31 @@ +from typing import Any + +from app.core.config import get_settings +from app.core.background.task_queue.constants import ( + TASK_DISPATCH_PENDING_EVENTS, +) +from app.core.background.task_queue.dispatcher import dispatch_task + + +def enqueue_event_dispatch( + limit: int | None = None, + actor: str = "scheduler", +) -> dict[str, Any]: + def inline() -> Any: + from app.core.database import SessionLocal + from app.modules.events.services import EventService + + db = SessionLocal() + try: + return EventService(db).dispatch_pending( + limit=limit or get_settings().event_dispatch_batch_size, + worker_id=actor, + ) + finally: + db.close() + + return dispatch_task( + TASK_DISPATCH_PENDING_EVENTS, + {"limit": limit, "actor": actor}, + inline, + ) diff --git a/app/core/background/task_queue/legacy.py b/app/core/background/task_queue/legacy.py new file mode 100644 index 0000000..80578a3 --- /dev/null +++ b/app/core/background/task_queue/legacy.py @@ -0,0 +1,84 @@ +from typing import Any + +from app.core.background.task_queue.constants import ( + TASK_SYNC_LEGACY_PROJECTS, + TASK_SYNC_LEGACY_TASKS, +) +from app.core.background.task_queue.dispatcher import dispatch_task + + +def enqueue_legacy_project_sync( + source_query: str | None = None, + source_query_name: str | None = None, + field_map: dict[str, str] | None = None, + limit: int = 100, + dry_run: bool = False, + actor: str = "scheduler", +) -> dict[str, Any]: + def inline() -> Any: + from app.core.database import SessionLocal + from app.modules.legacy_mysql.services import LegacyMySQLService + + db = SessionLocal() + try: + return LegacyMySQLService(db).sync_projects( + source_query=source_query, + source_query_name=source_query_name, + field_map=field_map or {}, + limit=limit, + dry_run=dry_run, + actor=actor, + ) + finally: + db.close() + + return dispatch_task( + TASK_SYNC_LEGACY_PROJECTS, + { + "source_query": source_query, + "source_query_name": source_query_name, + "field_map": field_map or {}, + "limit": limit, + "dry_run": dry_run, + "actor": actor, + }, + inline, + ) + +def enqueue_legacy_task_sync( + source_query: str | None = None, + source_query_name: str | None = None, + field_map: dict[str, str] | None = None, + limit: int = 100, + dry_run: bool = False, + actor: str = "scheduler", +) -> dict[str, Any]: + def inline() -> Any: + from app.core.database import SessionLocal + from app.modules.legacy_mysql.services import LegacyMySQLService + + db = SessionLocal() + try: + return LegacyMySQLService(db).sync_tasks( + source_query=source_query, + source_query_name=source_query_name, + field_map=field_map or {}, + limit=limit, + dry_run=dry_run, + actor=actor, + ) + finally: + db.close() + + return dispatch_task( + TASK_SYNC_LEGACY_TASKS, + { + "source_query": source_query, + "source_query_name": source_query_name, + "field_map": field_map or {}, + "limit": limit, + "dry_run": dry_run, + "actor": actor, + }, + inline, + ) diff --git a/app/core/background/task_queue/reports.py b/app/core/background/task_queue/reports.py new file mode 100644 index 0000000..ef8424b --- /dev/null +++ b/app/core/background/task_queue/reports.py @@ -0,0 +1,144 @@ +from typing import Any + +from app.core.config import get_settings +from app.modules.feishu.constants import FeishuReceiveIdType +from app.core.background.task_queue.constants import ( + TASK_PUSH_DAILY_BRIEF, + TASK_PUSH_PROJECT_WEEKLY, +) +from app.core.background.task_queue.dispatcher import dispatch_task + + +def enqueue_daily_brief_push( + receive_id: str | None = None, + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = "scheduler", +) -> dict[str, Any]: + settings = get_settings() + if settings.task_queue_enabled: + from app.core.database import SessionLocal + from app.modules.reports.constants import ReportPushStatus, ReportTitle, ReportType + from app.modules.reports.services import ReportService + from app.tasks import celery_app + + db = SessionLocal() + try: + push_run = ReportService(db).create_push_run( + report_type=ReportType.DAILY, + title=ReportTitle.DAILY_BRIEF, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + status=ReportPushStatus.QUEUED, + ) + async_result = celery_app.signature( + TASK_PUSH_DAILY_BRIEF, + kwargs={ + "receive_id": receive_id, + "receive_id_type": receive_id_type, + "actor": actor, + "push_run_code": push_run.code, + }, + ).apply_async() + ReportService(db).update_push_run( + push_run.code, + ReportPushStatus.QUEUED, + task_id=async_result.id, + ) + finally: + db.close() + return { + "queued": True, + "mode": "celery", + "task_name": TASK_PUSH_DAILY_BRIEF, + "task_id": async_result.id, + "push_run_code": push_run.code, + } + + def inline() -> Any: + from app.core.database import SessionLocal + from app.modules.reports.services import ReportService + + db = SessionLocal() + try: + report = ReportService(db).daily_brief() + return ReportService(db).push_report(report, receive_id, receive_id_type, actor) + finally: + db.close() + + return dispatch_task( + TASK_PUSH_DAILY_BRIEF, + { + "receive_id": receive_id, + "receive_id_type": receive_id_type, + "actor": actor, + }, + inline, + ) + +def enqueue_project_weekly_push( + receive_id: str | None = None, + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = "scheduler", +) -> dict[str, Any]: + settings = get_settings() + if settings.task_queue_enabled: + from app.core.database import SessionLocal + from app.modules.reports.constants import ReportPushStatus, ReportTitle, ReportType + from app.modules.reports.services import ReportService + from app.tasks import celery_app + + db = SessionLocal() + try: + push_run = ReportService(db).create_push_run( + report_type=ReportType.WEEKLY, + title=ReportTitle.PROJECT_WEEKLY, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + status=ReportPushStatus.QUEUED, + ) + async_result = celery_app.signature( + TASK_PUSH_PROJECT_WEEKLY, + kwargs={ + "receive_id": receive_id, + "receive_id_type": receive_id_type, + "actor": actor, + "push_run_code": push_run.code, + }, + ).apply_async() + ReportService(db).update_push_run( + push_run.code, + ReportPushStatus.QUEUED, + task_id=async_result.id, + ) + finally: + db.close() + return { + "queued": True, + "mode": "celery", + "task_name": TASK_PUSH_PROJECT_WEEKLY, + "task_id": async_result.id, + "push_run_code": push_run.code, + } + + def inline() -> Any: + from app.core.database import SessionLocal + from app.modules.reports.services import ReportService + + db = SessionLocal() + try: + report = ReportService(db).project_weekly() + return ReportService(db).push_report(report, receive_id, receive_id_type, actor) + finally: + db.close() + + return dispatch_task( + TASK_PUSH_PROJECT_WEEKLY, + { + "receive_id": receive_id, + "receive_id_type": receive_id_type, + "actor": actor, + }, + inline, + ) diff --git a/app/core/background/task_queue/risk.py b/app/core/background/task_queue/risk.py new file mode 100644 index 0000000..214c7e4 --- /dev/null +++ b/app/core/background/task_queue/risk.py @@ -0,0 +1,24 @@ +from typing import Any + +from app.core.background.task_queue.constants import ( + TASK_GENERATE_RISK_EVENTS, +) +from app.core.background.task_queue.dispatcher import dispatch_task + + +def enqueue_risk_event_generation(actor: str = "scheduler") -> dict[str, Any]: + def inline() -> Any: + from app.core.database import SessionLocal + from app.modules.risk.services import RiskService + + db = SessionLocal() + try: + return RiskService(db).generate_events(actor=actor) + finally: + db.close() + + return dispatch_task( + TASK_GENERATE_RISK_EVENTS, + {"actor": actor}, + inline, + ) diff --git a/app/modules/ai_agent/adapters.py b/app/modules/ai_agent/adapters.py deleted file mode 100644 index cbe1a1d..0000000 --- a/app/modules/ai_agent/adapters.py +++ /dev/null @@ -1,456 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -import httpx -from fastapi import HTTPException, status - -from app.core.config import Settings, get_settings -from app.modules.ai_agent.constants import ( - AUTHORIZATION_BEARER_TEMPLATE, - CHAT_USER_CONTENT_TEMPLATE, - COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS, - DIRECT_LLM_API_KEY_MISSING, - NOOP_PROVIDER_ANSWER, - OPENCLAW_ACTION_NOT_ALLOWED, - OPENCLAW_CHAT_PROVIDER_REQUIRED, - OPENCLAW_HERMES_PIPELINE, - OPENCLAW_TOOL_NOT_ALLOWED, - OPENCLAW_TOOL_COMPLETED_ANSWER, - UNEXPECTED_HERMES_RESPONSE, - AIDefault, - AIChatRole, - AIContextKey, - AIErrorKey, - AIHttpHeader, - AIHttpPath, - AIHttpPayloadKey, - AIMemoryMode, - AIProviderName, - AIRequestKey, - AIResponseKey, -) -from app.modules.ai_agent.skills import AISkillId, get_ai_skill - - -class AIAdapter(ABC): - """Interface for model provider adapters.""" - - provider_name: str - - @abstractmethod - def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: - raise NotImplementedError - - -class NoopAdapter(AIAdapter): - """Deterministic adapter used when no model provider is configured.""" - - provider_name = AIProviderName.NOOP - - def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: - return { - AIResponseKey.ANSWER: NOOP_PROVIDER_ANSWER, - AIResponseKey.RAW: { - AIRequestKey.PROMPT: prompt, - AIRequestKey.CONTEXT: context or {}, - }, - } - - -class OpenClawAdapter(AIAdapter): - """Adapter for the OpenClaw Gateway control-plane API.""" - - provider_name = AIProviderName.OPENCLAW - - def __init__(self, settings: Settings): - self.settings = settings - - def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: - context = context or {} - tool = context.get(AIContextKey.OPENCLAW_TOOL) - if not tool: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=OPENCLAW_CHAT_PROVIDER_REQUIRED, - ) - result = self.invoke_tool( - tool=str(tool), - action=str(context.get(AIContextKey.OPENCLAW_ACTION) or AIDefault.ACTION_JSON), - args=context.get(AIContextKey.OPENCLAW_ARGS) or {}, - session_key=str( - context.get(AIContextKey.OPENCLAW_SESSION_KEY) or AIDefault.SESSION_KEY_MAIN - ), - ) - return { - AIResponseKey.ANSWER: OPENCLAW_TOOL_COMPLETED_ANSWER, - AIResponseKey.RAW: result, - } - - def health(self) -> dict[str, Any]: - """Check OpenClaw Gateway health endpoints.""" - - headers = self._headers() - base_url = self._base_url() - with httpx.Client(timeout=5, trust_env=False) as client: - healthz = client.get(f"{base_url}{AIHttpPath.HEALTHZ}", headers=headers) - readyz = client.get(f"{base_url}{AIHttpPath.READYZ}", headers=headers) - return { - AIResponseKey.OK: ( - healthz.status_code < status.HTTP_400_BAD_REQUEST - and readyz.status_code < status.HTTP_400_BAD_REQUEST - ), - AIResponseKey.BASE_URL: base_url, - AIResponseKey.HEALTHZ: _response_payload(healthz), - AIResponseKey.READYZ: _response_payload(readyz), - } - - def invoke_tool( - self, - tool: str, - action: str = AIDefault.ACTION_JSON, - args: dict[str, Any] | None = None, - session_key: str = AIDefault.SESSION_KEY_MAIN, - ) -> dict[str, Any]: - """Invoke one OpenClaw Gateway tool through `/tools/invoke`.""" - - self._ensure_tool_allowed(tool, action) - payload = { - AIHttpPayloadKey.TOOL: tool, - AIHttpPayloadKey.ACTION: action, - AIHttpPayloadKey.ARGS: args or {}, - AIHttpPayloadKey.SESSION_KEY: session_key, - } - url = f"{self._base_url()}{AIHttpPath.TOOLS_INVOKE}" - with httpx.Client(timeout=120, trust_env=False) as client: - response = client.post(url, json=payload, headers=self._headers()) - if response.status_code >= status.HTTP_400_BAD_REQUEST: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail={AIErrorKey.OPENCLAW: response.text}, - ) - return _response_payload(response) - - def _base_url(self) -> str: - return (self.settings.openclaw_http_url or self.settings.openclaw_base_url).rstrip("/") - - def _headers(self) -> dict[str, str]: - token = self.settings.openclaw_gateway_token or self.settings.openclaw_api_key - if not token: - return {} - return { - AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(token=token) - } - - def _ensure_tool_allowed(self, tool: str, action: str) -> None: - if tool not in set(self.settings.openclaw_allowed_tools): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=OPENCLAW_TOOL_NOT_ALLOWED, - ) - if action not in set(self.settings.openclaw_allowed_actions): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=OPENCLAW_ACTION_NOT_ALLOWED, - ) - - -class HermesAdapter(AIAdapter): - """Adapter for the Hermes OpenAI-compatible agent endpoint.""" - - provider_name = AIProviderName.HERMES - - def __init__(self, settings: Settings): - self.settings = settings - - def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: - url = f"{self.settings.hermes_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}" - headers = {} - if self.settings.hermes_api_key: - headers[AIHttpHeader.AUTHORIZATION] = AUTHORIZATION_BEARER_TEMPLATE.format( - token=self.settings.hermes_api_key - ) - if self.settings.hermes_session_id: - headers[AIHttpHeader.HERMES_SESSION_ID] = self.settings.hermes_session_id - payload = { - AIHttpPayloadKey.MODEL: self.settings.hermes_model, - AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, context), - AIHttpPayloadKey.STREAM: False, - } - with httpx.Client(timeout=300, trust_env=False) as client: - response = client.post(url, json=payload, headers=headers) - if response.status_code >= status.HTTP_400_BAD_REQUEST: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail={AIErrorKey.HERMES: response.text}, - ) - data = _chat_completion_payload(response, AIErrorKey.HERMES) - try: - answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ - AIHttpPayloadKey.CONTENT - ] - except (KeyError, IndexError, TypeError) as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail={ - AIErrorKey.HERMES: UNEXPECTED_HERMES_RESPONSE, - AIResponseKey.RAW: data, - }, - ) from exc - return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data} - - def health(self) -> dict[str, Any]: - """Check Hermes Agent health without triggering a chat completion.""" - - url = f"{_service_root(self.settings.hermes_base_url, AIHttpPath.V1)}{AIHttpPath.HEALTH}" - headers = {} - if self.settings.hermes_api_key: - headers[AIHttpHeader.AUTHORIZATION] = AUTHORIZATION_BEARER_TEMPLATE.format( - token=self.settings.hermes_api_key - ) - with httpx.Client(timeout=5, trust_env=False) as client: - response = client.get(url, headers=headers) - return { - AIResponseKey.OK: response.status_code < status.HTTP_400_BAD_REQUEST, - AIResponseKey.BASE_URL: self.settings.hermes_base_url.rstrip("/"), - AIResponseKey.HEALTH: _response_payload(response), - } - - -class OpenClawHermesAdapter(AIAdapter): - """Compose OpenClaw Gateway context with Hermes Agent answers.""" - - provider_name = AIProviderName.OPENCLAW_HERMES - - def __init__(self, settings: Settings): - self.openclaw = OpenClawAdapter(settings) - self.hermes = HermesAdapter(settings) - - def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: - base_context = context or {} - recall = self._recall_memory(prompt, base_context) - openclaw = self._openclaw_context(base_context) - hermes_context = { - **base_context, - AIContextKey.AGENT_PIPELINE: self.provider_name, - AIContextKey.HERMES_MEMORY: recall[AIResponseKey.ANSWER], - AIContextKey.OPENCLAW: openclaw, - } - hermes_result = self.hermes.ask(prompt, hermes_context) - remember = self._remember_interaction( - prompt, - base_context, - hermes_result[AIResponseKey.ANSWER], - ) - return { - AIResponseKey.ANSWER: hermes_result[AIResponseKey.ANSWER], - AIResponseKey.RAW: { - AIResponseKey.PIPELINE: OPENCLAW_HERMES_PIPELINE, - AIResponseKey.HERMES_RECALL: recall, - AIResponseKey.OPENCLAW: openclaw, - AIResponseKey.HERMES_ANSWER: hermes_result.get(AIResponseKey.RAW, {}), - AIResponseKey.HERMES_REMEMBER: remember, - }, - } - - def _openclaw_context(self, context: dict[str, Any]) -> dict[str, Any]: - result: dict[str, Any] = {AIResponseKey.TOOL_INVOKED: False} - try: - result[AIResponseKey.HEALTH] = self.openclaw.health() - except Exception as exc: - result[AIResponseKey.HEALTH] = { - AIResponseKey.OK: False, - AIResponseKey.ERROR: _error_detail(exc), - } - - tool = context.get(AIContextKey.OPENCLAW_TOOL) - if not tool: - return result - - try: - result[AIResponseKey.TOOL_INVOKED] = True - result[AIResponseKey.TOOL] = self.openclaw.invoke_tool( - tool=str(tool), - action=str(context.get(AIContextKey.OPENCLAW_ACTION) or AIDefault.ACTION_JSON), - args=context.get(AIContextKey.OPENCLAW_ARGS) or {}, - session_key=str( - context.get(AIContextKey.OPENCLAW_SESSION_KEY) - or AIDefault.SESSION_KEY_MAIN - ), - ) - except HTTPException: - raise - except Exception as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail={AIErrorKey.OPENCLAW: _error_detail(exc)}, - ) from exc - return result - - def _recall_memory(self, prompt: str, context: dict[str, Any]) -> dict[str, Any]: - recall_skill = get_ai_skill(AISkillId.HERMES_MEMORY_RECALL) - try: - result = self.hermes.ask( - recall_skill.render(), - { - AIContextKey.MODE: AIMemoryMode.RECALL, - AIContextKey.USER_PROMPT: prompt, - AIContextKey.REQUEST_CONTEXT: context, - }, - ) - except Exception as exc: # Hermes memory should not block OpenClaw execution. - return { - AIResponseKey.ANSWER: "", - AIResponseKey.RAW: {}, - AIResponseKey.ERROR: _error_detail(exc), - } - return { - AIResponseKey.ANSWER: result[AIResponseKey.ANSWER], - AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}), - } - - def _remember_interaction( - self, - prompt: str, - context: dict[str, Any], - answer: str, - ) -> dict[str, Any]: - remember_skill = get_ai_skill(AISkillId.HERMES_MEMORY_WRITE) - try: - result = self.hermes.ask( - remember_skill.render(), - { - AIContextKey.MODE: AIMemoryMode.WRITE, - AIContextKey.USER_PROMPT: prompt, - AIContextKey.REQUEST_CONTEXT: context, - AIContextKey.ASSISTANT_ANSWER: answer, - }, - ) - except Exception as exc: - return { - AIResponseKey.OK: False, - AIResponseKey.RAW: {}, - AIResponseKey.ERROR: _error_detail(exc), - } - return { - AIResponseKey.OK: True, - AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}), - AIResponseKey.ANSWER: result[AIResponseKey.ANSWER], - } - - -class DirectLLMAdapter(AIAdapter): - """Adapter for OpenAI-compatible chat completions APIs.""" - - provider_name = AIProviderName.DIRECT_LLM - - def __init__(self, settings: Settings): - self.settings = settings - - def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: - if not self.settings.direct_llm_api_key: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=DIRECT_LLM_API_KEY_MISSING, - ) - url = f"{self.settings.direct_llm_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}" - headers = { - AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format( - token=self.settings.direct_llm_api_key - ) - } - payload = { - AIHttpPayloadKey.MODEL: self.settings.direct_llm_model, - AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, context), - } - with httpx.Client(timeout=60, trust_env=False) as client: - response = client.post(url, json=payload, headers=headers) - if response.status_code >= status.HTTP_400_BAD_REQUEST: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail={AIErrorKey.DIRECT_LLM: response.text}, - ) - data = _chat_completion_payload(response, AIErrorKey.DIRECT_LLM) - try: - answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ - AIHttpPayloadKey.CONTENT - ] - except (KeyError, IndexError, TypeError) as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail={ - AIErrorKey.DIRECT_LLM: UNEXPECTED_HERMES_RESPONSE, - AIResponseKey.RAW: data, - }, - ) from exc - return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data} - - -def get_adapter() -> AIAdapter: - """Return the configured AI provider adapter.""" - - settings = get_settings() - provider = settings.model_provider.lower() - if provider == AIProviderName.OPENCLAW: - return OpenClawAdapter(settings) - if provider == AIProviderName.HERMES: - return HermesAdapter(settings) - if provider in { - AIProviderName.OPENCLAW_HERMES, - AIProviderName.OPENCLAW_HERMES_DASH, - AIProviderName.HYBRID, - }: - return OpenClawHermesAdapter(settings) - if provider == AIProviderName.DIRECT_LLM: - return DirectLLMAdapter(settings) - return NoopAdapter() - - -def _error_detail(exc: Exception) -> Any: - if isinstance(exc, HTTPException): - return exc.detail - return {AIResponseKey.TYPE: type(exc).__name__, AIResponseKey.MESSAGE: str(exc)} - - -def _response_payload(response: httpx.Response) -> dict[str, Any]: - try: - data = response.json() - except ValueError: - data = {AIResponseKey.TEXT: response.text} - return {AIResponseKey.STATUS_CODE: response.status_code, AIResponseKey.DATA: data} - - -def _chat_completion_payload(response: httpx.Response, error_key: AIErrorKey) -> dict[str, Any]: - try: - return response.json() - except ValueError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail={ - error_key: UNEXPECTED_HERMES_RESPONSE, - AIResponseKey.RAW: {AIResponseKey.TEXT: response.text}, - }, - ) from exc - - -def _chat_messages(prompt: str, context: dict[str, Any] | None = None) -> list[dict[str, str]]: - return [ - { - AIHttpPayloadKey.ROLE: AIChatRole.SYSTEM, - AIHttpPayloadKey.CONTENT: COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS, - }, - { - AIHttpPayloadKey.ROLE: AIChatRole.USER, - AIHttpPayloadKey.CONTENT: CHAT_USER_CONTENT_TEMPLATE.format( - context=context or {}, - task=prompt, - ), - }, - ] - - -def _service_root(base_url: str, suffix: str) -> str: - root = base_url.rstrip("/") - normalized_suffix = suffix.rstrip("/") - if root.endswith(normalized_suffix): - root = root[: -len(normalized_suffix)] - return root.rstrip("/") diff --git a/app/modules/ai_agent/adapters/__init__.py b/app/modules/ai_agent/adapters/__init__.py new file mode 100644 index 0000000..dd3ad60 --- /dev/null +++ b/app/modules/ai_agent/adapters/__init__.py @@ -0,0 +1,21 @@ +import httpx + +from app.modules.ai_agent.adapters.base import AIAdapter +from app.modules.ai_agent.adapters.direct_llm import DirectLLMAdapter +from app.modules.ai_agent.adapters.factory import get_adapter +from app.modules.ai_agent.adapters.hermes import HermesAdapter +from app.modules.ai_agent.adapters.noop import NoopAdapter +from app.modules.ai_agent.adapters.openclaw import OpenClawAdapter +from app.modules.ai_agent.adapters.openclaw_hermes import OpenClawHermesAdapter + + +__all__ = [ + "AIAdapter", + "DirectLLMAdapter", + "HermesAdapter", + "NoopAdapter", + "OpenClawAdapter", + "OpenClawHermesAdapter", + "get_adapter", + "httpx", +] diff --git a/app/modules/ai_agent/adapters/base.py b/app/modules/ai_agent/adapters/base.py new file mode 100644 index 0000000..b629447 --- /dev/null +++ b/app/modules/ai_agent/adapters/base.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod +from typing import Any + + +class AIAdapter(ABC): + """Interface for model provider adapters.""" + + provider_name: str + + @abstractmethod + def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: + raise NotImplementedError diff --git a/app/modules/ai_agent/adapters/common.py b/app/modules/ai_agent/adapters/common.py new file mode 100644 index 0000000..5853e2f --- /dev/null +++ b/app/modules/ai_agent/adapters/common.py @@ -0,0 +1,64 @@ +from typing import Any + +from fastapi import HTTPException, status + +from app.modules.ai_agent.constants import ( + CHAT_USER_CONTENT_TEMPLATE, + COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS, + UNEXPECTED_HERMES_RESPONSE, + AIChatRole, + AIErrorKey, + AIHttpPayloadKey, + AIResponseKey, +) + + +def _error_detail(exc: Exception) -> Any: + if isinstance(exc, HTTPException): + return exc.detail + return {AIResponseKey.TYPE: type(exc).__name__, AIResponseKey.MESSAGE: str(exc)} + + +def _response_payload(response: Any) -> dict[str, Any]: + try: + data = response.json() + except ValueError: + data = {AIResponseKey.TEXT: response.text} + return {AIResponseKey.STATUS_CODE: response.status_code, AIResponseKey.DATA: data} + + +def _chat_completion_payload(response: Any, error_key: AIErrorKey) -> dict[str, Any]: + try: + return response.json() + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={ + error_key: UNEXPECTED_HERMES_RESPONSE, + AIResponseKey.RAW: {AIResponseKey.TEXT: response.text}, + }, + ) from exc + + +def _chat_messages(prompt: str, context: dict[str, Any] | None = None) -> list[dict[str, str]]: + return [ + { + AIHttpPayloadKey.ROLE: AIChatRole.SYSTEM, + AIHttpPayloadKey.CONTENT: COMPANY_MANAGEMENT_SYSTEM_INSTRUCTIONS, + }, + { + AIHttpPayloadKey.ROLE: AIChatRole.USER, + AIHttpPayloadKey.CONTENT: CHAT_USER_CONTENT_TEMPLATE.format( + context=context or {}, + task=prompt, + ), + }, + ] + + +def _service_root(base_url: str, suffix: str) -> str: + root = base_url.rstrip("/") + normalized_suffix = suffix.rstrip("/") + if root.endswith(normalized_suffix): + root = root[: -len(normalized_suffix)] + return root.rstrip("/") diff --git a/app/modules/ai_agent/adapters/direct_llm.py b/app/modules/ai_agent/adapters/direct_llm.py new file mode 100644 index 0000000..5931483 --- /dev/null +++ b/app/modules/ai_agent/adapters/direct_llm.py @@ -0,0 +1,69 @@ +from typing import Any + +from fastapi import HTTPException, status + +from app.core.config import Settings +from app.modules.ai_agent.adapters import httpx +from app.modules.ai_agent.adapters.base import AIAdapter +from app.modules.ai_agent.adapters.common import ( + _chat_completion_payload, + _chat_messages, +) +from app.modules.ai_agent.constants import ( + AUTHORIZATION_BEARER_TEMPLATE, + DIRECT_LLM_API_KEY_MISSING, + UNEXPECTED_HERMES_RESPONSE, + AIErrorKey, + AIHttpHeader, + AIHttpPath, + AIHttpPayloadKey, + AIProviderName, + AIResponseKey, +) + + +class DirectLLMAdapter(AIAdapter): + """Adapter for OpenAI-compatible chat completions APIs.""" + + provider_name = AIProviderName.DIRECT_LLM + + def __init__(self, settings: Settings): + self.settings = settings + + def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: + if not self.settings.direct_llm_api_key: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=DIRECT_LLM_API_KEY_MISSING, + ) + url = f"{self.settings.direct_llm_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}" + headers = { + AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format( + token=self.settings.direct_llm_api_key + ) + } + payload = { + AIHttpPayloadKey.MODEL: self.settings.direct_llm_model, + AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, context), + } + with httpx.Client(timeout=60, trust_env=False) as client: + response = client.post(url, json=payload, headers=headers) + if response.status_code >= status.HTTP_400_BAD_REQUEST: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={AIErrorKey.DIRECT_LLM: response.text}, + ) + data = _chat_completion_payload(response, AIErrorKey.DIRECT_LLM) + try: + answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ + AIHttpPayloadKey.CONTENT + ] + except (KeyError, IndexError, TypeError) as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={ + AIErrorKey.DIRECT_LLM: UNEXPECTED_HERMES_RESPONSE, + AIResponseKey.RAW: data, + }, + ) from exc + return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data} diff --git a/app/modules/ai_agent/adapters/factory.py b/app/modules/ai_agent/adapters/factory.py new file mode 100644 index 0000000..036ea14 --- /dev/null +++ b/app/modules/ai_agent/adapters/factory.py @@ -0,0 +1,28 @@ +from app.core.config import get_settings +from app.modules.ai_agent.adapters.base import AIAdapter +from app.modules.ai_agent.adapters.direct_llm import DirectLLMAdapter +from app.modules.ai_agent.adapters.hermes import HermesAdapter +from app.modules.ai_agent.adapters.noop import NoopAdapter +from app.modules.ai_agent.adapters.openclaw import OpenClawAdapter +from app.modules.ai_agent.adapters.openclaw_hermes import OpenClawHermesAdapter +from app.modules.ai_agent.constants import AIProviderName + + +def get_adapter() -> AIAdapter: + """Return the configured AI provider adapter.""" + + settings = get_settings() + provider = settings.model_provider.lower() + if provider == AIProviderName.OPENCLAW: + return OpenClawAdapter(settings) + if provider == AIProviderName.HERMES: + return HermesAdapter(settings) + if provider in { + AIProviderName.OPENCLAW_HERMES, + AIProviderName.OPENCLAW_HERMES_DASH, + AIProviderName.HYBRID, + }: + return OpenClawHermesAdapter(settings) + if provider == AIProviderName.DIRECT_LLM: + return DirectLLMAdapter(settings) + return NoopAdapter() diff --git a/app/modules/ai_agent/adapters/hermes.py b/app/modules/ai_agent/adapters/hermes.py new file mode 100644 index 0000000..7c5e8ff --- /dev/null +++ b/app/modules/ai_agent/adapters/hermes.py @@ -0,0 +1,85 @@ +from typing import Any + +from fastapi import HTTPException, status + +from app.core.config import Settings +from app.modules.ai_agent.adapters import httpx +from app.modules.ai_agent.adapters.base import AIAdapter +from app.modules.ai_agent.adapters.common import ( + _chat_completion_payload, + _chat_messages, + _response_payload, + _service_root, +) +from app.modules.ai_agent.constants import ( + AUTHORIZATION_BEARER_TEMPLATE, + UNEXPECTED_HERMES_RESPONSE, + AIErrorKey, + AIHttpHeader, + AIHttpPath, + AIHttpPayloadKey, + AIProviderName, + AIResponseKey, +) + + +class HermesAdapter(AIAdapter): + """Adapter for the Hermes OpenAI-compatible agent endpoint.""" + + provider_name = AIProviderName.HERMES + + def __init__(self, settings: Settings): + self.settings = settings + + def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: + url = f"{self.settings.hermes_base_url.rstrip('/')}{AIHttpPath.CHAT_COMPLETIONS}" + headers = {} + if self.settings.hermes_api_key: + headers[AIHttpHeader.AUTHORIZATION] = AUTHORIZATION_BEARER_TEMPLATE.format( + token=self.settings.hermes_api_key + ) + if self.settings.hermes_session_id: + headers[AIHttpHeader.HERMES_SESSION_ID] = self.settings.hermes_session_id + payload = { + AIHttpPayloadKey.MODEL: self.settings.hermes_model, + AIHttpPayloadKey.MESSAGES: _chat_messages(prompt, context), + AIHttpPayloadKey.STREAM: False, + } + with httpx.Client(timeout=300, trust_env=False) as client: + response = client.post(url, json=payload, headers=headers) + if response.status_code >= status.HTTP_400_BAD_REQUEST: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={AIErrorKey.HERMES: response.text}, + ) + data = _chat_completion_payload(response, AIErrorKey.HERMES) + try: + answer = data[AIHttpPayloadKey.CHOICES][0][AIHttpPayloadKey.MESSAGE][ + AIHttpPayloadKey.CONTENT + ] + except (KeyError, IndexError, TypeError) as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={ + AIErrorKey.HERMES: UNEXPECTED_HERMES_RESPONSE, + AIResponseKey.RAW: data, + }, + ) from exc + return {AIResponseKey.ANSWER: answer, AIResponseKey.RAW: data} + + def health(self) -> dict[str, Any]: + """Check Hermes Agent health without triggering a chat completion.""" + + url = f"{_service_root(self.settings.hermes_base_url, AIHttpPath.V1)}{AIHttpPath.HEALTH}" + headers = {} + if self.settings.hermes_api_key: + headers[AIHttpHeader.AUTHORIZATION] = AUTHORIZATION_BEARER_TEMPLATE.format( + token=self.settings.hermes_api_key + ) + with httpx.Client(timeout=5, trust_env=False) as client: + response = client.get(url, headers=headers) + return { + AIResponseKey.OK: response.status_code < status.HTTP_400_BAD_REQUEST, + AIResponseKey.BASE_URL: self.settings.hermes_base_url.rstrip("/"), + AIResponseKey.HEALTH: _response_payload(response), + } diff --git a/app/modules/ai_agent/adapters/noop.py b/app/modules/ai_agent/adapters/noop.py new file mode 100644 index 0000000..cb10775 --- /dev/null +++ b/app/modules/ai_agent/adapters/noop.py @@ -0,0 +1,25 @@ +from typing import Any + + +from app.modules.ai_agent.adapters.base import AIAdapter +from app.modules.ai_agent.constants import ( + NOOP_PROVIDER_ANSWER, + AIProviderName, + AIRequestKey, + AIResponseKey, +) + + +class NoopAdapter(AIAdapter): + """Deterministic adapter used when no model provider is configured.""" + + provider_name = AIProviderName.NOOP + + def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: + return { + AIResponseKey.ANSWER: NOOP_PROVIDER_ANSWER, + AIResponseKey.RAW: { + AIRequestKey.PROMPT: prompt, + AIRequestKey.CONTEXT: context or {}, + }, + } diff --git a/app/modules/ai_agent/adapters/openclaw.py b/app/modules/ai_agent/adapters/openclaw.py new file mode 100644 index 0000000..f8a6d58 --- /dev/null +++ b/app/modules/ai_agent/adapters/openclaw.py @@ -0,0 +1,122 @@ +from typing import Any + +from fastapi import HTTPException, status + +from app.core.config import Settings +from app.modules.ai_agent.adapters import httpx +from app.modules.ai_agent.adapters.base import AIAdapter +from app.modules.ai_agent.adapters.common import ( + _response_payload, +) +from app.modules.ai_agent.constants import ( + AUTHORIZATION_BEARER_TEMPLATE, + OPENCLAW_ACTION_NOT_ALLOWED, + OPENCLAW_CHAT_PROVIDER_REQUIRED, + OPENCLAW_TOOL_COMPLETED_ANSWER, + OPENCLAW_TOOL_NOT_ALLOWED, + AIDefault, + AIContextKey, + AIErrorKey, + AIHttpHeader, + AIHttpPath, + AIHttpPayloadKey, + AIProviderName, + AIResponseKey, +) + + +class OpenClawAdapter(AIAdapter): + """Adapter for the OpenClaw Gateway control-plane API.""" + + provider_name = AIProviderName.OPENCLAW + + def __init__(self, settings: Settings): + self.settings = settings + + def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: + context = context or {} + tool = context.get(AIContextKey.OPENCLAW_TOOL) + if not tool: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=OPENCLAW_CHAT_PROVIDER_REQUIRED, + ) + result = self.invoke_tool( + tool=str(tool), + action=str(context.get(AIContextKey.OPENCLAW_ACTION) or AIDefault.ACTION_JSON), + args=context.get(AIContextKey.OPENCLAW_ARGS) or {}, + session_key=str( + context.get(AIContextKey.OPENCLAW_SESSION_KEY) or AIDefault.SESSION_KEY_MAIN + ), + ) + return { + AIResponseKey.ANSWER: OPENCLAW_TOOL_COMPLETED_ANSWER, + AIResponseKey.RAW: result, + } + + def health(self) -> dict[str, Any]: + """Check OpenClaw Gateway health endpoints.""" + + headers = self._headers() + base_url = self._base_url() + with httpx.Client(timeout=5, trust_env=False) as client: + healthz = client.get(f"{base_url}{AIHttpPath.HEALTHZ}", headers=headers) + readyz = client.get(f"{base_url}{AIHttpPath.READYZ}", headers=headers) + return { + AIResponseKey.OK: ( + healthz.status_code < status.HTTP_400_BAD_REQUEST + and readyz.status_code < status.HTTP_400_BAD_REQUEST + ), + AIResponseKey.BASE_URL: base_url, + AIResponseKey.HEALTHZ: _response_payload(healthz), + AIResponseKey.READYZ: _response_payload(readyz), + } + + def invoke_tool( + self, + tool: str, + action: str = AIDefault.ACTION_JSON, + args: dict[str, Any] | None = None, + session_key: str = AIDefault.SESSION_KEY_MAIN, + ) -> dict[str, Any]: + """Invoke one OpenClaw Gateway tool through `/tools/invoke`.""" + + self._ensure_tool_allowed(tool, action) + payload = { + AIHttpPayloadKey.TOOL: tool, + AIHttpPayloadKey.ACTION: action, + AIHttpPayloadKey.ARGS: args or {}, + AIHttpPayloadKey.SESSION_KEY: session_key, + } + url = f"{self._base_url()}{AIHttpPath.TOOLS_INVOKE}" + with httpx.Client(timeout=120, trust_env=False) as client: + response = client.post(url, json=payload, headers=self._headers()) + if response.status_code >= status.HTTP_400_BAD_REQUEST: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={AIErrorKey.OPENCLAW: response.text}, + ) + return _response_payload(response) + + def _base_url(self) -> str: + return (self.settings.openclaw_http_url or self.settings.openclaw_base_url).rstrip("/") + + def _headers(self) -> dict[str, str]: + token = self.settings.openclaw_gateway_token or self.settings.openclaw_api_key + if not token: + return {} + return { + AIHttpHeader.AUTHORIZATION: AUTHORIZATION_BEARER_TEMPLATE.format(token=token) + } + + def _ensure_tool_allowed(self, tool: str, action: str) -> None: + if tool not in set(self.settings.openclaw_allowed_tools): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=OPENCLAW_TOOL_NOT_ALLOWED, + ) + if action not in set(self.settings.openclaw_allowed_actions): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=OPENCLAW_ACTION_NOT_ALLOWED, + ) diff --git a/app/modules/ai_agent/adapters/openclaw_hermes.py b/app/modules/ai_agent/adapters/openclaw_hermes.py new file mode 100644 index 0000000..6f98a1c --- /dev/null +++ b/app/modules/ai_agent/adapters/openclaw_hermes.py @@ -0,0 +1,143 @@ +from typing import Any + +from fastapi import HTTPException, status + +from app.core.config import Settings +from app.modules.ai_agent.adapters.base import AIAdapter +from app.modules.ai_agent.adapters.common import ( + _error_detail, +) +from app.modules.ai_agent.constants import ( + OPENCLAW_HERMES_PIPELINE, + AIDefault, + AIContextKey, + AIErrorKey, + AIMemoryMode, + AIProviderName, + AIResponseKey, +) +from app.modules.ai_agent.skills import AISkillId, get_ai_skill + +from app.modules.ai_agent.adapters.hermes import HermesAdapter +from app.modules.ai_agent.adapters.openclaw import OpenClawAdapter + +class OpenClawHermesAdapter(AIAdapter): + """Compose OpenClaw Gateway context with Hermes Agent answers.""" + + provider_name = AIProviderName.OPENCLAW_HERMES + + def __init__(self, settings: Settings): + self.openclaw = OpenClawAdapter(settings) + self.hermes = HermesAdapter(settings) + + def ask(self, prompt: str, context: dict[str, Any] | None = None) -> dict[str, Any]: + base_context = context or {} + recall = self._recall_memory(prompt, base_context) + openclaw = self._openclaw_context(base_context) + hermes_context = { + **base_context, + AIContextKey.AGENT_PIPELINE: self.provider_name, + AIContextKey.HERMES_MEMORY: recall[AIResponseKey.ANSWER], + AIContextKey.OPENCLAW: openclaw, + } + hermes_result = self.hermes.ask(prompt, hermes_context) + remember = self._remember_interaction( + prompt, + base_context, + hermes_result[AIResponseKey.ANSWER], + ) + return { + AIResponseKey.ANSWER: hermes_result[AIResponseKey.ANSWER], + AIResponseKey.RAW: { + AIResponseKey.PIPELINE: OPENCLAW_HERMES_PIPELINE, + AIResponseKey.HERMES_RECALL: recall, + AIResponseKey.OPENCLAW: openclaw, + AIResponseKey.HERMES_ANSWER: hermes_result.get(AIResponseKey.RAW, {}), + AIResponseKey.HERMES_REMEMBER: remember, + }, + } + + def _openclaw_context(self, context: dict[str, Any]) -> dict[str, Any]: + result: dict[str, Any] = {AIResponseKey.TOOL_INVOKED: False} + try: + result[AIResponseKey.HEALTH] = self.openclaw.health() + except Exception as exc: + result[AIResponseKey.HEALTH] = { + AIResponseKey.OK: False, + AIResponseKey.ERROR: _error_detail(exc), + } + + tool = context.get(AIContextKey.OPENCLAW_TOOL) + if not tool: + return result + + try: + result[AIResponseKey.TOOL_INVOKED] = True + result[AIResponseKey.TOOL] = self.openclaw.invoke_tool( + tool=str(tool), + action=str(context.get(AIContextKey.OPENCLAW_ACTION) or AIDefault.ACTION_JSON), + args=context.get(AIContextKey.OPENCLAW_ARGS) or {}, + session_key=str( + context.get(AIContextKey.OPENCLAW_SESSION_KEY) + or AIDefault.SESSION_KEY_MAIN + ), + ) + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={AIErrorKey.OPENCLAW: _error_detail(exc)}, + ) from exc + return result + + def _recall_memory(self, prompt: str, context: dict[str, Any]) -> dict[str, Any]: + recall_skill = get_ai_skill(AISkillId.HERMES_MEMORY_RECALL) + try: + result = self.hermes.ask( + recall_skill.render(), + { + AIContextKey.MODE: AIMemoryMode.RECALL, + AIContextKey.USER_PROMPT: prompt, + AIContextKey.REQUEST_CONTEXT: context, + }, + ) + except Exception as exc: # Hermes memory should not block OpenClaw execution. + return { + AIResponseKey.ANSWER: "", + AIResponseKey.RAW: {}, + AIResponseKey.ERROR: _error_detail(exc), + } + return { + AIResponseKey.ANSWER: result[AIResponseKey.ANSWER], + AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}), + } + + def _remember_interaction( + self, + prompt: str, + context: dict[str, Any], + answer: str, + ) -> dict[str, Any]: + remember_skill = get_ai_skill(AISkillId.HERMES_MEMORY_WRITE) + try: + result = self.hermes.ask( + remember_skill.render(), + { + AIContextKey.MODE: AIMemoryMode.WRITE, + AIContextKey.USER_PROMPT: prompt, + AIContextKey.REQUEST_CONTEXT: context, + AIContextKey.ASSISTANT_ANSWER: answer, + }, + ) + except Exception as exc: + return { + AIResponseKey.OK: False, + AIResponseKey.RAW: {}, + AIResponseKey.ERROR: _error_detail(exc), + } + return { + AIResponseKey.OK: True, + AIResponseKey.RAW: result.get(AIResponseKey.RAW, {}), + AIResponseKey.ANSWER: result[AIResponseKey.ANSWER], + } diff --git a/app/modules/ai_memory/service.py b/app/modules/ai_memory/service.py index 57e2512..a6b6237 100644 --- a/app/modules/ai_memory/service.py +++ b/app/modules/ai_memory/service.py @@ -29,7 +29,7 @@ from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.business.service import serialize_model from app.modules.events.constants import EventAggregateType, EventSource, EventType -from app.modules.events.service import EventService +from app.modules.events.services import EventService class AIMemoryService: diff --git a/app/modules/business/models.py b/app/modules/business/models.py deleted file mode 100644 index 4a5d96a..0000000 --- a/app/modules/business/models.py +++ /dev/null @@ -1,296 +0,0 @@ -from datetime import date, datetime -from decimal import Decimal - -from sqlalchemy import JSON, Date, DateTime, Integer, Numeric, String, Text -from sqlalchemy.orm import Mapped, mapped_column - -from app.core.constants import ActorValue -from app.core.database import Base -from app.core.utils.time import utc_now -from app.modules.business.constants import ( - AccountType, - PriorityValue, - RiskLevel, - SourceSystem, - StatusValue, - VersionValue, -) - - -class TimestampMixin: - created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now) - updated_at: Mapped[datetime] = mapped_column( - DateTime, default=utc_now, onupdate=utc_now - ) - - -class Project(Base, TimestampMixin): - __tablename__ = "projects" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - name: Mapped[str] = mapped_column(String(255), index=True) - owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - status: Mapped[str] = mapped_column(String(64), default=StatusValue.INITIATED, index=True) - priority: Mapped[str] = mapped_column(String(32), default=PriorityValue.P2) - progress_percent: Mapped[int] = mapped_column(Integer, default=0) - risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) - budget_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) - actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) - start_date: Mapped[date | None] = mapped_column(Date, nullable=True) - due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) - external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - - -class WorkTask(Base, TimestampMixin): - __tablename__ = "work_tasks" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - title: Mapped[str] = mapped_column(String(255), index=True) - project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - status: Mapped[str] = mapped_column(String(64), default=StatusValue.TODO, index=True) - priority: Mapped[str] = mapped_column(String(32), default=PriorityValue.P2) - due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) - completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - blocker: Mapped[str | None] = mapped_column(Text, nullable=True) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) - external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - - -class Procurement(Base, TimestampMixin): - __tablename__ = "procurements" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - name: Mapped[str] = mapped_column(String(255), index=True) - applicant: Mapped[str | None] = mapped_column(String(128), nullable=True) - project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - supplier_name: Mapped[str | None] = mapped_column(String(255), nullable=True) - budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True) - expected_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) - actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) - approval_status: Mapped[str] = mapped_column(String(64), default=StatusValue.DRAFT, index=True) - delivery_status: Mapped[str] = mapped_column( - String(64), - default=StatusValue.UNDELIVERED, - index=True, - ) - payment_status: Mapped[str] = mapped_column(String(64), default=StatusValue.UNPAID, index=True) - comparison_summary: Mapped[str | None] = mapped_column(Text, nullable=True) - - -class Expense(Base, TimestampMixin): - __tablename__ = "expenses" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - expense_type: Mapped[str] = mapped_column(String(128), index=True) - amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) - applicant: Mapped[str | None] = mapped_column(String(128), nullable=True) - department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True) - payment_account: Mapped[str | None] = mapped_column(String(128), nullable=True) - invoice_status: Mapped[str] = mapped_column( - String(64), - default=StatusValue.INVOICE_NOT_RECEIVED, - ) - approval_status: Mapped[str] = mapped_column(String(64), default=StatusValue.DRAFT, index=True) - payment_status: Mapped[str] = mapped_column(String(64), default=StatusValue.UNPAID, index=True) - - -class FundAccount(Base, TimestampMixin): - __tablename__ = "fund_accounts" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - name: Mapped[str] = mapped_column(String(255), index=True) - account_type: Mapped[str] = mapped_column(String(64), default=AccountType.BANK) - current_balance: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) - expected_receivable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) - expected_payable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) - safety_line: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) - risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) - note: Mapped[str | None] = mapped_column(Text, nullable=True) - - -class Policy(Base, TimestampMixin): - __tablename__ = "policies" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - title: Mapped[str] = mapped_column(String(255), index=True) - policy_type: Mapped[str | None] = mapped_column(String(128), nullable=True) - owner_department: Mapped[str | None] = mapped_column(String(128), nullable=True) - version: Mapped[str] = mapped_column(String(32), default=VersionValue.V1_0) - status: Mapped[str] = mapped_column(String(64), default=StatusValue.POLICY_DRAFT, index=True) - effective_date: Mapped[date | None] = mapped_column(Date, nullable=True) - feishu_doc_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) - summary: Mapped[str | None] = mapped_column(Text, nullable=True) - - -class Standard(Base, TimestampMixin): - __tablename__ = "standards" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - title: Mapped[str] = mapped_column(String(255), index=True) - standard_type: Mapped[str | None] = mapped_column(String(128), nullable=True) - applies_to: Mapped[str | None] = mapped_column(String(255), nullable=True) - status: Mapped[str] = mapped_column(String(64), default=StatusValue.VALID, index=True) - check_items: Mapped[str | None] = mapped_column(Text, nullable=True) - remediation: Mapped[str | None] = mapped_column(Text, nullable=True) - policy_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - - -class PerformanceMetric(Base, TimestampMixin): - __tablename__ = "performance_metrics" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - name: Mapped[str] = mapped_column(String(255), index=True) - applies_to_role: Mapped[str | None] = mapped_column(String(128), nullable=True) - formula: Mapped[str | None] = mapped_column(Text, nullable=True) - weight: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) - data_source: Mapped[str | None] = mapped_column(String(255), nullable=True) - auto_score: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=0) - confirmed_score: Mapped[Decimal | None] = mapped_column(Numeric(8, 2), nullable=True) - status: Mapped[str] = mapped_column(String(64), default=StatusValue.DRAFT, index=True) - - -class Supplier(Base, TimestampMixin): - __tablename__ = "suppliers" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - name: Mapped[str] = mapped_column(String(255), index=True) - category: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - contact: Mapped[str | None] = mapped_column(String(128), nullable=True) - quality_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) - delivery_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) - price_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) - risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) - blacklist_status: Mapped[str] = mapped_column( - String(32), - default=StatusValue.NORMAL, - index=True, - ) - - -class AttendanceRecord(Base, TimestampMixin): - __tablename__ = "attendance_records" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - employee_name: Mapped[str] = mapped_column(String(128), index=True) - employee_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - work_date: Mapped[date] = mapped_column(Date, index=True) - check_in_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - check_out_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - status: Mapped[str] = mapped_column(String(64), default=StatusValue.NORMAL_CN, index=True) - location: Mapped[str | None] = mapped_column(String(255), nullable=True) - source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) - external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - note: Mapped[str | None] = mapped_column(Text, nullable=True) - - -class WorkReport(Base, TimestampMixin): - __tablename__ = "work_reports" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - report_type: Mapped[str] = mapped_column(String(32), index=True) - title: Mapped[str] = mapped_column(String(255), index=True) - reporter: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) - department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - period_start: Mapped[date] = mapped_column(Date, index=True) - period_end: Mapped[date] = mapped_column(Date, index=True) - content: Mapped[str] = mapped_column(Text) - metrics: Mapped[dict | None] = mapped_column(JSON, nullable=True) - risk_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True) - status: Mapped[str] = mapped_column(String(64), default=StatusValue.GENERATED, index=True) - source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) - - -class RiskEvent(Base, TimestampMixin): - __tablename__ = "risk_events" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - title: Mapped[str] = mapped_column(String(255), index=True) - risk_type: Mapped[str] = mapped_column(String(64), index=True) - risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.MEDIUM, index=True) - status: Mapped[str] = mapped_column(String(32), default=StatusValue.OPEN, index=True) - source_domain: Mapped[str] = mapped_column(String(128), index=True) - source_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - detected_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) - due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) - assigned_to: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - closed_reason: Mapped[str | None] = mapped_column(Text, nullable=True) - review_summary: Mapped[str | None] = mapped_column(Text, nullable=True) - description: Mapped[str | None] = mapped_column(Text, nullable=True) - mitigation: Mapped[str | None] = mapped_column(Text, nullable=True) - evidence: Mapped[dict | None] = mapped_column(JSON, nullable=True) - - -class RiskEventAction(Base): - __tablename__ = "risk_event_actions" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - risk_event_id: Mapped[int] = mapped_column(Integer, index=True) - action: Mapped[str] = mapped_column(String(64), index=True) - actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) - from_status: Mapped[str | None] = mapped_column(String(32), nullable=True) - to_status: Mapped[str | None] = mapped_column(String(32), nullable=True) - assigned_to: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - comment: Mapped[str | None] = mapped_column(Text, nullable=True) - payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) - created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) - - -class LegacySyncRun(Base, TimestampMixin): - __tablename__ = "legacy_sync_runs" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - domain: Mapped[str] = mapped_column(String(128), index=True) - source_table: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - status: Mapped[str] = mapped_column(String(32), default=StatusValue.RUNNING, index=True) - started_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) - finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - created_count: Mapped[int] = mapped_column(Integer, default=0) - updated_count: Mapped[int] = mapped_column(Integer, default=0) - skipped_count: Mapped[int] = mapped_column(Integer, default=0) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - note: Mapped[str | None] = mapped_column(Text, nullable=True) - - -class ReportPushRun(Base, TimestampMixin): - __tablename__ = "report_push_runs" - - id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - code: Mapped[str] = mapped_column(String(64), unique=True, index=True) - report_type: Mapped[str] = mapped_column(String(64), index=True) - title: Mapped[str | None] = mapped_column(String(255), nullable=True) - receive_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - receive_id_type: Mapped[str] = mapped_column(String(64), index=True) - status: Mapped[str] = mapped_column(String(32), index=True) - task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) - actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) - provider_response: Mapped[dict | None] = mapped_column(JSON, nullable=True) - error_message: Mapped[str | None] = mapped_column(Text, nullable=True) - queued_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) - sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/app/modules/business/models/__init__.py b/app/modules/business/models/__init__.py new file mode 100644 index 0000000..ebef96c --- /dev/null +++ b/app/modules/business/models/__init__.py @@ -0,0 +1,27 @@ +from app.modules.business.models.attendance import AttendanceRecord +from app.modules.business.models.finance import Expense, FundAccount, Procurement +from app.modules.business.models.governance import PerformanceMetric, Policy, Standard +from app.modules.business.models.legacy import LegacySyncRun +from app.modules.business.models.projects import Project, WorkTask +from app.modules.business.models.reports import ReportPushRun, WorkReport +from app.modules.business.models.risks import RiskEvent, RiskEventAction +from app.modules.business.models.suppliers import Supplier + + +__all__ = [ + "AttendanceRecord", + "Expense", + "FundAccount", + "LegacySyncRun", + "PerformanceMetric", + "Policy", + "Procurement", + "Project", + "ReportPushRun", + "RiskEvent", + "RiskEventAction", + "Standard", + "Supplier", + "WorkReport", + "WorkTask", +] diff --git a/app/modules/business/models/attendance.py b/app/modules/business/models/attendance.py new file mode 100644 index 0000000..e6b965a --- /dev/null +++ b/app/modules/business/models/attendance.py @@ -0,0 +1,30 @@ +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.modules.business.constants import ( + SourceSystem, + StatusValue, +) +from app.modules.business.models.common import TimestampMixin + + +class AttendanceRecord(Base, TimestampMixin): + __tablename__ = "attendance_records" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + employee_name: Mapped[str] = mapped_column(String(128), index=True) + employee_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + work_date: Mapped[date] = mapped_column(Date, index=True) + check_in_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + check_out_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.NORMAL_CN, index=True) + location: Mapped[str | None] = mapped_column(String(255), nullable=True) + source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) + external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/app/modules/business/models/common.py b/app/modules/business/models/common.py new file mode 100644 index 0000000..476b0c1 --- /dev/null +++ b/app/modules/business/models/common.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from sqlalchemy import DateTime +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.utils.time import utc_now + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now) + updated_at: Mapped[datetime] = mapped_column( + DateTime, default=utc_now, onupdate=utc_now + ) diff --git a/app/modules/business/models/finance.py b/app/modules/business/models/finance.py new file mode 100644 index 0000000..8741fcb --- /dev/null +++ b/app/modules/business/models/finance.py @@ -0,0 +1,67 @@ +from decimal import Decimal + +from sqlalchemy import Integer, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.modules.business.constants import ( + AccountType, + RiskLevel, + StatusValue, +) +from app.modules.business.models.common import TimestampMixin + + +class Procurement(Base, TimestampMixin): + __tablename__ = "procurements" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), index=True) + applicant: Mapped[str | None] = mapped_column(String(128), nullable=True) + project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + supplier_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True) + expected_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) + actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) + approval_status: Mapped[str] = mapped_column(String(64), default=StatusValue.DRAFT, index=True) + delivery_status: Mapped[str] = mapped_column( + String(64), + default=StatusValue.UNDELIVERED, + index=True, + ) + payment_status: Mapped[str] = mapped_column(String(64), default=StatusValue.UNPAID, index=True) + comparison_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + +class Expense(Base, TimestampMixin): + __tablename__ = "expenses" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + expense_type: Mapped[str] = mapped_column(String(128), index=True) + amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) + applicant: Mapped[str | None] = mapped_column(String(128), nullable=True) + department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + budget_subject: Mapped[str | None] = mapped_column(String(128), nullable=True) + payment_account: Mapped[str | None] = mapped_column(String(128), nullable=True) + invoice_status: Mapped[str] = mapped_column( + String(64), + default=StatusValue.INVOICE_NOT_RECEIVED, + ) + approval_status: Mapped[str] = mapped_column(String(64), default=StatusValue.DRAFT, index=True) + payment_status: Mapped[str] = mapped_column(String(64), default=StatusValue.UNPAID, index=True) + +class FundAccount(Base, TimestampMixin): + __tablename__ = "fund_accounts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), index=True) + account_type: Mapped[str] = mapped_column(String(64), default=AccountType.BANK) + current_balance: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) + expected_receivable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) + expected_payable: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) + safety_line: Mapped[Decimal] = mapped_column(Numeric(16, 2), default=0) + risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/app/modules/business/models/governance.py b/app/modules/business/models/governance.py new file mode 100644 index 0000000..b22bc64 --- /dev/null +++ b/app/modules/business/models/governance.py @@ -0,0 +1,54 @@ +from datetime import date +from decimal import Decimal + +from sqlalchemy import Date, Integer, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.modules.business.constants import ( + StatusValue, + VersionValue, +) +from app.modules.business.models.common import TimestampMixin + + +class Policy(Base, TimestampMixin): + __tablename__ = "policies" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + title: Mapped[str] = mapped_column(String(255), index=True) + policy_type: Mapped[str | None] = mapped_column(String(128), nullable=True) + owner_department: Mapped[str | None] = mapped_column(String(128), nullable=True) + version: Mapped[str] = mapped_column(String(32), default=VersionValue.V1_0) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.POLICY_DRAFT, index=True) + effective_date: Mapped[date | None] = mapped_column(Date, nullable=True) + feishu_doc_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + +class Standard(Base, TimestampMixin): + __tablename__ = "standards" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + title: Mapped[str] = mapped_column(String(255), index=True) + standard_type: Mapped[str | None] = mapped_column(String(128), nullable=True) + applies_to: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.VALID, index=True) + check_items: Mapped[str | None] = mapped_column(Text, nullable=True) + remediation: Mapped[str | None] = mapped_column(Text, nullable=True) + policy_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + +class PerformanceMetric(Base, TimestampMixin): + __tablename__ = "performance_metrics" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), index=True) + applies_to_role: Mapped[str | None] = mapped_column(String(128), nullable=True) + formula: Mapped[str | None] = mapped_column(Text, nullable=True) + weight: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) + data_source: Mapped[str | None] = mapped_column(String(255), nullable=True) + auto_score: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=0) + confirmed_score: Mapped[Decimal | None] = mapped_column(Numeric(8, 2), nullable=True) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.DRAFT, index=True) diff --git a/app/modules/business/models/legacy.py b/app/modules/business/models/legacy.py new file mode 100644 index 0000000..7c3d54e --- /dev/null +++ b/app/modules/business/models/legacy.py @@ -0,0 +1,28 @@ +from datetime import datetime + +from sqlalchemy import DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.core.utils.time import utc_now +from app.modules.business.constants import ( + StatusValue, +) +from app.modules.business.models.common import TimestampMixin + + +class LegacySyncRun(Base, TimestampMixin): + __tablename__ = "legacy_sync_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + domain: Mapped[str] = mapped_column(String(128), index=True) + source_table: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(32), default=StatusValue.RUNNING, index=True) + started_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_count: Mapped[int] = mapped_column(Integer, default=0) + updated_count: Mapped[int] = mapped_column(Integer, default=0) + skipped_count: Mapped[int] = mapped_column(Integer, default=0) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + note: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/app/modules/business/models/projects.py b/app/modules/business/models/projects.py new file mode 100644 index 0000000..04bf1ec --- /dev/null +++ b/app/modules/business/models/projects.py @@ -0,0 +1,51 @@ +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import Date, DateTime, Integer, Numeric, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.modules.business.constants import ( + PriorityValue, + RiskLevel, + SourceSystem, + StatusValue, +) +from app.modules.business.models.common import TimestampMixin + + +class Project(Base, TimestampMixin): + __tablename__ = "projects" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), index=True) + owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.INITIATED, index=True) + priority: Mapped[str] = mapped_column(String(32), default=PriorityValue.P2) + progress_percent: Mapped[int] = mapped_column(Integer, default=0) + risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) + budget_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) + actual_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0) + start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) + external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + +class WorkTask(Base, TimestampMixin): + __tablename__ = "work_tasks" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + title: Mapped[str] = mapped_column(String(255), index=True) + project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.TODO, index=True) + priority: Mapped[str] = mapped_column(String(32), default=PriorityValue.P2) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + blocker: Mapped[str | None] = mapped_column(Text, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) + external_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) diff --git a/app/modules/business/models/reports.py b/app/modules/business/models/reports.py new file mode 100644 index 0000000..a405df0 --- /dev/null +++ b/app/modules/business/models/reports.py @@ -0,0 +1,49 @@ +from datetime import date, datetime + +from sqlalchemy import JSON, Date, DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.constants import ActorValue +from app.core.database import Base +from app.core.utils.time import utc_now +from app.modules.business.constants import ( + SourceSystem, + StatusValue, +) +from app.modules.business.models.common import TimestampMixin + + +class WorkReport(Base, TimestampMixin): + __tablename__ = "work_reports" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + report_type: Mapped[str] = mapped_column(String(32), index=True) + title: Mapped[str] = mapped_column(String(255), index=True) + reporter: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) + department: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + period_start: Mapped[date] = mapped_column(Date, index=True) + period_end: Mapped[date] = mapped_column(Date, index=True) + content: Mapped[str] = mapped_column(Text) + metrics: Mapped[dict | None] = mapped_column(JSON, nullable=True) + risk_summary: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status: Mapped[str] = mapped_column(String(64), default=StatusValue.GENERATED, index=True) + source_system: Mapped[str] = mapped_column(String(64), default=SourceSystem.INTERNAL) + +class ReportPushRun(Base, TimestampMixin): + __tablename__ = "report_push_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + report_type: Mapped[str] = mapped_column(String(64), index=True) + title: Mapped[str | None] = mapped_column(String(255), nullable=True) + receive_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + receive_id_type: Mapped[str] = mapped_column(String(64), index=True) + status: Mapped[str] = mapped_column(String(32), index=True) + task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) + provider_response: Mapped[dict | None] = mapped_column(JSON, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + queued_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) + sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/app/modules/business/models/risks.py b/app/modules/business/models/risks.py new file mode 100644 index 0000000..ff4251d --- /dev/null +++ b/app/modules/business/models/risks.py @@ -0,0 +1,53 @@ +from datetime import date, datetime + +from sqlalchemy import JSON, Date, DateTime, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.constants import ActorValue +from app.core.database import Base +from app.core.utils.time import utc_now +from app.modules.business.constants import ( + RiskLevel, + StatusValue, +) +from app.modules.business.models.common import TimestampMixin + + +class RiskEvent(Base, TimestampMixin): + __tablename__ = "risk_events" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + title: Mapped[str] = mapped_column(String(255), index=True) + risk_type: Mapped[str] = mapped_column(String(64), index=True) + risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.MEDIUM, index=True) + status: Mapped[str] = mapped_column(String(32), default=StatusValue.OPEN, index=True) + source_domain: Mapped[str] = mapped_column(String(128), index=True) + source_record_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + project_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + owner: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + detected_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) + due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True) + assigned_to: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + closed_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + review_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + mitigation: Mapped[str | None] = mapped_column(Text, nullable=True) + evidence: Mapped[dict | None] = mapped_column(JSON, nullable=True) + +class RiskEventAction(Base): + __tablename__ = "risk_event_actions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + risk_event_id: Mapped[int] = mapped_column(Integer, index=True) + action: Mapped[str] = mapped_column(String(64), index=True) + actor: Mapped[str] = mapped_column(String(128), default=ActorValue.SYSTEM, index=True) + from_status: Mapped[str | None] = mapped_column(String(32), nullable=True) + to_status: Mapped[str | None] = mapped_column(String(32), nullable=True) + assigned_to: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + comment: Mapped[str | None] = mapped_column(Text, nullable=True) + payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=utc_now, index=True) diff --git a/app/modules/business/models/suppliers.py b/app/modules/business/models/suppliers.py new file mode 100644 index 0000000..3fbcb53 --- /dev/null +++ b/app/modules/business/models/suppliers.py @@ -0,0 +1,30 @@ +from decimal import Decimal + +from sqlalchemy import Integer, Numeric, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.database import Base +from app.modules.business.constants import ( + RiskLevel, + StatusValue, +) +from app.modules.business.models.common import TimestampMixin + + +class Supplier(Base, TimestampMixin): + __tablename__ = "suppliers" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255), index=True) + category: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) + contact: Mapped[str | None] = mapped_column(String(128), nullable=True) + quality_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) + delivery_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) + price_score: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=0) + risk_level: Mapped[str] = mapped_column(String(32), default=RiskLevel.LOW, index=True) + blacklist_status: Mapped[str] = mapped_column( + String(32), + default=StatusValue.NORMAL, + index=True, + ) diff --git a/app/modules/dashboard/service.py b/app/modules/dashboard/service.py index 8ffe0a4..76cfd77 100644 --- a/app/modules/dashboard/service.py +++ b/app/modules/dashboard/service.py @@ -21,7 +21,7 @@ from app.modules.events.models import DomainEvent from app.modules.observability.constants import ObservabilityMetricKey from app.modules.observability.service import ObservabilityService from app.modules.reports.constants import ReportPushStatus -from app.modules.risk.service import RiskService +from app.modules.risk.services import RiskService from app.modules.workflows.constants import WorkflowStatus from app.modules.workflows.models import WorkflowInstance diff --git a/app/modules/events/routes.py b/app/modules/events/routes.py index 54188d4..53b159c 100644 --- a/app/modules/events/routes.py +++ b/app/modules/events/routes.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from app.core.database import get_db from app.core.security import ApiPrincipal, require_api_key from app.modules.events.constants import EventResponseKey -from app.modules.events.service import EventService, _serialize_event +from app.modules.events.services import EventService, _serialize_event router = APIRouter(dependencies=[Depends(require_api_key)]) diff --git a/app/modules/events/service.py b/app/modules/events/service.py deleted file mode 100644 index b055f4a..0000000 --- a/app/modules/events/service.py +++ /dev/null @@ -1,349 +0,0 @@ -from datetime import timedelta -from typing import Any -from uuid import uuid4 - -from fastapi import HTTPException, status -from sqlalchemy import func, or_, select -from sqlalchemy.orm import Session - -from app.core.config import get_settings -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.events.constants import ( - EVENT_CODE_PREFIX, - EventAggregateType, - EventErrorDetail, - EventPayloadKey, - EventStatus, - EventType, -) -from app.modules.events.models import DomainEvent - - -def _serialize_event(record: DomainEvent) -> dict[str, Any]: - return { - column.name: getattr(record, column.name) - for column in record.__table__.columns - } - - -class EventService: - """Persist outbox events and dispatch the V3 internal handlers.""" - - def __init__(self, db: Session): - self.db = db - - def emit( - self, - event_type: str, - source: str, - aggregate_type: str, - aggregate_id: str | int | None, - actor: str = ActorValue.SYSTEM, - payload: dict[str, Any] | None = None, - idempotency_key: str | None = None, - dispatch: bool = False, - ) -> DomainEvent: - if idempotency_key: - existing = self.db.execute( - select(DomainEvent).where(DomainEvent.idempotency_key == idempotency_key) - ).scalar_one_or_none() - if existing is not None: - if dispatch and existing.status == EventStatus.PENDING: - return self.dispatch_event(existing.event_id) - return existing - - settings = get_settings() - now = utc_now() - record = DomainEvent( - event_id=f"{EVENT_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}", - event_type=event_type, - source=source, - aggregate_type=aggregate_type, - aggregate_id=str(aggregate_id) if aggregate_id is not None else None, - actor=actor, - payload=payload or {}, - idempotency_key=idempotency_key, - next_attempt_at=now, - max_attempts=settings.event_dispatch_max_attempts, - ) - self.db.add(record) - self.db.commit() - self.db.refresh(record) - if dispatch: - return self.dispatch_event(record.event_id) - return record - - def list_events( - self, - status_filter: str | None = None, - event_type: str | None = None, - limit: int = 100, - ) -> list[dict[str, Any]]: - stmt = select(DomainEvent).order_by(DomainEvent.id.desc()).limit(bounded_limit(limit)) - if status_filter: - stmt = stmt.where(DomainEvent.status == status_filter) - if event_type: - stmt = stmt.where(DomainEvent.event_type == event_type) - return [_serialize_event(item) for item in self.db.execute(stmt).scalars()] - - def count_by_status(self) -> dict[str, int]: - rows = self.db.execute( - select(DomainEvent.status, func.count()).group_by(DomainEvent.status) - ).all() - return {str(status_value): int(count) for status_value, count in rows} - - def get_event(self, event_id: str) -> DomainEvent: - record = self.db.execute( - select(DomainEvent).where(DomainEvent.event_id == event_id) - ).scalar_one_or_none() - if record is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=EventErrorDetail.EVENT_NOT_FOUND, - ) - return record - - def dispatch_event(self, event_id: str, worker_id: str | None = None) -> DomainEvent: - record = self.get_event(event_id) - if record.status == EventStatus.PROCESSED: - return record - if not self._can_attempt(record): - return record - settings = get_settings() - now = utc_now() - lock_owner = worker_id or f"api:{uuid4().hex}" - record.locked_by = lock_owner - record.locked_until = now + timedelta(seconds=settings.event_dispatch_lock_seconds) - record.status = EventStatus.PENDING - record.attempts += 1 - try: - self._handle_event(record) - except Exception as exc: - retryable = record.attempts < self._max_attempts(record) - record.status = EventStatus.PENDING if retryable else EventStatus.FAILED - record.last_error = str(exc) - record.next_attempt_at = ( - utc_now() + timedelta(seconds=settings.event_dispatch_retry_delay_seconds) - if retryable - else None - ) - record.locked_by = None - record.locked_until = None - self.db.commit() - self.db.refresh(record) - self._audit_dispatch(record) - return record - record.status = EventStatus.PROCESSED - record.last_error = None - record.processed_at = utc_now() - record.next_attempt_at = None - record.locked_by = None - record.locked_until = None - self.db.commit() - self.db.refresh(record) - self._audit_dispatch(record) - return record - - def dispatch_pending( - self, - limit: int = 100, - worker_id: str | None = None, - ) -> list[dict[str, Any]]: - now = utc_now() - stmt = ( - select(DomainEvent) - .where( - DomainEvent.status == EventStatus.PENDING, - or_( - DomainEvent.next_attempt_at.is_(None), - DomainEvent.next_attempt_at <= now, - ), - or_( - DomainEvent.locked_until.is_(None), - DomainEvent.locked_until <= now, - ), - or_( - DomainEvent.max_attempts.is_(None), - DomainEvent.attempts < DomainEvent.max_attempts, - ), - ) - .order_by(DomainEvent.id.asc()) - .limit(bounded_limit(limit)) - ) - records = list(self.db.execute(stmt).scalars()) - lock_owner = worker_id or f"worker:{uuid4().hex}" - return [ - _serialize_event(self.dispatch_event(record.event_id, worker_id=lock_owner)) - for record in records - ] - - def retry_event(self, event_id: str, actor: str = ActorValue.API) -> DomainEvent: - record = self.get_event(event_id) - if record.status == EventStatus.PROCESSED: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=EventErrorDetail.EVENT_NOT_RETRYABLE, - ) - record.status = EventStatus.PENDING - record.actor = actor - record.attempts = 0 - record.max_attempts = record.max_attempts or get_settings().event_dispatch_max_attempts - record.last_error = None - record.locked_by = None - record.locked_until = None - record.next_attempt_at = utc_now() - self.db.commit() - self.db.refresh(record) - return record - - def _handle_event(self, record: DomainEvent) -> None: - if record.event_type == EventType.RISK_ACTION_RECORDED: - self._handle_risk_action(record) - return - if record.event_type in { - EventType.REPORT_PUSH_SUCCEEDED, - EventType.REPORT_PUSH_FAILED, - EventType.REPORT_GENERATED, - }: - self._handle_report_event(record) - return - if record.event_type in { - EventType.LEGACY_SYNC_COMPLETED, - EventType.LEGACY_SYNC_FAILED, - }: - self._handle_legacy_sync_event(record) - return - if record.event_type == EventType.AI_MEMORY_WRITTEN: - self._handle_ai_memory_event(record) - return - if record.event_type == EventType.ENTERPRISE_ANALYTICS_GENERATED: - self._handle_enterprise_analytics_event(record) - return - - def _handle_risk_action(self, record: DomainEvent) -> None: - from app.modules.risk.constants import RiskEventActionValue - from app.modules.workflows.service import WorkflowService - from app.modules.workflows.constants import WorkflowStatus, WorkflowType - - payload = record.payload or {} - action = str(payload.get(EventPayloadKey.ACTION) or "") - if action == RiskEventActionValue.CLOSE: - workflow_status = WorkflowStatus.COMPLETED - elif action == RiskEventActionValue.RESOLVE: - workflow_status = WorkflowStatus.WAITING_REVIEW - else: - workflow_status = WorkflowStatus.RUNNING - WorkflowService(self.db).start_or_update( - workflow_type=WorkflowType.RISK_EVENT_REVIEW, - aggregate_type=EventAggregateType.RISK_EVENT, - aggregate_id=record.aggregate_id, - status_value=workflow_status, - action=action or record.event_type, - actor=record.actor, - payload=payload, - ) - - def _handle_report_event(self, record: DomainEvent) -> None: - from app.modules.workflows.constants import WorkflowStatus, WorkflowType - - workflow_status = ( - WorkflowStatus.FAILED - if record.event_type == EventType.REPORT_PUSH_FAILED - else WorkflowStatus.COMPLETED - ) - self._track_operational_workflow( - record, - workflow_type=WorkflowType.REPORT_DELIVERY, - workflow_status=workflow_status, - ) - - def _handle_legacy_sync_event(self, record: DomainEvent) -> None: - from app.modules.workflows.constants import WorkflowStatus, WorkflowType - - workflow_status = ( - WorkflowStatus.FAILED - if record.event_type == EventType.LEGACY_SYNC_FAILED - else WorkflowStatus.COMPLETED - ) - self._track_operational_workflow( - record, - workflow_type=WorkflowType.LEGACY_SYNC_MONITOR, - workflow_status=workflow_status, - ) - - def _handle_ai_memory_event(self, record: DomainEvent) -> None: - from app.modules.ai_memory.constants import AIMemoryPayloadKey, AIMemoryStatus - from app.modules.workflows.constants import WorkflowStatus, WorkflowType - - payload = record.payload or {} - workflow_status = ( - WorkflowStatus.BLOCKED - if payload.get(AIMemoryPayloadKey.STATUS) == AIMemoryStatus.REJECTED - else WorkflowStatus.COMPLETED - ) - self._track_operational_workflow( - record, - workflow_type=WorkflowType.AI_MEMORY_CAPTURE, - workflow_status=workflow_status, - ) - - def _handle_enterprise_analytics_event(self, record: DomainEvent) -> None: - from app.modules.workflows.constants import WorkflowStatus, WorkflowType - - self._track_operational_workflow( - record, - workflow_type=WorkflowType.ENTERPRISE_ANALYTICS, - workflow_status=WorkflowStatus.COMPLETED, - ) - - def _track_operational_workflow( - self, - record: DomainEvent, - workflow_type: str, - workflow_status: str, - ) -> None: - from app.modules.workflows.service import WorkflowService - - WorkflowService(self.db).start_or_update( - workflow_type=workflow_type, - aggregate_type=record.aggregate_type, - aggregate_id=record.aggregate_id, - status_value=workflow_status, - action=record.event_type, - actor=record.actor, - payload=record.payload or {}, - ) - - def _audit_dispatch(self, record: DomainEvent) -> None: - AuditService(self.db).log( - AuditLogCreate( - actor=record.actor, - source=AuditSource.EVENTS, - action=AuditAction.EVENT_DISPATCH, - target_type=AuditTargetType.DOMAIN_EVENT, - target_id=record.event_id, - risk_level=AuditRiskLevel.LOW, - response_payload={ - EventPayloadKey.STATUS: record.status, - EventPayloadKey.ATTEMPTS: record.attempts, - EventPayloadKey.ERROR_MESSAGE: record.last_error, - }, - ) - ) - - def _can_attempt(self, record: DomainEvent) -> bool: - return record.attempts < self._max_attempts(record) - - @staticmethod - def _max_attempts(record: DomainEvent) -> int: - return record.max_attempts or get_settings().event_dispatch_max_attempts diff --git a/app/modules/events/services/__init__.py b/app/modules/events/services/__init__.py new file mode 100644 index 0000000..1301512 --- /dev/null +++ b/app/modules/events/services/__init__.py @@ -0,0 +1,5 @@ +from app.modules.events.services.serialization import _serialize_event +from app.modules.events.services.service import EventService + + +__all__ = ["EventService", "_serialize_event"] diff --git a/app/modules/events/services/dispatch.py b/app/modules/events/services/dispatch.py new file mode 100644 index 0000000..51f8924 --- /dev/null +++ b/app/modules/events/services/dispatch.py @@ -0,0 +1,145 @@ +from datetime import timedelta +from typing import Any +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import or_, select + +from app.core.config import get_settings +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.events.constants import ( + EventErrorDetail, + EventPayloadKey, + EventStatus, +) +from app.modules.events.models import DomainEvent +from app.modules.events.services.serialization import _serialize_event + + +class EventDispatchMixin: + def dispatch_event(self, event_id: str, worker_id: str | None = None) -> DomainEvent: + record = self.get_event(event_id) + if record.status == EventStatus.PROCESSED: + return record + if not self._can_attempt(record): + return record + settings = get_settings() + now = utc_now() + lock_owner = worker_id or f"api:{uuid4().hex}" + record.locked_by = lock_owner + record.locked_until = now + timedelta(seconds=settings.event_dispatch_lock_seconds) + record.status = EventStatus.PENDING + record.attempts += 1 + try: + self._handle_event(record) + except Exception as exc: + retryable = record.attempts < self._max_attempts(record) + record.status = EventStatus.PENDING if retryable else EventStatus.FAILED + record.last_error = str(exc) + record.next_attempt_at = ( + utc_now() + timedelta(seconds=settings.event_dispatch_retry_delay_seconds) + if retryable + else None + ) + record.locked_by = None + record.locked_until = None + self.db.commit() + self.db.refresh(record) + self._audit_dispatch(record) + return record + record.status = EventStatus.PROCESSED + record.last_error = None + record.processed_at = utc_now() + record.next_attempt_at = None + record.locked_by = None + record.locked_until = None + self.db.commit() + self.db.refresh(record) + self._audit_dispatch(record) + return record + + def dispatch_pending( + self, + limit: int = 100, + worker_id: str | None = None, + ) -> list[dict[str, Any]]: + now = utc_now() + stmt = ( + select(DomainEvent) + .where( + DomainEvent.status == EventStatus.PENDING, + or_( + DomainEvent.next_attempt_at.is_(None), + DomainEvent.next_attempt_at <= now, + ), + or_( + DomainEvent.locked_until.is_(None), + DomainEvent.locked_until <= now, + ), + or_( + DomainEvent.max_attempts.is_(None), + DomainEvent.attempts < DomainEvent.max_attempts, + ), + ) + .order_by(DomainEvent.id.asc()) + .limit(bounded_limit(limit)) + ) + records = list(self.db.execute(stmt).scalars()) + lock_owner = worker_id or f"worker:{uuid4().hex}" + return [ + _serialize_event(self.dispatch_event(record.event_id, worker_id=lock_owner)) + for record in records + ] + + def retry_event(self, event_id: str, actor: str = ActorValue.API) -> DomainEvent: + record = self.get_event(event_id) + if record.status == EventStatus.PROCESSED: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=EventErrorDetail.EVENT_NOT_RETRYABLE, + ) + record.status = EventStatus.PENDING + record.actor = actor + record.attempts = 0 + record.max_attempts = record.max_attempts or get_settings().event_dispatch_max_attempts + record.last_error = None + record.locked_by = None + record.locked_until = None + record.next_attempt_at = utc_now() + self.db.commit() + self.db.refresh(record) + return record + + def _audit_dispatch(self, record: DomainEvent) -> None: + AuditService(self.db).log( + AuditLogCreate( + actor=record.actor, + source=AuditSource.EVENTS, + action=AuditAction.EVENT_DISPATCH, + target_type=AuditTargetType.DOMAIN_EVENT, + target_id=record.event_id, + risk_level=AuditRiskLevel.LOW, + response_payload={ + EventPayloadKey.STATUS: record.status, + EventPayloadKey.ATTEMPTS: record.attempts, + EventPayloadKey.ERROR_MESSAGE: record.last_error, + }, + ) + ) + + def _can_attempt(self, record: DomainEvent) -> bool: + return record.attempts < self._max_attempts(record) + + @staticmethod + def _max_attempts(record: DomainEvent) -> int: + return record.max_attempts or get_settings().event_dispatch_max_attempts diff --git a/app/modules/events/services/handlers.py b/app/modules/events/services/handlers.py new file mode 100644 index 0000000..e5fa801 --- /dev/null +++ b/app/modules/events/services/handlers.py @@ -0,0 +1,128 @@ + + +from app.modules.events.constants import ( + EventAggregateType, + EventPayloadKey, + EventType, +) +from app.modules.events.models import DomainEvent + + +class EventHandlerMixin: + def _handle_event(self, record: DomainEvent) -> None: + if record.event_type == EventType.RISK_ACTION_RECORDED: + self._handle_risk_action(record) + return + if record.event_type in { + EventType.REPORT_PUSH_SUCCEEDED, + EventType.REPORT_PUSH_FAILED, + EventType.REPORT_GENERATED, + }: + self._handle_report_event(record) + return + if record.event_type in { + EventType.LEGACY_SYNC_COMPLETED, + EventType.LEGACY_SYNC_FAILED, + }: + self._handle_legacy_sync_event(record) + return + if record.event_type == EventType.AI_MEMORY_WRITTEN: + self._handle_ai_memory_event(record) + return + if record.event_type == EventType.ENTERPRISE_ANALYTICS_GENERATED: + self._handle_enterprise_analytics_event(record) + return + + def _handle_risk_action(self, record: DomainEvent) -> None: + from app.modules.risk.constants import RiskEventActionValue + from app.modules.workflows.service import WorkflowService + from app.modules.workflows.constants import WorkflowStatus, WorkflowType + + payload = record.payload or {} + action = str(payload.get(EventPayloadKey.ACTION) or "") + if action == RiskEventActionValue.CLOSE: + workflow_status = WorkflowStatus.COMPLETED + elif action == RiskEventActionValue.RESOLVE: + workflow_status = WorkflowStatus.WAITING_REVIEW + else: + workflow_status = WorkflowStatus.RUNNING + WorkflowService(self.db).start_or_update( + workflow_type=WorkflowType.RISK_EVENT_REVIEW, + aggregate_type=EventAggregateType.RISK_EVENT, + aggregate_id=record.aggregate_id, + status_value=workflow_status, + action=action or record.event_type, + actor=record.actor, + payload=payload, + ) + + def _handle_report_event(self, record: DomainEvent) -> None: + from app.modules.workflows.constants import WorkflowStatus, WorkflowType + + workflow_status = ( + WorkflowStatus.FAILED + if record.event_type == EventType.REPORT_PUSH_FAILED + else WorkflowStatus.COMPLETED + ) + self._track_operational_workflow( + record, + workflow_type=WorkflowType.REPORT_DELIVERY, + workflow_status=workflow_status, + ) + + def _handle_legacy_sync_event(self, record: DomainEvent) -> None: + from app.modules.workflows.constants import WorkflowStatus, WorkflowType + + workflow_status = ( + WorkflowStatus.FAILED + if record.event_type == EventType.LEGACY_SYNC_FAILED + else WorkflowStatus.COMPLETED + ) + self._track_operational_workflow( + record, + workflow_type=WorkflowType.LEGACY_SYNC_MONITOR, + workflow_status=workflow_status, + ) + + def _handle_ai_memory_event(self, record: DomainEvent) -> None: + from app.modules.ai_memory.constants import AIMemoryPayloadKey, AIMemoryStatus + from app.modules.workflows.constants import WorkflowStatus, WorkflowType + + payload = record.payload or {} + workflow_status = ( + WorkflowStatus.BLOCKED + if payload.get(AIMemoryPayloadKey.STATUS) == AIMemoryStatus.REJECTED + else WorkflowStatus.COMPLETED + ) + self._track_operational_workflow( + record, + workflow_type=WorkflowType.AI_MEMORY_CAPTURE, + workflow_status=workflow_status, + ) + + def _handle_enterprise_analytics_event(self, record: DomainEvent) -> None: + from app.modules.workflows.constants import WorkflowStatus, WorkflowType + + self._track_operational_workflow( + record, + workflow_type=WorkflowType.ENTERPRISE_ANALYTICS, + workflow_status=WorkflowStatus.COMPLETED, + ) + + def _track_operational_workflow( + self, + record: DomainEvent, + workflow_type: str, + workflow_status: str, + ) -> None: + from app.modules.workflows.service import WorkflowService + + WorkflowService(self.db).start_or_update( + workflow_type=workflow_type, + aggregate_type=record.aggregate_type, + aggregate_id=record.aggregate_id, + status_value=workflow_status, + action=record.event_type, + actor=record.actor, + payload=record.payload or {}, + ) diff --git a/app/modules/events/services/query.py b/app/modules/events/services/query.py new file mode 100644 index 0000000..108e6a1 --- /dev/null +++ b/app/modules/events/services/query.py @@ -0,0 +1,89 @@ +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import func, select + +from app.core.config import get_settings +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.events.constants import ( + EVENT_CODE_PREFIX, + EventErrorDetail, + EventStatus, +) +from app.modules.events.models import DomainEvent +from app.modules.events.services.serialization import _serialize_event + + +class EventQueryMixin: + def emit( + self, + event_type: str, + source: str, + aggregate_type: str, + aggregate_id: str | int | None, + actor: str = ActorValue.SYSTEM, + payload: dict[str, Any] | None = None, + idempotency_key: str | None = None, + dispatch: bool = False, + ) -> DomainEvent: + if idempotency_key: + existing = self.db.execute( + select(DomainEvent).where(DomainEvent.idempotency_key == idempotency_key) + ).scalar_one_or_none() + if existing is not None: + if dispatch and existing.status == EventStatus.PENDING: + return self.dispatch_event(existing.event_id) + return existing + + settings = get_settings() + now = utc_now() + record = DomainEvent( + event_id=f"{EVENT_CODE_PREFIX}-{now:%Y%m%d%H%M%S%f}", + event_type=event_type, + source=source, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id) if aggregate_id is not None else None, + actor=actor, + payload=payload or {}, + idempotency_key=idempotency_key, + next_attempt_at=now, + max_attempts=settings.event_dispatch_max_attempts, + ) + self.db.add(record) + self.db.commit() + self.db.refresh(record) + if dispatch: + return self.dispatch_event(record.event_id) + return record + + def list_events( + self, + status_filter: str | None = None, + event_type: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + stmt = select(DomainEvent).order_by(DomainEvent.id.desc()).limit(bounded_limit(limit)) + if status_filter: + stmt = stmt.where(DomainEvent.status == status_filter) + if event_type: + stmt = stmt.where(DomainEvent.event_type == event_type) + return [_serialize_event(item) for item in self.db.execute(stmt).scalars()] + + def count_by_status(self) -> dict[str, int]: + rows = self.db.execute( + select(DomainEvent.status, func.count()).group_by(DomainEvent.status) + ).all() + return {str(status_value): int(count) for status_value, count in rows} + + def get_event(self, event_id: str) -> DomainEvent: + record = self.db.execute( + select(DomainEvent).where(DomainEvent.event_id == event_id) + ).scalar_one_or_none() + if record is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=EventErrorDetail.EVENT_NOT_FOUND, + ) + return record diff --git a/app/modules/events/services/serialization.py b/app/modules/events/services/serialization.py new file mode 100644 index 0000000..5ba958c --- /dev/null +++ b/app/modules/events/services/serialization.py @@ -0,0 +1,10 @@ +from typing import Any + +from app.modules.events.models import DomainEvent + + +def _serialize_event(record: DomainEvent) -> dict[str, Any]: + return { + column.name: getattr(record, column.name) + for column in record.__table__.columns + } diff --git a/app/modules/events/services/service.py b/app/modules/events/services/service.py new file mode 100644 index 0000000..ed0ac5d --- /dev/null +++ b/app/modules/events/services/service.py @@ -0,0 +1,16 @@ +from sqlalchemy.orm import Session + +from app.modules.events.services.dispatch import EventDispatchMixin +from app.modules.events.services.handlers import EventHandlerMixin +from app.modules.events.services.query import EventQueryMixin + + +class EventService( + EventDispatchMixin, + EventHandlerMixin, + EventQueryMixin, +): + """Persist outbox events and dispatch the V3 internal handlers.""" + + def __init__(self, db: Session): + self.db = db diff --git a/app/modules/feishu/commands.py b/app/modules/feishu/commands.py index 8149661..8c338ff 100644 --- a/app/modules/feishu/commands.py +++ b/app/modules/feishu/commands.py @@ -21,9 +21,9 @@ from app.modules.feishu.constants import ( ) from app.modules.reports.constants import ReportResponseKey from app.modules.feishu.service import FeishuService -from app.modules.reports.service import ReportService +from app.modules.reports.services import ReportService from app.modules.risk.constants import RiskSummaryKey -from app.modules.risk.service import RiskService +from app.modules.risk.services import RiskService DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报") PROJECT_WEEKLY_KEYWORDS = ("周报", "项目周报") diff --git a/app/modules/legacy_mysql/routes.py b/app/modules/legacy_mysql/routes.py index 0ecc28d..a0ec768 100644 --- a/app/modules/legacy_mysql/routes.py +++ b/app/modules/legacy_mysql/routes.py @@ -13,7 +13,7 @@ from app.modules.legacy_mysql.schemas import ( QueryResult, ReadonlyQueryRequest, ) -from app.modules.legacy_mysql.service import LegacyMySQLService +from app.modules.legacy_mysql.services import LegacyMySQLService router = APIRouter(dependencies=[Depends(require_api_key)]) diff --git a/app/modules/legacy_mysql/service.py b/app/modules/legacy_mysql/service.py deleted file mode 100644 index 447defd..0000000 --- a/app/modules/legacy_mysql/service.py +++ /dev/null @@ -1,671 +0,0 @@ -from datetime import date, datetime -from decimal import Decimal -from typing import Any - -from fastapi import HTTPException, status -from sqlalchemy import select, text -from sqlalchemy.engine import Engine, RowMapping -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session - -from app.core.constants import ActorValue, ApiStatus -from app.core.config import get_settings -from app.core.database import legacy_engine -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, AuditStatus -from app.modules.audit.schemas import AuditLogCreate -from app.modules.audit.service import AuditService -from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue -from app.modules.business.models import LegacySyncRun, Project, 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.legacy_mysql.constants import ( - LEGACY_PROJECT_QUERY_SOURCE, - LEGACY_PROJECT_SYNC_NOTE, - LEGACY_SYNC_MISSING_ID_REASON, - LEGACY_SYNC_RUN_CODE_PREFIX, - LEGACY_TASK_CODE_TEMPLATE, - LEGACY_TASK_QUERY_SOURCE, - LEGACY_TASK_SYNC_NOTE, - LEGACY_TASK_SYNC_RUN_CODE_PREFIX, - LEGACY_HEALTH_SQL, - LEGACY_LIMIT_CLAUSE, - LEGACY_LIMIT_MARKER, - LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE, - LEGACY_PROJECT_CODE_TEMPLATE, - LEGACY_SELECT_PREFIX, - LEGACY_SQL_TRAILING_TERMINATOR, - LEGACY_UNNAMED_TASK, - LEGACY_UNNAMED_PROJECT, - LegacyProjectField, - LegacyQueryError, - LegacyQueryName, - LegacyResponseKey, - LegacySyncAction, - LegacyTaskField, -) - -FORBIDDEN_SQL_TOKENS = { - "insert", - "update", - "delete", - "drop", - "alter", - "truncate", - "create", - "replace", - "grant", - "revoke", -} - - -def _jsonable(value: Any) -> Any: - """Convert database scalar values into JSON-friendly values.""" - - if isinstance(value, (datetime, date)): - return value.isoformat() - if isinstance(value, Decimal): - return float(value) - return value - - -def _row_to_dict(row: RowMapping) -> dict[str, Any]: - """Convert a SQLAlchemy row mapping to a serializable dictionary.""" - - return {key: _jsonable(value) for key, value in row.items()} - - -def _normalize_sql(sql: str) -> str: - return " ".join(sql.strip().rstrip(LEGACY_SQL_TRAILING_TERMINATOR).split()).lower() - - -def _query_name_text(query_name: str | LegacyQueryName | None) -> str: - if query_name is None: - return LegacyQueryName.PROJECTS.value - if isinstance(query_name, LegacyQueryName): - return query_name.value - return str(query_name) - - -class LegacyMySQLService: - """Read legacy MySQL data and sync projects into the internal ledger.""" - - def __init__(self, db: Session | None): - self.db = db - - @staticmethod - def _ensure_engine() -> Engine: - if legacy_engine is None: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=LegacyQueryError.DATABASE_NOT_CONFIGURED, - ) - return legacy_engine - - @staticmethod - def _ensure_readonly(sql: str) -> None: - stripped = sql.strip().lower() - if not stripped.startswith(LEGACY_SELECT_PREFIX): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=LegacyQueryError.ONLY_SELECT_ALLOWED, - ) - tokens = {token.strip(" ;,\n\t") for token in stripped.replace("(", " ").split()} - if tokens & FORBIDDEN_SQL_TOKENS: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=LegacyQueryError.FORBIDDEN_SQL_TOKEN, - ) - - @staticmethod - def _allowed_queries() -> dict[str, str]: - settings = get_settings() - queries = { - _query_name_text(name): sql - for name, sql in settings.legacy_allowed_queries.items() - } - if settings.legacy_project_query: - queries.setdefault(LegacyQueryName.PROJECTS.value, settings.legacy_project_query) - if settings.legacy_task_query: - queries.setdefault(LegacyQueryName.TASKS.value, settings.legacy_task_query) - return queries - - def health(self) -> dict[str, str]: - engine = self._ensure_engine() - try: - with engine.connect() as conn: - conn.execute(text(LEGACY_HEALTH_SQL)) - except SQLAlchemyError as exc: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE.format(error=exc), - ) from exc - return {LegacyResponseKey.STATUS: ApiStatus.OK} - - def execute_readonly( - self, - sql: str, - params: dict[str, Any] | None = None, - limit: int = 100, - ) -> dict[str, Any]: - """Execute a readonly SQL statement only when it matches the allowlist.""" - - normalized_sql = _normalize_sql(sql) - for allowed_sql in self._allowed_queries().values(): - if _normalize_sql(allowed_sql) == normalized_sql: - return self._execute_readonly_sql(allowed_sql, params, limit) - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=LegacyQueryError.QUERY_NOT_ALLOWED, - ) - - def execute_allowed_query( - self, - query_name: str | None, - params: dict[str, Any] | None = None, - limit: int = 100, - ) -> dict[str, Any]: - queries = self._allowed_queries() - normalized_name = _query_name_text(query_name) - sql = queries.get(normalized_name) - if not sql: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail=LegacyQueryError.QUERY_NOT_ALLOWED, - ) - return self._execute_readonly_sql(sql, params, limit) - - def _execute_readonly_sql( - self, - sql: str, - params: dict[str, Any] | None = None, - limit: int = 100, - ) -> dict[str, Any]: - self._ensure_readonly(sql) - engine = self._ensure_engine() - params = dict(params or {}) - try: - params[LegacyResponseKey.LIMIT] = bounded_limit( - params.get(LegacyResponseKey.LIMIT, limit) - ) - except (TypeError, ValueError) as exc: - raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=LegacyQueryError.INVALID_LIMIT, - ) from exc - limited_sql = sql - if LEGACY_LIMIT_MARKER not in sql.lower(): - limited_sql = f"{sql.rstrip(LEGACY_SQL_TRAILING_TERMINATOR)}{LEGACY_LIMIT_CLAUSE}" - with engine.connect() as conn: - result = conn.execute(text(limited_sql), params) - rows = [_row_to_dict(row) for row in result.mappings().all()] - columns = list(rows[0].keys()) if rows else [] - return { - LegacyResponseKey.COLUMNS: columns, - LegacyResponseKey.ROWS: rows, - LegacyResponseKey.ROW_COUNT: len(rows), - } - - def fetch_default_projects(self, limit: int = 100) -> dict[str, Any]: - settings = get_settings() - if not settings.legacy_project_query: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=LegacyQueryError.PROJECT_QUERY_NOT_CONFIGURED, - ) - return self.execute_allowed_query( - LegacyQueryName.PROJECTS, - {LegacyResponseKey.LIMIT: limit}, - limit=limit, - ) - - def fetch_default_tasks(self, limit: int = 100) -> dict[str, Any]: - settings = get_settings() - if not settings.legacy_task_query: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=LegacyQueryError.TASK_QUERY_NOT_CONFIGURED, - ) - return self.execute_allowed_query( - LegacyQueryName.TASKS, - {LegacyResponseKey.LIMIT: limit}, - limit=limit, - ) - - @staticmethod - def _value( - row: dict[str, Any], - field_map: dict[str, str], - internal_name: str, - fallback: Any = None, - ) -> Any: - source_name = field_map.get(internal_name, internal_name) - if source_name in row: - return row[source_name] - return fallback - - def _project_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]: - settings = get_settings() - external_id = self._value( - row, - field_map, - LegacyProjectField.EXTERNAL_ID, - row.get(LegacyProjectField.ID), - ) - raw_code = self._value(row, field_map, LegacyProjectField.CODE, None) - code = None - if raw_code: - code = str(raw_code) - elif external_id is not None: - code = LEGACY_PROJECT_CODE_TEMPLATE.format( - prefix=settings.legacy_project_code_prefix, - external_id=external_id, - ) - return { - LegacyProjectField.CODE: code, - LegacyProjectField.EXTERNAL_ID: str(external_id) if external_id is not None else code, - LegacyProjectField.SOURCE_SYSTEM: SourceSystem.LEGACY_MYSQL, - LegacyProjectField.NAME: self._value( - row, - field_map, - LegacyProjectField.NAME, - LEGACY_UNNAMED_PROJECT, - ), - LegacyProjectField.OWNER: self._value(row, field_map, LegacyProjectField.OWNER, None), - LegacyProjectField.STATUS: self._value(row, field_map, LegacyProjectField.STATUS, StatusValue.UNKNOWN), - LegacyProjectField.PROGRESS_PERCENT: int( - self._value( - row, - field_map, - LegacyProjectField.PROGRESS_PERCENT, - row.get(LegacyProjectField.PROGRESS) or 0, - ) - or 0 - ), - LegacyProjectField.START_DATE: self._value(row, field_map, LegacyProjectField.START_DATE, None), - LegacyProjectField.DUE_DATE: self._value(row, field_map, LegacyProjectField.DUE_DATE, None), - LegacyProjectField.BUDGET_AMOUNT: ( - self._value(row, field_map, LegacyProjectField.BUDGET_AMOUNT, row.get(LegacyProjectField.BUDGET) or 0) or 0 - ), - LegacyProjectField.ACTUAL_AMOUNT: ( - self._value(row, field_map, LegacyProjectField.ACTUAL_AMOUNT, row.get(LegacyProjectField.ACTUAL_COST) or 0) or 0 - ), - LegacyProjectField.DESCRIPTION: self._value(row, field_map, LegacyProjectField.DESCRIPTION, None), - } - - def _task_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]: - settings = get_settings() - external_id = self._value( - row, - field_map, - LegacyTaskField.EXTERNAL_ID, - row.get(LegacyTaskField.ID), - ) - raw_code = self._value(row, field_map, LegacyTaskField.CODE, None) - code = None - if raw_code: - code = str(raw_code) - elif external_id is not None: - code = LEGACY_TASK_CODE_TEMPLATE.format( - prefix=settings.legacy_task_code_prefix, - external_id=external_id, - ) - return { - LegacyTaskField.CODE: code, - LegacyTaskField.EXTERNAL_ID: str(external_id) if external_id is not None else code, - LegacyTaskField.SOURCE_SYSTEM: SourceSystem.LEGACY_MYSQL, - LegacyTaskField.TITLE: self._value( - row, - field_map, - LegacyTaskField.TITLE, - LEGACY_UNNAMED_TASK, - ), - LegacyTaskField.PROJECT_CODE: self._value( - row, - field_map, - LegacyTaskField.PROJECT_CODE, - None, - ), - LegacyTaskField.OWNER: self._value(row, field_map, LegacyTaskField.OWNER, None), - LegacyTaskField.STATUS: self._value( - row, - field_map, - LegacyTaskField.STATUS, - StatusValue.TODO, - ), - LegacyTaskField.PRIORITY: self._value( - row, - field_map, - LegacyTaskField.PRIORITY, - "P2", - ), - LegacyTaskField.DUE_DATE: self._value(row, field_map, LegacyTaskField.DUE_DATE, None), - LegacyTaskField.COMPLETED_AT: self._value( - row, - field_map, - LegacyTaskField.COMPLETED_AT, - None, - ), - LegacyTaskField.BLOCKER: self._value(row, field_map, LegacyTaskField.BLOCKER, None), - LegacyTaskField.DESCRIPTION: self._value( - row, - field_map, - LegacyTaskField.DESCRIPTION, - None, - ), - } - - def sync_projects( - self, - source_query: str | None = None, - source_query_name: str | None = None, - field_map: dict[str, str] | None = None, - limit: int = 100, - dry_run: bool = True, - actor: str = ActorValue.API, - ) -> dict[str, Any]: - if self.db is None: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=LegacyQueryError.APP_DB_UNAVAILABLE, - ) - - query_name = source_query_name or LegacyQueryName.PROJECTS - if source_query: - rows = self.execute_readonly( - source_query, - {LegacyResponseKey.LIMIT: limit}, - limit=limit, - )[LegacyResponseKey.ROWS] - query_ref = LegacySyncAction.ALLOWLISTED_INLINE_SQL - else: - rows = self.execute_allowed_query( - query_name, - {LegacyResponseKey.LIMIT: limit}, - limit=limit, - )[LegacyResponseKey.ROWS] - query_ref = _query_name_text(query_name) - field_map = field_map or {} - created = 0 - updated = 0 - skipped = 0 - items: list[dict[str, Any]] = [] - - for row in rows: - payload = self._project_payload(row, field_map) - if not payload[LegacyProjectField.EXTERNAL_ID] and not payload[LegacyProjectField.CODE]: - skipped += 1 - items.append( - { - LegacyResponseKey.ACTION: LegacySyncAction.SKIPPED, - LegacyResponseKey.REASON: LEGACY_SYNC_MISSING_ID_REASON, - LegacyResponseKey.SOURCE: row, - } - ) - continue - - stmt = select(Project).where( - Project.source_system == SourceSystem.LEGACY_MYSQL, - Project.external_id == payload[LegacyProjectField.EXTERNAL_ID], - ) - record = self.db.execute(stmt).scalar_one_or_none() - if record is None: - record = self.db.execute( - select(Project).where(Project.code == payload[LegacyProjectField.CODE]) - ).scalar_one_or_none() - - if record is None: - created += 1 - action = LegacySyncAction.CREATE - result = payload - if not dry_run: - record = Project(**payload) - self.db.add(record) - self.db.flush() - result = serialize_model(record) - else: - updated += 1 - action = LegacySyncAction.UPDATE - if not dry_run: - for key, value in payload.items(): - setattr(record, key, value) - self.db.flush() - result = serialize_model(record) - else: - result = payload - items.append( - { - LegacyResponseKey.ACTION: action, - LegacyResponseKey.PROJECT: result, - LegacyResponseKey.SOURCE: row, - } - ) - - if not dry_run: - self.db.commit() - - result = { - LegacyResponseKey.DRY_RUN: dry_run, - LegacyResponseKey.CREATED: created, - LegacyResponseKey.UPDATED: updated, - LegacyResponseKey.SKIPPED: skipped, - LegacyResponseKey.ITEMS: items, - } - sync_run = LegacySyncRun( - code=f"{LEGACY_SYNC_RUN_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}", - domain=BusinessDomain.PROJECTS, - source_table=LEGACY_PROJECT_QUERY_SOURCE, - status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS, - finished_at=utc_now(), - created_count=created, - updated_count=updated, - skipped_count=skipped, - note=LEGACY_PROJECT_SYNC_NOTE, - ) - self.db.add(sync_run) - self.db.commit() - self.db.refresh(sync_run) - result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code - - AuditService(self.db).log( - AuditLogCreate( - actor=actor, - source=AuditSource.LEGACY_MYSQL, - action=AuditAction.LEGACY_SYNC_PROJECTS, - target_type=BusinessDomain.PROJECTS, - risk_level=AuditRiskLevel.MEDIUM, - request_payload={ - LegacyResponseKey.SOURCE_QUERY: query_ref, - LegacyResponseKey.FIELD_MAP: field_map, - LegacyResponseKey.LIMIT: limit, - LegacyResponseKey.DRY_RUN: dry_run, - }, - response_payload={ - key: result[key] - for key in [ - LegacyResponseKey.DRY_RUN, - LegacyResponseKey.CREATED, - LegacyResponseKey.UPDATED, - LegacyResponseKey.SKIPPED, - ] - }, - ) - ) - EventService(self.db).emit( - event_type=EventType.LEGACY_SYNC_COMPLETED, - source=EventSource.LEGACY_MYSQL, - aggregate_type=EventAggregateType.LEGACY_SYNC_RUN, - aggregate_id=sync_run.code, - actor=actor, - payload={ - EventPayloadKey.CODE: sync_run.code, - EventPayloadKey.DOMAIN: BusinessDomain.PROJECTS, - EventPayloadKey.STATUS: sync_run.status, - EventPayloadKey.CREATED: created, - EventPayloadKey.UPDATED: updated, - EventPayloadKey.SKIPPED: skipped, - }, - idempotency_key=f"legacy-sync:{sync_run.code}", - ) - return result - - def sync_tasks( - self, - source_query: str | None = None, - source_query_name: str | None = None, - field_map: dict[str, str] | None = None, - limit: int = 100, - dry_run: bool = True, - actor: str = ActorValue.API, - ) -> dict[str, Any]: - if self.db is None: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail=LegacyQueryError.APP_DB_UNAVAILABLE, - ) - - query_name = source_query_name or LegacyQueryName.TASKS - if source_query: - rows = self.execute_readonly( - source_query, - {LegacyResponseKey.LIMIT: limit}, - limit=limit, - )[LegacyResponseKey.ROWS] - query_ref = LegacySyncAction.ALLOWLISTED_INLINE_SQL - else: - rows = self.execute_allowed_query( - query_name, - {LegacyResponseKey.LIMIT: limit}, - limit=limit, - )[LegacyResponseKey.ROWS] - query_ref = _query_name_text(query_name) - field_map = field_map or {} - created = 0 - updated = 0 - skipped = 0 - items: list[dict[str, Any]] = [] - - for row in rows: - payload = self._task_payload(row, field_map) - if not payload[LegacyTaskField.EXTERNAL_ID] and not payload[LegacyTaskField.CODE]: - skipped += 1 - items.append( - { - LegacyResponseKey.ACTION: LegacySyncAction.SKIPPED, - LegacyResponseKey.REASON: LEGACY_SYNC_MISSING_ID_REASON, - LegacyResponseKey.SOURCE: row, - } - ) - continue - - stmt = select(WorkTask).where( - WorkTask.source_system == SourceSystem.LEGACY_MYSQL, - WorkTask.external_id == payload[LegacyTaskField.EXTERNAL_ID], - ) - record = self.db.execute(stmt).scalar_one_or_none() - if record is None: - record = self.db.execute( - select(WorkTask).where(WorkTask.code == payload[LegacyTaskField.CODE]) - ).scalar_one_or_none() - - if record is None: - created += 1 - action = LegacySyncAction.CREATE - result = payload - if not dry_run: - record = WorkTask(**payload) - self.db.add(record) - self.db.flush() - result = serialize_model(record) - else: - updated += 1 - action = LegacySyncAction.UPDATE - if not dry_run: - for key, value in payload.items(): - setattr(record, key, value) - self.db.flush() - result = serialize_model(record) - else: - result = payload - items.append( - { - LegacyResponseKey.ACTION: action, - LegacyResponseKey.TASK: result, - LegacyResponseKey.SOURCE: row, - } - ) - - if not dry_run: - self.db.commit() - - result = { - LegacyResponseKey.DRY_RUN: dry_run, - LegacyResponseKey.CREATED: created, - LegacyResponseKey.UPDATED: updated, - LegacyResponseKey.SKIPPED: skipped, - LegacyResponseKey.ITEMS: items, - } - sync_run = LegacySyncRun( - code=f"{LEGACY_TASK_SYNC_RUN_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}", - domain=BusinessDomain.TASKS, - source_table=LEGACY_TASK_QUERY_SOURCE, - status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS, - finished_at=utc_now(), - created_count=created, - updated_count=updated, - skipped_count=skipped, - note=LEGACY_TASK_SYNC_NOTE, - ) - self.db.add(sync_run) - self.db.commit() - self.db.refresh(sync_run) - result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code - - AuditService(self.db).log( - AuditLogCreate( - actor=actor, - source=AuditSource.LEGACY_MYSQL, - action=AuditAction.LEGACY_SYNC_TASKS, - target_type=BusinessDomain.TASKS, - risk_level=AuditRiskLevel.MEDIUM, - request_payload={ - LegacyResponseKey.SOURCE_QUERY: query_ref, - LegacyResponseKey.FIELD_MAP: field_map, - LegacyResponseKey.LIMIT: limit, - LegacyResponseKey.DRY_RUN: dry_run, - }, - response_payload={ - key: result[key] - for key in [ - LegacyResponseKey.DRY_RUN, - LegacyResponseKey.CREATED, - LegacyResponseKey.UPDATED, - LegacyResponseKey.SKIPPED, - ] - }, - ) - ) - EventService(self.db).emit( - event_type=EventType.LEGACY_SYNC_COMPLETED, - source=EventSource.LEGACY_MYSQL, - aggregate_type=EventAggregateType.LEGACY_SYNC_RUN, - aggregate_id=sync_run.code, - actor=actor, - payload={ - EventPayloadKey.CODE: sync_run.code, - EventPayloadKey.DOMAIN: BusinessDomain.TASKS, - EventPayloadKey.STATUS: sync_run.status, - EventPayloadKey.CREATED: created, - EventPayloadKey.UPDATED: updated, - EventPayloadKey.SKIPPED: skipped, - }, - idempotency_key=f"legacy-sync:{sync_run.code}", - ) - return result diff --git a/app/modules/legacy_mysql/services/__init__.py b/app/modules/legacy_mysql/services/__init__.py new file mode 100644 index 0000000..c8ac359 --- /dev/null +++ b/app/modules/legacy_mysql/services/__init__.py @@ -0,0 +1,4 @@ +from app.modules.legacy_mysql.services.service import LegacyMySQLService + + +__all__ = ["LegacyMySQLService"] diff --git a/app/modules/legacy_mysql/services/common.py b/app/modules/legacy_mysql/services/common.py new file mode 100644 index 0000000..21abd6f --- /dev/null +++ b/app/modules/legacy_mysql/services/common.py @@ -0,0 +1,48 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy.engine import RowMapping + +from app.modules.legacy_mysql.constants import LEGACY_SQL_TRAILING_TERMINATOR, LegacyQueryName + +FORBIDDEN_SQL_TOKENS = { + "insert", + "update", + "delete", + "drop", + "alter", + "truncate", + "create", + "replace", + "grant", + "revoke", +} + + +def _jsonable(value: Any) -> Any: + """Convert database scalar values into JSON-friendly values.""" + + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, Decimal): + return float(value) + return value + + +def _row_to_dict(row: RowMapping) -> dict[str, Any]: + """Convert a SQLAlchemy row mapping to a serializable dictionary.""" + + return {key: _jsonable(value) for key, value in row.items()} + + +def _normalize_sql(sql: str) -> str: + return " ".join(sql.strip().rstrip(LEGACY_SQL_TRAILING_TERMINATOR).split()).lower() + + +def _query_name_text(query_name: str | LegacyQueryName | None) -> str: + if query_name is None: + return LegacyQueryName.PROJECTS.value + if isinstance(query_name, LegacyQueryName): + return query_name.value + return str(query_name) diff --git a/app/modules/legacy_mysql/services/mappers.py b/app/modules/legacy_mysql/services/mappers.py new file mode 100644 index 0000000..325e557 --- /dev/null +++ b/app/modules/legacy_mysql/services/mappers.py @@ -0,0 +1,138 @@ +from typing import Any + + +from app.core.config import get_settings +from app.modules.business.constants import SourceSystem, StatusValue +from app.modules.legacy_mysql.constants import ( + LEGACY_PROJECT_CODE_TEMPLATE, + LEGACY_TASK_CODE_TEMPLATE, + LEGACY_UNNAMED_PROJECT, + LEGACY_UNNAMED_TASK, + LegacyProjectField, + LegacyTaskField, +) + + +class LegacyMapperMixin: + @staticmethod + def _value( + row: dict[str, Any], + field_map: dict[str, str], + internal_name: str, + fallback: Any = None, + ) -> Any: + source_name = field_map.get(internal_name, internal_name) + if source_name in row: + return row[source_name] + return fallback + + def _project_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]: + settings = get_settings() + external_id = self._value( + row, + field_map, + LegacyProjectField.EXTERNAL_ID, + row.get(LegacyProjectField.ID), + ) + raw_code = self._value(row, field_map, LegacyProjectField.CODE, None) + code = None + if raw_code: + code = str(raw_code) + elif external_id is not None: + code = LEGACY_PROJECT_CODE_TEMPLATE.format( + prefix=settings.legacy_project_code_prefix, + external_id=external_id, + ) + return { + LegacyProjectField.CODE: code, + LegacyProjectField.EXTERNAL_ID: str(external_id) if external_id is not None else code, + LegacyProjectField.SOURCE_SYSTEM: SourceSystem.LEGACY_MYSQL, + LegacyProjectField.NAME: self._value( + row, + field_map, + LegacyProjectField.NAME, + LEGACY_UNNAMED_PROJECT, + ), + LegacyProjectField.OWNER: self._value(row, field_map, LegacyProjectField.OWNER, None), + LegacyProjectField.STATUS: self._value(row, field_map, LegacyProjectField.STATUS, StatusValue.UNKNOWN), + LegacyProjectField.PROGRESS_PERCENT: int( + self._value( + row, + field_map, + LegacyProjectField.PROGRESS_PERCENT, + row.get(LegacyProjectField.PROGRESS) or 0, + ) + or 0 + ), + LegacyProjectField.START_DATE: self._value(row, field_map, LegacyProjectField.START_DATE, None), + LegacyProjectField.DUE_DATE: self._value(row, field_map, LegacyProjectField.DUE_DATE, None), + LegacyProjectField.BUDGET_AMOUNT: ( + self._value(row, field_map, LegacyProjectField.BUDGET_AMOUNT, row.get(LegacyProjectField.BUDGET) or 0) or 0 + ), + LegacyProjectField.ACTUAL_AMOUNT: ( + self._value(row, field_map, LegacyProjectField.ACTUAL_AMOUNT, row.get(LegacyProjectField.ACTUAL_COST) or 0) or 0 + ), + LegacyProjectField.DESCRIPTION: self._value(row, field_map, LegacyProjectField.DESCRIPTION, None), + } + + def _task_payload(self, row: dict[str, Any], field_map: dict[str, str]) -> dict[str, Any]: + settings = get_settings() + external_id = self._value( + row, + field_map, + LegacyTaskField.EXTERNAL_ID, + row.get(LegacyTaskField.ID), + ) + raw_code = self._value(row, field_map, LegacyTaskField.CODE, None) + code = None + if raw_code: + code = str(raw_code) + elif external_id is not None: + code = LEGACY_TASK_CODE_TEMPLATE.format( + prefix=settings.legacy_task_code_prefix, + external_id=external_id, + ) + return { + LegacyTaskField.CODE: code, + LegacyTaskField.EXTERNAL_ID: str(external_id) if external_id is not None else code, + LegacyTaskField.SOURCE_SYSTEM: SourceSystem.LEGACY_MYSQL, + LegacyTaskField.TITLE: self._value( + row, + field_map, + LegacyTaskField.TITLE, + LEGACY_UNNAMED_TASK, + ), + LegacyTaskField.PROJECT_CODE: self._value( + row, + field_map, + LegacyTaskField.PROJECT_CODE, + None, + ), + LegacyTaskField.OWNER: self._value(row, field_map, LegacyTaskField.OWNER, None), + LegacyTaskField.STATUS: self._value( + row, + field_map, + LegacyTaskField.STATUS, + StatusValue.TODO, + ), + LegacyTaskField.PRIORITY: self._value( + row, + field_map, + LegacyTaskField.PRIORITY, + "P2", + ), + LegacyTaskField.DUE_DATE: self._value(row, field_map, LegacyTaskField.DUE_DATE, None), + LegacyTaskField.COMPLETED_AT: self._value( + row, + field_map, + LegacyTaskField.COMPLETED_AT, + None, + ), + LegacyTaskField.BLOCKER: self._value(row, field_map, LegacyTaskField.BLOCKER, None), + LegacyTaskField.DESCRIPTION: self._value( + row, + field_map, + LegacyTaskField.DESCRIPTION, + None, + ), + } diff --git a/app/modules/legacy_mysql/services/project_sync.py b/app/modules/legacy_mysql/services/project_sync.py new file mode 100644 index 0000000..be96e8d --- /dev/null +++ b/app/modules/legacy_mysql/services/project_sync.py @@ -0,0 +1,189 @@ +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import select + +from app.core.constants import ActorValue +from app.core.utils.time import utc_now +from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus +from app.modules.audit.schemas import AuditLogCreate +from app.modules.audit.service import AuditService +from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue +from app.modules.business.models import LegacySyncRun, Project +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.legacy_mysql.constants import ( + LEGACY_PROJECT_QUERY_SOURCE, + LEGACY_PROJECT_SYNC_NOTE, + LEGACY_SYNC_MISSING_ID_REASON, + LEGACY_SYNC_RUN_CODE_PREFIX, + LegacyProjectField, + LegacyQueryError, + LegacyQueryName, + LegacyResponseKey, + LegacySyncAction, +) + +from app.modules.legacy_mysql.services.common import _query_name_text + + +class LegacyProjectSyncMixin: + def sync_projects( + self, + source_query: str | None = None, + source_query_name: str | None = None, + field_map: dict[str, str] | None = None, + limit: int = 100, + dry_run: bool = True, + actor: str = ActorValue.API, + ) -> dict[str, Any]: + if self.db is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=LegacyQueryError.APP_DB_UNAVAILABLE, + ) + + query_name = source_query_name or LegacyQueryName.PROJECTS + if source_query: + rows = self.execute_readonly( + source_query, + {LegacyResponseKey.LIMIT: limit}, + limit=limit, + )[LegacyResponseKey.ROWS] + query_ref = LegacySyncAction.ALLOWLISTED_INLINE_SQL + else: + rows = self.execute_allowed_query( + query_name, + {LegacyResponseKey.LIMIT: limit}, + limit=limit, + )[LegacyResponseKey.ROWS] + query_ref = _query_name_text(query_name) + field_map = field_map or {} + created = 0 + updated = 0 + skipped = 0 + items: list[dict[str, Any]] = [] + + for row in rows: + payload = self._project_payload(row, field_map) + if not payload[LegacyProjectField.EXTERNAL_ID] and not payload[LegacyProjectField.CODE]: + skipped += 1 + items.append( + { + LegacyResponseKey.ACTION: LegacySyncAction.SKIPPED, + LegacyResponseKey.REASON: LEGACY_SYNC_MISSING_ID_REASON, + LegacyResponseKey.SOURCE: row, + } + ) + continue + + stmt = select(Project).where( + Project.source_system == SourceSystem.LEGACY_MYSQL, + Project.external_id == payload[LegacyProjectField.EXTERNAL_ID], + ) + record = self.db.execute(stmt).scalar_one_or_none() + if record is None: + record = self.db.execute( + select(Project).where(Project.code == payload[LegacyProjectField.CODE]) + ).scalar_one_or_none() + + if record is None: + created += 1 + action = LegacySyncAction.CREATE + result = payload + if not dry_run: + record = Project(**payload) + self.db.add(record) + self.db.flush() + result = serialize_model(record) + else: + updated += 1 + action = LegacySyncAction.UPDATE + if not dry_run: + for key, value in payload.items(): + setattr(record, key, value) + self.db.flush() + result = serialize_model(record) + else: + result = payload + items.append( + { + LegacyResponseKey.ACTION: action, + LegacyResponseKey.PROJECT: result, + LegacyResponseKey.SOURCE: row, + } + ) + + if not dry_run: + self.db.commit() + + result = { + LegacyResponseKey.DRY_RUN: dry_run, + LegacyResponseKey.CREATED: created, + LegacyResponseKey.UPDATED: updated, + LegacyResponseKey.SKIPPED: skipped, + LegacyResponseKey.ITEMS: items, + } + sync_run = LegacySyncRun( + code=f"{LEGACY_SYNC_RUN_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}", + domain=BusinessDomain.PROJECTS, + source_table=LEGACY_PROJECT_QUERY_SOURCE, + status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS, + finished_at=utc_now(), + created_count=created, + updated_count=updated, + skipped_count=skipped, + note=LEGACY_PROJECT_SYNC_NOTE, + ) + self.db.add(sync_run) + self.db.commit() + self.db.refresh(sync_run) + result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code + + AuditService(self.db).log( + AuditLogCreate( + actor=actor, + source=AuditSource.LEGACY_MYSQL, + action=AuditAction.LEGACY_SYNC_PROJECTS, + target_type=BusinessDomain.PROJECTS, + risk_level=AuditRiskLevel.MEDIUM, + request_payload={ + LegacyResponseKey.SOURCE_QUERY: query_ref, + LegacyResponseKey.FIELD_MAP: field_map, + LegacyResponseKey.LIMIT: limit, + LegacyResponseKey.DRY_RUN: dry_run, + }, + response_payload={ + key: result[key] + for key in [ + LegacyResponseKey.DRY_RUN, + LegacyResponseKey.CREATED, + LegacyResponseKey.UPDATED, + LegacyResponseKey.SKIPPED, + ] + }, + ) + ) + EventService(self.db).emit( + event_type=EventType.LEGACY_SYNC_COMPLETED, + source=EventSource.LEGACY_MYSQL, + aggregate_type=EventAggregateType.LEGACY_SYNC_RUN, + aggregate_id=sync_run.code, + actor=actor, + payload={ + EventPayloadKey.CODE: sync_run.code, + EventPayloadKey.DOMAIN: BusinessDomain.PROJECTS, + EventPayloadKey.STATUS: sync_run.status, + EventPayloadKey.CREATED: created, + EventPayloadKey.UPDATED: updated, + EventPayloadKey.SKIPPED: skipped, + }, + idempotency_key=f"legacy-sync:{sync_run.code}", + ) + return result diff --git a/app/modules/legacy_mysql/services/query.py b/app/modules/legacy_mysql/services/query.py new file mode 100644 index 0000000..db3a844 --- /dev/null +++ b/app/modules/legacy_mysql/services/query.py @@ -0,0 +1,165 @@ +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import text +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError + +from app.core.config import get_settings +from app.core.constants import ApiStatus +from app.core.database import legacy_engine +from app.core.http.pagination import bounded_limit +from app.modules.legacy_mysql.constants import ( + LEGACY_HEALTH_SQL, + LEGACY_LIMIT_CLAUSE, + LEGACY_LIMIT_MARKER, + LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE, + LEGACY_SELECT_PREFIX, + LEGACY_SQL_TRAILING_TERMINATOR, + LegacyQueryError, + LegacyQueryName, + LegacyResponseKey, +) + +from app.modules.legacy_mysql.services.common import FORBIDDEN_SQL_TOKENS, _normalize_sql, _query_name_text, _row_to_dict + + +class LegacyQueryMixin: + @staticmethod + def _ensure_engine() -> Engine: + if legacy_engine is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=LegacyQueryError.DATABASE_NOT_CONFIGURED, + ) + return legacy_engine + + @staticmethod + def _ensure_readonly(sql: str) -> None: + stripped = sql.strip().lower() + if not stripped.startswith(LEGACY_SELECT_PREFIX): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=LegacyQueryError.ONLY_SELECT_ALLOWED, + ) + tokens = {token.strip(" ;,\n\t") for token in stripped.replace("(", " ").split()} + if tokens & FORBIDDEN_SQL_TOKENS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=LegacyQueryError.FORBIDDEN_SQL_TOKEN, + ) + + @staticmethod + def _allowed_queries() -> dict[str, str]: + settings = get_settings() + queries = { + _query_name_text(name): sql + for name, sql in settings.legacy_allowed_queries.items() + } + if settings.legacy_project_query: + queries.setdefault(LegacyQueryName.PROJECTS.value, settings.legacy_project_query) + if settings.legacy_task_query: + queries.setdefault(LegacyQueryName.TASKS.value, settings.legacy_task_query) + return queries + + def health(self) -> dict[str, str]: + engine = self._ensure_engine() + try: + with engine.connect() as conn: + conn.execute(text(LEGACY_HEALTH_SQL)) + except SQLAlchemyError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=LEGACY_MYSQL_CONNECTION_FAILED_TEMPLATE.format(error=exc), + ) from exc + return {LegacyResponseKey.STATUS: ApiStatus.OK} + + def execute_readonly( + self, + sql: str, + params: dict[str, Any] | None = None, + limit: int = 100, + ) -> dict[str, Any]: + """Execute a readonly SQL statement only when it matches the allowlist.""" + + normalized_sql = _normalize_sql(sql) + for allowed_sql in self._allowed_queries().values(): + if _normalize_sql(allowed_sql) == normalized_sql: + return self._execute_readonly_sql(allowed_sql, params, limit) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=LegacyQueryError.QUERY_NOT_ALLOWED, + ) + + def execute_allowed_query( + self, + query_name: str | None, + params: dict[str, Any] | None = None, + limit: int = 100, + ) -> dict[str, Any]: + queries = self._allowed_queries() + normalized_name = _query_name_text(query_name) + sql = queries.get(normalized_name) + if not sql: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=LegacyQueryError.QUERY_NOT_ALLOWED, + ) + return self._execute_readonly_sql(sql, params, limit) + + def _execute_readonly_sql( + self, + sql: str, + params: dict[str, Any] | None = None, + limit: int = 100, + ) -> dict[str, Any]: + self._ensure_readonly(sql) + engine = self._ensure_engine() + params = dict(params or {}) + try: + params[LegacyResponseKey.LIMIT] = bounded_limit( + params.get(LegacyResponseKey.LIMIT, limit) + ) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=LegacyQueryError.INVALID_LIMIT, + ) from exc + limited_sql = sql + if LEGACY_LIMIT_MARKER not in sql.lower(): + limited_sql = f"{sql.rstrip(LEGACY_SQL_TRAILING_TERMINATOR)}{LEGACY_LIMIT_CLAUSE}" + with engine.connect() as conn: + result = conn.execute(text(limited_sql), params) + rows = [_row_to_dict(row) for row in result.mappings().all()] + columns = list(rows[0].keys()) if rows else [] + return { + LegacyResponseKey.COLUMNS: columns, + LegacyResponseKey.ROWS: rows, + LegacyResponseKey.ROW_COUNT: len(rows), + } + + def fetch_default_projects(self, limit: int = 100) -> dict[str, Any]: + settings = get_settings() + if not settings.legacy_project_query: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=LegacyQueryError.PROJECT_QUERY_NOT_CONFIGURED, + ) + return self.execute_allowed_query( + LegacyQueryName.PROJECTS, + {LegacyResponseKey.LIMIT: limit}, + limit=limit, + ) + + def fetch_default_tasks(self, limit: int = 100) -> dict[str, Any]: + settings = get_settings() + if not settings.legacy_task_query: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=LegacyQueryError.TASK_QUERY_NOT_CONFIGURED, + ) + return self.execute_allowed_query( + LegacyQueryName.TASKS, + {LegacyResponseKey.LIMIT: limit}, + limit=limit, + ) diff --git a/app/modules/legacy_mysql/services/service.py b/app/modules/legacy_mysql/services/service.py new file mode 100644 index 0000000..1967e04 --- /dev/null +++ b/app/modules/legacy_mysql/services/service.py @@ -0,0 +1,18 @@ +from sqlalchemy.orm import Session + +from app.modules.legacy_mysql.services.mappers import LegacyMapperMixin +from app.modules.legacy_mysql.services.project_sync import LegacyProjectSyncMixin +from app.modules.legacy_mysql.services.query import LegacyQueryMixin +from app.modules.legacy_mysql.services.task_sync import LegacyTaskSyncMixin + + +class LegacyMySQLService( + LegacyTaskSyncMixin, + LegacyProjectSyncMixin, + LegacyMapperMixin, + LegacyQueryMixin, +): + """Read legacy MySQL data and sync projects into the internal ledger.""" + + def __init__(self, db: Session | None): + self.db = db diff --git a/app/modules/legacy_mysql/services/task_sync.py b/app/modules/legacy_mysql/services/task_sync.py new file mode 100644 index 0000000..f019b61 --- /dev/null +++ b/app/modules/legacy_mysql/services/task_sync.py @@ -0,0 +1,189 @@ +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import select + +from app.core.constants import ActorValue +from app.core.utils.time import utc_now +from app.modules.audit.constants import AuditAction, AuditRiskLevel, AuditSource, AuditStatus +from app.modules.audit.schemas import AuditLogCreate +from app.modules.audit.service import AuditService +from app.modules.business.constants import BusinessDomain, SourceSystem, StatusValue +from app.modules.business.models import LegacySyncRun, WorkTask +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.legacy_mysql.constants import ( + LEGACY_SYNC_MISSING_ID_REASON, + LEGACY_TASK_QUERY_SOURCE, + LEGACY_TASK_SYNC_NOTE, + LEGACY_TASK_SYNC_RUN_CODE_PREFIX, + LegacyQueryError, + LegacyQueryName, + LegacyResponseKey, + LegacySyncAction, + LegacyTaskField, +) + +from app.modules.legacy_mysql.services.common import _query_name_text + + +class LegacyTaskSyncMixin: + def sync_tasks( + self, + source_query: str | None = None, + source_query_name: str | None = None, + field_map: dict[str, str] | None = None, + limit: int = 100, + dry_run: bool = True, + actor: str = ActorValue.API, + ) -> dict[str, Any]: + if self.db is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=LegacyQueryError.APP_DB_UNAVAILABLE, + ) + + query_name = source_query_name or LegacyQueryName.TASKS + if source_query: + rows = self.execute_readonly( + source_query, + {LegacyResponseKey.LIMIT: limit}, + limit=limit, + )[LegacyResponseKey.ROWS] + query_ref = LegacySyncAction.ALLOWLISTED_INLINE_SQL + else: + rows = self.execute_allowed_query( + query_name, + {LegacyResponseKey.LIMIT: limit}, + limit=limit, + )[LegacyResponseKey.ROWS] + query_ref = _query_name_text(query_name) + field_map = field_map or {} + created = 0 + updated = 0 + skipped = 0 + items: list[dict[str, Any]] = [] + + for row in rows: + payload = self._task_payload(row, field_map) + if not payload[LegacyTaskField.EXTERNAL_ID] and not payload[LegacyTaskField.CODE]: + skipped += 1 + items.append( + { + LegacyResponseKey.ACTION: LegacySyncAction.SKIPPED, + LegacyResponseKey.REASON: LEGACY_SYNC_MISSING_ID_REASON, + LegacyResponseKey.SOURCE: row, + } + ) + continue + + stmt = select(WorkTask).where( + WorkTask.source_system == SourceSystem.LEGACY_MYSQL, + WorkTask.external_id == payload[LegacyTaskField.EXTERNAL_ID], + ) + record = self.db.execute(stmt).scalar_one_or_none() + if record is None: + record = self.db.execute( + select(WorkTask).where(WorkTask.code == payload[LegacyTaskField.CODE]) + ).scalar_one_or_none() + + if record is None: + created += 1 + action = LegacySyncAction.CREATE + result = payload + if not dry_run: + record = WorkTask(**payload) + self.db.add(record) + self.db.flush() + result = serialize_model(record) + else: + updated += 1 + action = LegacySyncAction.UPDATE + if not dry_run: + for key, value in payload.items(): + setattr(record, key, value) + self.db.flush() + result = serialize_model(record) + else: + result = payload + items.append( + { + LegacyResponseKey.ACTION: action, + LegacyResponseKey.TASK: result, + LegacyResponseKey.SOURCE: row, + } + ) + + if not dry_run: + self.db.commit() + + result = { + LegacyResponseKey.DRY_RUN: dry_run, + LegacyResponseKey.CREATED: created, + LegacyResponseKey.UPDATED: updated, + LegacyResponseKey.SKIPPED: skipped, + LegacyResponseKey.ITEMS: items, + } + sync_run = LegacySyncRun( + code=f"{LEGACY_TASK_SYNC_RUN_CODE_PREFIX}-{utc_now():%Y%m%d%H%M%S%f}", + domain=BusinessDomain.TASKS, + source_table=LEGACY_TASK_QUERY_SOURCE, + status=StatusValue.DRY_RUN if dry_run else AuditStatus.SUCCESS, + finished_at=utc_now(), + created_count=created, + updated_count=updated, + skipped_count=skipped, + note=LEGACY_TASK_SYNC_NOTE, + ) + self.db.add(sync_run) + self.db.commit() + self.db.refresh(sync_run) + result[LegacyResponseKey.SYNC_RUN_CODE] = sync_run.code + + AuditService(self.db).log( + AuditLogCreate( + actor=actor, + source=AuditSource.LEGACY_MYSQL, + action=AuditAction.LEGACY_SYNC_TASKS, + target_type=BusinessDomain.TASKS, + risk_level=AuditRiskLevel.MEDIUM, + request_payload={ + LegacyResponseKey.SOURCE_QUERY: query_ref, + LegacyResponseKey.FIELD_MAP: field_map, + LegacyResponseKey.LIMIT: limit, + LegacyResponseKey.DRY_RUN: dry_run, + }, + response_payload={ + key: result[key] + for key in [ + LegacyResponseKey.DRY_RUN, + LegacyResponseKey.CREATED, + LegacyResponseKey.UPDATED, + LegacyResponseKey.SKIPPED, + ] + }, + ) + ) + EventService(self.db).emit( + event_type=EventType.LEGACY_SYNC_COMPLETED, + source=EventSource.LEGACY_MYSQL, + aggregate_type=EventAggregateType.LEGACY_SYNC_RUN, + aggregate_id=sync_run.code, + actor=actor, + payload={ + EventPayloadKey.CODE: sync_run.code, + EventPayloadKey.DOMAIN: BusinessDomain.TASKS, + EventPayloadKey.STATUS: sync_run.status, + EventPayloadKey.CREATED: created, + EventPayloadKey.UPDATED: updated, + EventPayloadKey.SKIPPED: skipped, + }, + idempotency_key=f"legacy-sync:{sync_run.code}", + ) + return result diff --git a/app/modules/observability/service.py b/app/modules/observability/service.py index 459a9ed..ac76378 100644 --- a/app/modules/observability/service.py +++ b/app/modules/observability/service.py @@ -17,7 +17,7 @@ from app.modules.audit.constants import ( from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.events.constants import EventStatus -from app.modules.events.service import EventService +from app.modules.events.services import EventService from app.modules.observability.constants import ( HeartbeatStatus, ObservabilityKey, diff --git a/app/modules/reports/routes.py b/app/modules/reports/routes.py index 27a2494..8c08b6a 100644 --- a/app/modules/reports/routes.py +++ b/app/modules/reports/routes.py @@ -12,7 +12,7 @@ from app.modules.reports.schemas import ( ReportResponse, WorkReportGenerateRequest, ) -from app.modules.reports.service import ReportService +from app.modules.reports.services import ReportService router = APIRouter(dependencies=[Depends(require_api_key)]) diff --git a/app/modules/reports/service.py b/app/modules/reports/service.py deleted file mode 100644 index 16eb163..0000000 --- a/app/modules/reports/service.py +++ /dev/null @@ -1,1348 +0,0 @@ -from datetime import date, datetime, timedelta -from decimal import Decimal -from typing import Any - -from sqlalchemy import func, or_, 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, AuditSource, AuditTargetType -from app.modules.audit.schemas import AuditLogCreate -from app.modules.audit.service import AuditService -from app.modules.business.constants import ( - ATTENDANCE_ABNORMAL_STATUSES, - DONE_STATUSES, - GENERATED_RISK_EVENT_TYPES, - PROJECT_CLOSED_STATUSES, - PENDING_APPROVAL_STATUSES, - SUPPLIER_RISK_LEVELS, - RiskLevel, - StatusValue, -) -from app.modules.business.models import ( - AttendanceRecord, - Expense, - FundAccount, - PerformanceMetric, - Procurement, - Project, - ReportPushRun, - RiskEvent, - Supplier, - WorkReport, - 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.feishu.service import FeishuService -from app.modules.reports.constants import ( - ATTENTION_SCORE_THRESHOLD, - HEALTH_PENALTY_WEIGHTS, - HEALTH_SCORE_MAX, - HEALTH_SCORE_MIN, - HEALTHY_SCORE_THRESHOLD, - LIFECYCLE_RISK_SCORE_WEIGHTS, - HealthLevel, - LifecycleAttentionKey, - LifecycleFilterKey, - LifecycleResponseKey, - LifecycleSection, - MetricKey, - ReportErrorDetail, - ReportPushStatus, - ReportResponseKey, - ReportStatus, - ReportText, - ReportTitle, - ReportType, - EnterpriseAnalyticsKey, - WorkReportMetricKey, -) -from app.modules.risk.constants import RiskSummaryKey, risk_level_for_score -from app.modules.risk.service import RiskService - - -def _money(value: Decimal | int | float | None) -> str: - """Format a numeric value as a two-decimal money string.""" - - amount = Decimal(value or 0) - return f"{amount:,.2f}" - - -def _json_safe(value: Any) -> Any: - """Convert nested report payloads into JSON-storable values.""" - - if isinstance(value, Decimal): - return float(value) - if isinstance(value, (datetime, date)): - return value.isoformat() - if isinstance(value, list): - return [_json_safe(item) for item in value] - if isinstance(value, dict): - return {key: _json_safe(item) for key, item in value.items()} - return value - - -def _next_code(prefix: str) -> str: - """Build a compact unique code for generated report records.""" - - return f"{prefix}-{utc_now():%Y%m%d%H%M%S%f}" - - -def _rate(numerator: int | Decimal, denominator: int | Decimal) -> float: - """Return a rounded percentage rate, using zero when the denominator is empty.""" - - if not denominator: - return 0.0 - return round(float(numerator) / float(denominator) * 100, 2) - - -def _as_decimal(value: Decimal | int | float | None) -> Decimal: - return Decimal(str(value or 0)) - - -class ReportService: - """Build operational reports and push them through Feishu.""" - - def __init__(self, db: Session): - self.db = db - self.risks = RiskService(db) - - def _count(self, model: type, *conditions: Any) -> int: - stmt = select(func.count()).select_from(model) - for condition in conditions: - stmt = stmt.where(condition) - return int(self.db.execute(stmt).scalar() or 0) - - def _sum(self, column: Any, *conditions: Any) -> Decimal: - stmt = select(func.sum(column)) - for condition in conditions: - stmt = stmt.where(condition) - return _as_decimal(self.db.execute(stmt).scalar()) - - def _avg(self, column: Any, *conditions: Any) -> float: - stmt = select(func.avg(column)) - for condition in conditions: - stmt = stmt.where(condition) - value = self.db.execute(stmt).scalar() - return round(float(value or 0), 2) - - def _group_counts(self, model: type, column: Any, *conditions: Any) -> dict[str, int]: - stmt = select(column, func.count()).select_from(model) - for condition in conditions: - stmt = stmt.where(condition) - stmt = stmt.group_by(column) - return { - str(key or ReportStatus.UNKNOWN): int(count) - for key, count in self.db.execute(stmt).all() - } - - def _records( - self, - model: type, - *conditions: Any, - limit: int = 10, - order_by: Any | None = None, - ) -> list[Any]: - stmt = select(model) - for condition in conditions: - stmt = stmt.where(condition) - if order_by is not None: - stmt = stmt.order_by(order_by) - else: - stmt = stmt.order_by(model.id.desc()) - return list(self.db.execute(stmt.limit(bounded_limit(limit))).scalars()) - - def create_push_run( - self, - report_type: str, - title: str | None, - receive_id: str | None, - receive_id_type: str, - actor: str, - status: str = ReportPushStatus.PENDING, - ) -> ReportPushRun: - record = ReportPushRun( - code=_next_code("PUSH"), - report_type=report_type, - title=title, - receive_id=receive_id, - receive_id_type=receive_id_type, - status=status, - actor=actor, - queued_at=utc_now(), - ) - self.db.add(record) - self.db.commit() - self.db.refresh(record) - return record - - def update_push_run( - self, - code: str, - status: str, - task_id: str | None = None, - provider_response: dict[str, Any] | None = None, - error_message: str | None = None, - sent: bool = False, - ) -> ReportPushRun: - record = self._get_push_run(code) - record.status = status - if task_id is not None: - record.task_id = task_id - if provider_response is not None: - record.provider_response = _json_safe(provider_response) - record.error_message = error_message - if sent: - record.sent_at = utc_now() - self.db.commit() - self.db.refresh(record) - return record - - def list_push_runs( - self, - status_filter: str | None = None, - limit: int = 100, - ) -> list[dict[str, Any]]: - stmt = select(ReportPushRun).order_by(ReportPushRun.id.desc()).limit( - bounded_limit(limit) - ) - if status_filter: - stmt = ( - select(ReportPushRun) - .where(ReportPushRun.status == status_filter) - .order_by(ReportPushRun.id.desc()) - .limit(bounded_limit(limit)) - ) - return [serialize_model(item) for item in self.db.execute(stmt).scalars()] - - def get_push_run(self, code: str) -> dict[str, Any]: - return serialize_model(self._get_push_run(code)) - - def _get_push_run(self, code: str) -> ReportPushRun: - from fastapi import HTTPException, status - - record = self.db.execute( - select(ReportPushRun).where(ReportPushRun.code == code) - ).scalar_one_or_none() - if record is None: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=ReportErrorDetail.PUSH_RUN_NOT_FOUND, - ) - return record - - def daily_brief(self) -> dict: - project_count = self._count(Project) - task_count = self._count(WorkTask) - procurement_pending = self._count( - Procurement, - Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), - ) - expense_pending = self._count( - Expense, - Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), - ) - fund_total = ( - self.db.execute(select(func.sum(FundAccount.current_balance))).scalar() - or Decimal("0") - ) - risk_summary = self.risks.summary() - attendance = self.attendance_summary() - lines = [ - f"- 项目总数:{project_count}", - f"- 任务总数:{task_count}", - f"- 待处理采购:{procurement_pending}", - f"- 待处理费用:{expense_pending}", - f"- 当前账户总余额:{_money(fund_total)}", - ( - f"- 今日打卡记录:{attendance[ReportResponseKey.TOTAL]}," - f"异常:{attendance[ReportResponseKey.ABNORMAL_TOTAL]}" - ), - f"- 逾期任务:{len(risk_summary[RiskSummaryKey.OVERDUE_TASKS])}", - f"- 延期项目:{len(risk_summary[RiskSummaryKey.DELAYED_PROJECTS])}", - f"- 超预算项目:{len(risk_summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}", - f"- 资金风险账户:{len(risk_summary[RiskSummaryKey.FUND_RISKS])}", - f"- 供应商风险:{len(risk_summary[RiskSummaryKey.SUPPLIER_RISKS])}", - f"- 打开风险事件:{len(risk_summary[RiskSummaryKey.OPEN_EVENTS])}", - f"- 综合风险等级:{risk_summary[RiskSummaryKey.RISK_LEVEL]}", - ] - return { - ReportResponseKey.TITLE: ReportTitle.DAILY_BRIEF, - ReportResponseKey.LINES: lines, - ReportResponseKey.CONTENT: "\n".join(lines), - } - - def project_weekly(self) -> dict: - active = self._count( - Project, - Project.status.notin_(PROJECT_CLOSED_STATUSES), - ) - delayed = self.risks.delayed_projects() - over_budget = self.risks.over_budget_projects() - open_risks = self.risks.list_events(status_filter=StatusValue.OPEN) - lines = [ - f"- 活跃项目:{active}", - f"- 延期项目:{len(delayed)}", - f"- 超预算项目:{len(over_budget)}", - f"- 打开风险事件:{len(open_risks)}", - "- 需要管理层关注:", - ] - for item in delayed[:10]: - lines.append( - f" - 延期:{item.get('code')} {item.get('name')}," - f"负责人 {item.get('owner')}" - ) - for item in over_budget[:10]: - lines.append(f" - 超预算:{item.get('code')} {item.get('name')}") - return { - ReportResponseKey.TITLE: ReportTitle.PROJECT_WEEKLY, - ReportResponseKey.LINES: lines, - ReportResponseKey.CONTENT: "\n".join(lines), - } - - def project_lifecycle_report( - self, - project_code: str | None = None, - owner: str | None = None, - period_start: date | None = None, - period_end: date | None = None, - include_ai: bool = False, - actor: str = ActorValue.API, - ) -> dict[str, Any]: - """Build a full lifecycle report for project progress, delivery, cost, and risk.""" - - filters = self._lifecycle_filters(project_code, owner, period_start, period_end) - project_conditions = filters[LifecycleSection.PROJECTS] - task_conditions = filters[LifecycleSection.TASKS] - procurement_conditions = filters[LifecycleSection.PROCUREMENTS] - expense_conditions = filters[LifecycleSection.EXPENSES] - attendance_conditions = filters[LifecycleSection.ATTENDANCE] - risk_conditions = filters[LifecycleSection.RISKS] - - project_stats = self._lifecycle_project_stats(project_conditions) - task_stats = self._lifecycle_task_stats(task_conditions) - procurement_stats = self._lifecycle_procurement_stats(procurement_conditions) - expense_stats = self._lifecycle_expense_stats(expense_conditions) - fund_stats = self._lifecycle_fund_stats() - supplier_stats = self._lifecycle_supplier_stats() - attendance_stats = self._lifecycle_attendance_stats(attendance_conditions) - risk_stats = self._lifecycle_risk_stats( - project_conditions, - task_conditions, - risk_conditions, - ) - include_global_risk = not (project_code or owner) - health = self._lifecycle_health( - project_stats, - task_stats, - risk_stats, - supplier_stats, - include_global_risk, - ) - attention = self._lifecycle_attention(project_conditions, task_conditions) - recommendations = self._lifecycle_recommendations( - project_stats, - task_stats, - procurement_stats, - expense_stats, - fund_stats, - supplier_stats, - risk_stats, - include_global_risk, - ) - - metrics = { - LifecycleSection.HEALTH: health, - LifecycleSection.PROJECTS: project_stats, - LifecycleSection.TASKS: task_stats, - LifecycleSection.PROCUREMENTS: procurement_stats, - LifecycleSection.EXPENSES: expense_stats, - LifecycleSection.FUNDS: fund_stats, - LifecycleSection.SUPPLIERS: supplier_stats, - LifecycleSection.ATTENDANCE: attendance_stats, - LifecycleSection.RISKS: risk_stats, - } - lines = self._lifecycle_lines( - filters[LifecycleFilterKey.LABELS], - metrics, - recommendations, - ) - report = { - LifecycleResponseKey.TITLE: ReportTitle.PROJECT_LIFECYCLE, - LifecycleResponseKey.FILTERS: filters[LifecycleFilterKey.LABELS], - LifecycleResponseKey.METRICS: _json_safe(metrics), - LifecycleResponseKey.ATTENTION: _json_safe(attention), - LifecycleResponseKey.RECOMMENDATIONS: recommendations, - LifecycleResponseKey.LINES: lines, - LifecycleResponseKey.CONTENT: "\n".join(lines), - } - if include_ai: - report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor) - return report - - def enterprise_analytics( - self, - project_code: str | None = None, - owner: str | None = None, - period_start: date | None = None, - period_end: date | None = None, - actor: str = ActorValue.API, - ) -> dict[str, Any]: - """Build V3 read-only finance, procurement, performance, and operations analytics.""" - - code = _next_code("ANALYTICS") - lifecycle = self.project_lifecycle_report( - project_code=project_code, - owner=owner, - period_start=period_start, - period_end=period_end, - include_ai=False, - actor=actor, - ) - metrics = lifecycle[LifecycleResponseKey.METRICS] - projects = metrics[LifecycleSection.PROJECTS] - procurements = metrics[LifecycleSection.PROCUREMENTS] - expenses = metrics[LifecycleSection.EXPENSES] - funds = metrics[LifecycleSection.FUNDS] - tasks = metrics[LifecycleSection.TASKS] - risks = metrics[LifecycleSection.RISKS] - health = metrics[LifecycleSection.HEALTH] - finance = { - MetricKey.BUDGET_TOTAL: projects[MetricKey.BUDGET_TOTAL], - MetricKey.ACTUAL_TOTAL: projects[MetricKey.ACTUAL_TOTAL], - MetricKey.BUDGET_USAGE_RATE: projects[MetricKey.BUDGET_USAGE_RATE], - MetricKey.CURRENT_BALANCE_TOTAL: funds[MetricKey.CURRENT_BALANCE_TOTAL], - MetricKey.NET_POSITION: funds[MetricKey.NET_POSITION], - MetricKey.RISK_ACCOUNTS: funds[MetricKey.RISK_ACCOUNTS], - MetricKey.PAYMENT_EXPOSURE: ( - procurements[MetricKey.ACTUAL_TOTAL] + expenses[MetricKey.AMOUNT_TOTAL] - ), - } - procurement = { - MetricKey.TOTAL: procurements[MetricKey.TOTAL], - MetricKey.PENDING_APPROVAL: procurements[MetricKey.PENDING_APPROVAL], - MetricKey.PENDING_DELIVERY: procurements[MetricKey.PENDING_DELIVERY], - MetricKey.UNPAID: procurements[MetricKey.UNPAID], - MetricKey.EXPECTED_TOTAL: procurements[MetricKey.EXPECTED_TOTAL], - MetricKey.ACTUAL_TOTAL: procurements[MetricKey.ACTUAL_TOTAL], - MetricKey.DELIVERY_RISK: procurements[MetricKey.PENDING_DELIVERY], - } - performance = self._enterprise_performance_stats() - operations = { - MetricKey.READINESS_SCORE: health[MetricKey.SCORE], - MetricKey.LEVEL: health[MetricKey.LEVEL], - MetricKey.COMPLETION_RATE: tasks[MetricKey.COMPLETION_RATE], - MetricKey.OVERDUE_TASKS: risks[MetricKey.OVERDUE_TASKS], - MetricKey.DELAYED_PROJECTS: risks[MetricKey.DELAYED_PROJECTS], - MetricKey.OVER_BUDGET_PROJECTS: risks[MetricKey.OVER_BUDGET_PROJECTS], - MetricKey.OPEN_EVENTS: risks[MetricKey.OPEN_EVENTS], - MetricKey.HIGH_EVENTS: risks[MetricKey.HIGH_EVENTS], - } - recommendations = lifecycle[LifecycleResponseKey.RECOMMENDATIONS] - lines = self._enterprise_analytics_lines( - lifecycle[LifecycleResponseKey.FILTERS], - finance, - procurement, - performance, - operations, - recommendations, - ) - report = _json_safe( - { - EnterpriseAnalyticsKey.CODE: code, - EnterpriseAnalyticsKey.TITLE: ReportTitle.ENTERPRISE_ANALYTICS, - EnterpriseAnalyticsKey.FILTERS: lifecycle[LifecycleResponseKey.FILTERS], - EnterpriseAnalyticsKey.FINANCE: finance, - EnterpriseAnalyticsKey.PROCUREMENT: procurement, - EnterpriseAnalyticsKey.PERFORMANCE: performance, - EnterpriseAnalyticsKey.OPERATIONS: operations, - EnterpriseAnalyticsKey.RECOMMENDATIONS: recommendations, - EnterpriseAnalyticsKey.LINES: lines, - EnterpriseAnalyticsKey.CONTENT: "\n".join(lines), - } - ) - AuditService(self.db).log( - AuditLogCreate( - actor=actor, - source=AuditSource.REPORTS, - action=AuditAction.ENTERPRISE_ANALYTICS, - target_type=AuditTargetType.ENTERPRISE_ANALYTICS, - target_id=code, - response_payload=report, - ) - ) - EventService(self.db).emit( - event_type=EventType.ENTERPRISE_ANALYTICS_GENERATED, - source=EventSource.ANALYTICS, - aggregate_type=EventAggregateType.ENTERPRISE_ANALYTICS, - aggregate_id=code, - actor=actor, - payload={ - EventPayloadKey.CODE: code, - EventPayloadKey.STATUS: ReportStatus.GENERATED, - }, - idempotency_key=f"enterprise-analytics:{code}", - dispatch=True, - ) - return report - - def _enterprise_performance_stats(self) -> dict[str, Any]: - total = self._count(PerformanceMetric) - confirmed = self._count(PerformanceMetric, PerformanceMetric.confirmed_score.is_not(None)) - return { - MetricKey.TOTAL: total, - MetricKey.CONFIRMED: confirmed, - MetricKey.CONFIRMED_RATE: _rate(confirmed, total), - MetricKey.AVERAGE_AUTO_SCORE: self._avg(PerformanceMetric.auto_score), - MetricKey.AVERAGE_CONFIRMED_SCORE: self._avg(PerformanceMetric.confirmed_score), - MetricKey.WEIGHT_TOTAL: self._sum(PerformanceMetric.weight), - MetricKey.BY_STATUS: self._group_counts(PerformanceMetric, PerformanceMetric.status), - } - - def _enterprise_analytics_lines( - self, - filters: dict[str, Any], - finance: dict[str, Any], - procurement: dict[str, Any], - performance: dict[str, Any], - operations: dict[str, Any], - recommendations: list[str], - ) -> list[str]: - scope = ( - "、".join(f"{key}={value}" for key, value in filters.items() if value) - or ReportText.DEFAULT_SCOPE - ) - lines = [ - f"- 范围:{scope}", - ( - f"- 财务:预算 {_money(finance[MetricKey.BUDGET_TOTAL])}," - f"实际 {_money(finance[MetricKey.ACTUAL_TOTAL])}," - f"净头寸 {_money(finance[MetricKey.NET_POSITION])}," - f"支付暴露 {_money(finance[MetricKey.PAYMENT_EXPOSURE])}" - ), - ( - f"- 采购:总数 {procurement[MetricKey.TOTAL]}," - f"待审批 {procurement[MetricKey.PENDING_APPROVAL]}," - f"待交付 {procurement[MetricKey.PENDING_DELIVERY]}," - f"未付款 {procurement[MetricKey.UNPAID]}" - ), - ( - f"- 绩效:指标 {performance[MetricKey.TOTAL]}," - f"已确认 {performance[MetricKey.CONFIRMED]}," - f"确认率 {performance[MetricKey.CONFIRMED_RATE]}%," - f"平均自动分 {performance[MetricKey.AVERAGE_AUTO_SCORE]}" - ), - ( - f"- 运营:准备度 {operations[MetricKey.READINESS_SCORE]}," - f"任务完成率 {operations[MetricKey.COMPLETION_RATE]}%," - f"逾期任务 {operations[MetricKey.OVERDUE_TASKS]}," - f"打开风险 {operations[MetricKey.OPEN_EVENTS]}" - ), - ReportText.ACTION_HEADER, - ] - lines.extend(f" - {item}" for item in recommendations) - return lines - - def _lifecycle_filters( - self, - project_code: str | None, - owner: str | None, - period_start: date | None, - period_end: date | None, - ) -> dict[str, Any]: - project_conditions: list[Any] = [] - task_conditions: list[Any] = [] - procurement_conditions: list[Any] = [] - expense_conditions: list[Any] = [] - attendance_conditions: list[Any] = [] - risk_conditions: list[Any] = [] - labels = { - LifecycleFilterKey.PROJECT_CODE: project_code, - LifecycleFilterKey.OWNER: owner, - LifecycleFilterKey.PERIOD_START: period_start.isoformat() if period_start else None, - LifecycleFilterKey.PERIOD_END: period_end.isoformat() if period_end else None, - } - - if project_code: - project_conditions.append(Project.code == project_code) - task_conditions.append(WorkTask.project_code == project_code) - procurement_conditions.append(Procurement.project_code == project_code) - expense_conditions.append(Expense.project_code == project_code) - attendance_conditions.append(AttendanceRecord.project_code == project_code) - risk_conditions.append(RiskEvent.project_code == project_code) - if owner: - project_conditions.append(Project.owner == owner) - task_conditions.append(WorkTask.owner == owner) - risk_conditions.append(RiskEvent.owner == owner) - if period_start: - project_conditions.append( - or_(Project.due_date.is_(None), Project.due_date >= period_start) - ) - task_conditions.append(WorkTask.due_date >= period_start) - attendance_conditions.append(AttendanceRecord.work_date >= period_start) - risk_conditions.append( - RiskEvent.detected_at >= datetime.combine(period_start, datetime.min.time()) - ) - if period_end: - project_conditions.append( - or_(Project.start_date.is_(None), Project.start_date <= period_end) - ) - task_conditions.append(WorkTask.due_date <= period_end) - attendance_conditions.append(AttendanceRecord.work_date <= period_end) - risk_conditions.append( - RiskEvent.detected_at <= datetime.combine(period_end, datetime.max.time()) - ) - if period_start: - start_at = datetime.combine(period_start, datetime.min.time()) - procurement_conditions.append(Procurement.created_at >= start_at) - expense_conditions.append(Expense.created_at >= start_at) - if period_end: - end_at = datetime.combine(period_end, datetime.max.time()) - procurement_conditions.append(Procurement.created_at <= end_at) - expense_conditions.append(Expense.created_at <= end_at) - - return { - LifecycleFilterKey.LABELS: labels, - LifecycleSection.PROJECTS: project_conditions, - LifecycleSection.TASKS: task_conditions, - LifecycleSection.PROCUREMENTS: procurement_conditions, - LifecycleSection.EXPENSES: expense_conditions, - LifecycleSection.ATTENDANCE: attendance_conditions, - LifecycleSection.RISKS: risk_conditions, - } - - def _lifecycle_project_stats(self, conditions: list[Any]) -> dict[str, Any]: - total = self._count(Project, *conditions) - active = self._count(Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), *conditions) - closed = total - active - budget_total = self._sum(Project.budget_amount, *conditions) - actual_total = self._sum(Project.actual_amount, *conditions) - delayed = self._count( - Project, - Project.due_date.is_not(None), - Project.due_date < date.today(), - Project.status.notin_(PROJECT_CLOSED_STATUSES), - *conditions, - ) - over_budget = self._count( - Project, - Project.budget_amount > 0, - Project.actual_amount > Project.budget_amount, - *conditions, - ) - return { - MetricKey.TOTAL: total, - MetricKey.ACTIVE: active, - MetricKey.CLOSED: closed, - MetricKey.AVERAGE_PROGRESS_PERCENT: self._avg( - Project.progress_percent, - *conditions, - ), - MetricKey.BY_STATUS: self._group_counts(Project, Project.status, *conditions), - MetricKey.BY_RISK_LEVEL: self._group_counts(Project, Project.risk_level, *conditions), - MetricKey.BUDGET_TOTAL: budget_total, - MetricKey.ACTUAL_TOTAL: actual_total, - MetricKey.BUDGET_USAGE_RATE: _rate(actual_total, budget_total), - MetricKey.DELAYED: delayed, - MetricKey.OVER_BUDGET: over_budget, - } - - def _lifecycle_task_stats(self, conditions: list[Any]) -> dict[str, Any]: - total = self._count(WorkTask, *conditions) - completed = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *conditions) - overdue = self._count( - WorkTask, - WorkTask.due_date.is_not(None), - WorkTask.due_date < date.today(), - WorkTask.status.notin_(DONE_STATUSES), - *conditions, - ) - blocked = self._count( - WorkTask, - WorkTask.blocker.is_not(None), - WorkTask.status.notin_(DONE_STATUSES), - *conditions, - ) - return { - MetricKey.TOTAL: total, - MetricKey.COMPLETED: completed, - MetricKey.OPEN: total - completed, - MetricKey.OVERDUE: overdue, - MetricKey.BLOCKED: blocked, - MetricKey.COMPLETION_RATE: _rate(completed, total), - MetricKey.BY_STATUS: self._group_counts(WorkTask, WorkTask.status, *conditions), - MetricKey.BY_PRIORITY: self._group_counts(WorkTask, WorkTask.priority, *conditions), - } - - def _lifecycle_procurement_stats(self, conditions: list[Any]) -> dict[str, Any]: - total = self._count(Procurement, *conditions) - pending_approval = self._count( - Procurement, - Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), - *conditions, - ) - pending_delivery = self._count( - Procurement, - Procurement.delivery_status.notin_(DONE_STATUSES), - *conditions, - ) - unpaid = self._count( - Procurement, - Procurement.payment_status != StatusValue.PAID, - *conditions, - ) - expected_total = self._sum(Procurement.expected_amount, *conditions) - actual_total = self._sum(Procurement.actual_amount, *conditions) - return { - MetricKey.TOTAL: total, - MetricKey.PENDING_APPROVAL: pending_approval, - MetricKey.PENDING_DELIVERY: pending_delivery, - MetricKey.UNPAID: unpaid, - MetricKey.EXPECTED_TOTAL: expected_total, - MetricKey.ACTUAL_TOTAL: actual_total, - MetricKey.ACTUAL_VS_EXPECTED_RATE: _rate(actual_total, expected_total), - } - - def _lifecycle_expense_stats(self, conditions: list[Any]) -> dict[str, Any]: - total = self._count(Expense, *conditions) - pending_approval = self._count( - Expense, - Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), - *conditions, - ) - unpaid = self._count(Expense, Expense.payment_status != StatusValue.PAID, *conditions) - return { - MetricKey.TOTAL: total, - MetricKey.PENDING_APPROVAL: pending_approval, - MetricKey.UNPAID: unpaid, - MetricKey.AMOUNT_TOTAL: self._sum(Expense.amount, *conditions), - MetricKey.BY_TYPE: self._group_counts(Expense, Expense.expense_type, *conditions), - } - - def _lifecycle_fund_stats(self) -> dict[str, Any]: - balance = self._sum(FundAccount.current_balance) - receivable = self._sum(FundAccount.expected_receivable) - payable = self._sum(FundAccount.expected_payable) - safety_line = self._sum(FundAccount.safety_line) - risk_accounts = self._count( - FundAccount, - FundAccount.current_balance < FundAccount.safety_line, - ) - return { - MetricKey.ACCOUNTS_TOTAL: self._count(FundAccount), - MetricKey.CURRENT_BALANCE_TOTAL: balance, - MetricKey.EXPECTED_RECEIVABLE_TOTAL: receivable, - MetricKey.EXPECTED_PAYABLE_TOTAL: payable, - MetricKey.SAFETY_LINE_TOTAL: safety_line, - MetricKey.NET_POSITION: balance + receivable - payable, - MetricKey.RISK_ACCOUNTS: risk_accounts, - } - - def _lifecycle_supplier_stats(self) -> dict[str, Any]: - risky = self._count( - Supplier, - (Supplier.blacklist_status != StatusValue.NORMAL) - | Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS), - ) - blacklisted = self._count(Supplier, Supplier.blacklist_status != StatusValue.NORMAL) - return { - MetricKey.TOTAL: self._count(Supplier), - MetricKey.RISKY: risky, - MetricKey.BLACKLISTED: blacklisted, - MetricKey.BY_RISK_LEVEL: self._group_counts(Supplier, Supplier.risk_level), - } - - def _lifecycle_attendance_stats(self, conditions: list[Any]) -> dict[str, Any]: - total = self._count(AttendanceRecord, *conditions) - abnormal = self._count( - AttendanceRecord, - AttendanceRecord.status.in_(ATTENDANCE_ABNORMAL_STATUSES), - *conditions, - ) - return { - MetricKey.TOTAL: total, - MetricKey.ABNORMAL: abnormal, - MetricKey.ABNORMAL_RATE: _rate(abnormal, total), - MetricKey.BY_STATUS: self._group_counts( - AttendanceRecord, - AttendanceRecord.status, - *conditions, - ), - } - - def _lifecycle_risk_stats( - self, - project_conditions: list[Any], - task_conditions: list[Any], - risk_conditions: list[Any], - ) -> dict[str, Any]: - overdue_tasks = self._count( - WorkTask, - WorkTask.due_date.is_not(None), - WorkTask.due_date < date.today(), - WorkTask.status.notin_(DONE_STATUSES), - *task_conditions, - ) - delayed_projects = self._count( - Project, - Project.due_date.is_not(None), - Project.due_date < date.today(), - Project.status.notin_(PROJECT_CLOSED_STATUSES), - *project_conditions, - ) - over_budget_projects = self._count( - Project, - Project.budget_amount > 0, - Project.actual_amount > Project.budget_amount, - *project_conditions, - ) - open_events = self._count( - RiskEvent, - RiskEvent.status == StatusValue.OPEN, - *risk_conditions, - ) - high_events = self._count( - RiskEvent, - RiskEvent.status == StatusValue.OPEN, - RiskEvent.risk_level == RiskLevel.HIGH, - *risk_conditions, - ) - external_open_events = self._count( - RiskEvent, - RiskEvent.status == StatusValue.OPEN, - RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES), - *risk_conditions, - ) - external_high_events = self._count( - RiskEvent, - RiskEvent.status == StatusValue.OPEN, - RiskEvent.risk_level == RiskLevel.HIGH, - RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES), - *risk_conditions, - ) - risk_score = ( - overdue_tasks * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVERDUE_TASKS] - + delayed_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.DELAYED_PROJECTS] - + over_budget_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS] - + external_open_events * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.EXTERNAL_OPEN_EVENTS] - + external_high_events * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.EXTERNAL_HIGH_EVENTS] - ) - return { - MetricKey.RISK_LEVEL: risk_level_for_score(risk_score), - MetricKey.RISK_SCORE: risk_score, - MetricKey.OVERDUE_TASKS: overdue_tasks, - MetricKey.DELAYED_PROJECTS: delayed_projects, - MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects, - MetricKey.OPEN_EVENTS: open_events, - MetricKey.HIGH_EVENTS: high_events, - MetricKey.EXTERNAL_OPEN_EVENTS: external_open_events, - MetricKey.EXTERNAL_HIGH_EVENTS: external_high_events, - MetricKey.EVENTS_BY_TYPE: self._group_counts( - RiskEvent, - RiskEvent.risk_type, - *risk_conditions, - ), - MetricKey.EVENTS_BY_LEVEL: self._group_counts( - RiskEvent, - RiskEvent.risk_level, - *risk_conditions, - ), - } - - def _lifecycle_health( - self, - projects: dict[str, Any], - tasks: dict[str, Any], - risks: dict[str, Any], - suppliers: dict[str, Any], - include_global_risk: bool, - ) -> dict[str, Any]: - penalty = ( - risks[MetricKey.OVERDUE_TASKS] * HEALTH_PENALTY_WEIGHTS[MetricKey.OVERDUE_TASKS] - + risks[MetricKey.DELAYED_PROJECTS] - * HEALTH_PENALTY_WEIGHTS[MetricKey.DELAYED_PROJECTS] - + risks[MetricKey.OVER_BUDGET_PROJECTS] - * HEALTH_PENALTY_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS] - + risks[MetricKey.EXTERNAL_HIGH_EVENTS] - * HEALTH_PENALTY_WEIGHTS[MetricKey.EXTERNAL_HIGH_EVENTS] - + max( - HEALTH_SCORE_MIN, - projects[MetricKey.BUDGET_USAGE_RATE] - HEALTH_SCORE_MAX, - ) - * HEALTH_PENALTY_WEIGHTS[MetricKey.BUDGET_USAGE_RATE] - + (HEALTH_SCORE_MAX - tasks[MetricKey.COMPLETION_RATE]) - * HEALTH_PENALTY_WEIGHTS[MetricKey.COMPLETION_RATE] - ) - if include_global_risk: - penalty += suppliers[MetricKey.BLACKLISTED] * HEALTH_PENALTY_WEIGHTS[ - MetricKey.BLACKLISTED - ] - score = max( - HEALTH_SCORE_MIN, - min(HEALTH_SCORE_MAX, round(HEALTH_SCORE_MAX - penalty, 2)), - ) - if score >= HEALTHY_SCORE_THRESHOLD: - level = HealthLevel.HEALTHY - elif score >= ATTENTION_SCORE_THRESHOLD: - level = HealthLevel.ATTENTION - else: - level = HealthLevel.CRITICAL - return {MetricKey.SCORE: score, MetricKey.LEVEL: level} - - def _lifecycle_attention( - self, - project_conditions: list[Any], - task_conditions: list[Any], - ) -> dict[str, Any]: - delayed = self._records( - Project, - Project.due_date.is_not(None), - Project.due_date < date.today(), - Project.status.notin_(PROJECT_CLOSED_STATUSES), - *project_conditions, - limit=10, - order_by=Project.due_date.asc(), - ) - over_budget = self._records( - Project, - Project.budget_amount > 0, - Project.actual_amount > Project.budget_amount, - *project_conditions, - limit=10, - order_by=Project.id.desc(), - ) - overdue_tasks = self._records( - WorkTask, - WorkTask.due_date.is_not(None), - WorkTask.due_date < date.today(), - WorkTask.status.notin_(DONE_STATUSES), - *task_conditions, - limit=10, - order_by=WorkTask.due_date.asc(), - ) - return { - LifecycleAttentionKey.DELAYED_PROJECTS: [serialize_model(item) for item in delayed], - LifecycleAttentionKey.OVER_BUDGET_PROJECTS: [ - serialize_model(item) for item in over_budget - ], - LifecycleAttentionKey.OVERDUE_TASKS: [ - serialize_model(item) for item in overdue_tasks - ], - } - - def _lifecycle_recommendations( - self, - projects: dict[str, Any], - tasks: dict[str, Any], - procurements: dict[str, Any], - expenses: dict[str, Any], - funds: dict[str, Any], - suppliers: dict[str, Any], - risks: dict[str, Any], - include_global_risk: bool, - ) -> list[str]: - recommendations: list[str] = [] - if risks[MetricKey.DELAYED_PROJECTS]: - recommendations.append(ReportText.RECOMMEND_DELAYED) - if risks[MetricKey.OVER_BUDGET_PROJECTS]: - recommendations.append(ReportText.RECOMMEND_OVER_BUDGET) - if tasks[MetricKey.OVERDUE]: - recommendations.append(ReportText.RECOMMEND_OVERDUE_TASKS) - if ( - procurements[MetricKey.PENDING_APPROVAL] - or expenses[MetricKey.PENDING_APPROVAL] - ): - recommendations.append(ReportText.RECOMMEND_APPROVALS) - if include_global_risk and funds[MetricKey.RISK_ACCOUNTS]: - recommendations.append(ReportText.RECOMMEND_FUNDS) - if include_global_risk and suppliers[MetricKey.RISKY]: - recommendations.append(ReportText.RECOMMEND_SUPPLIERS) - if not recommendations: - recommendations.append(ReportText.RECOMMEND_STABLE) - return [str(item) for item in recommendations] - - def _lifecycle_lines( - self, - filters: dict[str, Any], - metrics: dict[str, Any], - recommendations: list[str], - ) -> list[str]: - scope = ( - "、".join(f"{key}={value}" for key, value in filters.items() if value) - or ReportText.DEFAULT_SCOPE - ) - projects = metrics[LifecycleSection.PROJECTS] - tasks = metrics[LifecycleSection.TASKS] - procurements = metrics[LifecycleSection.PROCUREMENTS] - expenses = metrics[LifecycleSection.EXPENSES] - funds = metrics[LifecycleSection.FUNDS] - suppliers = metrics[LifecycleSection.SUPPLIERS] - attendance = metrics[LifecycleSection.ATTENDANCE] - risks = metrics[LifecycleSection.RISKS] - health = metrics[LifecycleSection.HEALTH] - lines = [ - f"- 范围:{scope}", - f"- 生命周期健康分:{health[MetricKey.SCORE]}({health[MetricKey.LEVEL]})", - ( - f"- 项目:总数 {projects[MetricKey.TOTAL]}," - f"活跃 {projects[MetricKey.ACTIVE]}," - f"平均进度 {projects[MetricKey.AVERAGE_PROGRESS_PERCENT]}%" - ), - ( - f"- 成本:预算 {_money(projects[MetricKey.BUDGET_TOTAL])}," - f"实际 {_money(projects[MetricKey.ACTUAL_TOTAL])}," - f"预算使用率 {projects[MetricKey.BUDGET_USAGE_RATE]}%" - ), - ( - f"- 任务:总数 {tasks[MetricKey.TOTAL]}," - f"完成 {tasks[MetricKey.COMPLETED]}," - f"完成率 {tasks[MetricKey.COMPLETION_RATE]}%," - f"逾期 {tasks[MetricKey.OVERDUE]}" - ), - ( - f"- 采购/费用:待批采购 {procurements[MetricKey.PENDING_APPROVAL]}," - f"待批费用 {expenses[MetricKey.PENDING_APPROVAL]}," - f"未付款采购 {procurements[MetricKey.UNPAID]}" - ), - ( - f"- 资金:余额 {_money(funds[MetricKey.CURRENT_BALANCE_TOTAL])}," - f"净头寸 {_money(funds[MetricKey.NET_POSITION])}," - f"风险账户 {funds[MetricKey.RISK_ACCOUNTS]}" - ), - ( - f"- 风险:等级 {risks[MetricKey.RISK_LEVEL]}," - f"风险分 {risks[MetricKey.RISK_SCORE]}," - f"延期项目 {risks[MetricKey.DELAYED_PROJECTS]}," - f"超预算项目 {risks[MetricKey.OVER_BUDGET_PROJECTS]}," - f"打开事件 {risks[MetricKey.OPEN_EVENTS]}" - ), - ( - f"- 供应商/考勤:风险供应商 {suppliers[MetricKey.RISKY]}," - f"异常打卡 {attendance[MetricKey.ABNORMAL]}," - f"异常率 {attendance[MetricKey.ABNORMAL_RATE]}%" - ), - ReportText.ACTION_HEADER, - ] - lines.extend(f" - {item}" for item in recommendations) - return lines - - def _lifecycle_ai_analysis(self, report: dict[str, Any], actor: str) -> dict[str, Any]: - try: - from app.modules.ai_agent.constants import AIResponseKey - from app.modules.ai_agent.skills import AISkillId - from app.modules.ai_agent.service import AIService - - report_snapshot = _json_safe(report) - result = AIService(self.db).run_skill( - AISkillId.PROJECT_LIFECYCLE_ANALYSIS, - context={LifecycleResponseKey.PROJECT_LIFECYCLE_REPORT: report_snapshot}, - actor=actor, - ) - except Exception as exc: - return { - AIResponseKey.OK: False, - AIResponseKey.ERROR: str(exc), - AIResponseKey.TYPE: type(exc).__name__, - } - return _json_safe({AIResponseKey.OK: True, **result}) - - def attendance_summary(self, work_date: date | None = None) -> dict[str, Any]: - """Summarize attendance records for one business day.""" - - target_date = work_date or date.today() - rows = self.db.execute( - select(AttendanceRecord.status, func.count()) - .where(AttendanceRecord.work_date == target_date) - .group_by(AttendanceRecord.status) - ).all() - status_counts = {str(status): int(count) for status, count in rows} - abnormal_total = sum( - count - for status, count in status_counts.items() - if status in ATTENDANCE_ABNORMAL_STATUSES - ) - total = sum(status_counts.values()) - lines = [ - f"- 日期:{target_date.isoformat()}", - f"- 打卡记录:{total}", - f"- 异常记录:{abnormal_total}", - ] - for status, count in sorted(status_counts.items()): - lines.append(f"- {status}:{count}") - return { - ReportResponseKey.TITLE: ReportTitle.ATTENDANCE_SUMMARY, - ReportResponseKey.WORK_DATE: target_date.isoformat(), - ReportResponseKey.TOTAL: total, - ReportResponseKey.ABNORMAL_TOTAL: abnormal_total, - ReportResponseKey.STATUS_COUNTS: status_counts, - ReportResponseKey.LINES: lines, - ReportResponseKey.CONTENT: "\n".join(lines), - } - - def generate_work_report( - self, - report_type: str = ReportType.DAILY, - reporter: str = ActorValue.SYSTEM, - department: str | None = None, - project_code: str | None = None, - period_start: date | None = None, - period_end: date | None = None, - persist: bool = True, - actor: str = ActorValue.API, - ) -> dict[str, Any]: - """Generate a daily or weekly operating report, optionally persisting it.""" - - start, end = self._resolve_period(report_type, period_start, period_end) - metrics = self._report_metrics(start, end, project_code, department) - risk_summary = _json_safe(self.risks.summary()) - title = ( - ReportTitle.WORK_DAILY - if report_type == ReportType.DAILY - else ReportTitle.WORK_WEEKLY - ) - lines = self._work_report_lines(title, start, end, metrics, risk_summary) - report = { - ReportResponseKey.TITLE: title, - ReportResponseKey.REPORT_TYPE: report_type, - ReportResponseKey.PERIOD_START: start.isoformat(), - ReportResponseKey.PERIOD_END: end.isoformat(), - ReportResponseKey.LINES: lines, - ReportResponseKey.CONTENT: "\n".join(lines), - ReportResponseKey.METRICS: metrics, - ReportResponseKey.RISK_SUMMARY: risk_summary, - } - - record_data = None - if persist: - record = WorkReport( - code=_next_code(f"REPORT-{report_type.upper()}"), - report_type=report_type, - title=title, - reporter=reporter, - department=department, - project_code=project_code, - period_start=start, - period_end=end, - content=report[ReportResponseKey.CONTENT], - metrics=metrics, - risk_summary=risk_summary, - ) - self.db.add(record) - self.db.commit() - self.db.refresh(record) - record_data = serialize_model(record) - AuditService(self.db).log( - AuditLogCreate( - actor=actor, - source=AuditSource.REPORTS, - action=f"generate_{report_type}_report", - target_type=AuditTargetType.WORK_REPORTS, - target_id=str(record.id), - response_payload=record_data, - ) - ) - EventService(self.db).emit( - event_type=EventType.REPORT_GENERATED, - source=EventSource.REPORTS, - aggregate_type=EventAggregateType.WORK_REPORT, - aggregate_id=record.code, - actor=actor, - payload={ - EventPayloadKey.CODE: record.code, - EventPayloadKey.STATUS: record.status, - }, - idempotency_key=f"report-generated:{record.code}", - dispatch=True, - ) - - return {ReportResponseKey.REPORT: report, ReportResponseKey.DATA: record_data} - - def _resolve_period( - self, - report_type: str, - period_start: date | None, - period_end: date | None, - ) -> tuple[date, date]: - today = date.today() - if report_type == ReportType.DAILY: - start = period_start or period_end or today - return start, period_end or start - end = period_end or today - start = period_start or end - timedelta(days=6) - return start, end - - def _report_metrics( - self, - start: date, - end: date, - project_code: str | None, - department: str | None, - ) -> dict[str, Any]: - task_filters = [ - WorkTask.due_date.is_not(None), - WorkTask.due_date >= start, - WorkTask.due_date <= end, - ] - project_filters = [] - risk_filters = [RiskEvent.status == StatusValue.OPEN] - procurement_filters = [ - Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), - ] - expense_filters = [ - Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), - ] - attendance_filters = [ - AttendanceRecord.work_date >= start, - AttendanceRecord.work_date <= end, - ] - if project_code: - project_filters.append(Project.code == project_code) - task_filters.append(WorkTask.project_code == project_code) - procurement_filters.append(Procurement.project_code == project_code) - expense_filters.append(Expense.project_code == project_code) - attendance_filters.append(AttendanceRecord.project_code == project_code) - risk_filters.append(RiskEvent.project_code == project_code) - if department: - expense_filters.append(Expense.department == department) - attendance_filters.append(AttendanceRecord.department == department) - - completed_tasks = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *task_filters) - overdue_tasks = self._count( - WorkTask, - WorkTask.due_date < date.today(), - WorkTask.status.notin_(DONE_STATUSES), - *task_filters, - ) - return { - WorkReportMetricKey.PROJECTS_TOTAL: self._count(Project, *project_filters), - WorkReportMetricKey.ACTIVE_PROJECTS: self._count( - Project, - Project.status.notin_(PROJECT_CLOSED_STATUSES), - *project_filters, - ), - WorkReportMetricKey.TASKS_TOTAL: self._count(WorkTask, *task_filters), - WorkReportMetricKey.TASKS_COMPLETED: completed_tasks, - WorkReportMetricKey.TASKS_OVERDUE: overdue_tasks, - WorkReportMetricKey.PROCUREMENTS_PENDING: self._count( - Procurement, - *procurement_filters, - ), - WorkReportMetricKey.EXPENSES_PENDING: self._count(Expense, *expense_filters), - WorkReportMetricKey.ATTENDANCE_TOTAL: self._count( - AttendanceRecord, - *attendance_filters, - ), - WorkReportMetricKey.OPEN_RISK_EVENTS: self._count(RiskEvent, *risk_filters), - } - - def _work_report_lines( - self, - title: str, - start: date, - end: date, - metrics: dict[str, Any], - risk_summary: dict[str, Any], - ) -> list[str]: - return [ - f"- 报告:{title}", - f"- 周期:{start.isoformat()} 至 {end.isoformat()}", - ( - f"- 项目:总数 {metrics[WorkReportMetricKey.PROJECTS_TOTAL]}," - f"活跃 {metrics[WorkReportMetricKey.ACTIVE_PROJECTS]}" - ), - ( - f"- 任务:总数 {metrics[WorkReportMetricKey.TASKS_TOTAL]}," - f"完成 {metrics[WorkReportMetricKey.TASKS_COMPLETED]}" - ), - f"- 逾期任务:{metrics[WorkReportMetricKey.TASKS_OVERDUE]}", - f"- 待处理采购:{metrics[WorkReportMetricKey.PROCUREMENTS_PENDING]}", - f"- 待处理费用:{metrics[WorkReportMetricKey.EXPENSES_PENDING]}", - f"- 打卡记录:{metrics[WorkReportMetricKey.ATTENDANCE_TOTAL]}", - f"- 打开风险事件:{metrics[WorkReportMetricKey.OPEN_RISK_EVENTS]}", - f"- 综合风险等级:{risk_summary[RiskSummaryKey.RISK_LEVEL]}", - ] - - def push_report( - self, - report: dict, - receive_id: str | None, - receive_id_type: str, - actor: str, - push_run_code: str | None = None, - ) -> dict: - report_type = str(report.get(ReportResponseKey.REPORT_TYPE) or report.get("type") or "report") - title = report.get(ReportResponseKey.TITLE) - push_run = ( - self._get_push_run(push_run_code) - if push_run_code - else self.create_push_run( - report_type=report_type, - title=title, - receive_id=receive_id, - receive_id_type=receive_id_type, - actor=actor, - ) - ) - card = FeishuService.build_basic_card( - report[ReportResponseKey.TITLE], - report[ReportResponseKey.LINES], - ) - try: - result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor) - except Exception as exc: - failed_run = self.update_push_run( - push_run.code, - ReportPushStatus.FAILED, - error_message=str(exc), - ) - EventService(self.db).emit( - event_type=EventType.REPORT_PUSH_FAILED, - source=EventSource.REPORTS, - aggregate_type=EventAggregateType.REPORT_PUSH_RUN, - aggregate_id=failed_run.code, - actor=actor, - payload={ - EventPayloadKey.CODE: failed_run.code, - EventPayloadKey.STATUS: failed_run.status, - EventPayloadKey.ERROR_MESSAGE: failed_run.error_message, - }, - idempotency_key=f"report-push:{failed_run.code}:{failed_run.status}", - ) - raise - success_run = self.update_push_run( - push_run.code, - ReportPushStatus.SUCCESS, - provider_response=result, - sent=True, - ) - EventService(self.db).emit( - event_type=EventType.REPORT_PUSH_SUCCEEDED, - source=EventSource.REPORTS, - aggregate_type=EventAggregateType.REPORT_PUSH_RUN, - aggregate_id=success_run.code, - actor=actor, - payload={ - EventPayloadKey.CODE: success_run.code, - EventPayloadKey.STATUS: success_run.status, - }, - idempotency_key=f"report-push:{success_run.code}:{success_run.status}", - ) - AuditService(self.db).log( - AuditLogCreate( - actor=actor, - source=AuditSource.REPORTS, - action=AuditAction.REPORT_PUSH, - target_id=push_run.code, - response_payload={"status": ReportPushStatus.SUCCESS}, - ) - ) - return result diff --git a/app/modules/reports/services/__init__.py b/app/modules/reports/services/__init__.py new file mode 100644 index 0000000..5a6af75 --- /dev/null +++ b/app/modules/reports/services/__init__.py @@ -0,0 +1,4 @@ +from app.modules.reports.services.service import ReportService + + +__all__ = ["ReportService"] diff --git a/app/modules/reports/services/common.py b/app/modules/reports/services/common.py new file mode 100644 index 0000000..c37dd9a --- /dev/null +++ b/app/modules/reports/services/common.py @@ -0,0 +1,95 @@ +from datetime import date, datetime +from decimal import Decimal +from typing import Any + +from sqlalchemy import func, select + +from app.core.http.pagination import bounded_limit +from app.core.utils.time import utc_now +from app.modules.reports.constants import ReportStatus + + +def _money(value: Decimal | int | float | None) -> str: + """Format a numeric value as a two-decimal money string.""" + + amount = Decimal(value or 0) + return f"{amount:,.2f}" + + +def _json_safe(value: Any) -> Any: + """Convert nested report payloads into JSON-storable values.""" + + if isinstance(value, Decimal): + return float(value) + if isinstance(value, (datetime, date)): + return value.isoformat() + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, dict): + return {key: _json_safe(item) for key, item in value.items()} + return value + + +def _next_code(prefix: str) -> str: + """Build a compact unique code for generated report records.""" + + return f"{prefix}-{utc_now():%Y%m%d%H%M%S%f}" + + +def _rate(numerator: int | Decimal, denominator: int | Decimal) -> float: + """Return a rounded percentage rate, using zero when the denominator is empty.""" + + if not denominator: + return 0.0 + return round(float(numerator) / float(denominator) * 100, 2) + + +def _as_decimal(value: Decimal | int | float | None) -> Decimal: + return Decimal(str(value or 0)) + + +class ReportQueryMixin: + def _count(self, model: type, *conditions: Any) -> int: + stmt = select(func.count()).select_from(model) + for condition in conditions: + stmt = stmt.where(condition) + return int(self.db.execute(stmt).scalar() or 0) + + def _sum(self, column: Any, *conditions: Any) -> Decimal: + stmt = select(func.sum(column)) + for condition in conditions: + stmt = stmt.where(condition) + return _as_decimal(self.db.execute(stmt).scalar()) + + def _avg(self, column: Any, *conditions: Any) -> float: + stmt = select(func.avg(column)) + for condition in conditions: + stmt = stmt.where(condition) + value = self.db.execute(stmt).scalar() + return round(float(value or 0), 2) + + def _group_counts(self, model: type, column: Any, *conditions: Any) -> dict[str, int]: + stmt = select(column, func.count()).select_from(model) + for condition in conditions: + stmt = stmt.where(condition) + stmt = stmt.group_by(column) + return { + str(key or ReportStatus.UNKNOWN): int(count) + for key, count in self.db.execute(stmt).all() + } + + def _records( + self, + model: type, + *conditions: Any, + limit: int = 10, + order_by: Any | None = None, + ) -> list[Any]: + stmt = select(model) + for condition in conditions: + stmt = stmt.where(condition) + if order_by is not None: + stmt = stmt.order_by(order_by) + else: + stmt = stmt.order_by(model.id.desc()) + return list(self.db.execute(stmt.limit(bounded_limit(limit))).scalars()) diff --git a/app/modules/reports/services/delivery.py b/app/modules/reports/services/delivery.py new file mode 100644 index 0000000..439f001 --- /dev/null +++ b/app/modules/reports/services/delivery.py @@ -0,0 +1,95 @@ + + +from app.modules.audit.constants import AuditAction, AuditSource +from app.modules.audit.schemas import AuditLogCreate +from app.modules.audit.service import AuditService +from app.modules.events.constants import ( + EventAggregateType, + EventPayloadKey, + EventSource, + EventType, +) +from app.modules.events.services import EventService +from app.modules.feishu.service import FeishuService +from app.modules.reports.constants import ( + ReportPushStatus, + ReportResponseKey, +) + + +class ReportDeliveryMixin: + def push_report( + self, + report: dict, + receive_id: str | None, + receive_id_type: str, + actor: str, + push_run_code: str | None = None, + ) -> dict: + report_type = str(report.get(ReportResponseKey.REPORT_TYPE) or report.get("type") or "report") + title = report.get(ReportResponseKey.TITLE) + push_run = ( + self._get_push_run(push_run_code) + if push_run_code + else self.create_push_run( + report_type=report_type, + title=title, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + ) + ) + card = FeishuService.build_basic_card( + report[ReportResponseKey.TITLE], + report[ReportResponseKey.LINES], + ) + try: + result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor) + except Exception as exc: + failed_run = self.update_push_run( + push_run.code, + ReportPushStatus.FAILED, + error_message=str(exc), + ) + EventService(self.db).emit( + event_type=EventType.REPORT_PUSH_FAILED, + source=EventSource.REPORTS, + aggregate_type=EventAggregateType.REPORT_PUSH_RUN, + aggregate_id=failed_run.code, + actor=actor, + payload={ + EventPayloadKey.CODE: failed_run.code, + EventPayloadKey.STATUS: failed_run.status, + EventPayloadKey.ERROR_MESSAGE: failed_run.error_message, + }, + idempotency_key=f"report-push:{failed_run.code}:{failed_run.status}", + ) + raise + success_run = self.update_push_run( + push_run.code, + ReportPushStatus.SUCCESS, + provider_response=result, + sent=True, + ) + EventService(self.db).emit( + event_type=EventType.REPORT_PUSH_SUCCEEDED, + source=EventSource.REPORTS, + aggregate_type=EventAggregateType.REPORT_PUSH_RUN, + aggregate_id=success_run.code, + actor=actor, + payload={ + EventPayloadKey.CODE: success_run.code, + EventPayloadKey.STATUS: success_run.status, + }, + idempotency_key=f"report-push:{success_run.code}:{success_run.status}", + ) + AuditService(self.db).log( + AuditLogCreate( + actor=actor, + source=AuditSource.REPORTS, + action=AuditAction.REPORT_PUSH, + target_id=push_run.code, + response_payload={"status": ReportPushStatus.SUCCESS}, + ) + ) + return result diff --git a/app/modules/reports/services/enterprise.py b/app/modules/reports/services/enterprise.py new file mode 100644 index 0000000..e69f414 --- /dev/null +++ b/app/modules/reports/services/enterprise.py @@ -0,0 +1,194 @@ +from datetime import date +from typing import Any + + +from app.core.constants import ActorValue +from app.modules.audit.constants import AuditAction, AuditSource, AuditTargetType +from app.modules.audit.schemas import AuditLogCreate +from app.modules.audit.service import AuditService +from app.modules.business.models import ( + PerformanceMetric, +) +from app.modules.events.constants import ( + EventAggregateType, + EventPayloadKey, + EventSource, + EventType, +) +from app.modules.events.services import EventService +from app.modules.reports.constants import ( + EnterpriseAnalyticsKey, + LifecycleResponseKey, + LifecycleSection, + MetricKey, + ReportStatus, + ReportText, + ReportTitle, +) + +from app.modules.reports.services.common import _json_safe, _money, _next_code, _rate + + +class ReportEnterpriseAnalyticsMixin: + def enterprise_analytics( + self, + project_code: str | None = None, + owner: str | None = None, + period_start: date | None = None, + period_end: date | None = None, + actor: str = ActorValue.API, + ) -> dict[str, Any]: + """Build V3 read-only finance, procurement, performance, and operations analytics.""" + + code = _next_code("ANALYTICS") + lifecycle = self.project_lifecycle_report( + project_code=project_code, + owner=owner, + period_start=period_start, + period_end=period_end, + include_ai=False, + actor=actor, + ) + metrics = lifecycle[LifecycleResponseKey.METRICS] + projects = metrics[LifecycleSection.PROJECTS] + procurements = metrics[LifecycleSection.PROCUREMENTS] + expenses = metrics[LifecycleSection.EXPENSES] + funds = metrics[LifecycleSection.FUNDS] + tasks = metrics[LifecycleSection.TASKS] + risks = metrics[LifecycleSection.RISKS] + health = metrics[LifecycleSection.HEALTH] + finance = { + MetricKey.BUDGET_TOTAL: projects[MetricKey.BUDGET_TOTAL], + MetricKey.ACTUAL_TOTAL: projects[MetricKey.ACTUAL_TOTAL], + MetricKey.BUDGET_USAGE_RATE: projects[MetricKey.BUDGET_USAGE_RATE], + MetricKey.CURRENT_BALANCE_TOTAL: funds[MetricKey.CURRENT_BALANCE_TOTAL], + MetricKey.NET_POSITION: funds[MetricKey.NET_POSITION], + MetricKey.RISK_ACCOUNTS: funds[MetricKey.RISK_ACCOUNTS], + MetricKey.PAYMENT_EXPOSURE: ( + procurements[MetricKey.ACTUAL_TOTAL] + expenses[MetricKey.AMOUNT_TOTAL] + ), + } + procurement = { + MetricKey.TOTAL: procurements[MetricKey.TOTAL], + MetricKey.PENDING_APPROVAL: procurements[MetricKey.PENDING_APPROVAL], + MetricKey.PENDING_DELIVERY: procurements[MetricKey.PENDING_DELIVERY], + MetricKey.UNPAID: procurements[MetricKey.UNPAID], + MetricKey.EXPECTED_TOTAL: procurements[MetricKey.EXPECTED_TOTAL], + MetricKey.ACTUAL_TOTAL: procurements[MetricKey.ACTUAL_TOTAL], + MetricKey.DELIVERY_RISK: procurements[MetricKey.PENDING_DELIVERY], + } + performance = self._enterprise_performance_stats() + operations = { + MetricKey.READINESS_SCORE: health[MetricKey.SCORE], + MetricKey.LEVEL: health[MetricKey.LEVEL], + MetricKey.COMPLETION_RATE: tasks[MetricKey.COMPLETION_RATE], + MetricKey.OVERDUE_TASKS: risks[MetricKey.OVERDUE_TASKS], + MetricKey.DELAYED_PROJECTS: risks[MetricKey.DELAYED_PROJECTS], + MetricKey.OVER_BUDGET_PROJECTS: risks[MetricKey.OVER_BUDGET_PROJECTS], + MetricKey.OPEN_EVENTS: risks[MetricKey.OPEN_EVENTS], + MetricKey.HIGH_EVENTS: risks[MetricKey.HIGH_EVENTS], + } + recommendations = lifecycle[LifecycleResponseKey.RECOMMENDATIONS] + lines = self._enterprise_analytics_lines( + lifecycle[LifecycleResponseKey.FILTERS], + finance, + procurement, + performance, + operations, + recommendations, + ) + report = _json_safe( + { + EnterpriseAnalyticsKey.CODE: code, + EnterpriseAnalyticsKey.TITLE: ReportTitle.ENTERPRISE_ANALYTICS, + EnterpriseAnalyticsKey.FILTERS: lifecycle[LifecycleResponseKey.FILTERS], + EnterpriseAnalyticsKey.FINANCE: finance, + EnterpriseAnalyticsKey.PROCUREMENT: procurement, + EnterpriseAnalyticsKey.PERFORMANCE: performance, + EnterpriseAnalyticsKey.OPERATIONS: operations, + EnterpriseAnalyticsKey.RECOMMENDATIONS: recommendations, + EnterpriseAnalyticsKey.LINES: lines, + EnterpriseAnalyticsKey.CONTENT: "\n".join(lines), + } + ) + AuditService(self.db).log( + AuditLogCreate( + actor=actor, + source=AuditSource.REPORTS, + action=AuditAction.ENTERPRISE_ANALYTICS, + target_type=AuditTargetType.ENTERPRISE_ANALYTICS, + target_id=code, + response_payload=report, + ) + ) + EventService(self.db).emit( + event_type=EventType.ENTERPRISE_ANALYTICS_GENERATED, + source=EventSource.ANALYTICS, + aggregate_type=EventAggregateType.ENTERPRISE_ANALYTICS, + aggregate_id=code, + actor=actor, + payload={ + EventPayloadKey.CODE: code, + EventPayloadKey.STATUS: ReportStatus.GENERATED, + }, + idempotency_key=f"enterprise-analytics:{code}", + dispatch=True, + ) + return report + + def _enterprise_performance_stats(self) -> dict[str, Any]: + total = self._count(PerformanceMetric) + confirmed = self._count(PerformanceMetric, PerformanceMetric.confirmed_score.is_not(None)) + return { + MetricKey.TOTAL: total, + MetricKey.CONFIRMED: confirmed, + MetricKey.CONFIRMED_RATE: _rate(confirmed, total), + MetricKey.AVERAGE_AUTO_SCORE: self._avg(PerformanceMetric.auto_score), + MetricKey.AVERAGE_CONFIRMED_SCORE: self._avg(PerformanceMetric.confirmed_score), + MetricKey.WEIGHT_TOTAL: self._sum(PerformanceMetric.weight), + MetricKey.BY_STATUS: self._group_counts(PerformanceMetric, PerformanceMetric.status), + } + + def _enterprise_analytics_lines( + self, + filters: dict[str, Any], + finance: dict[str, Any], + procurement: dict[str, Any], + performance: dict[str, Any], + operations: dict[str, Any], + recommendations: list[str], + ) -> list[str]: + scope = ( + "、".join(f"{key}={value}" for key, value in filters.items() if value) + or ReportText.DEFAULT_SCOPE + ) + lines = [ + f"- 范围:{scope}", + ( + f"- 财务:预算 {_money(finance[MetricKey.BUDGET_TOTAL])}," + f"实际 {_money(finance[MetricKey.ACTUAL_TOTAL])}," + f"净头寸 {_money(finance[MetricKey.NET_POSITION])}," + f"支付暴露 {_money(finance[MetricKey.PAYMENT_EXPOSURE])}" + ), + ( + f"- 采购:总数 {procurement[MetricKey.TOTAL]}," + f"待审批 {procurement[MetricKey.PENDING_APPROVAL]}," + f"待交付 {procurement[MetricKey.PENDING_DELIVERY]}," + f"未付款 {procurement[MetricKey.UNPAID]}" + ), + ( + f"- 绩效:指标 {performance[MetricKey.TOTAL]}," + f"已确认 {performance[MetricKey.CONFIRMED]}," + f"确认率 {performance[MetricKey.CONFIRMED_RATE]}%," + f"平均自动分 {performance[MetricKey.AVERAGE_AUTO_SCORE]}" + ), + ( + f"- 运营:准备度 {operations[MetricKey.READINESS_SCORE]}," + f"任务完成率 {operations[MetricKey.COMPLETION_RATE]}%," + f"逾期任务 {operations[MetricKey.OVERDUE_TASKS]}," + f"打开风险 {operations[MetricKey.OPEN_EVENTS]}" + ), + ReportText.ACTION_HEADER, + ] + lines.extend(f" - {item}" for item in recommendations) + return lines diff --git a/app/modules/reports/services/lifecycle/__init__.py b/app/modules/reports/services/lifecycle/__init__.py new file mode 100644 index 0000000..bfe3c41 --- /dev/null +++ b/app/modules/reports/services/lifecycle/__init__.py @@ -0,0 +1,15 @@ +from app.modules.reports.services.lifecycle.filters import ReportLifecycleFilterMixin +from app.modules.reports.services.lifecycle.health import ReportLifecycleHealthMixin +from app.modules.reports.services.lifecycle.rendering import ReportLifecycleRenderingMixin +from app.modules.reports.services.lifecycle.report import ReportLifecycleReportMixin +from app.modules.reports.services.lifecycle.stats import ReportLifecycleStatsMixin + + +class ReportLifecycleMixin( + ReportLifecycleReportMixin, + ReportLifecycleRenderingMixin, + ReportLifecycleHealthMixin, + ReportLifecycleStatsMixin, + ReportLifecycleFilterMixin, +): + """Project lifecycle report composition and supporting calculations.""" diff --git a/app/modules/reports/services/lifecycle/filters.py b/app/modules/reports/services/lifecycle/filters.py new file mode 100644 index 0000000..11ddadc --- /dev/null +++ b/app/modules/reports/services/lifecycle/filters.py @@ -0,0 +1,87 @@ +from datetime import date, datetime +from typing import Any + +from sqlalchemy import or_ + +from app.modules.business.models import ( + AttendanceRecord, + Expense, + Procurement, + Project, + RiskEvent, + WorkTask, +) +from app.modules.reports.constants import ( + LifecycleFilterKey, + LifecycleSection, +) + + +class ReportLifecycleFilterMixin: + def _lifecycle_filters( + self, + project_code: str | None, + owner: str | None, + period_start: date | None, + period_end: date | None, + ) -> dict[str, Any]: + project_conditions: list[Any] = [] + task_conditions: list[Any] = [] + procurement_conditions: list[Any] = [] + expense_conditions: list[Any] = [] + attendance_conditions: list[Any] = [] + risk_conditions: list[Any] = [] + labels = { + LifecycleFilterKey.PROJECT_CODE: project_code, + LifecycleFilterKey.OWNER: owner, + LifecycleFilterKey.PERIOD_START: period_start.isoformat() if period_start else None, + LifecycleFilterKey.PERIOD_END: period_end.isoformat() if period_end else None, + } + + if project_code: + project_conditions.append(Project.code == project_code) + task_conditions.append(WorkTask.project_code == project_code) + procurement_conditions.append(Procurement.project_code == project_code) + expense_conditions.append(Expense.project_code == project_code) + attendance_conditions.append(AttendanceRecord.project_code == project_code) + risk_conditions.append(RiskEvent.project_code == project_code) + if owner: + project_conditions.append(Project.owner == owner) + task_conditions.append(WorkTask.owner == owner) + risk_conditions.append(RiskEvent.owner == owner) + if period_start: + project_conditions.append( + or_(Project.due_date.is_(None), Project.due_date >= period_start) + ) + task_conditions.append(WorkTask.due_date >= period_start) + attendance_conditions.append(AttendanceRecord.work_date >= period_start) + risk_conditions.append( + RiskEvent.detected_at >= datetime.combine(period_start, datetime.min.time()) + ) + if period_end: + project_conditions.append( + or_(Project.start_date.is_(None), Project.start_date <= period_end) + ) + task_conditions.append(WorkTask.due_date <= period_end) + attendance_conditions.append(AttendanceRecord.work_date <= period_end) + risk_conditions.append( + RiskEvent.detected_at <= datetime.combine(period_end, datetime.max.time()) + ) + if period_start: + start_at = datetime.combine(period_start, datetime.min.time()) + procurement_conditions.append(Procurement.created_at >= start_at) + expense_conditions.append(Expense.created_at >= start_at) + if period_end: + end_at = datetime.combine(period_end, datetime.max.time()) + procurement_conditions.append(Procurement.created_at <= end_at) + expense_conditions.append(Expense.created_at <= end_at) + + return { + LifecycleFilterKey.LABELS: labels, + LifecycleSection.PROJECTS: project_conditions, + LifecycleSection.TASKS: task_conditions, + LifecycleSection.PROCUREMENTS: procurement_conditions, + LifecycleSection.EXPENSES: expense_conditions, + LifecycleSection.ATTENDANCE: attendance_conditions, + LifecycleSection.RISKS: risk_conditions, + } diff --git a/app/modules/reports/services/lifecycle/health.py b/app/modules/reports/services/lifecycle/health.py new file mode 100644 index 0000000..b7594ac --- /dev/null +++ b/app/modules/reports/services/lifecycle/health.py @@ -0,0 +1,138 @@ +from datetime import date +from typing import Any + + +from app.modules.business.constants import ( + DONE_STATUSES, + PROJECT_CLOSED_STATUSES, +) +from app.modules.business.models import ( + Project, + WorkTask, +) +from app.modules.business.service import serialize_model +from app.modules.reports.constants import ( + ATTENTION_SCORE_THRESHOLD, + HEALTH_PENALTY_WEIGHTS, + HEALTH_SCORE_MAX, + HEALTH_SCORE_MIN, + HEALTHY_SCORE_THRESHOLD, + HealthLevel, + LifecycleAttentionKey, + MetricKey, + ReportText, +) + + +class ReportLifecycleHealthMixin: + def _lifecycle_health( + self, + projects: dict[str, Any], + tasks: dict[str, Any], + risks: dict[str, Any], + suppliers: dict[str, Any], + include_global_risk: bool, + ) -> dict[str, Any]: + penalty = ( + risks[MetricKey.OVERDUE_TASKS] * HEALTH_PENALTY_WEIGHTS[MetricKey.OVERDUE_TASKS] + + risks[MetricKey.DELAYED_PROJECTS] + * HEALTH_PENALTY_WEIGHTS[MetricKey.DELAYED_PROJECTS] + + risks[MetricKey.OVER_BUDGET_PROJECTS] + * HEALTH_PENALTY_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS] + + risks[MetricKey.EXTERNAL_HIGH_EVENTS] + * HEALTH_PENALTY_WEIGHTS[MetricKey.EXTERNAL_HIGH_EVENTS] + + max( + HEALTH_SCORE_MIN, + projects[MetricKey.BUDGET_USAGE_RATE] - HEALTH_SCORE_MAX, + ) + * HEALTH_PENALTY_WEIGHTS[MetricKey.BUDGET_USAGE_RATE] + + (HEALTH_SCORE_MAX - tasks[MetricKey.COMPLETION_RATE]) + * HEALTH_PENALTY_WEIGHTS[MetricKey.COMPLETION_RATE] + ) + if include_global_risk: + penalty += suppliers[MetricKey.BLACKLISTED] * HEALTH_PENALTY_WEIGHTS[ + MetricKey.BLACKLISTED + ] + score = max( + HEALTH_SCORE_MIN, + min(HEALTH_SCORE_MAX, round(HEALTH_SCORE_MAX - penalty, 2)), + ) + if score >= HEALTHY_SCORE_THRESHOLD: + level = HealthLevel.HEALTHY + elif score >= ATTENTION_SCORE_THRESHOLD: + level = HealthLevel.ATTENTION + else: + level = HealthLevel.CRITICAL + return {MetricKey.SCORE: score, MetricKey.LEVEL: level} + + def _lifecycle_attention( + self, + project_conditions: list[Any], + task_conditions: list[Any], + ) -> dict[str, Any]: + delayed = self._records( + Project, + Project.due_date.is_not(None), + Project.due_date < date.today(), + Project.status.notin_(PROJECT_CLOSED_STATUSES), + *project_conditions, + limit=10, + order_by=Project.due_date.asc(), + ) + over_budget = self._records( + Project, + Project.budget_amount > 0, + Project.actual_amount > Project.budget_amount, + *project_conditions, + limit=10, + order_by=Project.id.desc(), + ) + overdue_tasks = self._records( + WorkTask, + WorkTask.due_date.is_not(None), + WorkTask.due_date < date.today(), + WorkTask.status.notin_(DONE_STATUSES), + *task_conditions, + limit=10, + order_by=WorkTask.due_date.asc(), + ) + return { + LifecycleAttentionKey.DELAYED_PROJECTS: [serialize_model(item) for item in delayed], + LifecycleAttentionKey.OVER_BUDGET_PROJECTS: [ + serialize_model(item) for item in over_budget + ], + LifecycleAttentionKey.OVERDUE_TASKS: [ + serialize_model(item) for item in overdue_tasks + ], + } + + def _lifecycle_recommendations( + self, + projects: dict[str, Any], + tasks: dict[str, Any], + procurements: dict[str, Any], + expenses: dict[str, Any], + funds: dict[str, Any], + suppliers: dict[str, Any], + risks: dict[str, Any], + include_global_risk: bool, + ) -> list[str]: + recommendations: list[str] = [] + if risks[MetricKey.DELAYED_PROJECTS]: + recommendations.append(ReportText.RECOMMEND_DELAYED) + if risks[MetricKey.OVER_BUDGET_PROJECTS]: + recommendations.append(ReportText.RECOMMEND_OVER_BUDGET) + if tasks[MetricKey.OVERDUE]: + recommendations.append(ReportText.RECOMMEND_OVERDUE_TASKS) + if ( + procurements[MetricKey.PENDING_APPROVAL] + or expenses[MetricKey.PENDING_APPROVAL] + ): + recommendations.append(ReportText.RECOMMEND_APPROVALS) + if include_global_risk and funds[MetricKey.RISK_ACCOUNTS]: + recommendations.append(ReportText.RECOMMEND_FUNDS) + if include_global_risk and suppliers[MetricKey.RISKY]: + recommendations.append(ReportText.RECOMMEND_SUPPLIERS) + if not recommendations: + recommendations.append(ReportText.RECOMMEND_STABLE) + return [str(item) for item in recommendations] diff --git a/app/modules/reports/services/lifecycle/rendering.py b/app/modules/reports/services/lifecycle/rendering.py new file mode 100644 index 0000000..737724b --- /dev/null +++ b/app/modules/reports/services/lifecycle/rendering.py @@ -0,0 +1,97 @@ +from typing import Any + + +from app.modules.reports.constants import ( + LifecycleResponseKey, + LifecycleSection, + MetricKey, + ReportText, +) +from app.modules.reports.services.common import _json_safe, _money + + +class ReportLifecycleRenderingMixin: + def _lifecycle_lines( + self, + filters: dict[str, Any], + metrics: dict[str, Any], + recommendations: list[str], + ) -> list[str]: + scope = ( + "、".join(f"{key}={value}" for key, value in filters.items() if value) + or ReportText.DEFAULT_SCOPE + ) + projects = metrics[LifecycleSection.PROJECTS] + tasks = metrics[LifecycleSection.TASKS] + procurements = metrics[LifecycleSection.PROCUREMENTS] + expenses = metrics[LifecycleSection.EXPENSES] + funds = metrics[LifecycleSection.FUNDS] + suppliers = metrics[LifecycleSection.SUPPLIERS] + attendance = metrics[LifecycleSection.ATTENDANCE] + risks = metrics[LifecycleSection.RISKS] + health = metrics[LifecycleSection.HEALTH] + lines = [ + f"- 范围:{scope}", + f"- 生命周期健康分:{health[MetricKey.SCORE]}({health[MetricKey.LEVEL]})", + ( + f"- 项目:总数 {projects[MetricKey.TOTAL]}," + f"活跃 {projects[MetricKey.ACTIVE]}," + f"平均进度 {projects[MetricKey.AVERAGE_PROGRESS_PERCENT]}%" + ), + ( + f"- 成本:预算 {_money(projects[MetricKey.BUDGET_TOTAL])}," + f"实际 {_money(projects[MetricKey.ACTUAL_TOTAL])}," + f"预算使用率 {projects[MetricKey.BUDGET_USAGE_RATE]}%" + ), + ( + f"- 任务:总数 {tasks[MetricKey.TOTAL]}," + f"完成 {tasks[MetricKey.COMPLETED]}," + f"完成率 {tasks[MetricKey.COMPLETION_RATE]}%," + f"逾期 {tasks[MetricKey.OVERDUE]}" + ), + ( + f"- 采购/费用:待批采购 {procurements[MetricKey.PENDING_APPROVAL]}," + f"待批费用 {expenses[MetricKey.PENDING_APPROVAL]}," + f"未付款采购 {procurements[MetricKey.UNPAID]}" + ), + ( + f"- 资金:余额 {_money(funds[MetricKey.CURRENT_BALANCE_TOTAL])}," + f"净头寸 {_money(funds[MetricKey.NET_POSITION])}," + f"风险账户 {funds[MetricKey.RISK_ACCOUNTS]}" + ), + ( + f"- 风险:等级 {risks[MetricKey.RISK_LEVEL]}," + f"风险分 {risks[MetricKey.RISK_SCORE]}," + f"延期项目 {risks[MetricKey.DELAYED_PROJECTS]}," + f"超预算项目 {risks[MetricKey.OVER_BUDGET_PROJECTS]}," + f"打开事件 {risks[MetricKey.OPEN_EVENTS]}" + ), + ( + f"- 供应商/考勤:风险供应商 {suppliers[MetricKey.RISKY]}," + f"异常打卡 {attendance[MetricKey.ABNORMAL]}," + f"异常率 {attendance[MetricKey.ABNORMAL_RATE]}%" + ), + ReportText.ACTION_HEADER, + ] + lines.extend(f" - {item}" for item in recommendations) + return lines + + def _lifecycle_ai_analysis(self, report: dict[str, Any], actor: str) -> dict[str, Any]: + try: + from app.modules.ai_agent.constants import AIResponseKey + from app.modules.ai_agent.skills import AISkillId + from app.modules.ai_agent.service import AIService + + report_snapshot = _json_safe(report) + result = AIService(self.db).run_skill( + AISkillId.PROJECT_LIFECYCLE_ANALYSIS, + context={LifecycleResponseKey.PROJECT_LIFECYCLE_REPORT: report_snapshot}, + actor=actor, + ) + except Exception as exc: + return { + AIResponseKey.OK: False, + AIResponseKey.ERROR: str(exc), + AIResponseKey.TYPE: type(exc).__name__, + } + return _json_safe({AIResponseKey.OK: True, **result}) diff --git a/app/modules/reports/services/lifecycle/report.py b/app/modules/reports/services/lifecycle/report.py new file mode 100644 index 0000000..92c889b --- /dev/null +++ b/app/modules/reports/services/lifecycle/report.py @@ -0,0 +1,94 @@ +from datetime import date +from typing import Any + + +from app.core.constants import ActorValue +from app.modules.reports.constants import ( + LifecycleFilterKey, + LifecycleResponseKey, + LifecycleSection, + ReportTitle, +) +from app.modules.reports.services.common import _json_safe + + +class ReportLifecycleReportMixin: + def project_lifecycle_report( + self, + project_code: str | None = None, + owner: str | None = None, + period_start: date | None = None, + period_end: date | None = None, + include_ai: bool = False, + actor: str = ActorValue.API, + ) -> dict[str, Any]: + """Build a full lifecycle report for project progress, delivery, cost, and risk.""" + + filters = self._lifecycle_filters(project_code, owner, period_start, period_end) + project_conditions = filters[LifecycleSection.PROJECTS] + task_conditions = filters[LifecycleSection.TASKS] + procurement_conditions = filters[LifecycleSection.PROCUREMENTS] + expense_conditions = filters[LifecycleSection.EXPENSES] + attendance_conditions = filters[LifecycleSection.ATTENDANCE] + risk_conditions = filters[LifecycleSection.RISKS] + + project_stats = self._lifecycle_project_stats(project_conditions) + task_stats = self._lifecycle_task_stats(task_conditions) + procurement_stats = self._lifecycle_procurement_stats(procurement_conditions) + expense_stats = self._lifecycle_expense_stats(expense_conditions) + fund_stats = self._lifecycle_fund_stats() + supplier_stats = self._lifecycle_supplier_stats() + attendance_stats = self._lifecycle_attendance_stats(attendance_conditions) + risk_stats = self._lifecycle_risk_stats( + project_conditions, + task_conditions, + risk_conditions, + ) + include_global_risk = not (project_code or owner) + health = self._lifecycle_health( + project_stats, + task_stats, + risk_stats, + supplier_stats, + include_global_risk, + ) + attention = self._lifecycle_attention(project_conditions, task_conditions) + recommendations = self._lifecycle_recommendations( + project_stats, + task_stats, + procurement_stats, + expense_stats, + fund_stats, + supplier_stats, + risk_stats, + include_global_risk, + ) + + metrics = { + LifecycleSection.HEALTH: health, + LifecycleSection.PROJECTS: project_stats, + LifecycleSection.TASKS: task_stats, + LifecycleSection.PROCUREMENTS: procurement_stats, + LifecycleSection.EXPENSES: expense_stats, + LifecycleSection.FUNDS: fund_stats, + LifecycleSection.SUPPLIERS: supplier_stats, + LifecycleSection.ATTENDANCE: attendance_stats, + LifecycleSection.RISKS: risk_stats, + } + lines = self._lifecycle_lines( + filters[LifecycleFilterKey.LABELS], + metrics, + recommendations, + ) + report = { + LifecycleResponseKey.TITLE: ReportTitle.PROJECT_LIFECYCLE, + LifecycleResponseKey.FILTERS: filters[LifecycleFilterKey.LABELS], + LifecycleResponseKey.METRICS: _json_safe(metrics), + LifecycleResponseKey.ATTENTION: _json_safe(attention), + LifecycleResponseKey.RECOMMENDATIONS: recommendations, + LifecycleResponseKey.LINES: lines, + LifecycleResponseKey.CONTENT: "\n".join(lines), + } + if include_ai: + report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor) + return report diff --git a/app/modules/reports/services/lifecycle/stats.py b/app/modules/reports/services/lifecycle/stats.py new file mode 100644 index 0000000..ccd5fa2 --- /dev/null +++ b/app/modules/reports/services/lifecycle/stats.py @@ -0,0 +1,270 @@ +from datetime import date +from typing import Any + + +from app.modules.business.constants import ( + ATTENDANCE_ABNORMAL_STATUSES, + DONE_STATUSES, + GENERATED_RISK_EVENT_TYPES, + PROJECT_CLOSED_STATUSES, + PENDING_APPROVAL_STATUSES, + SUPPLIER_RISK_LEVELS, + RiskLevel, + StatusValue, +) +from app.modules.business.models import ( + AttendanceRecord, + Expense, + FundAccount, + Procurement, + Project, + RiskEvent, + Supplier, + WorkTask, +) +from app.modules.reports.constants import ( + LIFECYCLE_RISK_SCORE_WEIGHTS, + MetricKey, +) +from app.modules.reports.services.common import _rate +from app.modules.risk.constants import risk_level_for_score + + +class ReportLifecycleStatsMixin: + def _lifecycle_project_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(Project, *conditions) + active = self._count(Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), *conditions) + closed = total - active + budget_total = self._sum(Project.budget_amount, *conditions) + actual_total = self._sum(Project.actual_amount, *conditions) + delayed = self._count( + Project, + Project.due_date.is_not(None), + Project.due_date < date.today(), + Project.status.notin_(PROJECT_CLOSED_STATUSES), + *conditions, + ) + over_budget = self._count( + Project, + Project.budget_amount > 0, + Project.actual_amount > Project.budget_amount, + *conditions, + ) + return { + MetricKey.TOTAL: total, + MetricKey.ACTIVE: active, + MetricKey.CLOSED: closed, + MetricKey.AVERAGE_PROGRESS_PERCENT: self._avg( + Project.progress_percent, + *conditions, + ), + MetricKey.BY_STATUS: self._group_counts(Project, Project.status, *conditions), + MetricKey.BY_RISK_LEVEL: self._group_counts(Project, Project.risk_level, *conditions), + MetricKey.BUDGET_TOTAL: budget_total, + MetricKey.ACTUAL_TOTAL: actual_total, + MetricKey.BUDGET_USAGE_RATE: _rate(actual_total, budget_total), + MetricKey.DELAYED: delayed, + MetricKey.OVER_BUDGET: over_budget, + } + + def _lifecycle_task_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(WorkTask, *conditions) + completed = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *conditions) + overdue = self._count( + WorkTask, + WorkTask.due_date.is_not(None), + WorkTask.due_date < date.today(), + WorkTask.status.notin_(DONE_STATUSES), + *conditions, + ) + blocked = self._count( + WorkTask, + WorkTask.blocker.is_not(None), + WorkTask.status.notin_(DONE_STATUSES), + *conditions, + ) + return { + MetricKey.TOTAL: total, + MetricKey.COMPLETED: completed, + MetricKey.OPEN: total - completed, + MetricKey.OVERDUE: overdue, + MetricKey.BLOCKED: blocked, + MetricKey.COMPLETION_RATE: _rate(completed, total), + MetricKey.BY_STATUS: self._group_counts(WorkTask, WorkTask.status, *conditions), + MetricKey.BY_PRIORITY: self._group_counts(WorkTask, WorkTask.priority, *conditions), + } + + def _lifecycle_procurement_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(Procurement, *conditions) + pending_approval = self._count( + Procurement, + Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), + *conditions, + ) + pending_delivery = self._count( + Procurement, + Procurement.delivery_status.notin_(DONE_STATUSES), + *conditions, + ) + unpaid = self._count( + Procurement, + Procurement.payment_status != StatusValue.PAID, + *conditions, + ) + expected_total = self._sum(Procurement.expected_amount, *conditions) + actual_total = self._sum(Procurement.actual_amount, *conditions) + return { + MetricKey.TOTAL: total, + MetricKey.PENDING_APPROVAL: pending_approval, + MetricKey.PENDING_DELIVERY: pending_delivery, + MetricKey.UNPAID: unpaid, + MetricKey.EXPECTED_TOTAL: expected_total, + MetricKey.ACTUAL_TOTAL: actual_total, + MetricKey.ACTUAL_VS_EXPECTED_RATE: _rate(actual_total, expected_total), + } + + def _lifecycle_expense_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(Expense, *conditions) + pending_approval = self._count( + Expense, + Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), + *conditions, + ) + unpaid = self._count(Expense, Expense.payment_status != StatusValue.PAID, *conditions) + return { + MetricKey.TOTAL: total, + MetricKey.PENDING_APPROVAL: pending_approval, + MetricKey.UNPAID: unpaid, + MetricKey.AMOUNT_TOTAL: self._sum(Expense.amount, *conditions), + MetricKey.BY_TYPE: self._group_counts(Expense, Expense.expense_type, *conditions), + } + + def _lifecycle_fund_stats(self) -> dict[str, Any]: + balance = self._sum(FundAccount.current_balance) + receivable = self._sum(FundAccount.expected_receivable) + payable = self._sum(FundAccount.expected_payable) + safety_line = self._sum(FundAccount.safety_line) + risk_accounts = self._count( + FundAccount, + FundAccount.current_balance < FundAccount.safety_line, + ) + return { + MetricKey.ACCOUNTS_TOTAL: self._count(FundAccount), + MetricKey.CURRENT_BALANCE_TOTAL: balance, + MetricKey.EXPECTED_RECEIVABLE_TOTAL: receivable, + MetricKey.EXPECTED_PAYABLE_TOTAL: payable, + MetricKey.SAFETY_LINE_TOTAL: safety_line, + MetricKey.NET_POSITION: balance + receivable - payable, + MetricKey.RISK_ACCOUNTS: risk_accounts, + } + + def _lifecycle_supplier_stats(self) -> dict[str, Any]: + risky = self._count( + Supplier, + (Supplier.blacklist_status != StatusValue.NORMAL) + | Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS), + ) + blacklisted = self._count(Supplier, Supplier.blacklist_status != StatusValue.NORMAL) + return { + MetricKey.TOTAL: self._count(Supplier), + MetricKey.RISKY: risky, + MetricKey.BLACKLISTED: blacklisted, + MetricKey.BY_RISK_LEVEL: self._group_counts(Supplier, Supplier.risk_level), + } + + def _lifecycle_attendance_stats(self, conditions: list[Any]) -> dict[str, Any]: + total = self._count(AttendanceRecord, *conditions) + abnormal = self._count( + AttendanceRecord, + AttendanceRecord.status.in_(ATTENDANCE_ABNORMAL_STATUSES), + *conditions, + ) + return { + MetricKey.TOTAL: total, + MetricKey.ABNORMAL: abnormal, + MetricKey.ABNORMAL_RATE: _rate(abnormal, total), + MetricKey.BY_STATUS: self._group_counts( + AttendanceRecord, + AttendanceRecord.status, + *conditions, + ), + } + + def _lifecycle_risk_stats( + self, + project_conditions: list[Any], + task_conditions: list[Any], + risk_conditions: list[Any], + ) -> dict[str, Any]: + overdue_tasks = self._count( + WorkTask, + WorkTask.due_date.is_not(None), + WorkTask.due_date < date.today(), + WorkTask.status.notin_(DONE_STATUSES), + *task_conditions, + ) + delayed_projects = self._count( + Project, + Project.due_date.is_not(None), + Project.due_date < date.today(), + Project.status.notin_(PROJECT_CLOSED_STATUSES), + *project_conditions, + ) + over_budget_projects = self._count( + Project, + Project.budget_amount > 0, + Project.actual_amount > Project.budget_amount, + *project_conditions, + ) + open_events = self._count( + RiskEvent, + RiskEvent.status == StatusValue.OPEN, + *risk_conditions, + ) + high_events = self._count( + RiskEvent, + RiskEvent.status == StatusValue.OPEN, + RiskEvent.risk_level == RiskLevel.HIGH, + *risk_conditions, + ) + external_open_events = self._count( + RiskEvent, + RiskEvent.status == StatusValue.OPEN, + RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES), + *risk_conditions, + ) + external_high_events = self._count( + RiskEvent, + RiskEvent.status == StatusValue.OPEN, + RiskEvent.risk_level == RiskLevel.HIGH, + RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES), + *risk_conditions, + ) + risk_score = ( + overdue_tasks * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVERDUE_TASKS] + + delayed_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.DELAYED_PROJECTS] + + over_budget_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS] + + external_open_events * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.EXTERNAL_OPEN_EVENTS] + + external_high_events * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.EXTERNAL_HIGH_EVENTS] + ) + return { + MetricKey.RISK_LEVEL: risk_level_for_score(risk_score), + MetricKey.RISK_SCORE: risk_score, + MetricKey.OVERDUE_TASKS: overdue_tasks, + MetricKey.DELAYED_PROJECTS: delayed_projects, + MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects, + MetricKey.OPEN_EVENTS: open_events, + MetricKey.HIGH_EVENTS: high_events, + MetricKey.EXTERNAL_OPEN_EVENTS: external_open_events, + MetricKey.EXTERNAL_HIGH_EVENTS: external_high_events, + MetricKey.EVENTS_BY_TYPE: self._group_counts( + RiskEvent, + RiskEvent.risk_type, + *risk_conditions, + ), + MetricKey.EVENTS_BY_LEVEL: self._group_counts( + RiskEvent, + RiskEvent.risk_level, + *risk_conditions, + ), + } diff --git a/app/modules/reports/services/push_runs.py b/app/modules/reports/services/push_runs.py new file mode 100644 index 0000000..7a30374 --- /dev/null +++ b/app/modules/reports/services/push_runs.py @@ -0,0 +1,97 @@ +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import select + +from app.core.http.pagination import bounded_limit +from app.core.utils.time import utc_now +from app.modules.business.models import ( + ReportPushRun, +) +from app.modules.business.service import serialize_model +from app.modules.reports.constants import ( + ReportErrorDetail, + ReportPushStatus, +) + +from app.modules.reports.services.common import _json_safe, _next_code + + +class ReportPushRunMixin: + def create_push_run( + self, + report_type: str, + title: str | None, + receive_id: str | None, + receive_id_type: str, + actor: str, + status: str = ReportPushStatus.PENDING, + ) -> ReportPushRun: + record = ReportPushRun( + code=_next_code("PUSH"), + report_type=report_type, + title=title, + receive_id=receive_id, + receive_id_type=receive_id_type, + status=status, + actor=actor, + queued_at=utc_now(), + ) + self.db.add(record) + self.db.commit() + self.db.refresh(record) + return record + + def update_push_run( + self, + code: str, + status: str, + task_id: str | None = None, + provider_response: dict[str, Any] | None = None, + error_message: str | None = None, + sent: bool = False, + ) -> ReportPushRun: + record = self._get_push_run(code) + record.status = status + if task_id is not None: + record.task_id = task_id + if provider_response is not None: + record.provider_response = _json_safe(provider_response) + record.error_message = error_message + if sent: + record.sent_at = utc_now() + self.db.commit() + self.db.refresh(record) + return record + + def list_push_runs( + self, + status_filter: str | None = None, + limit: int = 100, + ) -> list[dict[str, Any]]: + stmt = select(ReportPushRun).order_by(ReportPushRun.id.desc()).limit( + bounded_limit(limit) + ) + if status_filter: + stmt = ( + select(ReportPushRun) + .where(ReportPushRun.status == status_filter) + .order_by(ReportPushRun.id.desc()) + .limit(bounded_limit(limit)) + ) + return [serialize_model(item) for item in self.db.execute(stmt).scalars()] + + def get_push_run(self, code: str) -> dict[str, Any]: + return serialize_model(self._get_push_run(code)) + + def _get_push_run(self, code: str) -> ReportPushRun: + + record = self.db.execute( + select(ReportPushRun).where(ReportPushRun.code == code) + ).scalar_one_or_none() + if record is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ReportErrorDetail.PUSH_RUN_NOT_FOUND, + ) + return record diff --git a/app/modules/reports/services/service.py b/app/modules/reports/services/service.py new file mode 100644 index 0000000..0d854c7 --- /dev/null +++ b/app/modules/reports/services/service.py @@ -0,0 +1,26 @@ +from sqlalchemy.orm import Session + +from app.modules.reports.services.common import ReportQueryMixin +from app.modules.reports.services.delivery import ReportDeliveryMixin +from app.modules.reports.services.enterprise import ReportEnterpriseAnalyticsMixin +from app.modules.reports.services.lifecycle import ReportLifecycleMixin +from app.modules.reports.services.push_runs import ReportPushRunMixin +from app.modules.reports.services.summaries import ReportSummaryMixin +from app.modules.reports.services.work_reports import ReportWorkReportMixin +from app.modules.risk.services import RiskService + + +class ReportService( + ReportDeliveryMixin, + ReportWorkReportMixin, + ReportEnterpriseAnalyticsMixin, + ReportLifecycleMixin, + ReportSummaryMixin, + ReportPushRunMixin, + ReportQueryMixin, +): + """Build operational reports and push them through Feishu.""" + + def __init__(self, db: Session): + self.db = db + self.risks = RiskService(db) diff --git a/app/modules/reports/services/summaries.py b/app/modules/reports/services/summaries.py new file mode 100644 index 0000000..e930dc7 --- /dev/null +++ b/app/modules/reports/services/summaries.py @@ -0,0 +1,131 @@ +from datetime import date +from decimal import Decimal +from typing import Any + +from sqlalchemy import func, select + +from app.modules.business.constants import ( + ATTENDANCE_ABNORMAL_STATUSES, + PROJECT_CLOSED_STATUSES, + PENDING_APPROVAL_STATUSES, + StatusValue, +) +from app.modules.business.models import ( + AttendanceRecord, + Expense, + FundAccount, + Procurement, + Project, + WorkTask, +) +from app.modules.reports.constants import ( + ReportResponseKey, + ReportTitle, +) +from app.modules.risk.constants import RiskSummaryKey + +from app.modules.reports.services.common import _money + + +class ReportSummaryMixin: + def daily_brief(self) -> dict: + project_count = self._count(Project) + task_count = self._count(WorkTask) + procurement_pending = self._count( + Procurement, + Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), + ) + expense_pending = self._count( + Expense, + Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), + ) + fund_total = ( + self.db.execute(select(func.sum(FundAccount.current_balance))).scalar() + or Decimal("0") + ) + risk_summary = self.risks.summary() + attendance = self.attendance_summary() + lines = [ + f"- 项目总数:{project_count}", + f"- 任务总数:{task_count}", + f"- 待处理采购:{procurement_pending}", + f"- 待处理费用:{expense_pending}", + f"- 当前账户总余额:{_money(fund_total)}", + ( + f"- 今日打卡记录:{attendance[ReportResponseKey.TOTAL]}," + f"异常:{attendance[ReportResponseKey.ABNORMAL_TOTAL]}" + ), + f"- 逾期任务:{len(risk_summary[RiskSummaryKey.OVERDUE_TASKS])}", + f"- 延期项目:{len(risk_summary[RiskSummaryKey.DELAYED_PROJECTS])}", + f"- 超预算项目:{len(risk_summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}", + f"- 资金风险账户:{len(risk_summary[RiskSummaryKey.FUND_RISKS])}", + f"- 供应商风险:{len(risk_summary[RiskSummaryKey.SUPPLIER_RISKS])}", + f"- 打开风险事件:{len(risk_summary[RiskSummaryKey.OPEN_EVENTS])}", + f"- 综合风险等级:{risk_summary[RiskSummaryKey.RISK_LEVEL]}", + ] + return { + ReportResponseKey.TITLE: ReportTitle.DAILY_BRIEF, + ReportResponseKey.LINES: lines, + ReportResponseKey.CONTENT: "\n".join(lines), + } + + def project_weekly(self) -> dict: + active = self._count( + Project, + Project.status.notin_(PROJECT_CLOSED_STATUSES), + ) + delayed = self.risks.delayed_projects() + over_budget = self.risks.over_budget_projects() + open_risks = self.risks.list_events(status_filter=StatusValue.OPEN) + lines = [ + f"- 活跃项目:{active}", + f"- 延期项目:{len(delayed)}", + f"- 超预算项目:{len(over_budget)}", + f"- 打开风险事件:{len(open_risks)}", + "- 需要管理层关注:", + ] + for item in delayed[:10]: + lines.append( + f" - 延期:{item.get('code')} {item.get('name')}," + f"负责人 {item.get('owner')}" + ) + for item in over_budget[:10]: + lines.append(f" - 超预算:{item.get('code')} {item.get('name')}") + return { + ReportResponseKey.TITLE: ReportTitle.PROJECT_WEEKLY, + ReportResponseKey.LINES: lines, + ReportResponseKey.CONTENT: "\n".join(lines), + } + + def attendance_summary(self, work_date: date | None = None) -> dict[str, Any]: + """Summarize attendance records for one business day.""" + + target_date = work_date or date.today() + rows = self.db.execute( + select(AttendanceRecord.status, func.count()) + .where(AttendanceRecord.work_date == target_date) + .group_by(AttendanceRecord.status) + ).all() + status_counts = {str(status): int(count) for status, count in rows} + abnormal_total = sum( + count + for status, count in status_counts.items() + if status in ATTENDANCE_ABNORMAL_STATUSES + ) + total = sum(status_counts.values()) + lines = [ + f"- 日期:{target_date.isoformat()}", + f"- 打卡记录:{total}", + f"- 异常记录:{abnormal_total}", + ] + for status, count in sorted(status_counts.items()): + lines.append(f"- {status}:{count}") + return { + ReportResponseKey.TITLE: ReportTitle.ATTENDANCE_SUMMARY, + ReportResponseKey.WORK_DATE: target_date.isoformat(), + ReportResponseKey.TOTAL: total, + ReportResponseKey.ABNORMAL_TOTAL: abnormal_total, + ReportResponseKey.STATUS_COUNTS: status_counts, + ReportResponseKey.LINES: lines, + ReportResponseKey.CONTENT: "\n".join(lines), + } diff --git a/app/modules/reports/services/work_reports.py b/app/modules/reports/services/work_reports.py new file mode 100644 index 0000000..d91c845 --- /dev/null +++ b/app/modules/reports/services/work_reports.py @@ -0,0 +1,225 @@ +from datetime import date, timedelta +from typing import Any + + +from app.core.constants import ActorValue +from app.modules.audit.constants import AuditSource, AuditTargetType +from app.modules.audit.schemas import AuditLogCreate +from app.modules.audit.service import AuditService +from app.modules.business.constants import ( + DONE_STATUSES, + PROJECT_CLOSED_STATUSES, + PENDING_APPROVAL_STATUSES, + StatusValue, +) +from app.modules.business.models import ( + AttendanceRecord, + Expense, + Procurement, + Project, + RiskEvent, + WorkReport, + WorkTask, +) +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.reports.constants import ( + ReportResponseKey, + ReportTitle, + ReportType, + WorkReportMetricKey, +) +from app.modules.risk.constants import RiskSummaryKey + +from app.modules.reports.services.common import _json_safe, _next_code + + +class ReportWorkReportMixin: + def generate_work_report( + self, + report_type: str = ReportType.DAILY, + reporter: str = ActorValue.SYSTEM, + department: str | None = None, + project_code: str | None = None, + period_start: date | None = None, + period_end: date | None = None, + persist: bool = True, + actor: str = ActorValue.API, + ) -> dict[str, Any]: + """Generate a daily or weekly operating report, optionally persisting it.""" + + start, end = self._resolve_period(report_type, period_start, period_end) + metrics = self._report_metrics(start, end, project_code, department) + risk_summary = _json_safe(self.risks.summary()) + title = ( + ReportTitle.WORK_DAILY + if report_type == ReportType.DAILY + else ReportTitle.WORK_WEEKLY + ) + lines = self._work_report_lines(title, start, end, metrics, risk_summary) + report = { + ReportResponseKey.TITLE: title, + ReportResponseKey.REPORT_TYPE: report_type, + ReportResponseKey.PERIOD_START: start.isoformat(), + ReportResponseKey.PERIOD_END: end.isoformat(), + ReportResponseKey.LINES: lines, + ReportResponseKey.CONTENT: "\n".join(lines), + ReportResponseKey.METRICS: metrics, + ReportResponseKey.RISK_SUMMARY: risk_summary, + } + + record_data = None + if persist: + record = WorkReport( + code=_next_code(f"REPORT-{report_type.upper()}"), + report_type=report_type, + title=title, + reporter=reporter, + department=department, + project_code=project_code, + period_start=start, + period_end=end, + content=report[ReportResponseKey.CONTENT], + metrics=metrics, + risk_summary=risk_summary, + ) + self.db.add(record) + self.db.commit() + self.db.refresh(record) + record_data = serialize_model(record) + AuditService(self.db).log( + AuditLogCreate( + actor=actor, + source=AuditSource.REPORTS, + action=f"generate_{report_type}_report", + target_type=AuditTargetType.WORK_REPORTS, + target_id=str(record.id), + response_payload=record_data, + ) + ) + EventService(self.db).emit( + event_type=EventType.REPORT_GENERATED, + source=EventSource.REPORTS, + aggregate_type=EventAggregateType.WORK_REPORT, + aggregate_id=record.code, + actor=actor, + payload={ + EventPayloadKey.CODE: record.code, + EventPayloadKey.STATUS: record.status, + }, + idempotency_key=f"report-generated:{record.code}", + dispatch=True, + ) + + return {ReportResponseKey.REPORT: report, ReportResponseKey.DATA: record_data} + + def _resolve_period( + self, + report_type: str, + period_start: date | None, + period_end: date | None, + ) -> tuple[date, date]: + today = date.today() + if report_type == ReportType.DAILY: + start = period_start or period_end or today + return start, period_end or start + end = period_end or today + start = period_start or end - timedelta(days=6) + return start, end + + def _report_metrics( + self, + start: date, + end: date, + project_code: str | None, + department: str | None, + ) -> dict[str, Any]: + task_filters = [ + WorkTask.due_date.is_not(None), + WorkTask.due_date >= start, + WorkTask.due_date <= end, + ] + project_filters = [] + risk_filters = [RiskEvent.status == StatusValue.OPEN] + procurement_filters = [ + Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), + ] + expense_filters = [ + Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), + ] + attendance_filters = [ + AttendanceRecord.work_date >= start, + AttendanceRecord.work_date <= end, + ] + if project_code: + project_filters.append(Project.code == project_code) + task_filters.append(WorkTask.project_code == project_code) + procurement_filters.append(Procurement.project_code == project_code) + expense_filters.append(Expense.project_code == project_code) + attendance_filters.append(AttendanceRecord.project_code == project_code) + risk_filters.append(RiskEvent.project_code == project_code) + if department: + expense_filters.append(Expense.department == department) + attendance_filters.append(AttendanceRecord.department == department) + + completed_tasks = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *task_filters) + overdue_tasks = self._count( + WorkTask, + WorkTask.due_date < date.today(), + WorkTask.status.notin_(DONE_STATUSES), + *task_filters, + ) + return { + WorkReportMetricKey.PROJECTS_TOTAL: self._count(Project, *project_filters), + WorkReportMetricKey.ACTIVE_PROJECTS: self._count( + Project, + Project.status.notin_(PROJECT_CLOSED_STATUSES), + *project_filters, + ), + WorkReportMetricKey.TASKS_TOTAL: self._count(WorkTask, *task_filters), + WorkReportMetricKey.TASKS_COMPLETED: completed_tasks, + WorkReportMetricKey.TASKS_OVERDUE: overdue_tasks, + WorkReportMetricKey.PROCUREMENTS_PENDING: self._count( + Procurement, + *procurement_filters, + ), + WorkReportMetricKey.EXPENSES_PENDING: self._count(Expense, *expense_filters), + WorkReportMetricKey.ATTENDANCE_TOTAL: self._count( + AttendanceRecord, + *attendance_filters, + ), + WorkReportMetricKey.OPEN_RISK_EVENTS: self._count(RiskEvent, *risk_filters), + } + + def _work_report_lines( + self, + title: str, + start: date, + end: date, + metrics: dict[str, Any], + risk_summary: dict[str, Any], + ) -> list[str]: + return [ + f"- 报告:{title}", + f"- 周期:{start.isoformat()} 至 {end.isoformat()}", + ( + f"- 项目:总数 {metrics[WorkReportMetricKey.PROJECTS_TOTAL]}," + f"活跃 {metrics[WorkReportMetricKey.ACTIVE_PROJECTS]}" + ), + ( + f"- 任务:总数 {metrics[WorkReportMetricKey.TASKS_TOTAL]}," + f"完成 {metrics[WorkReportMetricKey.TASKS_COMPLETED]}" + ), + f"- 逾期任务:{metrics[WorkReportMetricKey.TASKS_OVERDUE]}", + f"- 待处理采购:{metrics[WorkReportMetricKey.PROCUREMENTS_PENDING]}", + f"- 待处理费用:{metrics[WorkReportMetricKey.EXPENSES_PENDING]}", + f"- 打卡记录:{metrics[WorkReportMetricKey.ATTENDANCE_TOTAL]}", + f"- 打开风险事件:{metrics[WorkReportMetricKey.OPEN_RISK_EVENTS]}", + f"- 综合风险等级:{risk_summary[RiskSummaryKey.RISK_LEVEL]}", + ] diff --git a/app/modules/risk/routes.py b/app/modules/risk/routes.py index 5fe0703..365c308 100644 --- a/app/modules/risk/routes.py +++ b/app/modules/risk/routes.py @@ -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)]) diff --git a/app/modules/risk/service.py b/app/modules/risk/service.py deleted file mode 100644 index 7655b12..0000000 --- a/app/modules/risk/service.py +++ /dev/null @@ -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 diff --git a/app/modules/risk/services/__init__.py b/app/modules/risk/services/__init__.py new file mode 100644 index 0000000..98a82e6 --- /dev/null +++ b/app/modules/risk/services/__init__.py @@ -0,0 +1,4 @@ +from app.modules.risk.services.service import RiskService + + +__all__ = ["RiskService"] diff --git a/app/modules/risk/services/actions.py b/app/modules/risk/services/actions.py new file mode 100644 index 0000000..c88904f --- /dev/null +++ b/app/modules/risk/services/actions.py @@ -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), + } diff --git a/app/modules/risk/services/detectors.py b/app/modules/risk/services/detectors.py new file mode 100644 index 0000000..ec28483 --- /dev/null +++ b/app/modules/risk/services/detectors.py @@ -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 diff --git a/app/modules/risk/services/generation.py b/app/modules/risk/services/generation.py new file mode 100644 index 0000000..302566d --- /dev/null +++ b/app/modules/risk/services/generation.py @@ -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, + } diff --git a/app/modules/risk/services/query.py b/app/modules/risk/services/query.py new file mode 100644 index 0000000..8fa8701 --- /dev/null +++ b/app/modules/risk/services/query.py @@ -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, + } diff --git a/app/modules/risk/services/service.py b/app/modules/risk/services/service.py new file mode 100644 index 0000000..e8b8f30 --- /dev/null +++ b/app/modules/risk/services/service.py @@ -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 diff --git a/app/tasks.py b/app/tasks.py deleted file mode 100644 index d9d2cd2..0000000 --- a/app/tasks.py +++ /dev/null @@ -1,149 +0,0 @@ -from typing import Any -from socket import gethostname - -from celery import Celery - -from app.core.config import get_settings -from app.core.constants import ActorValue -from app.core.database import SessionLocal -from app.modules.feishu.constants import FeishuReceiveIdType - -settings = get_settings() - -celery_app = Celery( - "company_ai_platform", - broker=settings.redis_url, - backend=settings.celery_result_backend_url or settings.redis_url, -) -celery_app.conf.task_always_eager = settings.task_queue_always_eager - - -@celery_app.task(name="reports.push_daily_brief") -def push_daily_brief( - receive_id: str | None = None, - receive_id_type: str = FeishuReceiveIdType.CHAT_ID, - actor: str = ActorValue.SCHEDULER, - push_run_code: str | None = None, -) -> dict[str, Any]: - from app.modules.reports.service import ReportService - - db = SessionLocal() - try: - report = ReportService(db).daily_brief() - return ReportService(db).push_report( - report, - receive_id, - receive_id_type, - actor, - push_run_code=push_run_code, - ) - finally: - db.close() - - -@celery_app.task(name="events.dispatch_pending") -def dispatch_pending_events( - limit: int | None = None, - actor: str = ActorValue.WORKER, -) -> list[dict[str, Any]]: - from app.modules.events.service import EventService - from app.modules.observability.constants import HeartbeatComponent - from app.modules.observability.service import ObservabilityService - - db = SessionLocal() - try: - ObservabilityService(db).record_heartbeat( - component=HeartbeatComponent.WORKER, - instance_id=gethostname(), - actor=actor, - ) - return EventService(db).dispatch_pending( - limit=limit or settings.event_dispatch_batch_size, - worker_id=f"{actor}:{gethostname()}", - ) - finally: - db.close() - - -@celery_app.task(name="reports.push_project_weekly") -def push_project_weekly( - receive_id: str | None = None, - receive_id_type: str = FeishuReceiveIdType.CHAT_ID, - actor: str = ActorValue.SCHEDULER, - push_run_code: str | None = None, -) -> dict[str, Any]: - from app.modules.reports.service import ReportService - - db = SessionLocal() - try: - report = ReportService(db).project_weekly() - return ReportService(db).push_report( - report, - receive_id, - receive_id_type, - actor, - push_run_code=push_run_code, - ) - finally: - db.close() - - -@celery_app.task(name="risks.generate_events") -def generate_risk_events(actor: str = ActorValue.SCHEDULER) -> dict[str, Any]: - from app.modules.risk.service import RiskService - - db = SessionLocal() - try: - return RiskService(db).generate_events(actor=actor) - finally: - db.close() - - -@celery_app.task(name="legacy.sync_projects") -def sync_legacy_projects( - source_query: str | None = None, - source_query_name: str | None = None, - field_map: dict[str, str] | None = None, - limit: int = 100, - dry_run: bool = False, - actor: str = ActorValue.SCHEDULER, -) -> dict[str, Any]: - from app.modules.legacy_mysql.service import LegacyMySQLService - - db = SessionLocal() - try: - return LegacyMySQLService(db).sync_projects( - source_query=source_query, - source_query_name=source_query_name, - field_map=field_map or {}, - limit=limit, - dry_run=dry_run, - actor=actor, - ) - finally: - db.close() - - -@celery_app.task(name="legacy.sync_tasks") -def sync_legacy_tasks( - source_query: str | None = None, - source_query_name: str | None = None, - field_map: dict[str, str] | None = None, - limit: int = 100, - dry_run: bool = False, - actor: str = ActorValue.SCHEDULER, -) -> dict[str, Any]: - from app.modules.legacy_mysql.service import LegacyMySQLService - - db = SessionLocal() - try: - return LegacyMySQLService(db).sync_tasks( - source_query=source_query, - source_query_name=source_query_name, - field_map=field_map or {}, - limit=limit, - dry_run=dry_run, - actor=actor, - ) - finally: - db.close() diff --git a/app/tasks/__init__.py b/app/tasks/__init__.py new file mode 100644 index 0000000..682ed49 --- /dev/null +++ b/app/tasks/__init__.py @@ -0,0 +1,8 @@ +from app.tasks.app import celery_app +from app.tasks import events as _events # noqa: F401 +from app.tasks import legacy as _legacy # noqa: F401 +from app.tasks import reports as _reports # noqa: F401 +from app.tasks import risk as _risk # noqa: F401 + + +__all__ = ["celery_app"] diff --git a/app/tasks/app.py b/app/tasks/app.py new file mode 100644 index 0000000..ca5a9d6 --- /dev/null +++ b/app/tasks/app.py @@ -0,0 +1,12 @@ +from celery import Celery + +from app.core.config import get_settings + +settings = get_settings() + +celery_app = Celery( + "company_ai_platform", + broker=settings.redis_url, + backend=settings.celery_result_backend_url or settings.redis_url, +) +celery_app.conf.task_always_eager = settings.task_queue_always_eager diff --git a/app/tasks/events.py b/app/tasks/events.py new file mode 100644 index 0000000..36ddb47 --- /dev/null +++ b/app/tasks/events.py @@ -0,0 +1,30 @@ +from socket import gethostname +from typing import Any + +from app.core.constants import ActorValue +from app.core.database import SessionLocal +from app.tasks.app import celery_app, settings + + +@celery_app.task(name="events.dispatch_pending") +def dispatch_pending_events( + limit: int | None = None, + actor: str = ActorValue.WORKER, +) -> list[dict[str, Any]]: + from app.modules.events.services import EventService + from app.modules.observability.constants import HeartbeatComponent + from app.modules.observability.service import ObservabilityService + + db = SessionLocal() + try: + ObservabilityService(db).record_heartbeat( + component=HeartbeatComponent.WORKER, + instance_id=gethostname(), + actor=actor, + ) + return EventService(db).dispatch_pending( + limit=limit or settings.event_dispatch_batch_size, + worker_id=f"{actor}:{gethostname()}", + ) + finally: + db.close() diff --git a/app/tasks/legacy.py b/app/tasks/legacy.py new file mode 100644 index 0000000..98fc189 --- /dev/null +++ b/app/tasks/legacy.py @@ -0,0 +1,54 @@ +from typing import Any + +from app.core.constants import ActorValue +from app.core.database import SessionLocal +from app.tasks.app import celery_app + + +@celery_app.task(name="legacy.sync_projects") +def sync_legacy_projects( + source_query: str | None = None, + source_query_name: str | None = None, + field_map: dict[str, str] | None = None, + limit: int = 100, + dry_run: bool = False, + actor: str = ActorValue.SCHEDULER, +) -> dict[str, Any]: + from app.modules.legacy_mysql.services import LegacyMySQLService + + db = SessionLocal() + try: + return LegacyMySQLService(db).sync_projects( + source_query=source_query, + source_query_name=source_query_name, + field_map=field_map or {}, + limit=limit, + dry_run=dry_run, + actor=actor, + ) + finally: + db.close() + +@celery_app.task(name="legacy.sync_tasks") +def sync_legacy_tasks( + source_query: str | None = None, + source_query_name: str | None = None, + field_map: dict[str, str] | None = None, + limit: int = 100, + dry_run: bool = False, + actor: str = ActorValue.SCHEDULER, +) -> dict[str, Any]: + from app.modules.legacy_mysql.services import LegacyMySQLService + + db = SessionLocal() + try: + return LegacyMySQLService(db).sync_tasks( + source_query=source_query, + source_query_name=source_query_name, + field_map=field_map or {}, + limit=limit, + dry_run=dry_run, + actor=actor, + ) + finally: + db.close() diff --git a/app/tasks/reports.py b/app/tasks/reports.py new file mode 100644 index 0000000..4d6b456 --- /dev/null +++ b/app/tasks/reports.py @@ -0,0 +1,51 @@ +from typing import Any + +from app.core.constants import ActorValue +from app.core.database import SessionLocal +from app.modules.feishu.constants import FeishuReceiveIdType +from app.tasks.app import celery_app + + +@celery_app.task(name="reports.push_daily_brief") +def push_daily_brief( + receive_id: str | None = None, + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = ActorValue.SCHEDULER, + push_run_code: str | None = None, +) -> dict[str, Any]: + from app.modules.reports.services import ReportService + + db = SessionLocal() + try: + report = ReportService(db).daily_brief() + return ReportService(db).push_report( + report, + receive_id, + receive_id_type, + actor, + push_run_code=push_run_code, + ) + finally: + db.close() + +@celery_app.task(name="reports.push_project_weekly") +def push_project_weekly( + receive_id: str | None = None, + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = ActorValue.SCHEDULER, + push_run_code: str | None = None, +) -> dict[str, Any]: + from app.modules.reports.services import ReportService + + db = SessionLocal() + try: + report = ReportService(db).project_weekly() + return ReportService(db).push_report( + report, + receive_id, + receive_id_type, + actor, + push_run_code=push_run_code, + ) + finally: + db.close() diff --git a/app/tasks/risk.py b/app/tasks/risk.py new file mode 100644 index 0000000..e81892f --- /dev/null +++ b/app/tasks/risk.py @@ -0,0 +1,16 @@ +from typing import Any + +from app.core.constants import ActorValue +from app.core.database import SessionLocal +from app.tasks.app import celery_app + + +@celery_app.task(name="risks.generate_events") +def generate_risk_events(actor: str = ActorValue.SCHEDULER) -> dict[str, Any]: + from app.modules.risk.services import RiskService + + db = SessionLocal() + try: + return RiskService(db).generate_events(actor=actor) + finally: + db.close() diff --git a/tests/test_smoke.py b/tests/test_smoke.py index c463133..e7ced67 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -47,10 +47,10 @@ from app.modules.events.constants import ( EventStatus, EventType, ) -from app.modules.events.service import EventService +from app.modules.events.services import EventService from app.modules.business.registry import get_domain_model from app.modules.business.service import _model_payload, serialize_model -from app.modules.legacy_mysql.service import LegacyMySQLService +from app.modules.legacy_mysql.services import LegacyMySQLService from app.modules.observability.constants import ( HeartbeatComponent, ObservabilityKey,