from datetime import date, datetime, timedelta from decimal import Decimal from typing import Any from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.core.constants import ActorValue from app.core.pagination import bounded_limit from app.core.time import utc_now from app.modules.audit.constants import AuditAction, AuditSource, AuditTargetType from app.modules.audit.schemas import AuditLogCreate from app.modules.audit.service import AuditService from app.modules.business.constants import ( ATTENDANCE_ABNORMAL_STATUSES, DONE_STATUSES, GENERATED_RISK_EVENT_TYPES, PROJECT_CLOSED_STATUSES, PENDING_APPROVAL_STATUSES, SUPPLIER_RISK_LEVELS, RiskLevel, StatusValue, ) from app.modules.business.models import ( AttendanceRecord, Expense, FundAccount, Procurement, Project, ReportPushRun, RiskEvent, Supplier, WorkReport, WorkTask, ) from app.modules.business.service import serialize_model from app.modules.feishu.service import FeishuService from app.modules.reports.constants import ( ATTENTION_SCORE_THRESHOLD, HEALTH_PENALTY_WEIGHTS, HEALTH_SCORE_MAX, HEALTH_SCORE_MIN, HEALTHY_SCORE_THRESHOLD, LIFECYCLE_RISK_SCORE_WEIGHTS, HealthLevel, LifecycleAttentionKey, LifecycleFilterKey, LifecycleResponseKey, LifecycleSection, MetricKey, ReportResponseKey, ReportPushStatus, ReportStatus, ReportText, ReportTitle, ReportType, WorkReportMetricKey, ) from app.modules.risk.constants import RiskSummaryKey, risk_level_for_score from app.modules.risk.service import RiskService def _money(value: Decimal | int | float | None) -> str: """Format a numeric value as a two-decimal money string.""" amount = Decimal(value or 0) return f"{amount:,.2f}" def _json_safe(value: Any) -> Any: """Convert nested report payloads into JSON-storable values.""" if isinstance(value, Decimal): return float(value) if isinstance(value, (datetime, date)): return value.isoformat() if isinstance(value, list): return [_json_safe(item) for item in value] if isinstance(value, dict): return {key: _json_safe(item) for key, item in value.items()} return value def _next_code(prefix: str) -> str: """Build a compact unique code for generated report records.""" return f"{prefix}-{utc_now():%Y%m%d%H%M%S%f}" def _rate(numerator: int | Decimal, denominator: int | Decimal) -> float: """Return a rounded percentage rate, using zero when the denominator is empty.""" if not denominator: return 0.0 return round(float(numerator) / float(denominator) * 100, 2) def _as_decimal(value: Decimal | int | float | None) -> Decimal: return Decimal(str(value or 0)) class ReportService: """Build operational reports and push them through Feishu.""" def __init__(self, db: Session): self.db = db self.risks = RiskService(db) def _count(self, model: type, *conditions: Any) -> int: stmt = select(func.count()).select_from(model) for condition in conditions: stmt = stmt.where(condition) return int(self.db.execute(stmt).scalar() or 0) def _sum(self, column: Any, *conditions: Any) -> Decimal: stmt = select(func.sum(column)) for condition in conditions: stmt = stmt.where(condition) return _as_decimal(self.db.execute(stmt).scalar()) def _avg(self, column: Any, *conditions: Any) -> float: stmt = select(func.avg(column)) for condition in conditions: stmt = stmt.where(condition) value = self.db.execute(stmt).scalar() return round(float(value or 0), 2) def _group_counts(self, model: type, column: Any, *conditions: Any) -> dict[str, int]: stmt = select(column, func.count()).select_from(model) for condition in conditions: stmt = stmt.where(condition) stmt = stmt.group_by(column) return { str(key or ReportStatus.UNKNOWN): int(count) for key, count in self.db.execute(stmt).all() } def _records( self, model: type, *conditions: Any, limit: int = 10, order_by: Any | None = None, ) -> list[Any]: stmt = select(model) for condition in conditions: stmt = stmt.where(condition) if order_by is not None: stmt = stmt.order_by(order_by) else: stmt = stmt.order_by(model.id.desc()) return list(self.db.execute(stmt.limit(bounded_limit(limit))).scalars()) def create_push_run( self, report_type: str, title: str | None, receive_id: str | None, receive_id_type: str, actor: str, status: str = ReportPushStatus.PENDING, ) -> ReportPushRun: record = ReportPushRun( code=_next_code("PUSH"), report_type=report_type, title=title, receive_id=receive_id, receive_id_type=receive_id_type, status=status, actor=actor, queued_at=utc_now(), ) self.db.add(record) self.db.commit() self.db.refresh(record) return record def update_push_run( self, code: str, status: str, task_id: str | None = None, provider_response: dict[str, Any] | None = None, error_message: str | None = None, sent: bool = False, ) -> ReportPushRun: record = self._get_push_run(code) record.status = status if task_id is not None: record.task_id = task_id if provider_response is not None: record.provider_response = _json_safe(provider_response) record.error_message = error_message if sent: record.sent_at = utc_now() self.db.commit() self.db.refresh(record) return record def list_push_runs( self, status_filter: str | None = None, limit: int = 100, ) -> list[dict[str, Any]]: stmt = select(ReportPushRun).order_by(ReportPushRun.id.desc()).limit( bounded_limit(limit) ) if status_filter: stmt = ( select(ReportPushRun) .where(ReportPushRun.status == status_filter) .order_by(ReportPushRun.id.desc()) .limit(bounded_limit(limit)) ) return [serialize_model(item) for item in self.db.execute(stmt).scalars()] def get_push_run(self, code: str) -> dict[str, Any]: return serialize_model(self._get_push_run(code)) def _get_push_run(self, code: str) -> ReportPushRun: from fastapi import HTTPException, status record = self.db.execute( select(ReportPushRun).where(ReportPushRun.code == code) ).scalar_one_or_none() if record is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Report push run not found", ) return record def daily_brief(self) -> dict: project_count = self._count(Project) task_count = self._count(WorkTask) procurement_pending = self._count( Procurement, Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), ) expense_pending = self._count( Expense, Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), ) fund_total = ( self.db.execute(select(func.sum(FundAccount.current_balance))).scalar() or Decimal("0") ) risk_summary = self.risks.summary() attendance = self.attendance_summary() lines = [ f"- 项目总数:{project_count}", f"- 任务总数:{task_count}", f"- 待处理采购:{procurement_pending}", f"- 待处理费用:{expense_pending}", f"- 当前账户总余额:{_money(fund_total)}", ( f"- 今日打卡记录:{attendance[ReportResponseKey.TOTAL]}," f"异常:{attendance[ReportResponseKey.ABNORMAL_TOTAL]}" ), f"- 逾期任务:{len(risk_summary[RiskSummaryKey.OVERDUE_TASKS])}", f"- 延期项目:{len(risk_summary[RiskSummaryKey.DELAYED_PROJECTS])}", f"- 超预算项目:{len(risk_summary[RiskSummaryKey.OVER_BUDGET_PROJECTS])}", f"- 资金风险账户:{len(risk_summary[RiskSummaryKey.FUND_RISKS])}", f"- 供应商风险:{len(risk_summary[RiskSummaryKey.SUPPLIER_RISKS])}", f"- 打开风险事件:{len(risk_summary[RiskSummaryKey.OPEN_EVENTS])}", f"- 综合风险等级:{risk_summary[RiskSummaryKey.RISK_LEVEL]}", ] return { ReportResponseKey.TITLE: ReportTitle.DAILY_BRIEF, ReportResponseKey.LINES: lines, ReportResponseKey.CONTENT: "\n".join(lines), } def project_weekly(self) -> dict: active = self._count( Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), ) delayed = self.risks.delayed_projects() over_budget = self.risks.over_budget_projects() open_risks = self.risks.list_events(status_filter=StatusValue.OPEN) lines = [ f"- 活跃项目:{active}", f"- 延期项目:{len(delayed)}", f"- 超预算项目:{len(over_budget)}", f"- 打开风险事件:{len(open_risks)}", "- 需要管理层关注:", ] for item in delayed[:10]: lines.append( f" - 延期:{item.get('code')} {item.get('name')}," f"负责人 {item.get('owner')}" ) for item in over_budget[:10]: lines.append(f" - 超预算:{item.get('code')} {item.get('name')}") return { ReportResponseKey.TITLE: ReportTitle.PROJECT_WEEKLY, ReportResponseKey.LINES: lines, ReportResponseKey.CONTENT: "\n".join(lines), } def project_lifecycle_report( self, project_code: str | None = None, owner: str | None = None, period_start: date | None = None, period_end: date | None = None, include_ai: bool = False, actor: str = ActorValue.API, ) -> dict[str, Any]: """Build a full lifecycle report for project progress, delivery, cost, and risk.""" filters = self._lifecycle_filters(project_code, owner, period_start, period_end) project_conditions = filters[LifecycleSection.PROJECTS] task_conditions = filters[LifecycleSection.TASKS] procurement_conditions = filters[LifecycleSection.PROCUREMENTS] expense_conditions = filters[LifecycleSection.EXPENSES] attendance_conditions = filters[LifecycleSection.ATTENDANCE] risk_conditions = filters[LifecycleSection.RISKS] project_stats = self._lifecycle_project_stats(project_conditions) task_stats = self._lifecycle_task_stats(task_conditions) procurement_stats = self._lifecycle_procurement_stats(procurement_conditions) expense_stats = self._lifecycle_expense_stats(expense_conditions) fund_stats = self._lifecycle_fund_stats() supplier_stats = self._lifecycle_supplier_stats() attendance_stats = self._lifecycle_attendance_stats(attendance_conditions) risk_stats = self._lifecycle_risk_stats( project_conditions, task_conditions, risk_conditions, ) include_global_risk = not (project_code or owner) health = self._lifecycle_health( project_stats, task_stats, risk_stats, supplier_stats, include_global_risk, ) attention = self._lifecycle_attention(project_conditions, task_conditions) recommendations = self._lifecycle_recommendations( project_stats, task_stats, procurement_stats, expense_stats, fund_stats, supplier_stats, risk_stats, include_global_risk, ) metrics = { LifecycleSection.HEALTH: health, LifecycleSection.PROJECTS: project_stats, LifecycleSection.TASKS: task_stats, LifecycleSection.PROCUREMENTS: procurement_stats, LifecycleSection.EXPENSES: expense_stats, LifecycleSection.FUNDS: fund_stats, LifecycleSection.SUPPLIERS: supplier_stats, LifecycleSection.ATTENDANCE: attendance_stats, LifecycleSection.RISKS: risk_stats, } lines = self._lifecycle_lines( filters[LifecycleFilterKey.LABELS], metrics, recommendations, ) report = { LifecycleResponseKey.TITLE: ReportTitle.PROJECT_LIFECYCLE, LifecycleResponseKey.FILTERS: filters[LifecycleFilterKey.LABELS], LifecycleResponseKey.METRICS: _json_safe(metrics), LifecycleResponseKey.ATTENTION: _json_safe(attention), LifecycleResponseKey.RECOMMENDATIONS: recommendations, LifecycleResponseKey.LINES: lines, LifecycleResponseKey.CONTENT: "\n".join(lines), } if include_ai: report[LifecycleResponseKey.AI_ANALYSIS] = self._lifecycle_ai_analysis(report, actor) return report def _lifecycle_filters( self, project_code: str | None, owner: str | None, period_start: date | None, period_end: date | None, ) -> dict[str, Any]: project_conditions: list[Any] = [] task_conditions: list[Any] = [] procurement_conditions: list[Any] = [] expense_conditions: list[Any] = [] attendance_conditions: list[Any] = [] risk_conditions: list[Any] = [] labels = { LifecycleFilterKey.PROJECT_CODE: project_code, LifecycleFilterKey.OWNER: owner, LifecycleFilterKey.PERIOD_START: period_start.isoformat() if period_start else None, LifecycleFilterKey.PERIOD_END: period_end.isoformat() if period_end else None, } if project_code: project_conditions.append(Project.code == project_code) task_conditions.append(WorkTask.project_code == project_code) procurement_conditions.append(Procurement.project_code == project_code) expense_conditions.append(Expense.project_code == project_code) attendance_conditions.append(AttendanceRecord.project_code == project_code) risk_conditions.append(RiskEvent.project_code == project_code) if owner: project_conditions.append(Project.owner == owner) task_conditions.append(WorkTask.owner == owner) risk_conditions.append(RiskEvent.owner == owner) if period_start: project_conditions.append( or_(Project.due_date.is_(None), Project.due_date >= period_start) ) task_conditions.append(WorkTask.due_date >= period_start) attendance_conditions.append(AttendanceRecord.work_date >= period_start) risk_conditions.append( RiskEvent.detected_at >= datetime.combine(period_start, datetime.min.time()) ) if period_end: project_conditions.append( or_(Project.start_date.is_(None), Project.start_date <= period_end) ) task_conditions.append(WorkTask.due_date <= period_end) attendance_conditions.append(AttendanceRecord.work_date <= period_end) risk_conditions.append( RiskEvent.detected_at <= datetime.combine(period_end, datetime.max.time()) ) if period_start: start_at = datetime.combine(period_start, datetime.min.time()) procurement_conditions.append(Procurement.created_at >= start_at) expense_conditions.append(Expense.created_at >= start_at) if period_end: end_at = datetime.combine(period_end, datetime.max.time()) procurement_conditions.append(Procurement.created_at <= end_at) expense_conditions.append(Expense.created_at <= end_at) return { LifecycleFilterKey.LABELS: labels, LifecycleSection.PROJECTS: project_conditions, LifecycleSection.TASKS: task_conditions, LifecycleSection.PROCUREMENTS: procurement_conditions, LifecycleSection.EXPENSES: expense_conditions, LifecycleSection.ATTENDANCE: attendance_conditions, LifecycleSection.RISKS: risk_conditions, } def _lifecycle_project_stats(self, conditions: list[Any]) -> dict[str, Any]: total = self._count(Project, *conditions) active = self._count(Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), *conditions) closed = total - active budget_total = self._sum(Project.budget_amount, *conditions) actual_total = self._sum(Project.actual_amount, *conditions) delayed = self._count( Project, Project.due_date.is_not(None), Project.due_date < date.today(), Project.status.notin_(PROJECT_CLOSED_STATUSES), *conditions, ) over_budget = self._count( Project, Project.budget_amount > 0, Project.actual_amount > Project.budget_amount, *conditions, ) return { MetricKey.TOTAL: total, MetricKey.ACTIVE: active, MetricKey.CLOSED: closed, MetricKey.AVERAGE_PROGRESS_PERCENT: self._avg( Project.progress_percent, *conditions, ), MetricKey.BY_STATUS: self._group_counts(Project, Project.status, *conditions), MetricKey.BY_RISK_LEVEL: self._group_counts(Project, Project.risk_level, *conditions), MetricKey.BUDGET_TOTAL: budget_total, MetricKey.ACTUAL_TOTAL: actual_total, MetricKey.BUDGET_USAGE_RATE: _rate(actual_total, budget_total), MetricKey.DELAYED: delayed, MetricKey.OVER_BUDGET: over_budget, } def _lifecycle_task_stats(self, conditions: list[Any]) -> dict[str, Any]: total = self._count(WorkTask, *conditions) completed = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *conditions) overdue = self._count( WorkTask, WorkTask.due_date.is_not(None), WorkTask.due_date < date.today(), WorkTask.status.notin_(DONE_STATUSES), *conditions, ) blocked = self._count( WorkTask, WorkTask.blocker.is_not(None), WorkTask.status.notin_(DONE_STATUSES), *conditions, ) return { MetricKey.TOTAL: total, MetricKey.COMPLETED: completed, MetricKey.OPEN: total - completed, MetricKey.OVERDUE: overdue, MetricKey.BLOCKED: blocked, MetricKey.COMPLETION_RATE: _rate(completed, total), MetricKey.BY_STATUS: self._group_counts(WorkTask, WorkTask.status, *conditions), MetricKey.BY_PRIORITY: self._group_counts(WorkTask, WorkTask.priority, *conditions), } def _lifecycle_procurement_stats(self, conditions: list[Any]) -> dict[str, Any]: total = self._count(Procurement, *conditions) pending_approval = self._count( Procurement, Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), *conditions, ) pending_delivery = self._count( Procurement, Procurement.delivery_status.notin_(DONE_STATUSES), *conditions, ) unpaid = self._count( Procurement, Procurement.payment_status != StatusValue.PAID, *conditions, ) expected_total = self._sum(Procurement.expected_amount, *conditions) actual_total = self._sum(Procurement.actual_amount, *conditions) return { MetricKey.TOTAL: total, MetricKey.PENDING_APPROVAL: pending_approval, MetricKey.PENDING_DELIVERY: pending_delivery, MetricKey.UNPAID: unpaid, MetricKey.EXPECTED_TOTAL: expected_total, MetricKey.ACTUAL_TOTAL: actual_total, MetricKey.ACTUAL_VS_EXPECTED_RATE: _rate(actual_total, expected_total), } def _lifecycle_expense_stats(self, conditions: list[Any]) -> dict[str, Any]: total = self._count(Expense, *conditions) pending_approval = self._count( Expense, Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), *conditions, ) unpaid = self._count(Expense, Expense.payment_status != StatusValue.PAID, *conditions) return { MetricKey.TOTAL: total, MetricKey.PENDING_APPROVAL: pending_approval, MetricKey.UNPAID: unpaid, MetricKey.AMOUNT_TOTAL: self._sum(Expense.amount, *conditions), MetricKey.BY_TYPE: self._group_counts(Expense, Expense.expense_type, *conditions), } def _lifecycle_fund_stats(self) -> dict[str, Any]: balance = self._sum(FundAccount.current_balance) receivable = self._sum(FundAccount.expected_receivable) payable = self._sum(FundAccount.expected_payable) safety_line = self._sum(FundAccount.safety_line) risk_accounts = self._count( FundAccount, FundAccount.current_balance < FundAccount.safety_line, ) return { MetricKey.ACCOUNTS_TOTAL: self._count(FundAccount), MetricKey.CURRENT_BALANCE_TOTAL: balance, MetricKey.EXPECTED_RECEIVABLE_TOTAL: receivable, MetricKey.EXPECTED_PAYABLE_TOTAL: payable, MetricKey.SAFETY_LINE_TOTAL: safety_line, MetricKey.NET_POSITION: balance + receivable - payable, MetricKey.RISK_ACCOUNTS: risk_accounts, } def _lifecycle_supplier_stats(self) -> dict[str, Any]: risky = self._count( Supplier, (Supplier.blacklist_status != StatusValue.NORMAL) | Supplier.risk_level.in_(SUPPLIER_RISK_LEVELS), ) blacklisted = self._count(Supplier, Supplier.blacklist_status != StatusValue.NORMAL) return { MetricKey.TOTAL: self._count(Supplier), MetricKey.RISKY: risky, MetricKey.BLACKLISTED: blacklisted, MetricKey.BY_RISK_LEVEL: self._group_counts(Supplier, Supplier.risk_level), } def _lifecycle_attendance_stats(self, conditions: list[Any]) -> dict[str, Any]: total = self._count(AttendanceRecord, *conditions) abnormal = self._count( AttendanceRecord, AttendanceRecord.status.in_(ATTENDANCE_ABNORMAL_STATUSES), *conditions, ) return { MetricKey.TOTAL: total, MetricKey.ABNORMAL: abnormal, MetricKey.ABNORMAL_RATE: _rate(abnormal, total), MetricKey.BY_STATUS: self._group_counts( AttendanceRecord, AttendanceRecord.status, *conditions, ), } def _lifecycle_risk_stats( self, project_conditions: list[Any], task_conditions: list[Any], risk_conditions: list[Any], ) -> dict[str, Any]: overdue_tasks = self._count( WorkTask, WorkTask.due_date.is_not(None), WorkTask.due_date < date.today(), WorkTask.status.notin_(DONE_STATUSES), *task_conditions, ) delayed_projects = self._count( Project, Project.due_date.is_not(None), Project.due_date < date.today(), Project.status.notin_(PROJECT_CLOSED_STATUSES), *project_conditions, ) over_budget_projects = self._count( Project, Project.budget_amount > 0, Project.actual_amount > Project.budget_amount, *project_conditions, ) open_events = self._count( RiskEvent, RiskEvent.status == StatusValue.OPEN, *risk_conditions, ) high_events = self._count( RiskEvent, RiskEvent.status == StatusValue.OPEN, RiskEvent.risk_level == RiskLevel.HIGH, *risk_conditions, ) external_open_events = self._count( RiskEvent, RiskEvent.status == StatusValue.OPEN, RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES), *risk_conditions, ) external_high_events = self._count( RiskEvent, RiskEvent.status == StatusValue.OPEN, RiskEvent.risk_level == RiskLevel.HIGH, RiskEvent.risk_type.notin_(GENERATED_RISK_EVENT_TYPES), *risk_conditions, ) risk_score = ( overdue_tasks * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVERDUE_TASKS] + delayed_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.DELAYED_PROJECTS] + over_budget_projects * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS] + external_open_events * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.EXTERNAL_OPEN_EVENTS] + external_high_events * LIFECYCLE_RISK_SCORE_WEIGHTS[MetricKey.EXTERNAL_HIGH_EVENTS] ) return { MetricKey.RISK_LEVEL: risk_level_for_score(risk_score), MetricKey.RISK_SCORE: risk_score, MetricKey.OVERDUE_TASKS: overdue_tasks, MetricKey.DELAYED_PROJECTS: delayed_projects, MetricKey.OVER_BUDGET_PROJECTS: over_budget_projects, MetricKey.OPEN_EVENTS: open_events, MetricKey.HIGH_EVENTS: high_events, MetricKey.EXTERNAL_OPEN_EVENTS: external_open_events, MetricKey.EXTERNAL_HIGH_EVENTS: external_high_events, MetricKey.EVENTS_BY_TYPE: self._group_counts( RiskEvent, RiskEvent.risk_type, *risk_conditions, ), MetricKey.EVENTS_BY_LEVEL: self._group_counts( RiskEvent, RiskEvent.risk_level, *risk_conditions, ), } def _lifecycle_health( self, projects: dict[str, Any], tasks: dict[str, Any], risks: dict[str, Any], suppliers: dict[str, Any], include_global_risk: bool, ) -> dict[str, Any]: penalty = ( risks[MetricKey.OVERDUE_TASKS] * HEALTH_PENALTY_WEIGHTS[MetricKey.OVERDUE_TASKS] + risks[MetricKey.DELAYED_PROJECTS] * HEALTH_PENALTY_WEIGHTS[MetricKey.DELAYED_PROJECTS] + risks[MetricKey.OVER_BUDGET_PROJECTS] * HEALTH_PENALTY_WEIGHTS[MetricKey.OVER_BUDGET_PROJECTS] + risks[MetricKey.EXTERNAL_HIGH_EVENTS] * HEALTH_PENALTY_WEIGHTS[MetricKey.EXTERNAL_HIGH_EVENTS] + max( HEALTH_SCORE_MIN, projects[MetricKey.BUDGET_USAGE_RATE] - HEALTH_SCORE_MAX, ) * HEALTH_PENALTY_WEIGHTS[MetricKey.BUDGET_USAGE_RATE] + (HEALTH_SCORE_MAX - tasks[MetricKey.COMPLETION_RATE]) * HEALTH_PENALTY_WEIGHTS[MetricKey.COMPLETION_RATE] ) if include_global_risk: penalty += suppliers[MetricKey.BLACKLISTED] * HEALTH_PENALTY_WEIGHTS[ MetricKey.BLACKLISTED ] score = max( HEALTH_SCORE_MIN, min(HEALTH_SCORE_MAX, round(HEALTH_SCORE_MAX - penalty, 2)), ) if score >= HEALTHY_SCORE_THRESHOLD: level = HealthLevel.HEALTHY elif score >= ATTENTION_SCORE_THRESHOLD: level = HealthLevel.ATTENTION else: level = HealthLevel.CRITICAL return {MetricKey.SCORE: score, MetricKey.LEVEL: level} def _lifecycle_attention( self, project_conditions: list[Any], task_conditions: list[Any], ) -> dict[str, Any]: delayed = self._records( Project, Project.due_date.is_not(None), Project.due_date < date.today(), Project.status.notin_(PROJECT_CLOSED_STATUSES), *project_conditions, limit=10, order_by=Project.due_date.asc(), ) over_budget = self._records( Project, Project.budget_amount > 0, Project.actual_amount > Project.budget_amount, *project_conditions, limit=10, order_by=Project.id.desc(), ) overdue_tasks = self._records( WorkTask, WorkTask.due_date.is_not(None), WorkTask.due_date < date.today(), WorkTask.status.notin_(DONE_STATUSES), *task_conditions, limit=10, order_by=WorkTask.due_date.asc(), ) return { LifecycleAttentionKey.DELAYED_PROJECTS: [serialize_model(item) for item in delayed], LifecycleAttentionKey.OVER_BUDGET_PROJECTS: [ serialize_model(item) for item in over_budget ], LifecycleAttentionKey.OVERDUE_TASKS: [ serialize_model(item) for item in overdue_tasks ], } def _lifecycle_recommendations( self, projects: dict[str, Any], tasks: dict[str, Any], procurements: dict[str, Any], expenses: dict[str, Any], funds: dict[str, Any], suppliers: dict[str, Any], risks: dict[str, Any], include_global_risk: bool, ) -> list[str]: recommendations: list[str] = [] if risks[MetricKey.DELAYED_PROJECTS]: recommendations.append(ReportText.RECOMMEND_DELAYED) if risks[MetricKey.OVER_BUDGET_PROJECTS]: recommendations.append(ReportText.RECOMMEND_OVER_BUDGET) if tasks[MetricKey.OVERDUE]: recommendations.append(ReportText.RECOMMEND_OVERDUE_TASKS) if ( procurements[MetricKey.PENDING_APPROVAL] or expenses[MetricKey.PENDING_APPROVAL] ): recommendations.append(ReportText.RECOMMEND_APPROVALS) if include_global_risk and funds[MetricKey.RISK_ACCOUNTS]: recommendations.append(ReportText.RECOMMEND_FUNDS) if include_global_risk and suppliers[MetricKey.RISKY]: recommendations.append(ReportText.RECOMMEND_SUPPLIERS) if not recommendations: recommendations.append(ReportText.RECOMMEND_STABLE) return [str(item) for item in recommendations] def _lifecycle_lines( self, filters: dict[str, Any], metrics: dict[str, Any], recommendations: list[str], ) -> list[str]: scope = ( "、".join(f"{key}={value}" for key, value in filters.items() if value) or ReportText.DEFAULT_SCOPE ) projects = metrics[LifecycleSection.PROJECTS] tasks = metrics[LifecycleSection.TASKS] procurements = metrics[LifecycleSection.PROCUREMENTS] expenses = metrics[LifecycleSection.EXPENSES] funds = metrics[LifecycleSection.FUNDS] suppliers = metrics[LifecycleSection.SUPPLIERS] attendance = metrics[LifecycleSection.ATTENDANCE] risks = metrics[LifecycleSection.RISKS] health = metrics[LifecycleSection.HEALTH] lines = [ f"- 范围:{scope}", f"- 生命周期健康分:{health[MetricKey.SCORE]}({health[MetricKey.LEVEL]})", ( f"- 项目:总数 {projects[MetricKey.TOTAL]}," f"活跃 {projects[MetricKey.ACTIVE]}," f"平均进度 {projects[MetricKey.AVERAGE_PROGRESS_PERCENT]}%" ), ( f"- 成本:预算 {_money(projects[MetricKey.BUDGET_TOTAL])}," f"实际 {_money(projects[MetricKey.ACTUAL_TOTAL])}," f"预算使用率 {projects[MetricKey.BUDGET_USAGE_RATE]}%" ), ( f"- 任务:总数 {tasks[MetricKey.TOTAL]}," f"完成 {tasks[MetricKey.COMPLETED]}," f"完成率 {tasks[MetricKey.COMPLETION_RATE]}%," f"逾期 {tasks[MetricKey.OVERDUE]}" ), ( f"- 采购/费用:待批采购 {procurements[MetricKey.PENDING_APPROVAL]}," f"待批费用 {expenses[MetricKey.PENDING_APPROVAL]}," f"未付款采购 {procurements[MetricKey.UNPAID]}" ), ( f"- 资金:余额 {_money(funds[MetricKey.CURRENT_BALANCE_TOTAL])}," f"净头寸 {_money(funds[MetricKey.NET_POSITION])}," f"风险账户 {funds[MetricKey.RISK_ACCOUNTS]}" ), ( f"- 风险:等级 {risks[MetricKey.RISK_LEVEL]}," f"风险分 {risks[MetricKey.RISK_SCORE]}," f"延期项目 {risks[MetricKey.DELAYED_PROJECTS]}," f"超预算项目 {risks[MetricKey.OVER_BUDGET_PROJECTS]}," f"打开事件 {risks[MetricKey.OPEN_EVENTS]}" ), ( f"- 供应商/考勤:风险供应商 {suppliers[MetricKey.RISKY]}," f"异常打卡 {attendance[MetricKey.ABNORMAL]}," f"异常率 {attendance[MetricKey.ABNORMAL_RATE]}%" ), ReportText.ACTION_HEADER, ] lines.extend(f" - {item}" for item in recommendations) return lines def _lifecycle_ai_analysis(self, report: dict[str, Any], actor: str) -> dict[str, Any]: try: from app.modules.ai_agent.constants import AIResponseKey from app.modules.ai_agent.skills import AISkillId from app.modules.ai_agent.service import AIService report_snapshot = _json_safe(report) result = AIService(self.db).run_skill( AISkillId.PROJECT_LIFECYCLE_ANALYSIS, context={LifecycleResponseKey.PROJECT_LIFECYCLE_REPORT: report_snapshot}, actor=actor, ) except Exception as exc: return { AIResponseKey.OK: False, AIResponseKey.ERROR: str(exc), AIResponseKey.TYPE: type(exc).__name__, } return _json_safe({AIResponseKey.OK: True, **result}) def attendance_summary(self, work_date: date | None = None) -> dict[str, Any]: """Summarize attendance records for one business day.""" target_date = work_date or date.today() rows = self.db.execute( select(AttendanceRecord.status, func.count()) .where(AttendanceRecord.work_date == target_date) .group_by(AttendanceRecord.status) ).all() status_counts = {str(status): int(count) for status, count in rows} abnormal_total = sum( count for status, count in status_counts.items() if status in ATTENDANCE_ABNORMAL_STATUSES ) total = sum(status_counts.values()) lines = [ f"- 日期:{target_date.isoformat()}", f"- 打卡记录:{total}", f"- 异常记录:{abnormal_total}", ] for status, count in sorted(status_counts.items()): lines.append(f"- {status}:{count}") return { ReportResponseKey.TITLE: ReportTitle.ATTENDANCE_SUMMARY, ReportResponseKey.WORK_DATE: target_date.isoformat(), ReportResponseKey.TOTAL: total, ReportResponseKey.ABNORMAL_TOTAL: abnormal_total, ReportResponseKey.STATUS_COUNTS: status_counts, ReportResponseKey.LINES: lines, ReportResponseKey.CONTENT: "\n".join(lines), } def generate_work_report( self, report_type: str = ReportType.DAILY, reporter: str = ActorValue.SYSTEM, department: str | None = None, project_code: str | None = None, period_start: date | None = None, period_end: date | None = None, persist: bool = True, actor: str = ActorValue.API, ) -> dict[str, Any]: """Generate a daily or weekly operating report, optionally persisting it.""" start, end = self._resolve_period(report_type, period_start, period_end) metrics = self._report_metrics(start, end, project_code, department) risk_summary = _json_safe(self.risks.summary()) title = ( ReportTitle.WORK_DAILY if report_type == ReportType.DAILY else ReportTitle.WORK_WEEKLY ) lines = self._work_report_lines(title, start, end, metrics, risk_summary) report = { ReportResponseKey.TITLE: title, ReportResponseKey.REPORT_TYPE: report_type, ReportResponseKey.PERIOD_START: start.isoformat(), ReportResponseKey.PERIOD_END: end.isoformat(), ReportResponseKey.LINES: lines, ReportResponseKey.CONTENT: "\n".join(lines), ReportResponseKey.METRICS: metrics, ReportResponseKey.RISK_SUMMARY: risk_summary, } record_data = None if persist: record = WorkReport( code=_next_code(f"REPORT-{report_type.upper()}"), report_type=report_type, title=title, reporter=reporter, department=department, project_code=project_code, period_start=start, period_end=end, content=report[ReportResponseKey.CONTENT], metrics=metrics, risk_summary=risk_summary, ) self.db.add(record) self.db.commit() self.db.refresh(record) record_data = serialize_model(record) AuditService(self.db).log( AuditLogCreate( actor=actor, source=AuditSource.REPORTS, action=f"generate_{report_type}_report", target_type=AuditTargetType.WORK_REPORTS, target_id=str(record.id), response_payload=record_data, ) ) return {ReportResponseKey.REPORT: report, ReportResponseKey.DATA: record_data} def _resolve_period( self, report_type: str, period_start: date | None, period_end: date | None, ) -> tuple[date, date]: today = date.today() if report_type == ReportType.DAILY: start = period_start or period_end or today return start, period_end or start end = period_end or today start = period_start or end - timedelta(days=6) return start, end def _report_metrics( self, start: date, end: date, project_code: str | None, department: str | None, ) -> dict[str, Any]: task_filters = [ WorkTask.due_date.is_not(None), WorkTask.due_date >= start, WorkTask.due_date <= end, ] project_filters = [] risk_filters = [RiskEvent.status == StatusValue.OPEN] procurement_filters = [ Procurement.approval_status.in_(PENDING_APPROVAL_STATUSES), ] expense_filters = [ Expense.approval_status.in_(PENDING_APPROVAL_STATUSES), ] attendance_filters = [ AttendanceRecord.work_date >= start, AttendanceRecord.work_date <= end, ] if project_code: project_filters.append(Project.code == project_code) task_filters.append(WorkTask.project_code == project_code) procurement_filters.append(Procurement.project_code == project_code) expense_filters.append(Expense.project_code == project_code) attendance_filters.append(AttendanceRecord.project_code == project_code) risk_filters.append(RiskEvent.project_code == project_code) if department: expense_filters.append(Expense.department == department) attendance_filters.append(AttendanceRecord.department == department) completed_tasks = self._count(WorkTask, WorkTask.status.in_(DONE_STATUSES), *task_filters) overdue_tasks = self._count( WorkTask, WorkTask.due_date < date.today(), WorkTask.status.notin_(DONE_STATUSES), *task_filters, ) return { WorkReportMetricKey.PROJECTS_TOTAL: self._count(Project, *project_filters), WorkReportMetricKey.ACTIVE_PROJECTS: self._count( Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), *project_filters, ), WorkReportMetricKey.TASKS_TOTAL: self._count(WorkTask, *task_filters), WorkReportMetricKey.TASKS_COMPLETED: completed_tasks, WorkReportMetricKey.TASKS_OVERDUE: overdue_tasks, WorkReportMetricKey.PROCUREMENTS_PENDING: self._count( Procurement, *procurement_filters, ), WorkReportMetricKey.EXPENSES_PENDING: self._count(Expense, *expense_filters), WorkReportMetricKey.ATTENDANCE_TOTAL: self._count( AttendanceRecord, *attendance_filters, ), WorkReportMetricKey.OPEN_RISK_EVENTS: self._count(RiskEvent, *risk_filters), } def _work_report_lines( self, title: str, start: date, end: date, metrics: dict[str, Any], risk_summary: dict[str, Any], ) -> list[str]: return [ f"- 报告:{title}", f"- 周期:{start.isoformat()} 至 {end.isoformat()}", ( f"- 项目:总数 {metrics[WorkReportMetricKey.PROJECTS_TOTAL]}," f"活跃 {metrics[WorkReportMetricKey.ACTIVE_PROJECTS]}" ), ( f"- 任务:总数 {metrics[WorkReportMetricKey.TASKS_TOTAL]}," f"完成 {metrics[WorkReportMetricKey.TASKS_COMPLETED]}" ), f"- 逾期任务:{metrics[WorkReportMetricKey.TASKS_OVERDUE]}", f"- 待处理采购:{metrics[WorkReportMetricKey.PROCUREMENTS_PENDING]}", f"- 待处理费用:{metrics[WorkReportMetricKey.EXPENSES_PENDING]}", f"- 打卡记录:{metrics[WorkReportMetricKey.ATTENDANCE_TOTAL]}", f"- 打开风险事件:{metrics[WorkReportMetricKey.OPEN_RISK_EVENTS]}", f"- 综合风险等级:{risk_summary[RiskSummaryKey.RISK_LEVEL]}", ] def push_report( self, report: dict, receive_id: str | None, receive_id_type: str, actor: str, push_run_code: str | None = None, ) -> dict: report_type = str(report.get(ReportResponseKey.REPORT_TYPE) or report.get("type") or "report") title = report.get(ReportResponseKey.TITLE) push_run = ( self._get_push_run(push_run_code) if push_run_code else self.create_push_run( report_type=report_type, title=title, receive_id=receive_id, receive_id_type=receive_id_type, actor=actor, ) ) card = FeishuService.build_basic_card( report[ReportResponseKey.TITLE], report[ReportResponseKey.LINES], ) try: result = FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor) except Exception as exc: self.update_push_run( push_run.code, ReportPushStatus.FAILED, error_message=str(exc), ) raise self.update_push_run( push_run.code, ReportPushStatus.SUCCESS, provider_response=result, sent=True, ) 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