feat: Phase 6 — alarm system (auto-generate, list, acknowledge)
All checks were successful
Deploy to Production / deploy (push) Successful in 1m17s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m17s
- Add Alarm model with tenant/part/machine relationships and 3 indexes - Add alembic migration c5d6e7f8a9b0_add_alarms - Add alarms API: list (filter by ack/severity), summary, acknowledge - Auto-generate alarms on counter update (threshold warning, critical at 100%) - Duplicate alarm prevention for same part+alarm_type - Add alarms frontend page with active/acknowledged tabs, summary badges - 9 new tests (67/67 total passing) Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
184
src/api/alarms.py
Normal file
184
src/api/alarms.py
Normal file
@@ -0,0 +1,184 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Path, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.database.config import get_db
|
||||
from src.database.models import Alarm, EquipmentPart, PartCounter, Machine
|
||||
from src.auth.models import TokenData
|
||||
from src.auth.dependencies import require_auth, verify_tenant_access
|
||||
|
||||
router = APIRouter(tags=["alarms"])
|
||||
|
||||
|
||||
def _format_ts(val) -> Optional[str]:
|
||||
if val is None:
|
||||
return None
|
||||
if hasattr(val, "isoformat"):
|
||||
return val.isoformat()
|
||||
return str(val)
|
||||
|
||||
|
||||
def _alarm_to_dict(alarm: Alarm) -> dict:
|
||||
return {
|
||||
"id": str(alarm.id),
|
||||
"tenant_id": str(alarm.tenant_id),
|
||||
"equipment_part_id": str(alarm.equipment_part_id),
|
||||
"machine_id": str(alarm.machine_id),
|
||||
"alarm_type": str(alarm.alarm_type),
|
||||
"severity": str(alarm.severity),
|
||||
"message": str(alarm.message),
|
||||
"lifecycle_pct_at_trigger": float(alarm.lifecycle_pct_at_trigger),
|
||||
"is_acknowledged": bool(alarm.is_acknowledged),
|
||||
"acknowledged_by": str(alarm.acknowledged_by)
|
||||
if alarm.acknowledged_by
|
||||
else None,
|
||||
"acknowledged_at": _format_ts(alarm.acknowledged_at),
|
||||
"created_at": _format_ts(alarm.created_at),
|
||||
"machine_name": alarm.machine.name if alarm.machine else None,
|
||||
"part_name": alarm.equipment_part.name if alarm.equipment_part else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/{tenant_id}/alarms")
|
||||
async def list_alarms(
|
||||
tenant_id: str = Path(...),
|
||||
is_acknowledged: Optional[bool] = Query(None),
|
||||
severity: Optional[str] = Query(None),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
|
||||
stmt = (
|
||||
select(Alarm)
|
||||
.options(
|
||||
selectinload(Alarm.equipment_part),
|
||||
selectinload(Alarm.machine),
|
||||
)
|
||||
.where(Alarm.tenant_id == tenant_id)
|
||||
)
|
||||
|
||||
if is_acknowledged is not None:
|
||||
stmt = stmt.where(Alarm.is_acknowledged == is_acknowledged)
|
||||
if severity:
|
||||
stmt = stmt.where(Alarm.severity == severity)
|
||||
|
||||
stmt = stmt.order_by(Alarm.created_at.desc())
|
||||
|
||||
result = await db.execute(stmt)
|
||||
alarms = result.scalars().all()
|
||||
|
||||
return {"alarms": [_alarm_to_dict(a) for a in alarms]}
|
||||
|
||||
|
||||
@router.get("/api/{tenant_id}/alarms/summary")
|
||||
async def alarm_summary(
|
||||
tenant_id: str = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
|
||||
stmt = select(
|
||||
func.count().filter(Alarm.is_acknowledged == False).label("active_count"),
|
||||
func.count()
|
||||
.filter(Alarm.severity == "critical", Alarm.is_acknowledged == False)
|
||||
.label("critical_count"),
|
||||
func.count()
|
||||
.filter(Alarm.severity == "warning", Alarm.is_acknowledged == False)
|
||||
.label("warning_count"),
|
||||
).where(Alarm.tenant_id == tenant_id)
|
||||
result = await db.execute(stmt)
|
||||
row = result.one()
|
||||
|
||||
return {
|
||||
"active_count": row.active_count,
|
||||
"critical_count": row.critical_count,
|
||||
"warning_count": row.warning_count,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/{tenant_id}/alarms/{alarm_id}/acknowledge")
|
||||
async def acknowledge_alarm(
|
||||
tenant_id: str = Path(...),
|
||||
alarm_id: UUID = Path(...),
|
||||
current_user: TokenData = Depends(require_auth),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
verify_tenant_access(tenant_id, current_user)
|
||||
|
||||
stmt = select(Alarm).where(Alarm.id == alarm_id, Alarm.tenant_id == tenant_id)
|
||||
result = await db.execute(stmt)
|
||||
alarm = result.scalar_one_or_none()
|
||||
|
||||
if not alarm:
|
||||
raise HTTPException(status_code=404, detail="알람을 찾을 수 없습니다.")
|
||||
|
||||
if alarm.is_acknowledged:
|
||||
raise HTTPException(status_code=400, detail="이미 확인된 알람입니다.")
|
||||
|
||||
alarm.is_acknowledged = True
|
||||
alarm.acknowledged_by = UUID(current_user.user_id)
|
||||
alarm.acknowledged_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(alarm)
|
||||
|
||||
db.expire_all()
|
||||
stmt2 = (
|
||||
select(Alarm)
|
||||
.options(selectinload(Alarm.equipment_part), selectinload(Alarm.machine))
|
||||
.where(Alarm.id == alarm_id)
|
||||
)
|
||||
alarm = (await db.execute(stmt2)).scalar_one()
|
||||
|
||||
return _alarm_to_dict(alarm)
|
||||
|
||||
|
||||
async def generate_alarms_for_part(
|
||||
db: AsyncSession,
|
||||
tenant_id: str,
|
||||
part: EquipmentPart,
|
||||
counter: PartCounter,
|
||||
):
|
||||
pct = float(counter.lifecycle_pct or 0)
|
||||
threshold = float(part.alarm_threshold or 90)
|
||||
|
||||
if pct < threshold:
|
||||
return
|
||||
|
||||
if pct >= 100:
|
||||
alarm_type = "critical"
|
||||
severity = "critical"
|
||||
message = f"{part.name} 수명 {pct:.1f}% — 즉시 교체 필요"
|
||||
else:
|
||||
alarm_type = "threshold"
|
||||
severity = "warning"
|
||||
message = f"{part.name} 수명 {pct:.1f}% — 교체 예정 (기준 {threshold:.0f}%)"
|
||||
|
||||
existing = await db.execute(
|
||||
select(Alarm).where(
|
||||
Alarm.tenant_id == tenant_id,
|
||||
Alarm.equipment_part_id == part.id,
|
||||
Alarm.alarm_type == alarm_type,
|
||||
Alarm.is_acknowledged == False,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
return
|
||||
|
||||
alarm = Alarm(
|
||||
tenant_id=tenant_id,
|
||||
equipment_part_id=part.id,
|
||||
machine_id=part.machine_id,
|
||||
alarm_type=alarm_type,
|
||||
severity=severity,
|
||||
message=message,
|
||||
lifecycle_pct_at_trigger=pct,
|
||||
)
|
||||
db.add(alarm)
|
||||
@@ -12,6 +12,7 @@ from src.database.config import get_db
|
||||
from src.database.models import Machine, EquipmentPart, PartCounter, PartReplacementLog
|
||||
from src.auth.models import TokenData
|
||||
from src.auth.dependencies import require_auth, verify_tenant_access
|
||||
from src.api.alarms import generate_alarms_for_part
|
||||
|
||||
router = APIRouter(tags=["equipment_parts"])
|
||||
|
||||
@@ -349,6 +350,9 @@ async def update_counter(
|
||||
counter.last_updated_at = now
|
||||
counter.version = (counter.version or 0) + 1
|
||||
|
||||
await db.flush()
|
||||
await generate_alarms_for_part(db, tenant_id, part, counter)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(counter)
|
||||
|
||||
|
||||
@@ -305,3 +305,33 @@ class InspectionRecord(Base):
|
||||
),
|
||||
Index("ix_records_session", "session_id"),
|
||||
)
|
||||
|
||||
|
||||
class Alarm(Base):
|
||||
__tablename__ = "alarms"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
|
||||
equipment_part_id = Column(
|
||||
UUID(as_uuid=True), ForeignKey("equipment_parts.id"), nullable=False
|
||||
)
|
||||
machine_id = Column(UUID(as_uuid=True), ForeignKey("machines.id"), nullable=False)
|
||||
alarm_type = Column(String(30), nullable=False) # threshold | critical
|
||||
severity = Column(String(20), nullable=False) # warning | critical
|
||||
message = Column(String(500), nullable=False)
|
||||
lifecycle_pct_at_trigger = Column(Float, nullable=False)
|
||||
is_acknowledged = Column(Boolean, default=False)
|
||||
acknowledged_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
||||
acknowledged_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
|
||||
|
||||
tenant = relationship("Tenant")
|
||||
equipment_part = relationship("EquipmentPart")
|
||||
machine = relationship("Machine")
|
||||
acknowledged_user = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_alarms_tenant_ack", "tenant_id", "is_acknowledged"),
|
||||
Index("ix_alarms_tenant_severity", "tenant_id", "severity"),
|
||||
Index("ix_alarms_tenant_part", "tenant_id", "equipment_part_id"),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user