feat: complete 5 GAP requirements — auto counters, inspection alarms, machine specs, responsive CSS
All checks were successful
Deploy to Production / deploy (push) Successful in 1m8s

- GAP 2+4: auto_time/date counter auto-computation with manual update blocking
- GAP 3: auto-generate alarms on inspection completion for failed items
- GAP 1: machine spec fields (manufacturer, location, capacity, power, description)
- GAP 5: enhanced mobile responsive CSS (768px/480px breakpoints)
- Alembic migration for new columns, seed data enriched with specs and date-type parts
This commit is contained in:
Johngreen
2026-02-10 15:45:51 +09:00
parent ca29e5b809
commit 00a17c0b86
10 changed files with 564 additions and 22 deletions

View File

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

View File

@@ -261,6 +261,55 @@ export default function MachineDetailPage() {
</div>
</div>
{(machine.manufacturer || machine.location || machine.rated_capacity || machine.power_rating || machine.description || machine.installation_date) && (
<div className="section">
<div className="section-header">
<h3 className="section-title">
<span className="material-symbols-outlined">info</span>
</h3>
</div>
<div className="spec-grid">
{machine.manufacturer && (
<div className="spec-item">
<span className="spec-label"></span>
<span className="spec-value">{machine.manufacturer}</span>
</div>
)}
{machine.installation_date && (
<div className="spec-item">
<span className="spec-label"></span>
<span className="spec-value">{new Date(machine.installation_date).toLocaleDateString('ko-KR')}</span>
</div>
)}
{machine.location && (
<div className="spec-item">
<span className="spec-label"> </span>
<span className="spec-value">{machine.location}</span>
</div>
)}
{machine.rated_capacity && (
<div className="spec-item">
<span className="spec-label"> </span>
<span className="spec-value">{machine.rated_capacity}</span>
</div>
)}
{machine.power_rating && (
<div className="spec-item">
<span className="spec-label"> </span>
<span className="spec-value">{machine.power_rating}</span>
</div>
)}
{machine.description && (
<div className="spec-item spec-item-full">
<span className="spec-label"></span>
<span className="spec-value">{machine.description}</span>
</div>
)}
</div>
</div>
)}
<div className="section">
<div className="section-header">
<h3 className="section-title">
@@ -320,9 +369,15 @@ export default function MachineDetailPage() {
<td>{COUNTER_SOURCES.find((s) => s.value === part.counter_source)?.label || part.counter_source}</td>
<td>
<div className="part-actions">
<button className="btn-icon" onClick={() => openCounterModal(part)} title="카운터 업데이트">
<span className="material-symbols-outlined">speed</span>
</button>
{part.counter_source === 'auto_time' || part.lifecycle_type === 'date' ? (
<span className="btn-icon btn-icon-disabled" title="자동 계산 부품">
<span className="material-symbols-outlined">timer</span>
</span>
) : (
<button className="btn-icon" onClick={() => openCounterModal(part)} title="카운터 업데이트">
<span className="material-symbols-outlined">speed</span>
</button>
)}
<button className="btn-icon" onClick={() => openReplaceModal(part)} title="부품 교체">
<span className="material-symbols-outlined">swap_horiz</span>
</button>

View File

@@ -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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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 이상이어야 합니다.")

View File

@@ -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,
},
}

View File

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

View File

@@ -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__ = (