feat: Phase 6 — alarm system (auto-generate, list, acknowledge)
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:
Johngreen
2026-02-10 14:39:03 +09:00
parent 035d62f0e0
commit 843f72e048
10 changed files with 889 additions and 1 deletions

184
src/api/alarms.py Normal file
View 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)

View File

@@ -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)

View File

@@ -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"),
)