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.time import utc_now 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 ( 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, WorkReport, WorkTask, ) from app.modules.business.service import serialize_model from app.modules.feishu.service import FeishuService from app.modules.reports.constants import ( HealthLevel, LifecycleAttentionKey, LifecycleFilterKey, LifecycleResponseKey, LifecycleSection, MetricKey, ReportStatus, ReportText, ReportTitle, ReportType, ) 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(limit)).scalars()) 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['total']}," f"异常:{attendance['abnormal_total']}" ), f"- 逾期任务:{len(risk_summary['overdue_tasks'])}", f"- 延期项目:{len(risk_summary['delayed_projects'])}", f"- 超预算项目:{len(risk_summary['over_budget_projects'])}", f"- 资金风险账户:{len(risk_summary['fund_risks'])}", f"- 供应商风险:{len(risk_summary['supplier_risks'])}", f"- 打开风险事件:{len(risk_summary['open_events'])}", f"- 综合风险等级:{risk_summary['risk_level']}", ] return {"title": "每日经营晨报", "lines": lines, "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 {"title": "项目周报", "lines": lines, "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 * 1 + delayed_projects * 3 + over_budget_projects * 4 + external_open_events * 2 + external_high_events * 3 ) if risk_score >= 15: level = RiskLevel.HIGH elif risk_score >= 5: level = RiskLevel.MEDIUM else: level = RiskLevel.LOW return { MetricKey.RISK_LEVEL: level, 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] * 3 + risks[MetricKey.DELAYED_PROJECTS] * 8 + risks[MetricKey.OVER_BUDGET_PROJECTS] * 10 + risks[MetricKey.EXTERNAL_HIGH_EVENTS] * 8 + max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4 + (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1 ) if include_global_risk: penalty += suppliers[MetricKey.BLACKLISTED] * 10 score = max(0, min(100, round(100 - penalty, 2))) if score >= 80: level = HealthLevel.HEALTHY elif score >= 60: 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 { "title": "打卡汇总", "work_date": target_date.isoformat(), "total": total, "abnormal_total": abnormal_total, "status_counts": status_counts, "lines": lines, "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 = { "title": title, "report_type": report_type, "period_start": start.isoformat(), "period_end": end.isoformat(), "lines": lines, "content": "\n".join(lines), "metrics": metrics, "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["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 {"report": report, "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 { "projects_total": self._count(Project, *project_filters), "active_projects": self._count( Project, Project.status.notin_(PROJECT_CLOSED_STATUSES), *project_filters, ), "tasks_total": self._count(WorkTask, *task_filters), "tasks_completed": completed_tasks, "tasks_overdue": overdue_tasks, "procurements_pending": self._count(Procurement, *procurement_filters), "expenses_pending": self._count(Expense, *expense_filters), "attendance_total": self._count(AttendanceRecord, *attendance_filters), "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['projects_total']},活跃 {metrics['active_projects']}", f"- 任务:总数 {metrics['tasks_total']},完成 {metrics['tasks_completed']}", f"- 逾期任务:{metrics['tasks_overdue']}", f"- 待处理采购:{metrics['procurements_pending']}", f"- 待处理费用:{metrics['expenses_pending']}", f"- 打卡记录:{metrics['attendance_total']}", f"- 打开风险事件:{metrics['open_risk_events']}", f"- 综合风险等级:{risk_summary['risk_level']}", ] def push_report( self, report: dict, receive_id: str | None, receive_id_type: str, actor: str, ) -> dict: card = FeishuService.build_basic_card(report["title"], report["lines"]) return FeishuService(self.db).send_card(card, receive_id, receive_id_type, actor)