from collections import Counter, defaultdict from datetime import date, timedelta from typing import Any from sqlalchemy import func, select from app.core.config import get_settings from app.core.constants import ActorValue from app.modules.ai_agent.constants import AIProviderName, AIResponseKey from app.modules.business.constants import ATTENDANCE_ABNORMAL_STATUSES, SourceSystem, StatusValue from app.modules.business.models import ( AttendanceRecord, Employee, Project, ProjectMember, ProjectMilestone, RiskEvent, WorkReport, WorkTask, ) from app.modules.reports.constants import ReportResponseKey, ReportTitle, ReportType class IntasectLifecycleReportMixin: def source_project_lifecycle_summary( self, project_code: str | None = None, owner: str | None = None, ) -> dict[str, Any]: project_statement = select(Project).where( Project.source_system == SourceSystem.LEGACY_MYSQL, Project.is_active.is_(True), ) if project_code: project_statement = project_statement.where( (Project.code == project_code) | (Project.display_code == project_code) ) if owner: project_statement = project_statement.where(Project.owner == owner) projects = list(self.db.execute(project_statement).scalars()) project_codes = {item.code for item in projects} unarchived_codes = {item.code for item in projects if not item.source_archived} stage_counts = Counter( item.source_stage_label or item.source_stage or "阶段未知" for item in projects ) covered_projects = int( self.db.execute( select(func.count(func.distinct(ProjectMember.project_code))).where( ProjectMember.is_active.is_(True), ProjectMember.project_code.in_(project_codes or {""}), ) ).scalar() or 0 ) milestone_total = self._count_source_rows( ProjectMilestone, ProjectMilestone.is_active.is_(True), ProjectMilestone.project_code.in_(project_codes or {""}), ) overdue_milestones = list( self.db.execute( select(ProjectMilestone, Project) .join(Project, Project.code == ProjectMilestone.project_code) .where( ProjectMilestone.is_active.is_(True), ProjectMilestone.is_overdue.is_(True), ProjectMilestone.project_code.in_(unarchived_codes or {""}), ) .order_by(ProjectMilestone.plan_end.asc()) .limit(10) ).all() ) overdue_milestone_total = self._count_source_rows( ProjectMilestone, ProjectMilestone.is_active.is_(True), ProjectMilestone.is_overdue.is_(True), ProjectMilestone.project_code.in_(unarchived_codes or {""}), ) today = date.today() overdue_tasks = list( self.db.execute( select(WorkTask, Project) .join(Project, Project.code == WorkTask.project_code) .where( WorkTask.source_system == SourceSystem.LEGACY_MYSQL, WorkTask.is_active.is_(True), WorkTask.status != StatusValue.COMPLETED, WorkTask.due_date.is_not(None), WorkTask.due_date < today, WorkTask.project_code.in_(unarchived_codes or {""}), ) .order_by(WorkTask.due_date.asc()) .limit(10) ).all() ) overdue_task_total = self._count_source_rows( WorkTask, WorkTask.source_system == SourceSystem.LEGACY_MYSQL, WorkTask.is_active.is_(True), WorkTask.status != StatusValue.COMPLETED, WorkTask.due_date.is_not(None), WorkTask.due_date < today, WorkTask.project_code.in_(unarchived_codes or {""}), ) open_events = list( self.db.execute( select(RiskEvent) .where( RiskEvent.source_domain == "intasect_project_event", RiskEvent.status == StatusValue.OPEN, RiskEvent.project_code.in_(unarchived_codes or {""}), ) .limit(10) ).scalars() ) open_event_total = self._count_source_rows( RiskEvent, RiskEvent.source_domain == "intasect_project_event", RiskEvent.status == StatusValue.OPEN, RiskEvent.project_code.in_(unarchived_codes or {""}), ) linked_tasks = self._count_source_rows( WorkTask, WorkTask.source_system == SourceSystem.LEGACY_MYSQL, WorkTask.is_active.is_(True), WorkTask.project_code.in_(project_codes or {""}), ) return { "total": len(projects), "unarchived": len(unarchived_codes), "archived": sum(1 for item in projects if item.source_archived), "unknown_stage": sum(1 for item in projects if not item.source_stage), "stage_counts": dict(stage_counts.most_common()), "member_coverage": { "covered_projects": covered_projects, "uncovered_projects": max(len(projects) - covered_projects, 0), }, "milestones": { "total": milestone_total, "overdue": overdue_milestone_total, }, "linked_tasks": linked_tasks, "overdue_linked_tasks": overdue_task_total, "open_project_events": open_event_total, "attention": { "overdue_milestones": [ { "project_code": project.display_code or project.code, "project_name": project.name, "owner": project.owner, "stage": milestone.stage_name, "plan_end": milestone.plan_end.isoformat() if milestone.plan_end else None, } for milestone, project in overdue_milestones ], "overdue_tasks": [ { "project_code": project.display_code or project.code, "project_name": project.name, "task": task.title, "owner": task.owner, "due_date": task.due_date.isoformat() if task.due_date else None, } for task, project in overdue_tasks ], "project_events": [ { "project_code": item.project_code, "title": item.title, "risk_level": item.risk_level, "due_date": item.due_date.isoformat() if item.due_date else None, } for item in open_events ], }, } def personnel_lifecycle_report( self, department: str | None = None, employee_code: str | None = None, project_code: str | None = None, period_start: date | None = None, period_end: date | None = None, ) -> dict[str, Any]: end = period_end or self._latest_completed_attendance_date() start = period_start or end employee_stmt = select(Employee).where( Employee.source_system == SourceSystem.LEGACY_MYSQL, ) if department: employee_stmt = employee_stmt.where(Employee.department_name == department) if employee_code: employee_stmt = employee_stmt.where(Employee.code == employee_code) if project_code: member_codes = select(ProjectMember.employee_code).where( ProjectMember.project_code == project_code, ProjectMember.is_active.is_(True), ) employee_stmt = employee_stmt.where(Employee.code.in_(member_codes)) employees = list(self.db.execute(employee_stmt).scalars()) codes = {item.code for item in employees} member_counts: dict[str, int] = defaultdict(int) workload: dict[str, int] = defaultdict(int) for code, count, total_workload in self.db.execute( select( ProjectMember.employee_code, func.count(func.distinct(ProjectMember.project_code)), func.coalesce(func.sum(ProjectMember.workload_percent), 0), ) .where( ProjectMember.is_active.is_(True), ProjectMember.employee_code.in_(codes or {""}) ) .group_by(ProjectMember.employee_code) ): member_counts[str(code)] = int(count) workload[str(code)] = int(total_workload or 0) open_tasks: dict[str, int] = defaultdict(int) overdue_tasks: dict[str, int] = defaultdict(int) for code, task_status, due_date in self.db.execute( select(WorkTask.employee_code, WorkTask.status, WorkTask.due_date).where( WorkTask.source_system == SourceSystem.LEGACY_MYSQL, WorkTask.is_active.is_(True), WorkTask.employee_code.in_(codes or {""}), ) ): if task_status != StatusValue.COMPLETED: open_tasks[str(code)] += 1 if due_date and due_date < date.today(): overdue_tasks[str(code)] += 1 attendance_abnormal: dict[str, int] = defaultdict(int) attendance_seen: set[str] = set() for code, attendance_status in self.db.execute( select(AttendanceRecord.employee_id, AttendanceRecord.status).where( AttendanceRecord.source_system == SourceSystem.LEGACY_MYSQL, AttendanceRecord.attendance_scope == "company", AttendanceRecord.is_active.is_(True), AttendanceRecord.work_date >= start, AttendanceRecord.work_date <= end, AttendanceRecord.employee_id.in_(codes or {""}), ) ): attendance_seen.add(str(code)) if attendance_status in ATTENDANCE_ABNORMAL_STATUSES: attendance_abnormal[str(code)] += 1 report_counts: dict[str, int] = defaultdict(int) for code, count in self.db.execute( select(WorkReport.employee_code, func.count()) .where( WorkReport.source_system == SourceSystem.LEGACY_MYSQL, WorkReport.is_active.is_(True), WorkReport.is_draft.is_(False), WorkReport.period_end >= start, WorkReport.period_end <= end, WorkReport.employee_code.in_(codes or {""}), ) .group_by(WorkReport.employee_code) ): report_counts[str(code)] = int(count) items = [] for employee in employees: item = { "employee_code": employee.code, "name": employee.name, "department": employee.department_name, "title": employee.title, "employment_status": employee.employment_status, "project_count": member_counts[employee.code], "planned_workload_percent": workload[employee.code], "open_tasks": open_tasks[employee.code], "overdue_tasks": overdue_tasks[employee.code], "attendance_records": 1 if employee.code in attendance_seen else 0, "attendance_abnormal": attendance_abnormal[employee.code], "submitted_reports": report_counts[employee.code], "attendance_covered": bool(employee.ding_user_id), } item["needs_attention"] = bool( item["overdue_tasks"] or item["attendance_abnormal"] or item["planned_workload_percent"] > 100 ) items.append(item) attention = [item for item in items if item["needs_attention"]] attention.sort( key=lambda item: ( item["overdue_tasks"], item["attendance_abnormal"], item["planned_workload_percent"], ), reverse=True, ) metrics = { "total": len(employees), "active": sum(1 for item in employees if item.is_active), "inactive": sum(1 for item in employees if not item.is_active), "attendance_mapped": sum(1 for item in employees if item.ding_user_id), "attention_total": len(attention), "by_department": dict( Counter(item.department_name or "未设置部门" for item in employees) ), "period_start": start.isoformat(), "period_end": end.isoformat(), } lines = [ f"- 人员总数:{metrics['total']},在职:{metrics['active']},离职/失效:{metrics['inactive']}", f"- 考勤映射覆盖:{metrics['attendance_mapped']}/{metrics['total']}", f"- 需关注人员:{metrics['attention_total']}", ] for item in attention[:10]: lines.append( f" - {item['name']}({item['department'] or '未设置部门'}):" f"逾期任务 {item['overdue_tasks']},考勤异常 {item['attendance_abnormal']}," f"计划负荷 {item['planned_workload_percent']}%" ) return { ReportResponseKey.TITLE: ReportTitle.PERSONNEL_LIFECYCLE, ReportResponseKey.PERIOD_START: start.isoformat(), ReportResponseKey.PERIOD_END: end.isoformat(), ReportResponseKey.METRICS: metrics, "items": items, "attention": attention[:10], ReportResponseKey.LINES: lines, ReportResponseKey.CONTENT: "\n".join(lines), } def management_lifecycle_report( self, report_type: str, actor: str = ActorValue.SCHEDULER, include_ai: bool = True, reference_date: date | None = None, ) -> dict[str, Any]: start, end = self._management_period(report_type, reference_date) projects = self.source_project_lifecycle_summary() personnel = self.personnel_lifecycle_report(period_start=start, period_end=end) finance = ( self.project_finance_needs_report(as_of=end, include_ai=False, actor=actor) if get_settings().finance_needs_enabled else None ) title = ( ReportTitle.LIFECYCLE_DAILY if report_type == ReportType.DAILY else ReportTitle.LIFECYCLE_WEEKLY ) lines = [ f"- 统计周期:{start.isoformat()} 至 {end.isoformat()}", f"- 项目:总数 {projects['total']},未归档 {projects['unarchived']}," f"已归档 {projects['archived']},阶段未知 {projects['unknown_stage']}", f"- 项目成员覆盖:{projects['member_coverage']['covered_projects']}/{projects['total']}", f"- 里程碑:总数 {projects['milestones']['total']},逾期 {projects['milestones']['overdue']}", f"- 项目关联任务:{projects['linked_tasks']},逾期 {projects['overdue_linked_tasks']}," f"待协助/超期事项:{projects['open_project_events']}", *personnel[ReportResponseKey.LINES], ] if finance: lines.extend(finance[ReportResponseKey.LINES]) else: lines.append( "- 项目资金需求:功能未启用;采购、费用、资金、供应商首期未接入,不计为零。" ) report = { ReportResponseKey.TITLE: title, ReportResponseKey.REPORT_TYPE: report_type, ReportResponseKey.PERIOD_START: start.isoformat(), ReportResponseKey.PERIOD_END: end.isoformat(), ReportResponseKey.METRICS: { "projects": projects, "personnel": personnel[ReportResponseKey.METRICS], "finance": finance["summary"] if finance else {"status": "未启用"}, }, "attention": { "projects": projects["attention"], "personnel": personnel["attention"], "finance": finance["attention"] if finance else [], }, ReportResponseKey.LINES: lines, "chart_data": { "period": f"{start.isoformat()} - {end.isoformat()}", "projects": { "total": projects["total"], "unarchived": projects["unarchived"], "archived": projects["archived"], }, "risks": { "overdue_milestones": projects["milestones"]["overdue"], "overdue_tasks": projects["overdue_linked_tasks"], "open_events": projects["open_project_events"], }, "people": { "active": personnel[ReportResponseKey.METRICS]["active"], "attention": personnel[ReportResponseKey.METRICS]["attention_total"], "attendance_mapped": personnel[ReportResponseKey.METRICS][ "attendance_mapped" ], }, "finance": finance["finance_chart_data"] if finance else None, }, "finance": finance, "finance_chart_data": finance["finance_chart_data"] if finance else None, } ai_analysis = self._management_ai_analysis(report, actor) if include_ai else None report["ai_analysis"] = ai_analysis if ai_analysis and ai_analysis.get(AIResponseKey.OK): lines.append("- AI 管理分析:") answer = str(ai_analysis.get(AIResponseKey.ANSWER) or "")[:3000] lines.extend(f" {line}" for line in answer.splitlines() if line.strip()) report[ReportResponseKey.CONTENT] = "\n".join(lines) return report def _management_ai_analysis(self, report: dict[str, Any], actor: str) -> dict[str, Any]: from app.core.config import get_settings from app.modules.ai_agent.service import AIService from app.modules.ai_agent.skills import AISkillId attention = dict(report["attention"]) attention["finance"] = [ { "project_code": item["project_code"], "display_code": item["display_code"], "project_name": item["project_name"], "stage": item["stage"], "overdue_receivable": item["overdue_receivable"], "pending_outflow": item["pending_outflow"], "funding_need_30d": item["funding_need"]["30"], "delivery_risk": item["delivery_risk"], } for item in attention.get("finance", [])[:10] ] context = { "period_start": report[ReportResponseKey.PERIOD_START], "period_end": report[ReportResponseKey.PERIOD_END], "metrics": report[ReportResponseKey.METRICS], "attention": attention, } last_error: Exception | None = None max_attempts = max(1, min(get_settings().ai_analysis_max_attempts, 5)) for _ in range(max_attempts): try: result = AIService(self.db).run_skill( AISkillId.PROJECT_LIFECYCLE_ANALYSIS, context=context, actor=actor, ) if result.get(AIResponseKey.PROVIDER) == AIProviderName.NOOP: return { AIResponseKey.OK: False, AIResponseKey.ERROR: "AI provider is not configured", "attempts": 1, } return {AIResponseKey.OK: True, **result} except Exception as exc: last_error = exc self.db.rollback() return { AIResponseKey.OK: False, AIResponseKey.ERROR: str(last_error) if last_error else "AI analysis failed", AIResponseKey.TYPE: type(last_error).__name__ if last_error else "AIUnavailable", "attempts": max_attempts, } def _latest_completed_attendance_date(self) -> date: latest = self.db.execute( select(func.max(AttendanceRecord.work_date)).where( AttendanceRecord.source_system == SourceSystem.LEGACY_MYSQL, AttendanceRecord.attendance_scope == "company", AttendanceRecord.work_date < date.today(), ) ).scalar() return latest or (date.today() - timedelta(days=1)) def _management_period( self, report_type: str, reference_date: date | None ) -> tuple[date, date]: reference = reference_date or date.today() if report_type == ReportType.DAILY: target = self._latest_completed_attendance_date() return target, target end = reference - timedelta(days=reference.weekday() + 1) return end - timedelta(days=6), end def _count_source_rows(self, model: type, *conditions: Any) -> int: return int( self.db.execute(select(func.count()).select_from(model).where(*conditions)).scalar() or 0 )