feat: 添加生命周期报告和AI规则管理功能

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

View File

@@ -0,0 +1,492 @@
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
)