```
feat(ai_agent): 完善AI适配器和服务功能 - 添加OpenClaw和Hermes健康检查接口 - 实现OpenClaw工具调用功能 - 重构AI适配器使用常量定义 - 增加AI技能系统支持 - 更新配置文件中的默认模型提供者设置 refactor(scheduler): 使用常量替换硬编码值 - 将硬编码的actor值替换为ActorValue常量 - 将receive_id_type替换为FeishuReceiveIdType枚举 refactor(audit): 统一审计日志常量使用 - 将硬编码的actor、source、risk_level等值替换为对应常量 - 更新审核服务中的状态和操作常量引用 refactor(approvals): 标准化审批模块常量使用 - 将applicant默认值替换为ActorValue.API常量 - 使用ApprovalStatus常量替代硬编码状态值 - 更新审核操作常量引用 ```
This commit is contained in:
@@ -2,11 +2,21 @@ from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.constants import ActorValue
|
||||
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,
|
||||
PROJECT_CLOSED_STATUSES,
|
||||
PENDING_APPROVAL_STATUSES,
|
||||
SUPPLIER_RISK_LEVELS,
|
||||
RiskLevel,
|
||||
StatusValue,
|
||||
)
|
||||
from app.modules.business.models import (
|
||||
AttendanceRecord,
|
||||
Expense,
|
||||
@@ -14,17 +24,26 @@ from app.modules.business.models import (
|
||||
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
|
||||
|
||||
DONE_STATUSES = {"完成", "已完成", "关闭", "done", "completed", "closed"}
|
||||
PENDING_APPROVAL_STATUSES = {"草稿", "审批中", "待审批", "pending"}
|
||||
PROJECT_CLOSED_STATUSES = {"验收", "已完成", "复盘", "归档", "关闭", "closed"}
|
||||
|
||||
|
||||
def _money(value: Decimal | int | float | None) -> str:
|
||||
"""Format a numeric value as a two-decimal money string."""
|
||||
@@ -53,6 +72,18 @@ def _next_code(prefix: str) -> str:
|
||||
return f"{prefix}-{datetime.utcnow():%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."""
|
||||
|
||||
@@ -66,6 +97,45 @@ class ReportService:
|
||||
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)
|
||||
@@ -89,7 +159,10 @@ class ReportService:
|
||||
f"- 待处理采购:{procurement_pending}",
|
||||
f"- 待处理费用:{expense_pending}",
|
||||
f"- 当前账户总余额:{_money(fund_total)}",
|
||||
f"- 今日打卡记录:{attendance['total']},异常:{attendance['abnormal_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'])}",
|
||||
@@ -107,7 +180,7 @@ class ReportService:
|
||||
)
|
||||
delayed = self.risks.delayed_projects()
|
||||
over_budget = self.risks.over_budget_projects()
|
||||
open_risks = self.risks.list_events(status_filter="open")
|
||||
open_risks = self.risks.list_events(status_filter=StatusValue.OPEN)
|
||||
lines = [
|
||||
f"- 活跃项目:{active}",
|
||||
f"- 延期项目:{len(delayed)}",
|
||||
@@ -116,11 +189,564 @@ class ReportService:
|
||||
"- 需要管理层关注:",
|
||||
]
|
||||
for item in delayed[:10]:
|
||||
lines.append(f" - 延期:{item.get('code')} {item.get('name')},负责人 {item.get('owner')}")
|
||||
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,
|
||||
)
|
||||
health = self._lifecycle_health(project_stats, task_stats, risk_stats, supplier_stats)
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
)
|
||||
risk_score = (
|
||||
overdue_tasks * 1
|
||||
+ delayed_projects * 3
|
||||
+ over_budget_projects * 4
|
||||
+ open_events * 2
|
||||
+ 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.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],
|
||||
) -> dict[str, Any]:
|
||||
penalty = (
|
||||
risks[MetricKey.OVERDUE_TASKS] * 3
|
||||
+ risks[MetricKey.DELAYED_PROJECTS] * 8
|
||||
+ risks[MetricKey.OVER_BUDGET_PROJECTS] * 10
|
||||
+ risks[MetricKey.HIGH_EVENTS] * 8
|
||||
+ suppliers[MetricKey.BLACKLISTED] * 10
|
||||
+ max(0, projects[MetricKey.BUDGET_USAGE_RATE] - 100) * 0.4
|
||||
+ (100 - tasks[MetricKey.COMPLETION_RATE]) * 0.1
|
||||
)
|
||||
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],
|
||||
) -> 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 funds[MetricKey.RISK_ACCOUNTS]:
|
||||
recommendations.append(ReportText.RECOMMEND_FUNDS)
|
||||
if 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."""
|
||||
|
||||
@@ -134,7 +760,7 @@ class ReportService:
|
||||
abnormal_total = sum(
|
||||
count
|
||||
for status, count in status_counts.items()
|
||||
if status in {"迟到", "早退", "缺卡", "旷工", "异常"}
|
||||
if status in ATTENDANCE_ABNORMAL_STATUSES
|
||||
)
|
||||
total = sum(status_counts.values())
|
||||
lines = [
|
||||
@@ -156,21 +782,25 @@ class ReportService:
|
||||
|
||||
def generate_work_report(
|
||||
self,
|
||||
report_type: str = "daily",
|
||||
reporter: str = "system",
|
||||
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 = "api",
|
||||
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 = "经营日报" if report_type == "daily" else "经营周报"
|
||||
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,
|
||||
@@ -222,7 +852,7 @@ class ReportService:
|
||||
period_end: date | None,
|
||||
) -> tuple[date, date]:
|
||||
today = date.today()
|
||||
if report_type == "daily":
|
||||
if report_type == ReportType.DAILY:
|
||||
start = period_start or period_end or today
|
||||
return start, period_end or start
|
||||
end = period_end or today
|
||||
@@ -275,7 +905,10 @@ class ReportService:
|
||||
"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, RiskEvent.status == "open"),
|
||||
"open_risk_events": self._count(
|
||||
RiskEvent,
|
||||
RiskEvent.status == StatusValue.OPEN,
|
||||
),
|
||||
}
|
||||
|
||||
def _work_report_lines(
|
||||
|
||||
Reference in New Issue
Block a user