diff --git a/alembic/versions/d6e7f8a9b0c1_add_machine_specs_alarm_inspection.py b/alembic/versions/d6e7f8a9b0c1_add_machine_specs_alarm_inspection.py new file mode 100644 index 0000000..97835c5 --- /dev/null +++ b/alembic/versions/d6e7f8a9b0c1_add_machine_specs_alarm_inspection.py @@ -0,0 +1,61 @@ +"""add machine spec fields and alarm inspection_session_id + +Revision ID: d6e7f8a9b0c1 +Revises: c5d6e7f8a9b0 +Create Date: 2026-02-10 15:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "d6e7f8a9b0c1" +down_revision: Union[str, None] = "c5d6e7f8a9b0" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("machines", sa.Column("manufacturer", sa.String(100), nullable=True)) + op.add_column( + "machines", + sa.Column( + "installation_date", postgresql.TIMESTAMP(timezone=True), nullable=True + ), + ) + op.add_column("machines", sa.Column("location", sa.String(200), nullable=True)) + op.add_column( + "machines", sa.Column("rated_capacity", sa.String(100), nullable=True) + ) + op.add_column("machines", sa.Column("power_rating", sa.String(100), nullable=True)) + op.add_column("machines", sa.Column("description", sa.Text, nullable=True)) + + op.add_column( + "alarms", + sa.Column( + "inspection_session_id", + postgresql.UUID(as_uuid=True), + sa.ForeignKey("inspection_sessions.id"), + nullable=True, + ), + ) + op.alter_column("alarms", "equipment_part_id", nullable=True) + op.alter_column("alarms", "machine_id", nullable=True) + op.alter_column("alarms", "lifecycle_pct_at_trigger", nullable=True) + + +def downgrade() -> None: + op.alter_column("alarms", "lifecycle_pct_at_trigger", nullable=False) + op.alter_column("alarms", "machine_id", nullable=False) + op.alter_column("alarms", "equipment_part_id", nullable=False) + op.drop_column("alarms", "inspection_session_id") + + op.drop_column("machines", "description") + op.drop_column("machines", "power_rating") + op.drop_column("machines", "rated_capacity") + op.drop_column("machines", "location") + op.drop_column("machines", "installation_date") + op.drop_column("machines", "manufacturer") diff --git a/dashboard/app/[tenant]/machines/[id]/page.tsx b/dashboard/app/[tenant]/machines/[id]/page.tsx index 860a43e..459be7f 100644 --- a/dashboard/app/[tenant]/machines/[id]/page.tsx +++ b/dashboard/app/[tenant]/machines/[id]/page.tsx @@ -261,6 +261,55 @@ export default function MachineDetailPage() { + {(machine.manufacturer || machine.location || machine.rated_capacity || machine.power_rating || machine.description || machine.installation_date) && ( +
+
+

+ info + 설비 제원 +

+
+
+ {machine.manufacturer && ( +
+ 제조사 + {machine.manufacturer} +
+ )} + {machine.installation_date && ( +
+ 설치일 + {new Date(machine.installation_date).toLocaleDateString('ko-KR')} +
+ )} + {machine.location && ( +
+ 설치 위치 + {machine.location} +
+ )} + {machine.rated_capacity && ( +
+ 정격 용량 + {machine.rated_capacity} +
+ )} + {machine.power_rating && ( +
+ 전력 사양 + {machine.power_rating} +
+ )} + {machine.description && ( +
+ 설명 + {machine.description} +
+ )} +
+
+ )} +

@@ -320,9 +369,15 @@ export default function MachineDetailPage() { {COUNTER_SOURCES.find((s) => s.value === part.counter_source)?.label || part.counter_source}
- + {part.counter_source === 'auto_time' || part.lifecycle_type === 'date' ? ( + + timer + + ) : ( + + )} diff --git a/dashboard/app/globals.css b/dashboard/app/globals.css index eda102e..8659d98 100644 --- a/dashboard/app/globals.css +++ b/dashboard/app/globals.css @@ -1922,6 +1922,50 @@ a { font-size: 18px; } +.spec-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + padding: 16px 0; +} + +.spec-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.spec-item-full { + grid-column: 1 / -1; +} + +.spec-label { + font-size: 12px; + color: var(--md-on-surface-variant); + font-weight: 500; +} + +.spec-value { + font-size: 14px; + color: var(--md-on-surface); +} + +.btn-icon-disabled { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + opacity: 0.4; + cursor: default; +} + +.btn-icon-disabled .material-symbols-outlined { + font-size: 18px; + color: var(--md-on-surface-variant); +} + /* ===== Responsive ===== */ @media (max-width: 768px) { .topnav-center { @@ -1970,6 +2014,53 @@ a { .part-table td { padding: 8px 10px; } + + .spec-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + + .page-container { + padding: 16px; + } + + .detail-header { + flex-direction: column; + gap: 12px; + } + + .modal-content.modal-lg { + width: 95vw; + max-width: none; + } + + .part-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .history-table { + font-size: 12px; + } + + .alarm-card { + padding: 12px; + } + + .alarm-card-header { + flex-direction: column; + gap: 8px; + } + + .section-header { + flex-direction: column; + gap: 8px; + align-items: flex-start; + } + + .inspection-summary-grid { + grid-template-columns: repeat(2, 1fr); + } } @media (max-width: 480px) { @@ -1995,4 +2086,48 @@ a { .tenant-grid { grid-template-columns: 1fr; } + + .spec-grid { + grid-template-columns: 1fr; + } + + .page-container { + padding: 12px; + } + + .detail-title { + font-size: 18px; + } + + .detail-subtitle { + font-size: 12px; + } + + .part-table th:nth-child(n+6), + .part-table td:nth-child(n+6) { + display: none; + } + + .btn-primary, + .btn-outline { + font-size: 13px; + padding: 8px 14px; + } + + .modal-content { + margin: 8px; + max-height: 95vh; + } + + .modal-content.modal-sm { + width: 95vw; + } + + .topnav { + padding: 0 12px; + } + + .inspection-summary-grid { + grid-template-columns: 1fr; + } } diff --git a/dashboard/lib/types.ts b/dashboard/lib/types.ts index af698c1..126bb62 100644 --- a/dashboard/lib/types.ts +++ b/dashboard/lib/types.ts @@ -27,6 +27,12 @@ export interface Machine { name: string; equipment_code: string; model: string | null; + manufacturer: string | null; + installation_date: string | null; + location: string | null; + rated_capacity: string | null; + power_rating: string | null; + description: string | null; parts_count: number; created_at: string | null; updated_at: string | null; @@ -146,12 +152,13 @@ export interface PartReplacementLog { export interface Alarm { id: string; tenant_id: string; - equipment_part_id: string; - machine_id: string; - alarm_type: 'threshold' | 'critical'; + equipment_part_id: string | null; + machine_id: string | null; + inspection_session_id: string | null; + alarm_type: 'threshold' | 'critical' | 'inspection_fail'; severity: 'warning' | 'critical'; message: string; - lifecycle_pct_at_trigger: number; + lifecycle_pct_at_trigger: number | null; is_acknowledged: boolean; acknowledged_by: string | null; acknowledged_at: string | null; diff --git a/scripts/seed.py b/scripts/seed.py index e57d1d0..2a756df 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -78,12 +78,30 @@ MACHINES = { "name": "CVD Chamber #1", "equipment_code": "SF-CVD-001", "model": "AMAT Centura 5200", + "manufacturer": "Applied Materials", + "location": "클린룸 Bay 3", + "rated_capacity": "200mm 웨이퍼", + "power_rating": "30kW", + "description": "화학기상증착 장비. SiO2, Si3N4 박막 증착용.", + }, + { + "name": "Etcher DRY-A", + "equipment_code": "SF-ETCH-001", + "model": "LAM 9600", + "manufacturer": "Lam Research", + "location": "클린룸 Bay 5", + "rated_capacity": "200mm 웨이퍼", + "power_rating": "25kW", + "description": "건식 식각 장비. Poly-Si, Metal 식각용.", }, - {"name": "Etcher DRY-A", "equipment_code": "SF-ETCH-001", "model": "LAM 9600"}, { "name": "Sputter MC-200", "equipment_code": "SF-SPT-001", "model": "Ulvac SH-450", + "manufacturer": "ULVAC", + "location": "클린룸 Bay 7", + "rated_capacity": "Ti/TiN 300mm", + "power_rating": "20kW", }, ], "enkid": [ @@ -91,16 +109,29 @@ MACHINES = { "name": "CNC 선반 #1", "equipment_code": "EK-CNC-001", "model": "두산 LYNX 220", + "manufacturer": "두산공작기계", + "location": "1공장 A라인", + "rated_capacity": "최대 가공경 220mm", + "power_rating": "15kW (주축 모터)", + "description": "2축 CNC 선반. 알루미늄/스틸 가공용.", }, { "name": "프레스 500T", "equipment_code": "EK-PRS-001", "model": "현대위아 HF-500", + "manufacturer": "현대위아", + "location": "1공장 B라인", + "rated_capacity": "500톤", + "power_rating": "37kW", }, { "name": "용접 로봇 #1", "equipment_code": "EK-WLD-001", "model": "화낙 ARC Mate 120", + "manufacturer": "FANUC", + "location": "2공장 용접 셀 1", + "rated_capacity": "6축 120kg 가반하중", + "power_rating": "6kVA", }, ], "alpet": [ @@ -108,16 +139,29 @@ MACHINES = { "name": "반응기 R-101", "equipment_code": "AP-RCT-101", "model": "Custom Reactor 5000L", + "manufacturer": "한화솔루션 엔지니어링", + "location": "1플랜트 반응 구역", + "rated_capacity": "5,000L (SUS316L)", + "power_rating": "22kW (교반기)", + "description": "교반식 배치 반응기. HCl 합성 공정.", }, { "name": "증류탑 D-201", "equipment_code": "AP-DST-201", "model": "Sulzer Packed Tower", + "manufacturer": "Sulzer", + "location": "1플랜트 분리 구역", + "rated_capacity": "처리량 2,000 kg/h", + "power_rating": "리보일러 150kW", }, { "name": "열교환기 E-301", "equipment_code": "AP-HEX-301", "model": "Alfa Laval M10-BW", + "manufacturer": "Alfa Laval", + "location": "1플랜트 유틸리티", + "rated_capacity": "열전달 500kW", + "power_rating": "펌프 7.5kW", }, ], } @@ -183,6 +227,16 @@ PARTS = { "auto_time", 5000, ), + ( + "교정 인증서", + "CAL-SPT-01", + "인증", + "date", + 365, + 80, + "manual", + 0, + ), ], }, "enkid": { @@ -262,6 +316,16 @@ PARTS = { "auto_time", 1500, ), + ( + "안전 검사 인증", + "CERT-WLD-01", + "인증", + "date", + 180, + 85, + "manual", + 0, + ), ], }, "alpet": { @@ -323,6 +387,16 @@ PARTS = { 15000, ), ("압력 센서", "PS-HEX-01", "계측", "hours", 5000, 90, "auto_time", 2000), + ( + "내압 검사 인증", + "CERT-HEX-01", + "인증", + "date", + 730, + 85, + "manual", + 0, + ), ], }, } @@ -654,6 +728,7 @@ async def seed(): machine_map[(tenant_id, m["name"])] = row.id continue machine_id = uuid.uuid4() + install_date = NOW - timedelta(days=365 * 2) db.add( Machine( id=machine_id, @@ -661,6 +736,12 @@ async def seed(): name=m["name"], equipment_code=m["equipment_code"], model=m["model"], + manufacturer=m.get("manufacturer"), + installation_date=install_date, + location=m.get("location"), + rated_capacity=m.get("rated_capacity"), + power_rating=m.get("power_rating"), + description=m.get("description"), ) ) machine_map[(tenant_id, m["name"])] = machine_id diff --git a/src/api/alarms.py b/src/api/alarms.py index efd96fa..3b71a63 100644 --- a/src/api/alarms.py +++ b/src/api/alarms.py @@ -8,7 +8,16 @@ 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.database.models import ( + Alarm, + EquipmentPart, + PartCounter, + Machine, + InspectionSession, + InspectionRecord, + InspectionTemplate, + InspectionTemplateItem, +) from src.auth.models import TokenData from src.auth.dependencies import require_auth, verify_tenant_access @@ -27,12 +36,19 @@ 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), + "equipment_part_id": str(alarm.equipment_part_id) + if alarm.equipment_part_id + else None, + "machine_id": str(alarm.machine_id) if alarm.machine_id else None, + "inspection_session_id": str(alarm.inspection_session_id) + if alarm.inspection_session_id + else None, "alarm_type": str(alarm.alarm_type), "severity": str(alarm.severity), "message": str(alarm.message), - "lifecycle_pct_at_trigger": float(alarm.lifecycle_pct_at_trigger), + "lifecycle_pct_at_trigger": float(alarm.lifecycle_pct_at_trigger) + if alarm.lifecycle_pct_at_trigger is not None + else None, "is_acknowledged": bool(alarm.is_acknowledged), "acknowledged_by": str(alarm.acknowledged_by) if alarm.acknowledged_by @@ -182,3 +198,33 @@ async def generate_alarms_for_part( lifecycle_pct_at_trigger=pct, ) db.add(alarm) + + +async def generate_alarms_for_inspection( + db: AsyncSession, + tenant_id: str, + session: InspectionSession, + fail_records: list, + template_name: str, +): + if not fail_records: + return + + fail_count = len(fail_records) + severity = "critical" if fail_count >= 3 else "warning" + + for record, item_name, spec_info in fail_records: + message = ( + f"검사 '{template_name}'의 '{item_name}' 항목이 불합격입니다 ({spec_info})" + ) + if len(message) > 500: + message = message[:497] + "..." + + alarm = Alarm( + tenant_id=tenant_id, + inspection_session_id=session.id, + alarm_type="inspection_fail", + severity=severity, + message=message, + ) + db.add(alarm) diff --git a/src/api/equipment_parts.py b/src/api/equipment_parts.py index d7387ac..e1cc63d 100644 --- a/src/api/equipment_parts.py +++ b/src/api/equipment_parts.py @@ -53,6 +53,38 @@ def _format_ts(val) -> Optional[str]: return val.isoformat() if hasattr(val, "isoformat") else str(val) +def _compute_auto_counter(part: EquipmentPart, counter: PartCounter) -> tuple: + source = str(part.counter_source or "manual") + lt = str(part.lifecycle_type) + limit = float(part.lifecycle_limit) if part.lifecycle_limit else 0 + + if source == "auto_time" and lt == "hours" and limit > 0: + ref = counter.last_reset_at or part.installed_at + if ref: + now = datetime.now(timezone.utc) + if ref.tzinfo is None: + from datetime import timezone as tz + + ref = ref.replace(tzinfo=tz.utc) + elapsed_hours = (now - ref).total_seconds() / 3600 + pct = round((elapsed_hours / limit) * 100, 1) + return (round(elapsed_hours, 1), pct) + + if lt == "date" and limit > 0: + ref = counter.last_reset_at or part.installed_at + if ref: + now = datetime.now(timezone.utc) + if ref.tzinfo is None: + from datetime import timezone as tz + + ref = ref.replace(tzinfo=tz.utc) + elapsed_days = (now - ref).total_seconds() / 86400 + pct = round((elapsed_days / limit) * 100, 1) + return (round(elapsed_days, 1), pct) + + return (float(counter.current_value or 0), float(counter.lifecycle_pct or 0)) + + def _part_to_dict(p: EquipmentPart, counter: Optional[PartCounter] = None) -> dict: result = { "id": str(p.id), @@ -71,9 +103,10 @@ def _part_to_dict(p: EquipmentPart, counter: Optional[PartCounter] = None) -> di "updated_at": _format_ts(p.updated_at), } if counter: + cv, pct = _compute_auto_counter(p, counter) result["counter"] = { - "current_value": float(counter.current_value or 0), - "lifecycle_pct": float(counter.lifecycle_pct or 0), + "current_value": cv, + "lifecycle_pct": pct, "last_reset_at": _format_ts(counter.last_reset_at), "last_updated_at": _format_ts(counter.last_updated_at), "version": int(counter.version or 0), @@ -337,6 +370,14 @@ async def update_counter( if not counter: raise HTTPException(status_code=404, detail="카운터를 찾을 수 없습니다.") + source = str(part.counter_source or "manual") + lt = str(part.lifecycle_type) + if source == "auto_time" or lt == "date": + raise HTTPException( + status_code=400, + detail="자동 계산 부품(auto_time/date)은 수동으로 카운터를 업데이트할 수 없습니다.", + ) + if body.value < 0: raise HTTPException(status_code=400, detail="카운터 값은 0 이상이어야 합니다.") diff --git a/src/api/inspections.py b/src/api/inspections.py index 4fb0537..8234a9f 100644 --- a/src/api/inspections.py +++ b/src/api/inspections.py @@ -17,6 +17,7 @@ from src.database.models import ( ) from src.auth.models import TokenData from src.auth.dependencies import require_auth, verify_tenant_access +from src.api.alarms import generate_alarms_for_inspection router = APIRouter(prefix="/api/{tenant_id}/inspections", tags=["inspections"]) @@ -471,11 +472,50 @@ async def complete_inspection( if body and body.notes is not None: session.notes = body.notes + fail_records_data = [] + records_with_items_stmt = ( + select(InspectionRecord) + .options(selectinload(InspectionRecord.template_item)) + .where( + InspectionRecord.session_id == inspection_id, + InspectionRecord.is_pass == False, + ) + ) + fail_result = await db.execute(records_with_items_stmt) + fail_records = fail_result.scalars().all() + + template_stmt = select(InspectionTemplate).where( + InspectionTemplate.id == session.template_id + ) + tmpl = (await db.execute(template_stmt)).scalar_one_or_none() + template_name = str(tmpl.name) if tmpl else "알 수 없는 템플릿" + + for r in fail_records: + ti = r.template_item + item_name = str(ti.name) if ti else "?" + spec_parts = [] + if ti and ti.spec_min is not None: + spec_parts.append(f"최소: {ti.spec_min}") + if ti and ti.spec_max is not None: + spec_parts.append(f"최대: {ti.spec_max}") + measured = "" + if r.value_numeric is not None: + measured = f"측정값: {r.value_numeric}" + elif r.value_boolean is not None: + measured = f"측정값: {'합격' if r.value_boolean else '불합격'}" + spec_info = ", ".join(filter(None, [measured] + spec_parts)) + fail_records_data.append((r, item_name, spec_info)) + + await db.flush() + await generate_alarms_for_inspection( + db, tenant_id, session, fail_records_data, template_name + ) + await db.commit() await db.refresh(session) pass_count = sum(1 for r in session.records if r.is_pass is True) - fail_count = sum(1 for r in session.records if r.is_pass is False) + fail_count_val = sum(1 for r in session.records if r.is_pass is False) total = len(session.records) return { @@ -484,8 +524,8 @@ async def complete_inspection( "summary": { "total": total, "pass_count": pass_count, - "fail_count": fail_count, - "no_judgment": total - pass_count - fail_count, + "fail_count": fail_count_val, + "no_judgment": total - pass_count - fail_count_val, }, } diff --git a/src/api/machines.py b/src/api/machines.py index 9d0975a..1925a6a 100644 --- a/src/api/machines.py +++ b/src/api/machines.py @@ -19,12 +19,24 @@ class MachineCreate(BaseModel): name: str equipment_code: str = "" model: Optional[str] = None + manufacturer: Optional[str] = None + installation_date: Optional[str] = None + location: Optional[str] = None + rated_capacity: Optional[str] = None + power_rating: Optional[str] = None + description: Optional[str] = None class MachineUpdate(BaseModel): name: Optional[str] = None equipment_code: Optional[str] = None model: Optional[str] = None + manufacturer: Optional[str] = None + installation_date: Optional[str] = None + location: Optional[str] = None + rated_capacity: Optional[str] = None + power_rating: Optional[str] = None + description: Optional[str] = None class MachineResponse(BaseModel): @@ -33,6 +45,12 @@ class MachineResponse(BaseModel): name: str equipment_code: str model: Optional[str] + manufacturer: Optional[str] = None + installation_date: Optional[str] = None + location: Optional[str] = None + rated_capacity: Optional[str] = None + power_rating: Optional[str] = None + description: Optional[str] = None parts_count: int = 0 created_at: Optional[str] = None updated_at: Optional[str] = None @@ -57,6 +75,12 @@ def _machine_to_response(m: Machine, parts_count: int = 0) -> MachineResponse: name=str(m.name), equipment_code=str(m.equipment_code or ""), model=str(m.model) if m.model else None, + manufacturer=str(m.manufacturer) if m.manufacturer else None, + installation_date=_format_ts(m.installation_date), + location=str(m.location) if m.location else None, + rated_capacity=str(m.rated_capacity) if m.rated_capacity else None, + power_rating=str(m.power_rating) if m.power_rating else None, + description=str(m.description) if m.description else None, parts_count=parts_count, created_at=_format_ts(m.created_at), updated_at=_format_ts(m.updated_at), @@ -97,11 +121,26 @@ async def create_machine( ): verify_tenant_access(tenant_id, current_user) + install_dt = None + if body.installation_date: + from datetime import datetime as dt + + try: + install_dt = dt.fromisoformat(body.installation_date.replace("Z", "+00:00")) + except ValueError: + pass + machine = Machine( tenant_id=tenant_id, name=body.name, equipment_code=body.equipment_code, model=body.model, + manufacturer=body.manufacturer, + installation_date=install_dt, + location=body.location, + rated_capacity=body.rated_capacity, + power_rating=body.power_rating, + description=body.description, ) db.add(machine) await db.commit() @@ -154,6 +193,12 @@ async def get_machine( name=str(machine.name), equipment_code=str(machine.equipment_code or ""), model=str(machine.model) if machine.model else None, + manufacturer=str(machine.manufacturer) if machine.manufacturer else None, + installation_date=_format_ts(machine.installation_date), + location=str(machine.location) if machine.location else None, + rated_capacity=str(machine.rated_capacity) if machine.rated_capacity else None, + power_rating=str(machine.power_rating) if machine.power_rating else None, + description=str(machine.description) if machine.description else None, parts_count=len(active_parts), parts=parts_data, created_at=_format_ts(machine.created_at), @@ -187,6 +232,25 @@ async def update_machine( machine.equipment_code = body.equipment_code if body.model is not None: machine.model = body.model + if body.manufacturer is not None: + machine.manufacturer = body.manufacturer + if body.installation_date is not None: + from datetime import datetime as dt + + try: + machine.installation_date = dt.fromisoformat( + body.installation_date.replace("Z", "+00:00") + ) + except ValueError: + pass + if body.location is not None: + machine.location = body.location + if body.rated_capacity is not None: + machine.rated_capacity = body.rated_capacity + if body.power_rating is not None: + machine.power_rating = body.power_rating + if body.description is not None: + machine.description = body.description await db.commit() await db.refresh(machine) diff --git a/src/database/models.py b/src/database/models.py index a1bfd4f..0202199 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -62,6 +62,12 @@ class Machine(Base): name = Column(String(100), nullable=False) equipment_code = Column(String(50), default="") model = Column(String(100), nullable=True) + manufacturer = Column(String(100), nullable=True) + installation_date = Column(TIMESTAMP(timezone=True), nullable=True) + location = Column(String(200), nullable=True) + rated_capacity = Column(String(100), nullable=True) + power_rating = Column(String(100), nullable=True) + description = Column(Text, nullable=True) created_at = Column(TIMESTAMP(timezone=True), default=utcnow) updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow) @@ -313,13 +319,18 @@ class Alarm(Base): 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 + UUID(as_uuid=True), ForeignKey("equipment_parts.id"), nullable=True ) - 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 + machine_id = Column(UUID(as_uuid=True), ForeignKey("machines.id"), nullable=True) + inspection_session_id = Column( + UUID(as_uuid=True), + ForeignKey("inspection_sessions.id"), + nullable=True, + ) + alarm_type = Column(String(30), nullable=False) + severity = Column(String(20), nullable=False) message = Column(String(500), nullable=False) - lifecycle_pct_at_trigger = Column(Float, nullable=False) + lifecycle_pct_at_trigger = Column(Float, nullable=True) 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) @@ -328,6 +339,7 @@ class Alarm(Base): tenant = relationship("Tenant") equipment_part = relationship("EquipmentPart") machine = relationship("Machine") + inspection_session = relationship("InspectionSession") acknowledged_user = relationship("User") __table_args__ = (