refactor(core): 重构核心模块结构并更新导入路径 - 将配置相关的设置从 app.core.config 移除 - 将常量定义从 app.core.constants 移除 - 将数据库相关功能从 app.core.database 移除 - 将基础数据库模型从 app.core.db_base 移除 - 将敏感信息掩码功能从 app.core.masking 移除 - 将中间件定义从 app.core.middleware 移除 - 将操作保护功能从 app.core.operation_guard 移除 - 将分页工具从 app.core.pagination 移除 - 将请求上下文管理从 app.core.request_context 移除 - 将调度器功能从 app.core.scheduler 移除 - 将安全认证逻辑从 app.core.security 移除 - 将任务队列相关功能从 app.core.task_queue 移除 - 将时间工具从 app.core.time 移除 - 更新 alembic 配置中的 Base 模型导入路径 - 更新各模块中对重构后组件的引用路径 ```
1349 lines
53 KiB
Python
1349 lines
53 KiB
Python
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.http.pagination import bounded_limit
|
||
from app.core.utils.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,
|
||
PerformanceMetric,
|
||
Procurement,
|
||
Project,
|
||
ReportPushRun,
|
||
RiskEvent,
|
||
Supplier,
|
||
WorkReport,
|
||
WorkTask,
|
||
)
|
||
from app.modules.business.service import serialize_model
|
||
from app.modules.events.constants import (
|
||
EventAggregateType,
|
||
EventPayloadKey,
|
||
EventSource,
|
||
EventType,
|
||
)
|
||
from app.modules.events.service import EventService
|
||
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,
|
||
ReportErrorDetail,
|
||
ReportPushStatus,
|
||
ReportResponseKey,
|
||
ReportStatus,
|
||
ReportText,
|
||
ReportTitle,
|
||
ReportType,
|
||
EnterpriseAnalyticsKey,
|
||
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=ReportErrorDetail.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 enterprise_analytics(
|
||
self,
|
||
project_code: str | None = None,
|
||
owner: str | None = None,
|
||
period_start: date | None = None,
|
||
period_end: date | None = None,
|
||
actor: str = ActorValue.API,
|
||
) -> dict[str, Any]:
|
||
"""Build V3 read-only finance, procurement, performance, and operations analytics."""
|
||
|
||
code = _next_code("ANALYTICS")
|
||
lifecycle = self.project_lifecycle_report(
|
||
project_code=project_code,
|
||
owner=owner,
|
||
period_start=period_start,
|
||
period_end=period_end,
|
||
include_ai=False,
|
||
actor=actor,
|
||
)
|
||
metrics = lifecycle[LifecycleResponseKey.METRICS]
|
||
projects = metrics[LifecycleSection.PROJECTS]
|
||
procurements = metrics[LifecycleSection.PROCUREMENTS]
|
||
expenses = metrics[LifecycleSection.EXPENSES]
|
||
funds = metrics[LifecycleSection.FUNDS]
|
||
tasks = metrics[LifecycleSection.TASKS]
|
||
risks = metrics[LifecycleSection.RISKS]
|
||
health = metrics[LifecycleSection.HEALTH]
|
||
finance = {
|
||
MetricKey.BUDGET_TOTAL: projects[MetricKey.BUDGET_TOTAL],
|
||
MetricKey.ACTUAL_TOTAL: projects[MetricKey.ACTUAL_TOTAL],
|
||
MetricKey.BUDGET_USAGE_RATE: projects[MetricKey.BUDGET_USAGE_RATE],
|
||
MetricKey.CURRENT_BALANCE_TOTAL: funds[MetricKey.CURRENT_BALANCE_TOTAL],
|
||
MetricKey.NET_POSITION: funds[MetricKey.NET_POSITION],
|
||
MetricKey.RISK_ACCOUNTS: funds[MetricKey.RISK_ACCOUNTS],
|
||
MetricKey.PAYMENT_EXPOSURE: (
|
||
procurements[MetricKey.ACTUAL_TOTAL] + expenses[MetricKey.AMOUNT_TOTAL]
|
||
),
|
||
}
|
||
procurement = {
|
||
MetricKey.TOTAL: procurements[MetricKey.TOTAL],
|
||
MetricKey.PENDING_APPROVAL: procurements[MetricKey.PENDING_APPROVAL],
|
||
MetricKey.PENDING_DELIVERY: procurements[MetricKey.PENDING_DELIVERY],
|
||
MetricKey.UNPAID: procurements[MetricKey.UNPAID],
|
||
MetricKey.EXPECTED_TOTAL: procurements[MetricKey.EXPECTED_TOTAL],
|
||
MetricKey.ACTUAL_TOTAL: procurements[MetricKey.ACTUAL_TOTAL],
|
||
MetricKey.DELIVERY_RISK: procurements[MetricKey.PENDING_DELIVERY],
|
||
}
|
||
performance = self._enterprise_performance_stats()
|
||
operations = {
|
||
MetricKey.READINESS_SCORE: health[MetricKey.SCORE],
|
||
MetricKey.LEVEL: health[MetricKey.LEVEL],
|
||
MetricKey.COMPLETION_RATE: tasks[MetricKey.COMPLETION_RATE],
|
||
MetricKey.OVERDUE_TASKS: risks[MetricKey.OVERDUE_TASKS],
|
||
MetricKey.DELAYED_PROJECTS: risks[MetricKey.DELAYED_PROJECTS],
|
||
MetricKey.OVER_BUDGET_PROJECTS: risks[MetricKey.OVER_BUDGET_PROJECTS],
|
||
MetricKey.OPEN_EVENTS: risks[MetricKey.OPEN_EVENTS],
|
||
MetricKey.HIGH_EVENTS: risks[MetricKey.HIGH_EVENTS],
|
||
}
|
||
recommendations = lifecycle[LifecycleResponseKey.RECOMMENDATIONS]
|
||
lines = self._enterprise_analytics_lines(
|
||
lifecycle[LifecycleResponseKey.FILTERS],
|
||
finance,
|
||
procurement,
|
||
performance,
|
||
operations,
|
||
recommendations,
|
||
)
|
||
report = _json_safe(
|
||
{
|
||
EnterpriseAnalyticsKey.CODE: code,
|
||
EnterpriseAnalyticsKey.TITLE: ReportTitle.ENTERPRISE_ANALYTICS,
|
||
EnterpriseAnalyticsKey.FILTERS: lifecycle[LifecycleResponseKey.FILTERS],
|
||
EnterpriseAnalyticsKey.FINANCE: finance,
|
||
EnterpriseAnalyticsKey.PROCUREMENT: procurement,
|
||
EnterpriseAnalyticsKey.PERFORMANCE: performance,
|
||
EnterpriseAnalyticsKey.OPERATIONS: operations,
|
||
EnterpriseAnalyticsKey.RECOMMENDATIONS: recommendations,
|
||
EnterpriseAnalyticsKey.LINES: lines,
|
||
EnterpriseAnalyticsKey.CONTENT: "\n".join(lines),
|
||
}
|
||
)
|
||
AuditService(self.db).log(
|
||
AuditLogCreate(
|
||
actor=actor,
|
||
source=AuditSource.REPORTS,
|
||
action=AuditAction.ENTERPRISE_ANALYTICS,
|
||
target_type=AuditTargetType.ENTERPRISE_ANALYTICS,
|
||
target_id=code,
|
||
response_payload=report,
|
||
)
|
||
)
|
||
EventService(self.db).emit(
|
||
event_type=EventType.ENTERPRISE_ANALYTICS_GENERATED,
|
||
source=EventSource.ANALYTICS,
|
||
aggregate_type=EventAggregateType.ENTERPRISE_ANALYTICS,
|
||
aggregate_id=code,
|
||
actor=actor,
|
||
payload={
|
||
EventPayloadKey.CODE: code,
|
||
EventPayloadKey.STATUS: ReportStatus.GENERATED,
|
||
},
|
||
idempotency_key=f"enterprise-analytics:{code}",
|
||
dispatch=True,
|
||
)
|
||
return report
|
||
|
||
def _enterprise_performance_stats(self) -> dict[str, Any]:
|
||
total = self._count(PerformanceMetric)
|
||
confirmed = self._count(PerformanceMetric, PerformanceMetric.confirmed_score.is_not(None))
|
||
return {
|
||
MetricKey.TOTAL: total,
|
||
MetricKey.CONFIRMED: confirmed,
|
||
MetricKey.CONFIRMED_RATE: _rate(confirmed, total),
|
||
MetricKey.AVERAGE_AUTO_SCORE: self._avg(PerformanceMetric.auto_score),
|
||
MetricKey.AVERAGE_CONFIRMED_SCORE: self._avg(PerformanceMetric.confirmed_score),
|
||
MetricKey.WEIGHT_TOTAL: self._sum(PerformanceMetric.weight),
|
||
MetricKey.BY_STATUS: self._group_counts(PerformanceMetric, PerformanceMetric.status),
|
||
}
|
||
|
||
def _enterprise_analytics_lines(
|
||
self,
|
||
filters: dict[str, Any],
|
||
finance: dict[str, Any],
|
||
procurement: dict[str, Any],
|
||
performance: dict[str, Any],
|
||
operations: 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
|
||
)
|
||
lines = [
|
||
f"- 范围:{scope}",
|
||
(
|
||
f"- 财务:预算 {_money(finance[MetricKey.BUDGET_TOTAL])},"
|
||
f"实际 {_money(finance[MetricKey.ACTUAL_TOTAL])},"
|
||
f"净头寸 {_money(finance[MetricKey.NET_POSITION])},"
|
||
f"支付暴露 {_money(finance[MetricKey.PAYMENT_EXPOSURE])}"
|
||
),
|
||
(
|
||
f"- 采购:总数 {procurement[MetricKey.TOTAL]},"
|
||
f"待审批 {procurement[MetricKey.PENDING_APPROVAL]},"
|
||
f"待交付 {procurement[MetricKey.PENDING_DELIVERY]},"
|
||
f"未付款 {procurement[MetricKey.UNPAID]}"
|
||
),
|
||
(
|
||
f"- 绩效:指标 {performance[MetricKey.TOTAL]},"
|
||
f"已确认 {performance[MetricKey.CONFIRMED]},"
|
||
f"确认率 {performance[MetricKey.CONFIRMED_RATE]}%,"
|
||
f"平均自动分 {performance[MetricKey.AVERAGE_AUTO_SCORE]}"
|
||
),
|
||
(
|
||
f"- 运营:准备度 {operations[MetricKey.READINESS_SCORE]},"
|
||
f"任务完成率 {operations[MetricKey.COMPLETION_RATE]}%,"
|
||
f"逾期任务 {operations[MetricKey.OVERDUE_TASKS]},"
|
||
f"打开风险 {operations[MetricKey.OPEN_EVENTS]}"
|
||
),
|
||
ReportText.ACTION_HEADER,
|
||
]
|
||
lines.extend(f" - {item}" for item in recommendations)
|
||
return lines
|
||
|
||
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,
|
||
)
|
||
)
|
||
EventService(self.db).emit(
|
||
event_type=EventType.REPORT_GENERATED,
|
||
source=EventSource.REPORTS,
|
||
aggregate_type=EventAggregateType.WORK_REPORT,
|
||
aggregate_id=record.code,
|
||
actor=actor,
|
||
payload={
|
||
EventPayloadKey.CODE: record.code,
|
||
EventPayloadKey.STATUS: record.status,
|
||
},
|
||
idempotency_key=f"report-generated:{record.code}",
|
||
dispatch=True,
|
||
)
|
||
|
||
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:
|
||
failed_run = self.update_push_run(
|
||
push_run.code,
|
||
ReportPushStatus.FAILED,
|
||
error_message=str(exc),
|
||
)
|
||
EventService(self.db).emit(
|
||
event_type=EventType.REPORT_PUSH_FAILED,
|
||
source=EventSource.REPORTS,
|
||
aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
|
||
aggregate_id=failed_run.code,
|
||
actor=actor,
|
||
payload={
|
||
EventPayloadKey.CODE: failed_run.code,
|
||
EventPayloadKey.STATUS: failed_run.status,
|
||
EventPayloadKey.ERROR_MESSAGE: failed_run.error_message,
|
||
},
|
||
idempotency_key=f"report-push:{failed_run.code}:{failed_run.status}",
|
||
)
|
||
raise
|
||
success_run = self.update_push_run(
|
||
push_run.code,
|
||
ReportPushStatus.SUCCESS,
|
||
provider_response=result,
|
||
sent=True,
|
||
)
|
||
EventService(self.db).emit(
|
||
event_type=EventType.REPORT_PUSH_SUCCEEDED,
|
||
source=EventSource.REPORTS,
|
||
aggregate_type=EventAggregateType.REPORT_PUSH_RUN,
|
||
aggregate_id=success_run.code,
|
||
actor=actor,
|
||
payload={
|
||
EventPayloadKey.CODE: success_run.code,
|
||
EventPayloadKey.STATUS: success_run.status,
|
||
},
|
||
idempotency_key=f"report-push:{success_run.code}:{success_run.status}",
|
||
)
|
||
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
|