diff --git a/app/core/background/scheduler.py b/app/core/background/scheduler.py index 0c7c766..885e0bc 100644 --- a/app/core/background/scheduler.py +++ b/app/core/background/scheduler.py @@ -1,3 +1,4 @@ +from collections.abc import Callable from datetime import date from socket import gethostname from typing import Any @@ -35,6 +36,7 @@ def create_scheduler(app: FastAPI | None = None) -> Any: from app.core.database import SessionLocal from app.core.background.task_queue import ( + enqueue_attendance_summary_push, enqueue_daily_brief_push, enqueue_event_dispatch, enqueue_legacy_project_sync, @@ -42,6 +44,9 @@ def create_scheduler(app: FastAPI | None = None) -> Any: enqueue_lifecycle_report, enqueue_market_report, enqueue_project_weekly_push, + enqueue_risk_progress_push, + enqueue_work_daily_push, + enqueue_work_weekly_push, ) from app.modules.observability.service import ObservabilityService from app.modules.reports.services import ReportService @@ -49,59 +54,82 @@ def create_scheduler(app: FastAPI | None = None) -> Any: settings = get_settings() scheduler = BackgroundScheduler(timezone="Asia/Shanghai") - def run_daily_brief() -> None: + def deliver_report( + state_key: str, + build_report: Callable[[Any], dict[str, Any]], + enqueue_push: Callable[..., dict[str, Any]], + ) -> None: db = SessionLocal() try: - report = ReportService(db).daily_brief() - _set_state(app, "last_daily_brief", report) - if ( - settings.feishu_app_id - and settings.feishu_app_secret - and settings.feishu_default_chat_id - ): - if settings.task_queue_enabled: - dispatch = enqueue_daily_brief_push( - receive_id=settings.feishu_default_chat_id, - receive_id_type=FeishuReceiveIdType.CHAT_ID, - actor=ActorValue.SCHEDULER, - ) - _set_state(app, "last_daily_brief_dispatch", dispatch) - return - ReportService(db).push_report( - report, + service = ReportService(db) + report = build_report(service) + _set_state(app, state_key, report) + if not _feishu_delivery_configured(settings): + return + if settings.task_queue_enabled: + dispatch = enqueue_push( receive_id=settings.feishu_default_chat_id, receive_id_type=FeishuReceiveIdType.CHAT_ID, actor=ActorValue.SCHEDULER, ) + _set_state(app, f"{state_key}_dispatch", dispatch) + return + service.push_report( + report, + receive_id=settings.feishu_default_chat_id, + receive_id_type=FeishuReceiveIdType.CHAT_ID, + actor=ActorValue.SCHEDULER, + ) finally: db.close() + def run_daily_brief() -> None: + deliver_report( + "last_daily_brief", + lambda service: service.daily_brief(), + enqueue_daily_brief_push, + ) + def run_project_weekly() -> None: - db = SessionLocal() - try: - report = ReportService(db).project_weekly() - _set_state(app, "last_project_weekly", report) - if ( - settings.feishu_app_id - and settings.feishu_app_secret - and settings.feishu_default_chat_id - ): - if settings.task_queue_enabled: - dispatch = enqueue_project_weekly_push( - receive_id=settings.feishu_default_chat_id, - receive_id_type=FeishuReceiveIdType.CHAT_ID, - actor=ActorValue.SCHEDULER, - ) - _set_state(app, "last_project_weekly_dispatch", dispatch) - return - ReportService(db).push_report( - report, - receive_id=settings.feishu_default_chat_id, - receive_id_type=FeishuReceiveIdType.CHAT_ID, - actor=ActorValue.SCHEDULER, - ) - finally: - db.close() + deliver_report( + "last_project_weekly", + lambda service: service.project_weekly(), + enqueue_project_weekly_push, + ) + + def run_attendance_summary() -> None: + deliver_report( + "last_attendance_summary", + lambda service: service.attendance_summary(), + enqueue_attendance_summary_push, + ) + + def run_risk_progress() -> None: + deliver_report( + "last_risk_progress", + lambda service: service.risk_progress(), + enqueue_risk_progress_push, + ) + + def run_work_daily() -> None: + deliver_report( + "last_work_daily", + lambda service: service.work_daily_report( + reporter=ActorValue.SCHEDULER, + actor=ActorValue.SCHEDULER, + ), + enqueue_work_daily_push, + ) + + def run_work_weekly() -> None: + deliver_report( + "last_work_weekly", + lambda service: service.work_weekly_report( + reporter=ActorValue.SCHEDULER, + actor=ActorValue.SCHEDULER, + ), + enqueue_work_weekly_push, + ) def run_legacy_project_sync() -> None: dispatch = enqueue_legacy_project_sync(actor=ActorValue.SCHEDULER) @@ -196,6 +224,39 @@ def create_scheduler(app: FastAPI | None = None) -> Any: id="project_weekly_push", replace_existing=True, ) + scheduler.add_job( + run_attendance_summary, + trigger="cron", + hour=settings.attendance_summary_cron_hour, + minute=settings.attendance_summary_cron_minute, + id="attendance_summary_push", + replace_existing=True, + ) + scheduler.add_job( + run_risk_progress, + trigger="cron", + hour=settings.risk_progress_cron_hour, + minute=settings.risk_progress_cron_minute, + id="risk_progress_push", + replace_existing=True, + ) + scheduler.add_job( + run_work_daily, + trigger="cron", + hour=settings.work_daily_cron_hour, + minute=settings.work_daily_cron_minute, + id="work_daily_push", + replace_existing=True, + ) + scheduler.add_job( + run_work_weekly, + trigger="cron", + day_of_week=settings.work_weekly_report_day_of_week, + hour=settings.work_weekly_cron_hour, + minute=settings.work_weekly_cron_minute, + id="work_weekly_push", + replace_existing=True, + ) if settings.event_dispatch_enabled: scheduler.add_job( run_event_dispatch, @@ -271,3 +332,11 @@ def create_scheduler(app: FastAPI | None = None) -> Any: def _set_state(app: FastAPI | None, key: str, value: Any) -> None: if app is not None: setattr(app.state, key, value) + + +def _feishu_delivery_configured(settings: Any) -> bool: + return bool( + settings.feishu_app_id + and settings.feishu_app_secret + and settings.feishu_default_chat_id + ) diff --git a/app/core/background/task_queue/__init__.py b/app/core/background/task_queue/__init__.py index f2f39d9..2389286 100644 --- a/app/core/background/task_queue/__init__.py +++ b/app/core/background/task_queue/__init__.py @@ -1,8 +1,12 @@ from app.core.background.task_queue.constants import ( TASK_DISPATCH_PENDING_EVENTS, TASK_GENERATE_RISK_EVENTS, + TASK_PUSH_ATTENDANCE_SUMMARY, TASK_PUSH_DAILY_BRIEF, TASK_PUSH_PROJECT_WEEKLY, + TASK_PUSH_RISK_PROGRESS, + TASK_PUSH_WORK_DAILY, + TASK_PUSH_WORK_WEEKLY, TASK_SYNC_LEGACY_PROJECTS, TASK_SYNC_LEGACY_TASKS, TASK_RUN_LIFECYCLE, @@ -18,8 +22,12 @@ from app.core.background.task_queue.legacy import ( from app.core.background.task_queue.lifecycle import enqueue_lifecycle_report from app.core.background.task_queue.market import enqueue_market_close, enqueue_market_report from app.core.background.task_queue.reports import ( + enqueue_attendance_summary_push, enqueue_daily_brief_push, enqueue_project_weekly_push, + enqueue_risk_progress_push, + enqueue_work_daily_push, + enqueue_work_weekly_push, ) from app.core.background.task_queue.risk import enqueue_risk_event_generation @@ -27,14 +35,19 @@ from app.core.background.task_queue.risk import enqueue_risk_event_generation __all__ = [ "TASK_DISPATCH_PENDING_EVENTS", "TASK_GENERATE_RISK_EVENTS", + "TASK_PUSH_ATTENDANCE_SUMMARY", "TASK_PUSH_DAILY_BRIEF", "TASK_PUSH_PROJECT_WEEKLY", + "TASK_PUSH_RISK_PROGRESS", + "TASK_PUSH_WORK_DAILY", + "TASK_PUSH_WORK_WEEKLY", "TASK_SYNC_LEGACY_PROJECTS", "TASK_SYNC_LEGACY_TASKS", "TASK_RUN_LIFECYCLE", "TASK_RUN_MARKET_CLOSE", "TASK_RUN_MARKET_REPORT", "dispatch_task", + "enqueue_attendance_summary_push", "enqueue_daily_brief_push", "enqueue_event_dispatch", "enqueue_legacy_project_sync", @@ -43,5 +56,8 @@ __all__ = [ "enqueue_market_close", "enqueue_market_report", "enqueue_project_weekly_push", + "enqueue_risk_progress_push", "enqueue_risk_event_generation", + "enqueue_work_daily_push", + "enqueue_work_weekly_push", ] diff --git a/app/core/background/task_queue/constants.py b/app/core/background/task_queue/constants.py index 67c5bee..6f74431 100644 --- a/app/core/background/task_queue/constants.py +++ b/app/core/background/task_queue/constants.py @@ -1,5 +1,9 @@ TASK_PUSH_DAILY_BRIEF = "reports.push_daily_brief" TASK_PUSH_PROJECT_WEEKLY = "reports.push_project_weekly" +TASK_PUSH_ATTENDANCE_SUMMARY = "reports.push_attendance_summary" +TASK_PUSH_RISK_PROGRESS = "reports.push_risk_progress" +TASK_PUSH_WORK_DAILY = "reports.push_work_daily" +TASK_PUSH_WORK_WEEKLY = "reports.push_work_weekly" TASK_GENERATE_RISK_EVENTS = "risks.generate_events" TASK_SYNC_LEGACY_PROJECTS = "legacy.sync_projects" TASK_SYNC_LEGACY_TASKS = "legacy.sync_tasks" diff --git a/app/core/background/task_queue/reports.py b/app/core/background/task_queue/reports.py index 765952a..3e8e0af 100644 --- a/app/core/background/task_queue/reports.py +++ b/app/core/background/task_queue/reports.py @@ -1,127 +1,138 @@ +from collections.abc import Callable 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_ATTENDANCE_SUMMARY, TASK_PUSH_DAILY_BRIEF, TASK_PUSH_PROJECT_WEEKLY, + TASK_PUSH_RISK_PROGRESS, + TASK_PUSH_WORK_DAILY, + TASK_PUSH_WORK_WEEKLY, ) from app.core.background.task_queue.dispatcher import dispatch_task +from app.core.config import get_settings +from app.core.constants import ActorValue +from app.modules.feishu.constants import FeishuReceiveIdType +from app.modules.reports.constants import ReportPushType, ReportTitle + +ReportBuilder = Callable[[Any, str], dict[str, Any]] def enqueue_daily_brief_push( receive_id: str | None = None, receive_id_type: str = FeishuReceiveIdType.CHAT_ID, - actor: str = "scheduler", + actor: str = ActorValue.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, + return _enqueue_report_push( + task_name=TASK_PUSH_DAILY_BRIEF, + report_type=ReportPushType.DAILY_BRIEF, + title=ReportTitle.DAILY_BRIEF, + build_report=_build_daily_brief, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, ) def enqueue_project_weekly_push( receive_id: str | None = None, receive_id_type: str = FeishuReceiveIdType.CHAT_ID, - actor: str = "scheduler", + actor: str = ActorValue.SCHEDULER, +) -> dict[str, Any]: + return _enqueue_report_push( + task_name=TASK_PUSH_PROJECT_WEEKLY, + report_type=ReportPushType.PROJECT_WEEKLY, + title=ReportTitle.PROJECT_WEEKLY, + build_report=_build_project_weekly, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + ) + + +def enqueue_attendance_summary_push( + receive_id: str | None = None, + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = ActorValue.SCHEDULER, +) -> dict[str, Any]: + return _enqueue_report_push( + task_name=TASK_PUSH_ATTENDANCE_SUMMARY, + report_type=ReportPushType.ATTENDANCE_SUMMARY, + title=ReportTitle.ATTENDANCE_SUMMARY, + build_report=_build_attendance_summary, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + ) + + +def enqueue_risk_progress_push( + receive_id: str | None = None, + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = ActorValue.SCHEDULER, +) -> dict[str, Any]: + return _enqueue_report_push( + task_name=TASK_PUSH_RISK_PROGRESS, + report_type=ReportPushType.RISK_PROGRESS, + title=ReportTitle.RISK_PROGRESS, + build_report=_build_risk_progress, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + ) + + +def enqueue_work_daily_push( + receive_id: str | None = None, + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = ActorValue.SCHEDULER, +) -> dict[str, Any]: + return _enqueue_report_push( + task_name=TASK_PUSH_WORK_DAILY, + report_type=ReportPushType.WORK_DAILY, + title=ReportTitle.WORK_DAILY, + build_report=_build_work_daily, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + ) + + +def enqueue_work_weekly_push( + receive_id: str | None = None, + receive_id_type: str = FeishuReceiveIdType.CHAT_ID, + actor: str = ActorValue.SCHEDULER, +) -> dict[str, Any]: + return _enqueue_report_push( + task_name=TASK_PUSH_WORK_WEEKLY, + report_type=ReportPushType.WORK_WEEKLY, + title=ReportTitle.WORK_WEEKLY, + build_report=_build_work_weekly, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + ) + + +def _enqueue_report_push( + task_name: str, + report_type: str, + title: str, + build_report: ReportBuilder, + receive_id: str | None, + receive_id_type: str, + actor: str, ) -> 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, - } + return _queue_celery_report_push( + task_name=task_name, + report_type=report_type, + title=title, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + ) def inline() -> Any: from app.core.database import SessionLocal @@ -129,13 +140,14 @@ def enqueue_project_weekly_push( db = SessionLocal() try: - report = ReportService(db).project_weekly() - return ReportService(db).push_report(report, receive_id, receive_id_type, actor) + service = ReportService(db) + report = build_report(service, actor) + return service.push_report(report, receive_id, receive_id_type, actor) finally: db.close() return dispatch_task( - TASK_PUSH_PROJECT_WEEKLY, + task_name, { "receive_id": receive_id, "receive_id_type": receive_id_type, @@ -143,3 +155,79 @@ def enqueue_project_weekly_push( }, inline, ) + + +def _queue_celery_report_push( + task_name: str, + report_type: str, + title: str, + receive_id: str | None, + receive_id_type: str, + actor: str, +) -> dict[str, Any]: + from app.core.database import SessionLocal + from app.modules.reports.constants import ReportPushStatus + 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=report_type, + title=title, + receive_id=receive_id, + receive_id_type=receive_id_type, + actor=actor, + status=ReportPushStatus.QUEUED, + ) + async_result = celery_app.signature( + task_name, + 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_name, + "task_id": async_result.id, + "push_run_code": push_run.code, + } + + +def _build_daily_brief(service: Any, actor: str) -> dict[str, Any]: + _ = actor + return service.daily_brief() + + +def _build_project_weekly(service: Any, actor: str) -> dict[str, Any]: + _ = actor + return service.project_weekly() + + +def _build_attendance_summary(service: Any, actor: str) -> dict[str, Any]: + _ = actor + return service.attendance_summary() + + +def _build_risk_progress(service: Any, actor: str) -> dict[str, Any]: + _ = actor + return service.risk_progress() + + +def _build_work_daily(service: Any, actor: str) -> dict[str, Any]: + return service.work_daily_report(reporter=actor, actor=actor) + + +def _build_work_weekly(service: Any, actor: str) -> dict[str, Any]: + return service.work_weekly_report(reporter=actor, actor=actor) diff --git a/app/core/config/settings.py b/app/core/config/settings.py index 751e7e8..ca6dcc4 100644 --- a/app/core/config/settings.py +++ b/app/core/config/settings.py @@ -86,6 +86,15 @@ class Settings(BaseSettings): celery_result_backend_url: str | None = None daily_brief_cron_hour: int = 9 daily_brief_cron_minute: int = 0 + attendance_summary_cron_hour: int = 18 + attendance_summary_cron_minute: int = 0 + risk_progress_cron_hour: int = 17 + risk_progress_cron_minute: int = 30 + work_daily_cron_hour: int = 18 + work_daily_cron_minute: int = 30 + work_weekly_report_day_of_week: str = "fri" + work_weekly_cron_hour: int = 18 + work_weekly_cron_minute: int = 45 weekly_project_report_day_of_week: str = "mon" weekly_project_report_cron_hour: int = 9 weekly_project_report_cron_minute: int = 30 diff --git a/app/modules/feishu/commands.py b/app/modules/feishu/commands.py index 6b9f25f..c7f615b 100644 --- a/app/modules/feishu/commands.py +++ b/app/modules/feishu/commands.py @@ -22,21 +22,18 @@ from app.modules.feishu.constants import ( FeishuPayloadKey, FeishuReplyType, ) -from app.modules.reports.constants import ReportResponseKey -from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart from app.modules.feishu.service import FeishuService -from app.modules.reports.services import ReportService from app.modules.market.chart import render_market_chart from app.modules.market.service import MarketService -from app.modules.risk.constants import RiskSummaryKey -from app.modules.risk.services import RiskService +from app.modules.reports.chart import lifecycle_chart_alt, render_lifecycle_chart +from app.modules.reports.constants import ReportResponseKey +from app.modules.reports.services import ReportService DAILY_REPORT_KEYWORDS = ("日报", "晨报", "经营日报", "经营晨报") PROJECT_WEEKLY_KEYWORDS = ("周报", "项目周报") ATTENDANCE_KEYWORDS = ("打卡", "考勤", "attendance") RISK_KEYWORDS = ("风险", "预警", "risk") AI_COMMAND_PREFIXES = ("问 ", "ai ", "AI ", "/ask ") -RISK_TITLE = "风险预警" DEFAULT_AI_PROMPT = "请说明你能做什么。" RULE_TITLE = "AI 学习规则" RULE_CREATE_PATTERN = re.compile(r"^学习规则(?:\s+(\d{1,3}))?\s*[::]\s*(.*)$") @@ -246,31 +243,21 @@ class FeishuCommandService: ) if any(keyword in command_text for keyword in RISK_KEYWORDS): - summary = RiskService(self.db).summary() - lines = [ - f"- 综合风险等级:{summary[RiskSummaryKey.RISK_LEVEL]}", - f"- 风险分:{summary[RiskSummaryKey.RISK_SCORE]}", - f"- 逾期任务:{len(summary[RiskSummaryKey.OVERDUE_TASKS])}", - f"- 延期项目:{len(summary[RiskSummaryKey.DELAYED_PROJECTS])}", - f"- 超预算项目:{len(summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}", - f"- 资金风险账户:{len(summary[RiskSummaryKey.FUND_RISKS])}", - f"- 供应商风险:{len(summary[RiskSummaryKey.SUPPLIER_RISKS])}", - f"- 打开风险事件:{len(summary[RiskSummaryKey.OPEN_EVENTS])}", - ] + report = ReportService(self.db).risk_progress() if auto_reply: provider_response = self._send_card_if_configured( chat_id, - RISK_TITLE, - lines, + report[ReportResponseKey.TITLE], + report[ReportResponseKey.LINES], actor, ) return _command_result( FeishuCommandName.RISK_SUMMARY, FeishuReplyType.CARD, - RISK_TITLE, - "\n".join(lines), + report[ReportResponseKey.TITLE], + report[ReportResponseKey.CONTENT], provider_response, - lines, + report[ReportResponseKey.LINES], ) prompt = command_text diff --git a/app/modules/reports/constants.py b/app/modules/reports/constants.py index cb0e830..8a9a9ab 100644 --- a/app/modules/reports/constants.py +++ b/app/modules/reports/constants.py @@ -6,10 +6,20 @@ class ReportType(StrEnum): WEEKLY = "weekly" +class ReportPushType(StrEnum): + DAILY_BRIEF = "daily_brief" + PROJECT_WEEKLY = "project_weekly" + ATTENDANCE_SUMMARY = "attendance_summary" + RISK_PROGRESS = "risk_progress" + WORK_DAILY = "work_daily" + WORK_WEEKLY = "work_weekly" + + class ReportTitle(StrEnum): DAILY_BRIEF = "每日经营晨报" PROJECT_WEEKLY = "项目周报" ATTENDANCE_SUMMARY = "打卡汇总" + RISK_PROGRESS = "项目风险进度" WORK_DAILY = "经营日报" WORK_WEEKLY = "经营周报" PROJECT_LIFECYCLE = "项目全生命周期报告" diff --git a/app/modules/reports/routes.py b/app/modules/reports/routes.py index 2880447..f97b4e8 100644 --- a/app/modules/reports/routes.py +++ b/app/modules/reports/routes.py @@ -4,9 +4,13 @@ from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from app.core.background.task_queue import ( + enqueue_attendance_summary_push, enqueue_daily_brief_push, enqueue_lifecycle_report, enqueue_project_weekly_push, + enqueue_risk_progress_push, + enqueue_work_daily_push, + enqueue_work_weekly_push, ) from app.core.database import get_db from app.core.security import ApiPrincipal, require_api_key, require_operations_enabled @@ -36,6 +40,13 @@ def project_weekly( return ReportService(db).project_weekly() +@router.get("/risk-progress") +def risk_progress( + db: Session = Depends(get_db), +) -> dict: + return ReportService(db).risk_progress() + + @router.get("/project-lifecycle") def project_lifecycle_report( project_code: str | None = None, @@ -175,8 +186,9 @@ def push_daily_brief( db: Session = Depends(get_db), principal: ApiPrincipal = Depends(require_api_key), ) -> dict: - report = ReportService(db).daily_brief() - return ReportService(db).push_report( + service = ReportService(db) + report = service.daily_brief() + return service.push_report( report, payload.receive_id, payload.receive_id_type, @@ -190,8 +202,73 @@ def push_project_weekly( db: Session = Depends(get_db), principal: ApiPrincipal = Depends(require_api_key), ) -> dict: - report = ReportService(db).project_weekly() - return ReportService(db).push_report( + service = ReportService(db) + report = service.project_weekly() + return service.push_report( + report, + payload.receive_id, + payload.receive_id_type, + principal.actor, + ) + + +@router.post("/attendance-summary/push") +def push_attendance_summary( + payload: PushReportRequest, + db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), +) -> dict: + service = ReportService(db) + report = service.attendance_summary() + return service.push_report( + report, + payload.receive_id, + payload.receive_id_type, + principal.actor, + ) + + +@router.post("/risk-progress/push") +def push_risk_progress( + payload: PushReportRequest, + db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), +) -> dict: + service = ReportService(db) + report = service.risk_progress() + return service.push_report( + report, + payload.receive_id, + payload.receive_id_type, + principal.actor, + ) + + +@router.post("/work-daily/push") +def push_work_daily( + payload: PushReportRequest, + db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), +) -> dict: + service = ReportService(db) + report = service.work_daily_report(reporter=principal.actor, actor=principal.actor) + return service.push_report( + report, + payload.receive_id, + payload.receive_id_type, + principal.actor, + ) + + +@router.post("/work-weekly/push") +def push_work_weekly( + payload: PushReportRequest, + db: Session = Depends(get_db), + principal: ApiPrincipal = Depends(require_api_key), +) -> dict: + service = ReportService(db) + report = service.work_weekly_report(reporter=principal.actor, actor=principal.actor) + return service.push_report( report, payload.receive_id, payload.receive_id_type, @@ -221,3 +298,51 @@ def enqueue_project_weekly( receive_id_type=payload.receive_id_type, actor=principal.actor, ) + + +@router.post("/attendance-summary/enqueue") +def enqueue_attendance_summary( + payload: PushReportRequest, + principal: ApiPrincipal = Depends(require_api_key), +) -> dict: + return enqueue_attendance_summary_push( + receive_id=payload.receive_id, + receive_id_type=payload.receive_id_type, + actor=principal.actor, + ) + + +@router.post("/risk-progress/enqueue") +def enqueue_risk_progress( + payload: PushReportRequest, + principal: ApiPrincipal = Depends(require_api_key), +) -> dict: + return enqueue_risk_progress_push( + receive_id=payload.receive_id, + receive_id_type=payload.receive_id_type, + actor=principal.actor, + ) + + +@router.post("/work-daily/enqueue") +def enqueue_work_daily( + payload: PushReportRequest, + principal: ApiPrincipal = Depends(require_api_key), +) -> dict: + return enqueue_work_daily_push( + receive_id=payload.receive_id, + receive_id_type=payload.receive_id_type, + actor=principal.actor, + ) + + +@router.post("/work-weekly/enqueue") +def enqueue_work_weekly( + payload: PushReportRequest, + principal: ApiPrincipal = Depends(require_api_key), +) -> dict: + return enqueue_work_weekly_push( + receive_id=payload.receive_id, + receive_id_type=payload.receive_id_type, + actor=principal.actor, + ) diff --git a/app/modules/reports/services/summaries.py b/app/modules/reports/services/summaries.py index e930dc7..219c186 100644 --- a/app/modules/reports/services/summaries.py +++ b/app/modules/reports/services/summaries.py @@ -19,6 +19,7 @@ from app.modules.business.models import ( WorkTask, ) from app.modules.reports.constants import ( + ReportPushType, ReportResponseKey, ReportTitle, ) @@ -65,6 +66,7 @@ class ReportSummaryMixin: ] return { ReportResponseKey.TITLE: ReportTitle.DAILY_BRIEF, + ReportResponseKey.REPORT_TYPE: ReportPushType.DAILY_BRIEF, ReportResponseKey.LINES: lines, ReportResponseKey.CONTENT: "\n".join(lines), } @@ -93,6 +95,28 @@ class ReportSummaryMixin: lines.append(f" - 超预算:{item.get('code')} {item.get('name')}") return { ReportResponseKey.TITLE: ReportTitle.PROJECT_WEEKLY, + ReportResponseKey.REPORT_TYPE: ReportPushType.PROJECT_WEEKLY, + ReportResponseKey.LINES: lines, + ReportResponseKey.CONTENT: "\n".join(lines), + } + + def risk_progress(self) -> dict[str, Any]: + """Build a standalone project risk progress report.""" + + summary = self.risks.summary() + lines = [ + f"- 综合风险等级:{summary[RiskSummaryKey.RISK_LEVEL]}", + f"- 风险分:{summary[RiskSummaryKey.RISK_SCORE]}", + f"- 逾期任务:{len(summary[RiskSummaryKey.OVERDUE_TASKS])}", + f"- 延期项目:{len(summary[RiskSummaryKey.DELAYED_PROJECTS])}", + f"- 超预算项目:{len(summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}", + f"- 资金风险账户:{len(summary[RiskSummaryKey.FUND_RISKS])}", + f"- 供应商风险:{len(summary[RiskSummaryKey.SUPPLIER_RISKS])}", + f"- 打开风险事件:{len(summary[RiskSummaryKey.OPEN_EVENTS])}", + ] + return { + ReportResponseKey.TITLE: ReportTitle.RISK_PROGRESS, + ReportResponseKey.REPORT_TYPE: ReportPushType.RISK_PROGRESS, ReportResponseKey.LINES: lines, ReportResponseKey.CONTENT: "\n".join(lines), } @@ -122,6 +146,7 @@ class ReportSummaryMixin: lines.append(f"- {status}:{count}") return { ReportResponseKey.TITLE: ReportTitle.ATTENDANCE_SUMMARY, + ReportResponseKey.REPORT_TYPE: ReportPushType.ATTENDANCE_SUMMARY, ReportResponseKey.WORK_DATE: target_date.isoformat(), ReportResponseKey.TOTAL: total, ReportResponseKey.ABNORMAL_TOTAL: abnormal_total, diff --git a/app/modules/reports/services/work_reports.py b/app/modules/reports/services/work_reports.py index d91c845..788f335 100644 --- a/app/modules/reports/services/work_reports.py +++ b/app/modules/reports/services/work_reports.py @@ -30,6 +30,7 @@ from app.modules.events.constants import ( ) from app.modules.events.services import EventService from app.modules.reports.constants import ( + ReportPushType, ReportResponseKey, ReportTitle, ReportType, @@ -41,6 +42,34 @@ from app.modules.reports.services.common import _json_safe, _next_code class ReportWorkReportMixin: + def work_daily_report( + self, + reporter: str = ActorValue.SCHEDULER, + actor: str = ActorValue.SCHEDULER, + ) -> dict[str, Any]: + """Build a transient work daily report for Feishu delivery.""" + + return self._transient_work_report( + report_type=ReportType.DAILY, + push_type=ReportPushType.WORK_DAILY, + reporter=reporter, + actor=actor, + ) + + def work_weekly_report( + self, + reporter: str = ActorValue.SCHEDULER, + actor: str = ActorValue.SCHEDULER, + ) -> dict[str, Any]: + """Build a transient work weekly report for Feishu delivery.""" + + return self._transient_work_report( + report_type=ReportType.WEEKLY, + push_type=ReportPushType.WORK_WEEKLY, + reporter=reporter, + actor=actor, + ) + def generate_work_report( self, report_type: str = ReportType.DAILY, @@ -119,6 +148,23 @@ class ReportWorkReportMixin: return {ReportResponseKey.REPORT: report, ReportResponseKey.DATA: record_data} + def _transient_work_report( + self, + report_type: str, + push_type: str, + reporter: str, + actor: str, + ) -> dict[str, Any]: + result = self.generate_work_report( + report_type=report_type, + reporter=reporter, + persist=False, + actor=actor, + ) + report = dict(result[ReportResponseKey.REPORT]) + report[ReportResponseKey.REPORT_TYPE] = push_type + return report + def _resolve_period( self, report_type: str, diff --git a/app/tasks/reports.py b/app/tasks/reports.py index 4d6b456..6deea98 100644 --- a/app/tasks/reports.py +++ b/app/tasks/reports.py @@ -1,46 +1,102 @@ +from collections.abc import Callable from typing import Any +from app.core.background.task_queue.constants import ( + TASK_PUSH_ATTENDANCE_SUMMARY, + TASK_PUSH_DAILY_BRIEF, + TASK_PUSH_PROJECT_WEEKLY, + TASK_PUSH_RISK_PROGRESS, + TASK_PUSH_WORK_DAILY, + TASK_PUSH_WORK_WEEKLY, +) 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 +ReportBuilder = Callable[[Any, str], dict[str, Any]] -@celery_app.task(name="reports.push_daily_brief") + +@celery_app.task(name=TASK_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 + return _push_report(_build_daily_brief, receive_id, receive_id_type, actor, push_run_code) - 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") +@celery_app.task(name=TASK_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]: + return _push_report(_build_project_weekly, receive_id, receive_id_type, actor, push_run_code) + + +@celery_app.task(name=TASK_PUSH_ATTENDANCE_SUMMARY) +def push_attendance_summary( + 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]: + return _push_report( + _build_attendance_summary, + receive_id, + receive_id_type, + actor, + push_run_code, + ) + + +@celery_app.task(name=TASK_PUSH_RISK_PROGRESS) +def push_risk_progress( + 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]: + return _push_report(_build_risk_progress, receive_id, receive_id_type, actor, push_run_code) + + +@celery_app.task(name=TASK_PUSH_WORK_DAILY) +def push_work_daily( + 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]: + return _push_report(_build_work_daily, receive_id, receive_id_type, actor, push_run_code) + + +@celery_app.task(name=TASK_PUSH_WORK_WEEKLY) +def push_work_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]: + return _push_report(_build_work_weekly, receive_id, receive_id_type, actor, push_run_code) + + +def _push_report( + build_report: ReportBuilder, + receive_id: str | None, + receive_id_type: str, + actor: str, + push_run_code: str | None, ) -> dict[str, Any]: from app.modules.reports.services import ReportService db = SessionLocal() try: - report = ReportService(db).project_weekly() - return ReportService(db).push_report( + service = ReportService(db) + report = build_report(service, actor) + return service.push_report( report, receive_id, receive_id_type, @@ -49,3 +105,31 @@ def push_project_weekly( ) finally: db.close() + + +def _build_daily_brief(service: Any, actor: str) -> dict[str, Any]: + _ = actor + return service.daily_brief() + + +def _build_project_weekly(service: Any, actor: str) -> dict[str, Any]: + _ = actor + return service.project_weekly() + + +def _build_attendance_summary(service: Any, actor: str) -> dict[str, Any]: + _ = actor + return service.attendance_summary() + + +def _build_risk_progress(service: Any, actor: str) -> dict[str, Any]: + _ = actor + return service.risk_progress() + + +def _build_work_daily(service: Any, actor: str) -> dict[str, Any]: + return service.work_daily_report(reporter=actor, actor=actor) + + +def _build_work_weekly(service: Any, actor: str) -> dict[str, Any]: + return service.work_weekly_report(reporter=actor, actor=actor) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index a8935f4..97b8cbc 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -96,6 +96,8 @@ from app.modules.reports.constants import ( LifecycleSection, MetricKey, ReportPushStatus, + ReportPushType, + ReportResponseKey, ReportTitle, ReportType, ) @@ -773,6 +775,72 @@ def test_new_ledgers_reports_and_risk_events() -> None: assert any(item["code"] == "TASK-RISK-001" for item in overdue_response.json()["items"]) +def test_independent_feishu_report_schedules_and_tasks_are_registered() -> None: + from app.core.background.scheduler import create_scheduler + from app.core.background.task_queue.constants import ( + TASK_PUSH_ATTENDANCE_SUMMARY, + TASK_PUSH_DAILY_BRIEF, + TASK_PUSH_PROJECT_WEEKLY, + TASK_PUSH_RISK_PROGRESS, + TASK_PUSH_WORK_DAILY, + TASK_PUSH_WORK_WEEKLY, + ) + from app.tasks import celery_app + + scheduler = create_scheduler() + job_ids = {job.id for job in scheduler.get_jobs()} + assert { + "daily_brief_push", + "project_weekly_push", + "attendance_summary_push", + "risk_progress_push", + "work_daily_push", + "work_weekly_push", + } <= job_ids + + task_names = { + TASK_PUSH_DAILY_BRIEF, + TASK_PUSH_PROJECT_WEEKLY, + TASK_PUSH_ATTENDANCE_SUMMARY, + TASK_PUSH_RISK_PROGRESS, + TASK_PUSH_WORK_DAILY, + TASK_PUSH_WORK_WEEKLY, + } + assert task_names <= set(celery_app.tasks) + + +def test_independent_feishu_report_surfaces_are_available() -> None: + from app.modules.reports.services import ReportService + + route_paths = {route.path for route in app.routes} + assert { + "/api/v1/reports/attendance-summary/push", + "/api/v1/reports/attendance-summary/enqueue", + "/api/v1/reports/risk-progress/push", + "/api/v1/reports/risk-progress/enqueue", + "/api/v1/reports/work-daily/push", + "/api/v1/reports/work-daily/enqueue", + "/api/v1/reports/work-weekly/push", + "/api/v1/reports/work-weekly/enqueue", + } <= route_paths + + risk_response = client.get("/api/v1/reports/risk-progress", headers=headers) + assert risk_response.status_code == 200 + assert risk_response.json()["title"] == ReportTitle.RISK_PROGRESS + assert risk_response.json()["report_type"] == ReportPushType.RISK_PROGRESS + + db = SessionLocal() + try: + service = ReportService(db) + work_daily = service.work_daily_report(reporter="pytest", actor="pytest") + work_weekly = service.work_weekly_report(reporter="pytest", actor="pytest") + finally: + db.close() + + assert work_daily[ReportResponseKey.REPORT_TYPE] == ReportPushType.WORK_DAILY + assert work_weekly[ReportResponseKey.REPORT_TYPE] == ReportPushType.WORK_WEEKLY + + def test_project_lifecycle_report_summarizes_progress_cost_and_risk() -> None: today = date.today() project_code = "P-LIFECYCLE-001"