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

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

View File

@@ -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")

View File

@@ -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,
)

View File

@@ -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",
]

View File

@@ -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"

View File

@@ -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(),
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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("/")

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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("/")

View File

@@ -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}

View File

@@ -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()

View File

@@ -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),
}

View File

@@ -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 {},
},
}

View File

@@ -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,
)

View File

@@ -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],
}

View File

@@ -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:

View File

@@ -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)

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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)])

View File

@@ -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

View File

@@ -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"]

View File

@@ -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

View File

@@ -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 {},
)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 = ("周报", "项目周报")

View File

@@ -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)])

View File

@@ -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

View File

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

View File

@@ -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)

View File

@@ -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,
),
}

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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)])

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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())

View File

@@ -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

View File

@@ -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

View File

@@ -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."""

View File

@@ -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,
}

View File

@@ -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]

View File

@@ -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})

View File

@@ -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

View File

@@ -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,
),
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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),
}

View File

@@ -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]}",
]

View File

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

View File

@@ -1,577 +0,0 @@
from datetime import date
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.constants import ActorValue
from app.core.http.pagination import bounded_limit
from app.core.utils.time import utc_now
from app.modules.audit.constants import (
AuditAction,
AuditRiskLevel,
AuditSource,
AuditTargetType,
)
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.constants import (
CLOSED_RISK_STATUSES,
DONE_STATUSES,
GENERATED_RISK_EVENT_TYPES,
PROJECT_CLOSED_STATUSES,
SUPPLIER_RISK_LEVELS,
BusinessDomain,
RiskEventType,
RiskLevel,
StatusValue,
)
from app.modules.business.models import (
FundAccount,
Project,
RiskEvent,
RiskEventAction,
Supplier,
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventType,
)
from app.modules.events.service import EventService
from app.modules.risk.constants import (
RISK_SCORE_WEIGHTS,
RiskEventActionKey,
RiskEventActionValue,
RiskErrorDetail,
RiskGenerationAction,
RiskGenerationResultKey,
RiskEventPayloadKey,
RiskSummaryKey,
risk_level_for_score,
)
class RiskService:
"""Evaluate rule-based business risk signals from internal ledgers."""
def __init__(self, db: Session):
self.db = db
def overdue_tasks(self) -> list[dict[str, Any]]:
stmt = select(WorkTask).where(
WorkTask.due_date.is_not(None),
WorkTask.due_date < date.today(),
WorkTask.status.notin_(DONE_STATUSES),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def delayed_projects(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.due_date.is_not(None),
Project.due_date < date.today(),
Project.status.notin_(PROJECT_CLOSED_STATUSES),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def over_budget_projects(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.budget_amount > 0,
Project.actual_amount > Project.budget_amount,
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def fund_risks(self) -> list[dict[str, Any]]:
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def supplier_risks(self) -> list[dict[str, Any]]:
stmt = select(Supplier).where(
(Supplier.blacklist_status != StatusValue.NORMAL)
| Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS)
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def list_events(
self,
limit: int = 100,
status_filter: str | None = None,
) -> list[dict[str, Any]]:
limit_value = bounded_limit(limit)
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(limit_value)
if status_filter:
stmt = (
select(RiskEvent)
.where(RiskEvent.status == status_filter)
.order_by(RiskEvent.id.desc())
.limit(limit_value)
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def list_actions(self, risk_event_id: int, limit: int = 100) -> list[dict[str, Any]]:
self._get_event(risk_event_id)
stmt = (
select(RiskEventAction)
.where(RiskEventAction.risk_event_id == risk_event_id)
.order_by(RiskEventAction.id.desc())
.limit(bounded_limit(limit))
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def assign_event(
self,
risk_event_id: int,
assigned_to: str,
comment: str | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
record.assigned_to = assigned_to
action = self._record_action(
record,
RiskEventActionValue.ASSIGN,
actor,
from_status,
record.status,
comment,
{RiskEventActionKey.ASSIGNED_TO: assigned_to},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def comment_event(
self,
risk_event_id: int,
comment: str,
payload: dict[str, Any] | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
action = self._record_action(
record,
RiskEventActionValue.COMMENT,
actor,
record.status,
record.status,
comment,
payload or {},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def resolve_event(
self,
risk_event_id: int,
comment: str | None = None,
payload: dict[str, Any] | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
record.status = StatusValue.RESOLVED
record.resolved_at = utc_now()
action = self._record_action(
record,
RiskEventActionValue.RESOLVE,
actor,
from_status,
record.status,
comment,
payload or {},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def close_event(
self,
risk_event_id: int,
closed_reason: str,
review_summary: str | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
now = utc_now()
record.status = StatusValue.CLOSED
record.closed_reason = closed_reason
record.review_summary = review_summary
record.closed_at = now
if record.resolved_at is None:
record.resolved_at = now
action = self._record_action(
record,
RiskEventActionValue.CLOSE,
actor,
from_status,
record.status,
closed_reason,
{RiskEventActionKey.REVIEW_SUMMARY: review_summary},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def reopen_event(
self,
risk_event_id: int,
comment: str | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
record.status = StatusValue.OPEN
record.resolved_at = None
record.closed_at = None
action = self._record_action(
record,
RiskEventActionValue.REOPEN,
actor,
from_status,
record.status,
comment,
{},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def summary(self) -> dict[str, Any]:
overdue_tasks = self.overdue_tasks()
delayed_projects = self.delayed_projects()
over_budget_projects = self.over_budget_projects()
fund_risks = self.fund_risks()
supplier_risks = self.supplier_risks()
open_events = self.list_events(status_filter=StatusValue.OPEN)
external_open_events = [
item
for item in open_events
if item.get(RiskEventPayloadKey.RISK_TYPE) not in GENERATED_RISK_EVENT_TYPES
]
risk_score = (
len(overdue_tasks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OVERDUE_TASKS]
+ len(delayed_projects) * RISK_SCORE_WEIGHTS[RiskSummaryKey.DELAYED_PROJECTS]
+ len(over_budget_projects)
* RISK_SCORE_WEIGHTS[RiskSummaryKey.OVER_BUDGET_PROJECTS]
+ len(fund_risks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.FUND_RISKS]
+ len(supplier_risks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.SUPPLIER_RISKS]
+ len(external_open_events) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OPEN_EVENTS]
)
return {
RiskSummaryKey.RISK_LEVEL: risk_level_for_score(risk_score),
RiskSummaryKey.RISK_SCORE: Decimal(risk_score),
RiskSummaryKey.OVERDUE_TASKS: overdue_tasks,
RiskSummaryKey.DELAYED_PROJECTS: delayed_projects,
RiskSummaryKey.OVER_BUDGET_PROJECTS: over_budget_projects,
RiskSummaryKey.FUND_RISKS: fund_risks,
RiskSummaryKey.SUPPLIER_RISKS: supplier_risks,
RiskSummaryKey.OPEN_EVENTS: open_events,
}
def _get_event(self, risk_event_id: int) -> RiskEvent:
record = self.db.get(RiskEvent, risk_event_id)
if record is None:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=RiskErrorDetail.RISK_EVENT_NOT_FOUND,
)
return record
def _record_action(
self,
record: RiskEvent,
action: str,
actor: str,
from_status: str | None,
to_status: str | None,
comment: str | None,
payload: dict[str, Any],
) -> RiskEventAction:
action_record = RiskEventAction(
code=f"RISK-ACTION-{utc_now():%Y%m%d%H%M%S%f}",
risk_event_id=record.id,
action=action,
actor=actor,
from_status=from_status,
to_status=to_status,
assigned_to=record.assigned_to,
comment=comment,
payload=payload,
)
self.db.add(action_record)
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source=AuditSource.RISK,
action=AuditAction.RISK_EVENT_ACTION,
target_type=BusinessDomain.RISK_EVENTS,
target_id=str(record.id),
risk_level=AuditRiskLevel.MEDIUM,
request_payload={
RiskEventActionKey.ACTION: action,
RiskEventActionKey.FROM_STATUS: from_status,
RiskEventActionKey.TO_STATUS: to_status,
RiskEventActionKey.COMMENT: comment,
RiskEventActionKey.PAYLOAD: payload,
},
)
)
EventService(self.db).emit(
event_type=EventType.RISK_ACTION_RECORDED,
source=EventSource.RISK,
aggregate_type=EventAggregateType.RISK_EVENT,
aggregate_id=record.id,
actor=actor,
payload={
EventPayloadKey.ACTION: action,
EventPayloadKey.STATUS: to_status,
EventPayloadKey.RECORD_ID: str(record.id),
RiskEventActionKey.FROM_STATUS: from_status,
RiskEventActionKey.TO_STATUS: to_status,
RiskEventActionKey.COMMENT: comment,
RiskEventActionKey.PAYLOAD: payload,
},
idempotency_key=f"risk:{record.id}:{action_record.code}",
dispatch=True,
)
return action_record
@staticmethod
def _action_response(record: RiskEvent, action: RiskEventAction) -> dict[str, Any]:
return {
RiskEventActionKey.RISK_EVENT: serialize_model(record),
RiskEventActionKey.ACTION_RECORD: serialize_model(action),
}
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
"""Generate or refresh risk-event ledger entries from current signals."""
payloads = self._build_event_payloads()
created = 0
updated = 0
skipped = 0
items: list[dict[str, Any]] = []
for payload in payloads:
record = self.db.execute(
select(RiskEvent).where(RiskEvent.code == payload[RiskEventPayloadKey.CODE])
).scalar_one_or_none()
if record is None:
record = RiskEvent(**payload)
self.db.add(record)
self.db.flush()
created += 1
action = RiskGenerationAction.CREATED
elif record.status in CLOSED_RISK_STATUSES:
skipped += 1
items.append(
{
RiskGenerationResultKey.ACTION: RiskGenerationAction.SKIPPED,
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
}
)
continue
else:
for key, value in payload.items():
if key != RiskEventPayloadKey.CODE:
setattr(record, key, value)
updated += 1
action = RiskGenerationAction.UPDATED
items.append(
{
RiskGenerationResultKey.ACTION: action,
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
}
)
self.db.commit()
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source=AuditSource.RISK,
action=AuditAction.GENERATE_EVENTS,
target_type=AuditTargetType.RISK_EVENTS,
risk_level=AuditRiskLevel.MEDIUM,
response_payload={
RiskGenerationResultKey.CREATED: created,
RiskGenerationResultKey.UPDATED: updated,
RiskGenerationResultKey.SKIPPED: skipped,
},
)
)
return {
RiskGenerationResultKey.CREATED: created,
RiskGenerationResultKey.UPDATED: updated,
RiskGenerationResultKey.SKIPPED: skipped,
RiskGenerationResultKey.ITEMS: items,
}
def _build_event_payloads(self) -> list[dict[str, Any]]:
payloads: list[dict[str, Any]] = []
payloads.extend(self._overdue_task_payloads())
payloads.extend(self._delayed_project_payloads())
payloads.extend(self._over_budget_project_payloads())
payloads.extend(self._fund_risk_payloads())
payloads.extend(self._supplier_risk_payloads())
return payloads
def _overdue_task_payloads(self) -> list[dict[str, Any]]:
stmt = select(WorkTask).where(
WorkTask.due_date.is_not(None),
WorkTask.due_date < date.today(),
WorkTask.status.notin_(DONE_STATUSES),
)
payloads = []
for task in self.db.execute(stmt).scalars():
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-TASK-OVERDUE-{task.id}",
RiskEventPayloadKey.TITLE: f"任务逾期:{task.title}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVERDUE_TASK,
RiskEventPayloadKey.RISK_LEVEL: RiskLevel.MEDIUM,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.TASKS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(task.id),
RiskEventPayloadKey.PROJECT_CODE: task.project_code,
RiskEventPayloadKey.OWNER: task.owner,
RiskEventPayloadKey.DUE_DATE: task.due_date,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "任务已超过截止日期且未完成。",
RiskEventPayloadKey.MITIGATION: (
"请负责人更新进度、明确阻塞项并给出新的完成时间。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(task),
}
)
return payloads
def _delayed_project_payloads(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.due_date.is_not(None),
Project.due_date < date.today(),
Project.status.notin_(PROJECT_CLOSED_STATUSES),
)
payloads = []
for project in self.db.execute(stmt).scalars():
level = RiskLevel.HIGH if project.progress_percent < 80 else RiskLevel.MEDIUM
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-PROJECT-DELAY-{project.id}",
RiskEventPayloadKey.TITLE: f"项目延期:{project.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.DELAYED_PROJECT,
RiskEventPayloadKey.RISK_LEVEL: level,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id),
RiskEventPayloadKey.PROJECT_CODE: project.code,
RiskEventPayloadKey.OWNER: project.owner,
RiskEventPayloadKey.DUE_DATE: project.due_date,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "项目已超过计划截止日期且未进入完成状态。",
RiskEventPayloadKey.MITIGATION: (
"请项目负责人提交延期原因、资源需求和纠偏计划。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(project),
}
)
return payloads
def _over_budget_project_payloads(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.budget_amount > 0,
Project.actual_amount > Project.budget_amount,
)
payloads = []
for project in self.db.execute(stmt).scalars():
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-PROJECT-BUDGET-{project.id}",
RiskEventPayloadKey.TITLE: f"项目超预算:{project.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVER_BUDGET_PROJECT,
RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id),
RiskEventPayloadKey.PROJECT_CODE: project.code,
RiskEventPayloadKey.OWNER: project.owner,
RiskEventPayloadKey.DUE_DATE: project.due_date,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "项目实际成本已超过预算。",
RiskEventPayloadKey.MITIGATION: (
"请复核预算科目、冻结非必要采购并补充审批依据。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(project),
}
)
return payloads
def _fund_risk_payloads(self) -> list[dict[str, Any]]:
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
payloads = []
for account in self.db.execute(stmt).scalars():
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-FUND-{account.id}",
RiskEventPayloadKey.TITLE: f"资金低于安全线:{account.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.FUND_SAFETY_LINE,
RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.FUND_ACCOUNTS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(account.id),
RiskEventPayloadKey.OWNER: None,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "账户当前余额低于设置的安全线。",
RiskEventPayloadKey.MITIGATION: (
"请财务确认收付款计划,"
"并优先处理关键项目资金安排。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(account),
}
)
return payloads
def _supplier_risk_payloads(self) -> list[dict[str, Any]]:
stmt = select(Supplier).where(
(Supplier.blacklist_status != StatusValue.NORMAL)
| Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS)
)
payloads = []
for supplier in self.db.execute(stmt).scalars():
level = (
RiskLevel.HIGH
if supplier.blacklist_status != StatusValue.NORMAL
else supplier.risk_level
)
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-SUPPLIER-{supplier.id}",
RiskEventPayloadKey.TITLE: f"供应商风险:{supplier.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.SUPPLIER_RISK,
RiskEventPayloadKey.RISK_LEVEL: level,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.SUPPLIERS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(supplier.id),
RiskEventPayloadKey.OWNER: supplier.contact,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "供应商风险等级或黑名单状态需要关注。",
RiskEventPayloadKey.MITIGATION: (
"请采购负责人复核供应商准入、履约和替代方案。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(supplier),
}
)
return payloads

View File

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

View File

@@ -0,0 +1,238 @@
from typing import Any
from app.core.constants import ActorValue
from app.core.utils.time import utc_now
from app.modules.audit.constants import (
AuditAction,
AuditRiskLevel,
AuditSource,
)
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.constants import (
BusinessDomain,
StatusValue,
)
from app.modules.business.models import (
RiskEvent,
RiskEventAction,
)
from app.modules.business.service import serialize_model
from app.modules.events.constants import (
EventAggregateType,
EventPayloadKey,
EventSource,
EventType,
)
from app.modules.events.services import EventService
from app.modules.risk.constants import (
RiskEventActionKey,
RiskEventActionValue,
RiskErrorDetail,
)
class RiskActionMixin:
def assign_event(
self,
risk_event_id: int,
assigned_to: str,
comment: str | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
record.assigned_to = assigned_to
action = self._record_action(
record,
RiskEventActionValue.ASSIGN,
actor,
from_status,
record.status,
comment,
{RiskEventActionKey.ASSIGNED_TO: assigned_to},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def comment_event(
self,
risk_event_id: int,
comment: str,
payload: dict[str, Any] | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
action = self._record_action(
record,
RiskEventActionValue.COMMENT,
actor,
record.status,
record.status,
comment,
payload or {},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def resolve_event(
self,
risk_event_id: int,
comment: str | None = None,
payload: dict[str, Any] | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
record.status = StatusValue.RESOLVED
record.resolved_at = utc_now()
action = self._record_action(
record,
RiskEventActionValue.RESOLVE,
actor,
from_status,
record.status,
comment,
payload or {},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def close_event(
self,
risk_event_id: int,
closed_reason: str,
review_summary: str | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
now = utc_now()
record.status = StatusValue.CLOSED
record.closed_reason = closed_reason
record.review_summary = review_summary
record.closed_at = now
if record.resolved_at is None:
record.resolved_at = now
action = self._record_action(
record,
RiskEventActionValue.CLOSE,
actor,
from_status,
record.status,
closed_reason,
{RiskEventActionKey.REVIEW_SUMMARY: review_summary},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def reopen_event(
self,
risk_event_id: int,
comment: str | None = None,
actor: str = ActorValue.API,
) -> dict[str, Any]:
record = self._get_event(risk_event_id)
from_status = record.status
record.status = StatusValue.OPEN
record.resolved_at = None
record.closed_at = None
action = self._record_action(
record,
RiskEventActionValue.REOPEN,
actor,
from_status,
record.status,
comment,
{},
)
self.db.commit()
self.db.refresh(record)
self.db.refresh(action)
return self._action_response(record, action)
def _get_event(self, risk_event_id: int) -> RiskEvent:
record = self.db.get(RiskEvent, risk_event_id)
if record is None:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=RiskErrorDetail.RISK_EVENT_NOT_FOUND,
)
return record
def _record_action(
self,
record: RiskEvent,
action: str,
actor: str,
from_status: str | None,
to_status: str | None,
comment: str | None,
payload: dict[str, Any],
) -> RiskEventAction:
action_record = RiskEventAction(
code=f"RISK-ACTION-{utc_now():%Y%m%d%H%M%S%f}",
risk_event_id=record.id,
action=action,
actor=actor,
from_status=from_status,
to_status=to_status,
assigned_to=record.assigned_to,
comment=comment,
payload=payload,
)
self.db.add(action_record)
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source=AuditSource.RISK,
action=AuditAction.RISK_EVENT_ACTION,
target_type=BusinessDomain.RISK_EVENTS,
target_id=str(record.id),
risk_level=AuditRiskLevel.MEDIUM,
request_payload={
RiskEventActionKey.ACTION: action,
RiskEventActionKey.FROM_STATUS: from_status,
RiskEventActionKey.TO_STATUS: to_status,
RiskEventActionKey.COMMENT: comment,
RiskEventActionKey.PAYLOAD: payload,
},
)
)
EventService(self.db).emit(
event_type=EventType.RISK_ACTION_RECORDED,
source=EventSource.RISK,
aggregate_type=EventAggregateType.RISK_EVENT,
aggregate_id=record.id,
actor=actor,
payload={
EventPayloadKey.ACTION: action,
EventPayloadKey.STATUS: to_status,
EventPayloadKey.RECORD_ID: str(record.id),
RiskEventActionKey.FROM_STATUS: from_status,
RiskEventActionKey.TO_STATUS: to_status,
RiskEventActionKey.COMMENT: comment,
RiskEventActionKey.PAYLOAD: payload,
},
idempotency_key=f"risk:{record.id}:{action_record.code}",
dispatch=True,
)
return action_record
@staticmethod
def _action_response(record: RiskEvent, action: RiskEventAction) -> dict[str, Any]:
return {
RiskEventActionKey.RISK_EVENT: serialize_model(record),
RiskEventActionKey.ACTION_RECORD: serialize_model(action),
}

View File

@@ -0,0 +1,183 @@
from datetime import date
from typing import Any
from sqlalchemy import select
from app.core.utils.time import utc_now
from app.modules.business.constants import (
DONE_STATUSES,
PROJECT_CLOSED_STATUSES,
SUPPLIER_RISK_LEVELS,
BusinessDomain,
RiskEventType,
RiskLevel,
StatusValue,
)
from app.modules.business.models import (
FundAccount,
Project,
Supplier,
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.risk.constants import (
RiskEventPayloadKey,
)
class RiskDetectorMixin:
def _build_event_payloads(self) -> list[dict[str, Any]]:
payloads: list[dict[str, Any]] = []
payloads.extend(self._overdue_task_payloads())
payloads.extend(self._delayed_project_payloads())
payloads.extend(self._over_budget_project_payloads())
payloads.extend(self._fund_risk_payloads())
payloads.extend(self._supplier_risk_payloads())
return payloads
def _overdue_task_payloads(self) -> list[dict[str, Any]]:
stmt = select(WorkTask).where(
WorkTask.due_date.is_not(None),
WorkTask.due_date < date.today(),
WorkTask.status.notin_(DONE_STATUSES),
)
payloads = []
for task in self.db.execute(stmt).scalars():
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-TASK-OVERDUE-{task.id}",
RiskEventPayloadKey.TITLE: f"任务逾期:{task.title}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVERDUE_TASK,
RiskEventPayloadKey.RISK_LEVEL: RiskLevel.MEDIUM,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.TASKS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(task.id),
RiskEventPayloadKey.PROJECT_CODE: task.project_code,
RiskEventPayloadKey.OWNER: task.owner,
RiskEventPayloadKey.DUE_DATE: task.due_date,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "任务已超过截止日期且未完成。",
RiskEventPayloadKey.MITIGATION: (
"请负责人更新进度、明确阻塞项并给出新的完成时间。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(task),
}
)
return payloads
def _delayed_project_payloads(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.due_date.is_not(None),
Project.due_date < date.today(),
Project.status.notin_(PROJECT_CLOSED_STATUSES),
)
payloads = []
for project in self.db.execute(stmt).scalars():
level = RiskLevel.HIGH if project.progress_percent < 80 else RiskLevel.MEDIUM
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-PROJECT-DELAY-{project.id}",
RiskEventPayloadKey.TITLE: f"项目延期:{project.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.DELAYED_PROJECT,
RiskEventPayloadKey.RISK_LEVEL: level,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id),
RiskEventPayloadKey.PROJECT_CODE: project.code,
RiskEventPayloadKey.OWNER: project.owner,
RiskEventPayloadKey.DUE_DATE: project.due_date,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "项目已超过计划截止日期且未进入完成状态。",
RiskEventPayloadKey.MITIGATION: (
"请项目负责人提交延期原因、资源需求和纠偏计划。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(project),
}
)
return payloads
def _over_budget_project_payloads(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.budget_amount > 0,
Project.actual_amount > Project.budget_amount,
)
payloads = []
for project in self.db.execute(stmt).scalars():
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-PROJECT-BUDGET-{project.id}",
RiskEventPayloadKey.TITLE: f"项目超预算:{project.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.OVER_BUDGET_PROJECT,
RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.PROJECTS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(project.id),
RiskEventPayloadKey.PROJECT_CODE: project.code,
RiskEventPayloadKey.OWNER: project.owner,
RiskEventPayloadKey.DUE_DATE: project.due_date,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "项目实际成本已超过预算。",
RiskEventPayloadKey.MITIGATION: (
"请复核预算科目、冻结非必要采购并补充审批依据。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(project),
}
)
return payloads
def _fund_risk_payloads(self) -> list[dict[str, Any]]:
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
payloads = []
for account in self.db.execute(stmt).scalars():
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-FUND-{account.id}",
RiskEventPayloadKey.TITLE: f"资金低于安全线:{account.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.FUND_SAFETY_LINE,
RiskEventPayloadKey.RISK_LEVEL: RiskLevel.HIGH,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.FUND_ACCOUNTS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(account.id),
RiskEventPayloadKey.OWNER: None,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "账户当前余额低于设置的安全线。",
RiskEventPayloadKey.MITIGATION: (
"请财务确认收付款计划,"
"并优先处理关键项目资金安排。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(account),
}
)
return payloads
def _supplier_risk_payloads(self) -> list[dict[str, Any]]:
stmt = select(Supplier).where(
(Supplier.blacklist_status != StatusValue.NORMAL)
| Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS)
)
payloads = []
for supplier in self.db.execute(stmt).scalars():
level = (
RiskLevel.HIGH
if supplier.blacklist_status != StatusValue.NORMAL
else supplier.risk_level
)
payloads.append(
{
RiskEventPayloadKey.CODE: f"RISK-SUPPLIER-{supplier.id}",
RiskEventPayloadKey.TITLE: f"供应商风险:{supplier.name}",
RiskEventPayloadKey.RISK_TYPE: RiskEventType.SUPPLIER_RISK,
RiskEventPayloadKey.RISK_LEVEL: level,
RiskEventPayloadKey.STATUS: StatusValue.OPEN,
RiskEventPayloadKey.SOURCE_DOMAIN: BusinessDomain.SUPPLIERS,
RiskEventPayloadKey.SOURCE_RECORD_ID: str(supplier.id),
RiskEventPayloadKey.OWNER: supplier.contact,
RiskEventPayloadKey.DETECTED_AT: utc_now(),
RiskEventPayloadKey.DESCRIPTION: "供应商风险等级或黑名单状态需要关注。",
RiskEventPayloadKey.MITIGATION: (
"请采购负责人复核供应商准入、履约和替代方案。"
),
RiskEventPayloadKey.EVIDENCE: serialize_model(supplier),
}
)
return payloads

View File

@@ -0,0 +1,90 @@
from typing import Any
from sqlalchemy import select
from app.core.constants import ActorValue
from app.modules.audit.constants import (
AuditAction,
AuditRiskLevel,
AuditSource,
AuditTargetType,
)
from app.modules.audit.schemas import AuditLogCreate
from app.modules.audit.service import AuditService
from app.modules.business.constants import (
CLOSED_RISK_STATUSES,
)
from app.modules.business.models import (
RiskEvent,
)
from app.modules.business.service import serialize_model
from app.modules.risk.constants import (
RiskGenerationAction,
RiskGenerationResultKey,
RiskEventPayloadKey,
)
class RiskGenerationMixin:
def generate_events(self, actor: str = ActorValue.API) -> dict[str, Any]:
"""Generate or refresh risk-event ledger entries from current signals."""
payloads = self._build_event_payloads()
created = 0
updated = 0
skipped = 0
items: list[dict[str, Any]] = []
for payload in payloads:
record = self.db.execute(
select(RiskEvent).where(RiskEvent.code == payload[RiskEventPayloadKey.CODE])
).scalar_one_or_none()
if record is None:
record = RiskEvent(**payload)
self.db.add(record)
self.db.flush()
created += 1
action = RiskGenerationAction.CREATED
elif record.status in CLOSED_RISK_STATUSES:
skipped += 1
items.append(
{
RiskGenerationResultKey.ACTION: RiskGenerationAction.SKIPPED,
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
}
)
continue
else:
for key, value in payload.items():
if key != RiskEventPayloadKey.CODE:
setattr(record, key, value)
updated += 1
action = RiskGenerationAction.UPDATED
items.append(
{
RiskGenerationResultKey.ACTION: action,
RiskGenerationResultKey.RISK_EVENT: serialize_model(record),
}
)
self.db.commit()
AuditService(self.db).log(
AuditLogCreate(
actor=actor,
source=AuditSource.RISK,
action=AuditAction.GENERATE_EVENTS,
target_type=AuditTargetType.RISK_EVENTS,
risk_level=AuditRiskLevel.MEDIUM,
response_payload={
RiskGenerationResultKey.CREATED: created,
RiskGenerationResultKey.UPDATED: updated,
RiskGenerationResultKey.SKIPPED: skipped,
},
)
)
return {
RiskGenerationResultKey.CREATED: created,
RiskGenerationResultKey.UPDATED: updated,
RiskGenerationResultKey.SKIPPED: skipped,
RiskGenerationResultKey.ITEMS: items,
}

View File

@@ -0,0 +1,123 @@
from datetime import date
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from app.core.http.pagination import bounded_limit
from app.modules.business.constants import (
DONE_STATUSES,
GENERATED_RISK_EVENT_TYPES,
PROJECT_CLOSED_STATUSES,
SUPPLIER_RISK_LEVELS,
StatusValue,
)
from app.modules.business.models import (
FundAccount,
Project,
RiskEvent,
RiskEventAction,
Supplier,
WorkTask,
)
from app.modules.business.service import serialize_model
from app.modules.risk.constants import (
RISK_SCORE_WEIGHTS,
RiskEventPayloadKey,
RiskSummaryKey,
risk_level_for_score,
)
class RiskQueryMixin:
def overdue_tasks(self) -> list[dict[str, Any]]:
stmt = select(WorkTask).where(
WorkTask.due_date.is_not(None),
WorkTask.due_date < date.today(),
WorkTask.status.notin_(DONE_STATUSES),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def delayed_projects(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.due_date.is_not(None),
Project.due_date < date.today(),
Project.status.notin_(PROJECT_CLOSED_STATUSES),
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def over_budget_projects(self) -> list[dict[str, Any]]:
stmt = select(Project).where(
Project.budget_amount > 0,
Project.actual_amount > Project.budget_amount,
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def fund_risks(self) -> list[dict[str, Any]]:
stmt = select(FundAccount).where(FundAccount.current_balance < FundAccount.safety_line)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def supplier_risks(self) -> list[dict[str, Any]]:
stmt = select(Supplier).where(
(Supplier.blacklist_status != StatusValue.NORMAL)
| Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS)
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def list_events(
self,
limit: int = 100,
status_filter: str | None = None,
) -> list[dict[str, Any]]:
limit_value = bounded_limit(limit)
stmt = select(RiskEvent).order_by(RiskEvent.id.desc()).limit(limit_value)
if status_filter:
stmt = (
select(RiskEvent)
.where(RiskEvent.status == status_filter)
.order_by(RiskEvent.id.desc())
.limit(limit_value)
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def list_actions(self, risk_event_id: int, limit: int = 100) -> list[dict[str, Any]]:
self._get_event(risk_event_id)
stmt = (
select(RiskEventAction)
.where(RiskEventAction.risk_event_id == risk_event_id)
.order_by(RiskEventAction.id.desc())
.limit(bounded_limit(limit))
)
return [serialize_model(item) for item in self.db.execute(stmt).scalars()]
def summary(self) -> dict[str, Any]:
overdue_tasks = self.overdue_tasks()
delayed_projects = self.delayed_projects()
over_budget_projects = self.over_budget_projects()
fund_risks = self.fund_risks()
supplier_risks = self.supplier_risks()
open_events = self.list_events(status_filter=StatusValue.OPEN)
external_open_events = [
item
for item in open_events
if item.get(RiskEventPayloadKey.RISK_TYPE) not in GENERATED_RISK_EVENT_TYPES
]
risk_score = (
len(overdue_tasks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OVERDUE_TASKS]
+ len(delayed_projects) * RISK_SCORE_WEIGHTS[RiskSummaryKey.DELAYED_PROJECTS]
+ len(over_budget_projects)
* RISK_SCORE_WEIGHTS[RiskSummaryKey.OVER_BUDGET_PROJECTS]
+ len(fund_risks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.FUND_RISKS]
+ len(supplier_risks) * RISK_SCORE_WEIGHTS[RiskSummaryKey.SUPPLIER_RISKS]
+ len(external_open_events) * RISK_SCORE_WEIGHTS[RiskSummaryKey.OPEN_EVENTS]
)
return {
RiskSummaryKey.RISK_LEVEL: risk_level_for_score(risk_score),
RiskSummaryKey.RISK_SCORE: Decimal(risk_score),
RiskSummaryKey.OVERDUE_TASKS: overdue_tasks,
RiskSummaryKey.DELAYED_PROJECTS: delayed_projects,
RiskSummaryKey.OVER_BUDGET_PROJECTS: over_budget_projects,
RiskSummaryKey.FUND_RISKS: fund_risks,
RiskSummaryKey.SUPPLIER_RISKS: supplier_risks,
RiskSummaryKey.OPEN_EVENTS: open_events,
}

View File

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

View File

@@ -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()

8
app/tasks/__init__.py Normal file
View File

@@ -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"]

12
app/tasks/app.py Normal file
View File

@@ -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

30
app/tasks/events.py Normal file
View File

@@ -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()

54
app/tasks/legacy.py Normal file
View File

@@ -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()

51
app/tasks/reports.py Normal file
View File

@@ -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()

16
app/tasks/risk.py Normal file
View File

@@ -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()

View File

@@ -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,