Files
company-ai-platform/app/modules/reports/services/intasect_lifecycle.py
JiuContinent 9cf7c44393 ```
feat: 添加生命周期报告和AI规则管理功能

- 在Dockerfile中添加pillow依赖包用于图像处理
- 实现生命周期报告调度任务,支持日报和周报两种类型
- 新增TASK_RUN_LIFECYCLE任务常量和相关配置选项
- 扩展AI Agent服务以支持用户规则,并在分析时应用规则
- 添加AI用户规则创建、更新和查询接口
- 增加项目生命周期和财务需求分析技能
- 扩展现有模型以支持更完整的业务数据字段
- 实现飞书图片上传功能用于报告展示
```
2026-07-12 17:44:49 +08:00

493 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
)