Files
ems-core/backend/app/services/quota_checker.py
Du Wenbo 2b9797d61b feat: add customer hooks plugin system (v1.1.0)
New plugin architecture for customer-specific business logic:
- hooks/base.py: CustomerHooks base class with 12 hook points
  (on_alarm_created, on_alarm_resolved, on_energy_data_received,
   on_device_status_changed, on_quota_exceeded, on_work_order_created,
   on_work_order_completed, on_inspection_completed, on_report_generated,
   calculate_custom_kpis, on_charging_order_created/completed)
- hooks/loader.py: Dynamic loader that imports from customers/{CUSTOMER}/hooks/
- alarm_checker.py: calls on_alarm_created and on_alarm_resolved hooks
- quota_checker.py: calls on_quota_exceeded hook

Customers override hooks by creating customers/{name}/hooks/__init__.py
without modifying core code. Scales to 10-20+ customers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:30:53 +08:00

133 lines
5.1 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.
"""配额检测服务 - 计算配额使用率,超限时生成告警事件"""
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.quota import EnergyQuota, QuotaUsage
from app.models.alarm import AlarmEvent
from app.models.energy import EnergyDailySummary
from app.hooks import get_hooks
logger = logging.getLogger("quota_checker")
def _get_period_range(period: str, now: datetime) -> tuple[datetime, datetime]:
"""根据配额周期计算当前统计区间"""
if period == "monthly":
start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# 下月1号
if now.month == 12:
end = start.replace(year=now.year + 1, month=1)
else:
end = start.replace(month=now.month + 1)
else: # yearly
start = now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
end = start.replace(year=now.year + 1)
return start, end
async def check_quotas(session: AsyncSession):
"""主配额检测循环,计算每个活跃配额的使用率并更新记录"""
now = datetime.now(timezone.utc)
result = await session.execute(
select(EnergyQuota).where(EnergyQuota.is_active == True)
)
quotas = result.scalars().all()
hooks = get_hooks()
for quota in quotas:
period_start, period_end = _get_period_range(quota.period, now)
# 从 EnergyDailySummary 汇总实际用量
# target_id 对应 device_groups这里按 device_id 关联
# 简化处理:按 energy_type 汇总所有匹配设备的消耗
usage_query = select(func.coalesce(func.sum(EnergyDailySummary.total_consumption), 0)).where(
and_(
EnergyDailySummary.energy_type == quota.energy_type,
EnergyDailySummary.date >= period_start,
EnergyDailySummary.date < period_end,
)
)
actual_value = (await session.execute(usage_query)).scalar() or 0
# 计算使用率
usage_rate_pct = (actual_value / quota.quota_value * 100) if quota.quota_value > 0 else 0
# 确定状态
if usage_rate_pct >= quota.alert_threshold_pct:
status = "exceeded"
elif usage_rate_pct >= quota.warning_threshold_pct:
status = "warning"
else:
status = "normal"
# 更新或创建 QuotaUsage 记录
existing_result = await session.execute(
select(QuotaUsage).where(
and_(
QuotaUsage.quota_id == quota.id,
QuotaUsage.period_start == period_start,
QuotaUsage.period_end == period_end,
)
)
)
usage_record = existing_result.scalar_one_or_none()
if usage_record:
usage_record.actual_value = actual_value
usage_record.usage_rate_pct = usage_rate_pct
usage_record.status = status
usage_record.calculated_at = now
else:
usage_record = QuotaUsage(
quota_id=quota.id,
period_start=period_start,
period_end=period_end,
actual_value=actual_value,
quota_value=quota.quota_value,
usage_rate_pct=usage_rate_pct,
status=status,
)
session.add(usage_record)
# 超过预警阈值时生成告警事件
if status in ("warning", "exceeded"):
# 检查是否已存在未解决的同配额告警
active_alarm = await session.execute(
select(AlarmEvent).where(
and_(
AlarmEvent.title == f"配额预警: {quota.name}",
AlarmEvent.status.in_(["active", "acknowledged"]),
)
)
)
if not active_alarm.scalar_one_or_none():
severity = "critical" if status == "exceeded" else "warning"
event = AlarmEvent(
rule_id=None,
device_id=quota.target_id,
severity=severity,
title=f"配额预警: {quota.name}",
description=f"当前使用 {actual_value:.1f}{quota.unit}"
f"配额 {quota.quota_value:.1f}{quota.unit}"
f"使用率 {usage_rate_pct:.1f}%",
value=actual_value,
threshold=quota.quota_value,
status="active",
triggered_at=now,
)
session.add(event)
logger.info(
f"Quota alert: {quota.name} | usage={actual_value:.1f} "
f"quota={quota.quota_value:.1f} rate={usage_rate_pct:.1f}%"
)
# Customer hook: on_quota_exceeded
try:
await hooks.on_quota_exceeded(quota, usage_record, session)
except Exception as _he:
logger.error(f"Hook on_quota_exceeded error: {_he}")
await session.flush()