From 843f72e048cbe02e62db6539d4b46f64ae43814d Mon Sep 17 00:00:00 2001 From: Johngreen Date: Tue, 10 Feb 2026 14:39:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=206=20=E2=80=94=20alarm=20system?= =?UTF-8?q?=20(auto-generate,=20list,=20acknowledge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- alembic/versions/c5d6e7f8a9b0_add_alarms.py | 76 +++++++ dashboard/app/[tenant]/alarms/page.tsx | 154 ++++++++++++++ dashboard/app/globals.css | 155 ++++++++++++++ dashboard/lib/hooks.ts | 37 +++- dashboard/lib/types.ts | 23 ++ main.py | 2 + src/api/alarms.py | 184 ++++++++++++++++ src/api/equipment_parts.py | 4 + src/database/models.py | 30 +++ tests/test_alarms.py | 225 ++++++++++++++++++++ 10 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/c5d6e7f8a9b0_add_alarms.py create mode 100644 dashboard/app/[tenant]/alarms/page.tsx create mode 100644 src/api/alarms.py create mode 100644 tests/test_alarms.py diff --git a/alembic/versions/c5d6e7f8a9b0_add_alarms.py b/alembic/versions/c5d6e7f8a9b0_add_alarms.py new file mode 100644 index 0000000..32ba7ca --- /dev/null +++ b/alembic/versions/c5d6e7f8a9b0_add_alarms.py @@ -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") diff --git a/dashboard/app/[tenant]/alarms/page.tsx b/dashboard/app/[tenant]/alarms/page.tsx new file mode 100644 index 0000000..a6ee11c --- /dev/null +++ b/dashboard/app/[tenant]/alarms/page.tsx @@ -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('active'); + const [acknowledging, setAcknowledging] = useState(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(`/api/${tenantId}/alarms/${alarm.id}/acknowledge`, {}); + addToast('알람이 확인 처리되었습니다.', 'success'); + mutate(); + mutateSummary(); + } catch { + addToast('알람 확인 처리에 실패했습니다.', 'error'); + } finally { + setAcknowledging(null); + } + }, [tenantId, mutate, mutateSummary, addToast]); + + return ( +
+
+

+ notifications + 알람 +

+ {summary && ( +
+ {summary.critical_count > 0 && ( + + 긴급 {summary.critical_count} + + )} + {summary.warning_count > 0 && ( + + 주의 {summary.warning_count} + + )} + {summary.active_count === 0 && ( + + 알람 없음 + + )} +
+ )} +
+ +
+ + +
+ + {isLoading ? ( +
+ progress_activity +
+ ) : alarms.length === 0 ? ( +
+ + {tab === 'active' ? 'notifications_off' : 'check_circle'} + +

{tab === 'active' ? '미확인 알람이 없습니다.' : '확인된 알람이 없습니다.'}

+
+ ) : ( +
+ {alarms.map((alarm) => ( +
+
+ + {alarm.severity === 'critical' ? 'error' : 'warning'} + +
+
+
+ + {alarm.severity === 'critical' ? '긴급' : '주의'} + + + {alarm.created_at ? new Date(alarm.created_at).toLocaleString('ko-KR') : ''} + +
+

{alarm.message}

+
+ + precision_manufacturing + {alarm.machine_name || '-'} + + + settings + {alarm.part_name || '-'} + +
+
+
+ {!alarm.is_acknowledged ? ( + + ) : ( + + check_circle + {alarm.acknowledged_at ? new Date(alarm.acknowledged_at).toLocaleString('ko-KR') : ''} + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index 99846ee..eda102e 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -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 { diff --git a/dashboard/lib/hooks.ts b/dashboard/lib/hooks.ts index d64f51f..9ab9105 100644 --- a/dashboard/lib/hooks.ts +++ b/dashboard/lib/hooks.ts @@ -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( + 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); diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index 1ae8b08..af698c1 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -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; diff --git a/main.py b/main.py index 3c6499f..7b099fd 100644 --- a/main.py +++ b/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") diff --git a/src/api/alarms.py b/src/api/alarms.py new file mode 100644 index 0000000..efd96fa --- /dev/null +++ b/src/api/alarms.py @@ -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) diff --git a/src/api/equipment_parts.py b/src/api/equipment_parts.py index b1efb13..d7387ac 100644 --- a/src/api/equipment_parts.py +++ b/src/api/equipment_parts.py @@ -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) diff --git a/src/database/models.py b/src/database/models.py index e998fa4..a1bfd4f 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -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"), + ) diff --git a/tests/test_alarms.py b/tests/test_alarms.py new file mode 100644 index 0000000..e8e035b --- /dev/null +++ b/tests/test_alarms.py @@ -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