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:
76
alembic/versions/c5d6e7f8a9b0_add_alarms.py
Normal file
76
alembic/versions/c5d6e7f8a9b0_add_alarms.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""add_alarms
|
||||
|
||||
Revision ID: c5d6e7f8a9b0
|
||||
Revises: b4c5d6e7f8a9
|
||||
Create Date: 2026-02-10 14:20:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "c5d6e7f8a9b0"
|
||||
down_revision: Union[str, None] = "b4c5d6e7f8a9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"alarms",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column(
|
||||
"tenant_id",
|
||||
sa.String(50),
|
||||
sa.ForeignKey("tenants.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"equipment_part_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("equipment_parts.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"machine_id",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("machines.id"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("alarm_type", sa.String(30), nullable=False),
|
||||
sa.Column("severity", sa.String(20), nullable=False),
|
||||
sa.Column("message", sa.String(500), nullable=False),
|
||||
sa.Column("lifecycle_pct_at_trigger", sa.Float, nullable=False),
|
||||
sa.Column("is_acknowledged", sa.Boolean, default=False),
|
||||
sa.Column(
|
||||
"acknowledged_by",
|
||||
postgresql.UUID(as_uuid=True),
|
||||
sa.ForeignKey("users.id"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"acknowledged_at",
|
||||
postgresql.TIMESTAMP(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
postgresql.TIMESTAMP(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_alarms_tenant_ack", "alarms", ["tenant_id", "is_acknowledged"])
|
||||
op.create_index("ix_alarms_tenant_severity", "alarms", ["tenant_id", "severity"])
|
||||
op.create_index(
|
||||
"ix_alarms_tenant_part", "alarms", ["tenant_id", "equipment_part_id"]
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_alarms_tenant_part", table_name="alarms")
|
||||
op.drop_index("ix_alarms_tenant_severity", table_name="alarms")
|
||||
op.drop_index("ix_alarms_tenant_ack", table_name="alarms")
|
||||
op.drop_table("alarms")
|
||||
154
dashboard/app/[tenant]/alarms/page.tsx
Normal file
154
dashboard/app/[tenant]/alarms/page.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useAlarms, useAlarmSummary } from '@/lib/hooks';
|
||||
import { useToast } from '@/lib/toast-context';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Alarm } from '@/lib/types';
|
||||
|
||||
type TabType = 'active' | 'acknowledged';
|
||||
|
||||
export default function AlarmsPage() {
|
||||
const params = useParams();
|
||||
const tenantId = params?.tenant as string;
|
||||
const { addToast } = useToast();
|
||||
|
||||
const [tab, setTab] = useState<TabType>('active');
|
||||
const [acknowledging, setAcknowledging] = useState<string | null>(null);
|
||||
|
||||
const isAcknowledged = tab === 'acknowledged';
|
||||
const { alarms, isLoading, mutate } = useAlarms(tenantId, isAcknowledged);
|
||||
const { summary, mutate: mutateSummary } = useAlarmSummary(tenantId);
|
||||
|
||||
const handleAcknowledge = useCallback(async (alarm: Alarm) => {
|
||||
setAcknowledging(alarm.id);
|
||||
try {
|
||||
await api.post<Alarm>(`/api/${tenantId}/alarms/${alarm.id}/acknowledge`, {});
|
||||
addToast('알람이 확인 처리되었습니다.', 'success');
|
||||
mutate();
|
||||
mutateSummary();
|
||||
} catch {
|
||||
addToast('알람 확인 처리에 실패했습니다.', 'error');
|
||||
} finally {
|
||||
setAcknowledging(null);
|
||||
}
|
||||
}, [tenantId, mutate, mutateSummary, addToast]);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h2 className="page-title">
|
||||
<span className="material-symbols-outlined">notifications</span>
|
||||
알람
|
||||
</h2>
|
||||
{summary && (
|
||||
<div className="alarm-summary-badges">
|
||||
{summary.critical_count > 0 && (
|
||||
<span className="alarm-badge alarm-badge-critical">
|
||||
긴급 {summary.critical_count}
|
||||
</span>
|
||||
)}
|
||||
{summary.warning_count > 0 && (
|
||||
<span className="alarm-badge alarm-badge-warning">
|
||||
주의 {summary.warning_count}
|
||||
</span>
|
||||
)}
|
||||
{summary.active_count === 0 && (
|
||||
<span className="alarm-badge alarm-badge-ok">
|
||||
알람 없음
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tab-bar">
|
||||
<button
|
||||
className={`tab-item ${tab === 'active' ? 'active' : ''}`}
|
||||
onClick={() => setTab('active')}
|
||||
>
|
||||
<span className="material-symbols-outlined">warning</span>
|
||||
미확인 {summary ? `(${summary.active_count})` : ''}
|
||||
</button>
|
||||
<button
|
||||
className={`tab-item ${tab === 'acknowledged' ? 'active' : ''}`}
|
||||
onClick={() => setTab('acknowledged')}
|
||||
>
|
||||
<span className="material-symbols-outlined">check_circle</span>
|
||||
확인 완료
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="loading">
|
||||
<span className="material-symbols-outlined spinning">progress_activity</span>
|
||||
</div>
|
||||
) : alarms.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<span className="material-symbols-outlined">
|
||||
{tab === 'active' ? 'notifications_off' : 'check_circle'}
|
||||
</span>
|
||||
<p>{tab === 'active' ? '미확인 알람이 없습니다.' : '확인된 알람이 없습니다.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="alarm-list">
|
||||
{alarms.map((alarm) => (
|
||||
<div
|
||||
key={alarm.id}
|
||||
className={`alarm-card alarm-card-${alarm.severity}`}
|
||||
>
|
||||
<div className="alarm-card-icon">
|
||||
<span className="material-symbols-outlined">
|
||||
{alarm.severity === 'critical' ? 'error' : 'warning'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="alarm-card-body">
|
||||
<div className="alarm-card-header">
|
||||
<span className={`alarm-severity-badge severity-${alarm.severity}`}>
|
||||
{alarm.severity === 'critical' ? '긴급' : '주의'}
|
||||
</span>
|
||||
<span className="alarm-card-time">
|
||||
{alarm.created_at ? new Date(alarm.created_at).toLocaleString('ko-KR') : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="alarm-card-message">{alarm.message}</p>
|
||||
<div className="alarm-card-meta">
|
||||
<span>
|
||||
<span className="material-symbols-outlined">precision_manufacturing</span>
|
||||
{alarm.machine_name || '-'}
|
||||
</span>
|
||||
<span>
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
{alarm.part_name || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="alarm-card-actions">
|
||||
{!alarm.is_acknowledged ? (
|
||||
<button
|
||||
className="btn-primary btn-sm"
|
||||
onClick={() => handleAcknowledge(alarm)}
|
||||
disabled={acknowledging === alarm.id}
|
||||
>
|
||||
{acknowledging === alarm.id ? (
|
||||
<span className="material-symbols-outlined spinning">progress_activity</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined">check</span>
|
||||
)}
|
||||
확인
|
||||
</button>
|
||||
) : (
|
||||
<span className="alarm-ack-info">
|
||||
<span className="material-symbols-outlined">check_circle</span>
|
||||
{alarm.acknowledged_at ? new Date(alarm.acknowledged_at).toLocaleString('ko-KR') : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1767,6 +1767,161 @@ a {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* ===== Phase 6: Alarms ===== */
|
||||
|
||||
.alarm-summary-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.alarm-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alarm-badge-critical {
|
||||
background: #fce8e6;
|
||||
color: var(--md-error);
|
||||
}
|
||||
|
||||
.alarm-badge-warning {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.alarm-badge-ok {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.alarm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.alarm-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--md-outline-variant);
|
||||
background: var(--md-surface);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.alarm-card:hover {
|
||||
box-shadow: var(--md-shadow-1);
|
||||
}
|
||||
|
||||
.alarm-card-warning {
|
||||
border-left: 4px solid #e65100;
|
||||
}
|
||||
|
||||
.alarm-card-critical {
|
||||
border-left: 4px solid var(--md-error);
|
||||
background: #fff8f7;
|
||||
}
|
||||
|
||||
.alarm-card-icon {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.alarm-card-warning .alarm-card-icon {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.alarm-card-critical .alarm-card-icon {
|
||||
background: #fce8e6;
|
||||
color: var(--md-error);
|
||||
}
|
||||
|
||||
.alarm-card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.alarm-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.alarm-severity-badge {
|
||||
padding: 2px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.severity-warning {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
}
|
||||
|
||||
.severity-critical {
|
||||
background: #fce8e6;
|
||||
color: var(--md-error);
|
||||
}
|
||||
|
||||
.alarm-card-time {
|
||||
font-size: 12px;
|
||||
color: var(--md-on-surface-variant);
|
||||
}
|
||||
|
||||
.alarm-card-message {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--md-on-surface);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.alarm-card-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--md-on-surface-variant);
|
||||
}
|
||||
|
||||
.alarm-card-meta span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.alarm-card-meta .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.alarm-card-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.alarm-ack-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.alarm-ack-info .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 768px) {
|
||||
.topnav-center {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import useSWR from 'swr';
|
||||
import { fetcher, getTenantUrl } from './api';
|
||||
import type { Tenant, Machine, MachineDetail, EquipmentPart, InspectionTemplate, InspectionSession, PartReplacementLog } from './types';
|
||||
import type { Tenant, Machine, MachineDetail, EquipmentPart, InspectionTemplate, InspectionSession, PartReplacementLog, Alarm, AlarmSummary } from './types';
|
||||
|
||||
export function useTenants() {
|
||||
const { data, error, isLoading, mutate } = useSWR<{ tenants: Tenant[] }>(
|
||||
@@ -125,6 +125,41 @@ export function useReplacements(tenantId?: string, partId?: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export function useAlarms(tenantId?: string, isAcknowledged?: boolean) {
|
||||
const params = new URLSearchParams();
|
||||
if (isAcknowledged !== undefined) params.set('is_acknowledged', String(isAcknowledged));
|
||||
const qs = params.toString();
|
||||
const url = tenantId ? `/api/${tenantId}/alarms${qs ? `?${qs}` : ''}` : null;
|
||||
const { data, error, isLoading, mutate } = useSWR<{ alarms: Alarm[] }>(
|
||||
url,
|
||||
fetcher,
|
||||
{ refreshInterval: 10000, dedupingInterval: 2000 },
|
||||
);
|
||||
|
||||
return {
|
||||
alarms: data?.alarms || [],
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAlarmSummary(tenantId?: string) {
|
||||
const url = tenantId ? `/api/${tenantId}/alarms/summary` : null;
|
||||
const { data, error, isLoading, mutate } = useSWR<AlarmSummary>(
|
||||
url,
|
||||
fetcher,
|
||||
{ refreshInterval: 10000, dedupingInterval: 2000 },
|
||||
);
|
||||
|
||||
return {
|
||||
summary: data ?? null,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useInspections(tenantId?: string, status?: string, templateId?: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (status) params.set('status', status);
|
||||
|
||||
@@ -143,6 +143,29 @@ export interface PartReplacementLog {
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface Alarm {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
equipment_part_id: string;
|
||||
machine_id: string;
|
||||
alarm_type: 'threshold' | 'critical';
|
||||
severity: 'warning' | 'critical';
|
||||
message: string;
|
||||
lifecycle_pct_at_trigger: number;
|
||||
is_acknowledged: boolean;
|
||||
acknowledged_by: string | null;
|
||||
acknowledged_at: string | null;
|
||||
created_at: string | null;
|
||||
machine_name: string | null;
|
||||
part_name: string | null;
|
||||
}
|
||||
|
||||
export interface AlarmSummary {
|
||||
active_count: number;
|
||||
critical_count: number;
|
||||
warning_count: number;
|
||||
}
|
||||
|
||||
export interface InspectionTemplate {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
|
||||
2
main.py
2
main.py
@@ -23,6 +23,7 @@ from src.api.machines import router as machines_router
|
||||
from src.api.equipment_parts import router as equipment_parts_router
|
||||
from src.api.templates import router as templates_router
|
||||
from src.api.inspections import router as inspections_router
|
||||
from src.api.alarms import router as alarms_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,6 +56,7 @@ app.include_router(machines_router)
|
||||
app.include_router(equipment_parts_router)
|
||||
app.include_router(templates_router)
|
||||
app.include_router(inspections_router)
|
||||
app.include_router(alarms_router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
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"),
|
||||
)
|
||||
|
||||
225
tests/test_alarms.py
Normal file
225
tests/test_alarms.py
Normal file
@@ -0,0 +1,225 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from tests.conftest import get_auth_headers
|
||||
|
||||
|
||||
async def _create_machine(
|
||||
client: AsyncClient, headers: dict, tenant_id: str = "test-co"
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"/api/{tenant_id}/machines",
|
||||
json={"name": "알람 테스트 설비", "equipment_code": "A-001"},
|
||||
headers=headers,
|
||||
)
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
async def _create_part(
|
||||
client: AsyncClient,
|
||||
headers: dict,
|
||||
machine_id: str,
|
||||
tenant_id: str = "test-co",
|
||||
alarm_threshold: float = 80.0,
|
||||
) -> str:
|
||||
resp = await client.post(
|
||||
f"/api/{tenant_id}/machines/{machine_id}/parts",
|
||||
json={
|
||||
"name": "알람 부품",
|
||||
"lifecycle_type": "hours",
|
||||
"lifecycle_limit": 1000,
|
||||
"alarm_threshold": alarm_threshold,
|
||||
"counter_source": "manual",
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_alarm_below_threshold(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
mid = await _create_machine(client, headers)
|
||||
pid = await _create_part(client, headers, mid)
|
||||
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 700},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get("/api/test-co/alarms", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()["alarms"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_warning_alarm_at_threshold(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
mid = await _create_machine(client, headers)
|
||||
pid = await _create_part(client, headers, mid)
|
||||
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 850},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get("/api/test-co/alarms", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
alarms = resp.json()["alarms"]
|
||||
assert len(alarms) == 1
|
||||
assert alarms[0]["severity"] == "warning"
|
||||
assert alarms[0]["alarm_type"] == "threshold"
|
||||
assert alarms[0]["is_acknowledged"] is False
|
||||
assert "85.0%" in alarms[0]["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_critical_alarm_at_100pct(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
mid = await _create_machine(client, headers)
|
||||
pid = await _create_part(client, headers, mid)
|
||||
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 1100},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get("/api/test-co/alarms", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
alarms = resp.json()["alarms"]
|
||||
assert len(alarms) == 1
|
||||
assert alarms[0]["severity"] == "critical"
|
||||
assert alarms[0]["alarm_type"] == "critical"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_duplicate_alarm(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
mid = await _create_machine(client, headers)
|
||||
pid = await _create_part(client, headers, mid)
|
||||
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 900},
|
||||
headers=headers,
|
||||
)
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 950},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get("/api/test-co/alarms", headers=headers)
|
||||
alarms = resp.json()["alarms"]
|
||||
assert len(alarms) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_alarm(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
mid = await _create_machine(client, headers)
|
||||
pid = await _create_part(client, headers, mid)
|
||||
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 850},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get("/api/test-co/alarms", headers=headers)
|
||||
alarm_id = resp.json()["alarms"][0]["id"]
|
||||
|
||||
ack_resp = await client.post(
|
||||
f"/api/test-co/alarms/{alarm_id}/acknowledge",
|
||||
headers=headers,
|
||||
)
|
||||
assert ack_resp.status_code == 200
|
||||
assert ack_resp.json()["is_acknowledged"] is True
|
||||
assert ack_resp.json()["acknowledged_by"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acknowledge_already_acknowledged(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
mid = await _create_machine(client, headers)
|
||||
pid = await _create_part(client, headers, mid)
|
||||
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 850},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get("/api/test-co/alarms", headers=headers)
|
||||
alarm_id = resp.json()["alarms"][0]["id"]
|
||||
|
||||
await client.post(f"/api/test-co/alarms/{alarm_id}/acknowledge", headers=headers)
|
||||
ack2 = await client.post(
|
||||
f"/api/test-co/alarms/{alarm_id}/acknowledge", headers=headers
|
||||
)
|
||||
assert ack2.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_unacknowledged(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
mid = await _create_machine(client, headers)
|
||||
pid = await _create_part(client, headers, mid)
|
||||
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 850},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get(
|
||||
"/api/test-co/alarms?is_acknowledged=false", headers=headers
|
||||
)
|
||||
assert len(resp.json()["alarms"]) == 1
|
||||
|
||||
alarm_id = resp.json()["alarms"][0]["id"]
|
||||
await client.post(f"/api/test-co/alarms/{alarm_id}/acknowledge", headers=headers)
|
||||
|
||||
resp2 = await client.get(
|
||||
"/api/test-co/alarms?is_acknowledged=false", headers=headers
|
||||
)
|
||||
assert len(resp2.json()["alarms"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alarm_summary(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
mid = await _create_machine(client, headers)
|
||||
pid = await _create_part(client, headers, mid)
|
||||
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 850},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp = await client.get("/api/test-co/alarms/summary", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["active_count"] == 1
|
||||
assert data["warning_count"] == 1
|
||||
assert data["critical_count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_isolation(client: AsyncClient, seeded_db):
|
||||
headers = await get_auth_headers(client)
|
||||
mid = await _create_machine(client, headers)
|
||||
pid = await _create_part(client, headers, mid)
|
||||
|
||||
await client.put(
|
||||
f"/api/test-co/parts/{pid}/counter",
|
||||
json={"value": 900},
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
resp_other = await client.get("/api/other-co/alarms", headers=headers)
|
||||
assert resp_other.status_code == 200
|
||||
assert len(resp_other.json()["alarms"]) == 0
|
||||
Reference in New Issue
Block a user