Files
company-ai-platform/app/modules/reports/service.py
JiuContinent 92f490b97e ```
feat(core): 添加多API密钥支持和配置字段

添加了api_keys、audit_api_keys、approval_api_keys等字段用于支持多个服务密钥,
新增masked_response_fields用于配置响应掩码字段,以及legacy相关配置项。

feat(core): 增强响应数据掩码功能

扩展mask_configured函数支持域名参数,实现更精确的敏感字段掩码控制,
添加自定义掩码字段配置验证器。

feat(scheduler): 添加遗留系统同步调度任务

集成遗留项目和任务同步到定时调度器中,支持通过配置启用或禁用同步功能,
并可设置不同的执行时间计划。

feat(security): 实现多服务密钥认证机制

重构API密钥验证逻辑,支持单个主密钥和多个配置密钥的混合验证模式,
增加服务密钥启用状态检查和角色映射功能。

feat(task_queue): 扩展现有队列任务处理

为日常简报和周报推送任务添加Celery异步处理支持,新增遗留项目和任务同步任务,
统一任务分发接口。

feat(business): 扩展业务模型字段

为工作任务模型添加外部系统标识和外部ID字段,为风险事件模型增加分配、解决、关闭
等相关字段,并创建风险事件操作记录表。

feat(legacy_mysql): 实现遗留任务同步功能

添加遗留任务查询和同步路由,支持从旧MySQL数据库同步任务数据到内部系统,
包括同步结果统计和运行记录。

refactor(dashboard): 更新仪表板统计数据

增加未分配风险和失败推送运行统计,在概览中显示最新的推送和同步运行记录,
完善数据序列化展示。

fix(feishu): 修复审批事件重复处理

实现审批卡片操作事件的唯一性检查,防止重复审批操作,添加事件审计日志记录。
```
2026-07-08 12:05:09 +08:00

1138 lines
44 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 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