```
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:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
30
app/core/background/task_queue/__init__.py
Normal file
30
app/core/background/task_queue/__init__.py
Normal 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",
|
||||
]
|
||||
6
app/core/background/task_queue/constants.py
Normal file
6
app/core/background/task_queue/constants.py
Normal 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"
|
||||
30
app/core/background/task_queue/dispatcher.py
Normal file
30
app/core/background/task_queue/dispatcher.py
Normal 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(),
|
||||
}
|
||||
31
app/core/background/task_queue/events.py
Normal file
31
app/core/background/task_queue/events.py
Normal 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,
|
||||
)
|
||||
84
app/core/background/task_queue/legacy.py
Normal file
84
app/core/background/task_queue/legacy.py
Normal 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,
|
||||
)
|
||||
144
app/core/background/task_queue/reports.py
Normal file
144
app/core/background/task_queue/reports.py
Normal 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,
|
||||
)
|
||||
24
app/core/background/task_queue/risk.py
Normal file
24
app/core/background/task_queue/risk.py
Normal 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,
|
||||
)
|
||||
@@ -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("/")
|
||||
21
app/modules/ai_agent/adapters/__init__.py
Normal file
21
app/modules/ai_agent/adapters/__init__.py
Normal 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",
|
||||
]
|
||||
12
app/modules/ai_agent/adapters/base.py
Normal file
12
app/modules/ai_agent/adapters/base.py
Normal 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
|
||||
64
app/modules/ai_agent/adapters/common.py
Normal file
64
app/modules/ai_agent/adapters/common.py
Normal 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("/")
|
||||
69
app/modules/ai_agent/adapters/direct_llm.py
Normal file
69
app/modules/ai_agent/adapters/direct_llm.py
Normal 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}
|
||||
28
app/modules/ai_agent/adapters/factory.py
Normal file
28
app/modules/ai_agent/adapters/factory.py
Normal 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()
|
||||
85
app/modules/ai_agent/adapters/hermes.py
Normal file
85
app/modules/ai_agent/adapters/hermes.py
Normal 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),
|
||||
}
|
||||
25
app/modules/ai_agent/adapters/noop.py
Normal file
25
app/modules/ai_agent/adapters/noop.py
Normal 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 {},
|
||||
},
|
||||
}
|
||||
122
app/modules/ai_agent/adapters/openclaw.py
Normal file
122
app/modules/ai_agent/adapters/openclaw.py
Normal 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,
|
||||
)
|
||||
143
app/modules/ai_agent/adapters/openclaw_hermes.py
Normal file
143
app/modules/ai_agent/adapters/openclaw_hermes.py
Normal 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],
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
27
app/modules/business/models/__init__.py
Normal file
27
app/modules/business/models/__init__.py
Normal 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",
|
||||
]
|
||||
30
app/modules/business/models/attendance.py
Normal file
30
app/modules/business/models/attendance.py
Normal 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)
|
||||
13
app/modules/business/models/common.py
Normal file
13
app/modules/business/models/common.py
Normal 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
|
||||
)
|
||||
67
app/modules/business/models/finance.py
Normal file
67
app/modules/business/models/finance.py
Normal 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)
|
||||
54
app/modules/business/models/governance.py
Normal file
54
app/modules/business/models/governance.py
Normal 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)
|
||||
28
app/modules/business/models/legacy.py
Normal file
28
app/modules/business/models/legacy.py
Normal 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)
|
||||
51
app/modules/business/models/projects.py
Normal file
51
app/modules/business/models/projects.py
Normal 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)
|
||||
49
app/modules/business/models/reports.py
Normal file
49
app/modules/business/models/reports.py
Normal 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)
|
||||
53
app/modules/business/models/risks.py
Normal file
53
app/modules/business/models/risks.py
Normal 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)
|
||||
30
app/modules/business/models/suppliers.py
Normal file
30
app/modules/business/models/suppliers.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -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
|
||||
5
app/modules/events/services/__init__.py
Normal file
5
app/modules/events/services/__init__.py
Normal 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"]
|
||||
145
app/modules/events/services/dispatch.py
Normal file
145
app/modules/events/services/dispatch.py
Normal 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
|
||||
128
app/modules/events/services/handlers.py
Normal file
128
app/modules/events/services/handlers.py
Normal 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 {},
|
||||
)
|
||||
89
app/modules/events/services/query.py
Normal file
89
app/modules/events/services/query.py
Normal 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
|
||||
10
app/modules/events/services/serialization.py
Normal file
10
app/modules/events/services/serialization.py
Normal 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
|
||||
}
|
||||
16
app/modules/events/services/service.py
Normal file
16
app/modules/events/services/service.py
Normal 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
|
||||
@@ -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 = ("周报", "项目周报")
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -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
|
||||
4
app/modules/legacy_mysql/services/__init__.py
Normal file
4
app/modules/legacy_mysql/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.modules.legacy_mysql.services.service import LegacyMySQLService
|
||||
|
||||
|
||||
__all__ = ["LegacyMySQLService"]
|
||||
48
app/modules/legacy_mysql/services/common.py
Normal file
48
app/modules/legacy_mysql/services/common.py
Normal 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)
|
||||
138
app/modules/legacy_mysql/services/mappers.py
Normal file
138
app/modules/legacy_mysql/services/mappers.py
Normal 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,
|
||||
),
|
||||
}
|
||||
189
app/modules/legacy_mysql/services/project_sync.py
Normal file
189
app/modules/legacy_mysql/services/project_sync.py
Normal 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
|
||||
165
app/modules/legacy_mysql/services/query.py
Normal file
165
app/modules/legacy_mysql/services/query.py
Normal 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,
|
||||
)
|
||||
18
app/modules/legacy_mysql/services/service.py
Normal file
18
app/modules/legacy_mysql/services/service.py
Normal 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
|
||||
189
app/modules/legacy_mysql/services/task_sync.py
Normal file
189
app/modules/legacy_mysql/services/task_sync.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
4
app/modules/reports/services/__init__.py
Normal file
4
app/modules/reports/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.modules.reports.services.service import ReportService
|
||||
|
||||
|
||||
__all__ = ["ReportService"]
|
||||
95
app/modules/reports/services/common.py
Normal file
95
app/modules/reports/services/common.py
Normal 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())
|
||||
95
app/modules/reports/services/delivery.py
Normal file
95
app/modules/reports/services/delivery.py
Normal 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
|
||||
194
app/modules/reports/services/enterprise.py
Normal file
194
app/modules/reports/services/enterprise.py
Normal 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
|
||||
15
app/modules/reports/services/lifecycle/__init__.py
Normal file
15
app/modules/reports/services/lifecycle/__init__.py
Normal 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."""
|
||||
87
app/modules/reports/services/lifecycle/filters.py
Normal file
87
app/modules/reports/services/lifecycle/filters.py
Normal 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,
|
||||
}
|
||||
138
app/modules/reports/services/lifecycle/health.py
Normal file
138
app/modules/reports/services/lifecycle/health.py
Normal 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]
|
||||
97
app/modules/reports/services/lifecycle/rendering.py
Normal file
97
app/modules/reports/services/lifecycle/rendering.py
Normal 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})
|
||||
94
app/modules/reports/services/lifecycle/report.py
Normal file
94
app/modules/reports/services/lifecycle/report.py
Normal 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
|
||||
270
app/modules/reports/services/lifecycle/stats.py
Normal file
270
app/modules/reports/services/lifecycle/stats.py
Normal 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,
|
||||
),
|
||||
}
|
||||
97
app/modules/reports/services/push_runs.py
Normal file
97
app/modules/reports/services/push_runs.py
Normal 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
|
||||
26
app/modules/reports/services/service.py
Normal file
26
app/modules/reports/services/service.py
Normal 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)
|
||||
131
app/modules/reports/services/summaries.py
Normal file
131
app/modules/reports/services/summaries.py
Normal 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),
|
||||
}
|
||||
225
app/modules/reports/services/work_reports.py
Normal file
225
app/modules/reports/services/work_reports.py
Normal 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]}",
|
||||
]
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -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
|
||||
4
app/modules/risk/services/__init__.py
Normal file
4
app/modules/risk/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.modules.risk.services.service import RiskService
|
||||
|
||||
|
||||
__all__ = ["RiskService"]
|
||||
238
app/modules/risk/services/actions.py
Normal file
238
app/modules/risk/services/actions.py
Normal 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),
|
||||
}
|
||||
183
app/modules/risk/services/detectors.py
Normal file
183
app/modules/risk/services/detectors.py
Normal 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
|
||||
90
app/modules/risk/services/generation.py
Normal file
90
app/modules/risk/services/generation.py
Normal 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,
|
||||
}
|
||||
123
app/modules/risk/services/query.py
Normal file
123
app/modules/risk/services/query.py
Normal 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,
|
||||
}
|
||||
18
app/modules/risk/services/service.py
Normal file
18
app/modules/risk/services/service.py
Normal 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
|
||||
149
app/tasks.py
149
app/tasks.py
@@ -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
8
app/tasks/__init__.py
Normal 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
12
app/tasks/app.py
Normal 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
30
app/tasks/events.py
Normal 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
54
app/tasks/legacy.py
Normal 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
51
app/tasks/reports.py
Normal 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
16
app/tasks/risk.py
Normal 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()
|
||||
Reference in New Issue
Block a user