From ca29e5b80948c778fafd7f2479b2ca070d0f0062 Mon Sep 17 00:00:00 2001 From: Johngreen Date: Tue, 10 Feb 2026 14:49:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=207=20=E2=80=94=20enriched=20seed?= =?UTF-8?q?=20data=20and=20E2E=20integration=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Seed: 9 machines, 24 parts with counters, 6 inspection templates (per tenant) - Realistic industry data: semiconductor (SpiFox), manufacturing (Enkid), chemical (Alpet) - E2E tests: full lifecycle flow, tenant isolation, spec-based pass/fail validation - 70/70 tests passing Co-Authored-By: Claude Opus 4 --- scripts/seed.py | 709 +++++++++++++++++++++++++++++++++- tests/test_e2e_integration.py | 353 +++++++++++++++++ 2 files changed, 1046 insertions(+), 16 deletions(-) create mode 100644 tests/test_e2e_integration.py diff --git a/scripts/seed.py b/scripts/seed.py index 23bacac..e57d1d0 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -2,6 +2,7 @@ import asyncio import os import sys import uuid +from datetime import datetime, timezone, timedelta sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -12,7 +13,16 @@ load_dotenv() from sqlalchemy import select from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from src.database.models import Base, User, Tenant +from src.database.models import ( + Base, + User, + Tenant, + Machine, + EquipmentPart, + PartCounter, + InspectionTemplate, + InspectionTemplateItem, +) from src.auth.password import hash_password DATABASE_URL = os.getenv( @@ -21,21 +31,9 @@ DATABASE_URL = os.getenv( ) TENANTS = [ - { - "id": "spifox", - "name": "SpiFox", - "industry_type": "semiconductor", - }, - { - "id": "enkid", - "name": "Enkid", - "industry_type": "manufacturing", - }, - { - "id": "alpet", - "name": "Alpet", - "industry_type": "chemical", - }, + {"id": "spifox", "name": "SpiFox", "industry_type": "semiconductor"}, + {"id": "enkid", "name": "Enkid", "industry_type": "manufacturing"}, + {"id": "alpet", "name": "Alpet", "industry_type": "chemical"}, ] SUPERADMIN = { @@ -70,6 +68,536 @@ TENANT_ADMINS = [ }, ] +# ─── 고객사별 설비 데이터 ─── + +NOW = datetime.now(timezone.utc) + +MACHINES = { + "spifox": [ + { + "name": "CVD Chamber #1", + "equipment_code": "SF-CVD-001", + "model": "AMAT Centura 5200", + }, + {"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", + }, + ], + "enkid": [ + { + "name": "CNC 선반 #1", + "equipment_code": "EK-CNC-001", + "model": "두산 LYNX 220", + }, + { + "name": "프레스 500T", + "equipment_code": "EK-PRS-001", + "model": "현대위아 HF-500", + }, + { + "name": "용접 로봇 #1", + "equipment_code": "EK-WLD-001", + "model": "화낙 ARC Mate 120", + }, + ], + "alpet": [ + { + "name": "반응기 R-101", + "equipment_code": "AP-RCT-101", + "model": "Custom Reactor 5000L", + }, + { + "name": "증류탑 D-201", + "equipment_code": "AP-DST-201", + "model": "Sulzer Packed Tower", + }, + { + "name": "열교환기 E-301", + "equipment_code": "AP-HEX-301", + "model": "Alfa Laval M10-BW", + }, + ], +} + +# 부품: (name, part_number, category, lifecycle_type, lifecycle_limit, alarm_threshold, counter_source, current_value) +PARTS = { + "spifox": { + "CVD Chamber #1": [ + ("Showerhead", "SH-CVD-01", "소모품", "hours", 3000, 80, "auto_time", 2450), + ( + "Heater Block", + "HB-CVD-01", + "핵심부품", + "hours", + 8000, + 85, + "auto_time", + 3200, + ), + ("O-Ring Seal", "OR-CVD-01", "소모품", "count", 500, 90, "manual", 480), + ], + "Etcher DRY-A": [ + ( + "RF Generator", + "RF-ETCH-01", + "핵심부품", + "hours", + 10000, + 80, + "auto_time", + 4500, + ), + ( + "Focus Ring", + "FR-ETCH-01", + "소모품", + "hours", + 2000, + 85, + "auto_time", + 1750, + ), + ("ESC Plate", "ESC-ETCH-01", "핵심부품", "count", 5000, 80, "manual", 2100), + ], + "Sputter MC-200": [ + ( + "Target (Ti)", + "TG-SPT-01", + "소모품", + "hours", + 1500, + 80, + "auto_time", + 1200, + ), + ( + "Magnet Assembly", + "MA-SPT-01", + "핵심부품", + "hours", + 12000, + 85, + "auto_time", + 5000, + ), + ], + }, + "enkid": { + "CNC 선반 #1": [ + ( + "스핀들 베어링", + "BRG-CNC-01", + "핵심부품", + "hours", + 15000, + 85, + "auto_time", + 12800, + ), + ( + "절삭공구 홀더", + "TH-CNC-01", + "소모품", + "count", + 10000, + 80, + "manual", + 7500, + ), + ( + "볼스크류", + "BS-CNC-01", + "핵심부품", + "hours", + 20000, + 90, + "auto_time", + 8000, + ), + ], + "프레스 500T": [ + ( + "유압 실린더 씰", + "HS-PRS-01", + "소모품", + "count", + 50000, + 85, + "manual", + 42000, + ), + ( + "금형 상판", + "MD-PRS-01", + "핵심부품", + "count", + 100000, + 80, + "manual", + 65000, + ), + ( + "유압 펌프", + "HP-PRS-01", + "핵심부품", + "hours", + 10000, + 85, + "auto_time", + 4200, + ), + ], + "용접 로봇 #1": [ + ("용접 토치", "WT-WLD-01", "소모품", "hours", 500, 80, "auto_time", 320), + ( + "와이어 피더", + "WF-WLD-01", + "소모품", + "hours", + 3000, + 85, + "auto_time", + 1500, + ), + ], + }, + "alpet": { + "반응기 R-101": [ + ( + "교반기 임펠러", + "IMP-RCT-01", + "핵심부품", + "hours", + 20000, + 85, + "auto_time", + 15500, + ), + ("기계적 씰", "MS-RCT-01", "소모품", "hours", 8000, 80, "auto_time", 6800), + ("온도 센서", "TS-RCT-01", "계측", "hours", 5000, 90, "auto_time", 4600), + ], + "증류탑 D-201": [ + ( + "충전물 (패킹)", + "PK-DST-01", + "소모품", + "hours", + 30000, + 85, + "auto_time", + 12000, + ), + ( + "컨덴서 튜브", + "CT-DST-01", + "핵심부품", + "hours", + 25000, + 80, + "auto_time", + 10000, + ), + ], + "열교환기 E-301": [ + ( + "개스킷 세트", + "GK-HEX-01", + "소모품", + "hours", + 8000, + 80, + "auto_time", + 7200, + ), + ( + "플레이트 팩", + "PP-HEX-01", + "핵심부품", + "hours", + 40000, + 85, + "auto_time", + 15000, + ), + ("압력 센서", "PS-HEX-01", "계측", "hours", 5000, 90, "auto_time", 2000), + ], + }, +} + +# ─── 고객사별 검사 템플릿 ─── + +TEMPLATES = { + "spifox": [ + { + "name": "CVD 챔버 일일 점검", + "subject_type": "equipment", + "machine_name": "CVD Chamber #1", + "schedule_type": "daily", + "inspection_mode": "checklist", + "items": [ + { + "name": "챔버 진공도 확인", + "category": "진공", + "data_type": "numeric", + "unit": "mTorr", + "spec_min": 0, + "spec_max": 10, + "warning_max": 8, + }, + { + "name": "가스 유량 정상 여부", + "category": "가스", + "data_type": "boolean", + }, + { + "name": "히터 온도", + "category": "온도", + "data_type": "numeric", + "unit": "°C", + "spec_min": 350, + "spec_max": 450, + "warning_min": 360, + "warning_max": 440, + }, + { + "name": "RF 전력 안정성", + "category": "전기", + "data_type": "select", + "select_options": ["정상", "불안정", "이상"], + }, + { + "name": "파티클 카운트", + "category": "청정도", + "data_type": "numeric", + "unit": "ea", + "spec_min": 0, + "spec_max": 50, + "warning_max": 30, + }, + ], + }, + { + "name": "웨이퍼 두께 품질검사", + "subject_type": "product", + "product_code": "WF-300-SI", + "schedule_type": "ad_hoc", + "inspection_mode": "measurement", + "items": [ + { + "name": "중심부 두께", + "category": "두께", + "data_type": "numeric", + "unit": "μm", + "spec_min": 725, + "spec_max": 775, + "warning_min": 730, + "warning_max": 770, + }, + { + "name": "Edge 두께 편차", + "category": "두께", + "data_type": "numeric", + "unit": "μm", + "spec_min": 0, + "spec_max": 5, + "warning_max": 3, + }, + {"name": "표면 결함 유무", "category": "외관", "data_type": "boolean"}, + { + "name": "비고", + "category": "기타", + "data_type": "text", + "is_required": False, + }, + ], + }, + ], + "enkid": [ + { + "name": "CNC 선반 주간 점검", + "subject_type": "equipment", + "machine_name": "CNC 선반 #1", + "schedule_type": "weekly", + "inspection_mode": "checklist", + "items": [ + { + "name": "스핀들 진동값", + "category": "진동", + "data_type": "numeric", + "unit": "mm/s", + "spec_min": 0, + "spec_max": 4.5, + "warning_max": 3.5, + }, + { + "name": "절삭유 농도", + "category": "유체", + "data_type": "numeric", + "unit": "%", + "spec_min": 3, + "spec_max": 8, + "warning_min": 4, + "warning_max": 7, + }, + {"name": "척 그립력 정상", "category": "기계", "data_type": "boolean"}, + { + "name": "윤활유 레벨", + "category": "유체", + "data_type": "select", + "select_options": ["적정", "부족", "과다"], + }, + {"name": "이상 소음 여부", "category": "소음", "data_type": "boolean"}, + { + "name": "툴 마모 상태", + "category": "공구", + "data_type": "select", + "select_options": ["양호", "주의", "교체필요"], + }, + ], + }, + { + "name": "가공품 치수 검사", + "subject_type": "product", + "product_code": "SHAFT-AL-20", + "schedule_type": "ad_hoc", + "inspection_mode": "measurement", + "items": [ + { + "name": "외경", + "category": "치수", + "data_type": "numeric", + "unit": "mm", + "spec_min": 19.98, + "spec_max": 20.02, + "warning_min": 19.99, + "warning_max": 20.01, + }, + { + "name": "길이", + "category": "치수", + "data_type": "numeric", + "unit": "mm", + "spec_min": 99.9, + "spec_max": 100.1, + "warning_min": 99.95, + "warning_max": 100.05, + }, + { + "name": "표면 거칠기 (Ra)", + "category": "표면", + "data_type": "numeric", + "unit": "μm", + "spec_min": 0, + "spec_max": 1.6, + "warning_max": 1.2, + }, + {"name": "외관 검사 합격", "category": "외관", "data_type": "boolean"}, + ], + }, + ], + "alpet": [ + { + "name": "반응기 일일 점검", + "subject_type": "equipment", + "machine_name": "반응기 R-101", + "schedule_type": "daily", + "inspection_mode": "checklist", + "items": [ + { + "name": "반응 온도", + "category": "온도", + "data_type": "numeric", + "unit": "°C", + "spec_min": 180, + "spec_max": 220, + "warning_min": 185, + "warning_max": 215, + }, + { + "name": "반응 압력", + "category": "압력", + "data_type": "numeric", + "unit": "bar", + "spec_min": 2.0, + "spec_max": 5.0, + "warning_min": 2.5, + "warning_max": 4.5, + }, + { + "name": "교반 속도", + "category": "기계", + "data_type": "numeric", + "unit": "RPM", + "spec_min": 100, + "spec_max": 300, + "warning_min": 120, + "warning_max": 280, + }, + { + "name": "냉각수 유량 정상", + "category": "유체", + "data_type": "boolean", + }, + {"name": "누출 여부", "category": "안전", "data_type": "boolean"}, + { + "name": "씰 상태", + "category": "기계", + "data_type": "select", + "select_options": ["양호", "주의", "교체필요"], + }, + ], + }, + { + "name": "제품 농도 분석", + "subject_type": "product", + "product_code": "SOL-HCL-35", + "schedule_type": "ad_hoc", + "inspection_mode": "measurement", + "items": [ + { + "name": "농도 (wt%)", + "category": "농도", + "data_type": "numeric", + "unit": "wt%", + "spec_min": 34.0, + "spec_max": 36.0, + "warning_min": 34.5, + "warning_max": 35.5, + }, + { + "name": "pH", + "category": "농도", + "data_type": "numeric", + "unit": "pH", + "spec_min": 0.1, + "spec_max": 1.0, + "warning_max": 0.8, + }, + { + "name": "비중", + "category": "물성", + "data_type": "numeric", + "unit": "g/mL", + "spec_min": 1.16, + "spec_max": 1.19, + "warning_min": 1.165, + "warning_max": 1.185, + }, + {"name": "색상 이상 유무", "category": "외관", "data_type": "boolean"}, + { + "name": "비고", + "category": "기타", + "data_type": "text", + "is_required": False, + }, + ], + }, + ], +} + async def seed(): engine = create_async_engine(DATABASE_URL, echo=False) @@ -78,6 +606,7 @@ async def seed(): ) async with session_factory() as db: + # ── 1. Tenants ── for t in TENANTS: existing = await db.execute(select(Tenant).where(Tenant.id == t["id"])) if existing.scalar_one_or_none(): @@ -87,6 +616,7 @@ async def seed(): print(f" + Tenant '{t['id']}' ({t['name']})") await db.commit() + # ── 2. Users ── all_users = [SUPERADMIN] + TENANT_ADMINS for u in all_users: existing = await db.execute(select(User).where(User.email == u["email"])) @@ -106,6 +636,153 @@ async def seed(): print(f" + User '{u['email']}' (role={u['role']})") await db.commit() + # ── 3. Machines ── + machine_map = {} # (tenant_id, machine_name) → machine_id + for tenant_id, machines in MACHINES.items(): + for m in machines: + existing = await db.execute( + select(Machine).where( + Machine.tenant_id == tenant_id, + Machine.name == m["name"], + ) + ) + row = existing.scalar_one_or_none() + if row: + print( + f" Machine '{m['name']}' ({tenant_id}) already exists, skipping" + ) + machine_map[(tenant_id, m["name"])] = row.id + continue + machine_id = uuid.uuid4() + db.add( + Machine( + id=machine_id, + tenant_id=tenant_id, + name=m["name"], + equipment_code=m["equipment_code"], + model=m["model"], + ) + ) + machine_map[(tenant_id, m["name"])] = machine_id + print(f" + Machine '{m['name']}' ({tenant_id})") + await db.commit() + + # ── 4. Equipment Parts + Counters ── + for tenant_id, machines_parts in PARTS.items(): + for machine_name, parts in machines_parts.items(): + machine_id = machine_map.get((tenant_id, machine_name)) + if not machine_id: + print(f" ! Machine '{machine_name}' not found for parts, skipping") + continue + for name, part_number, category, lt, ll, at, cs, cv in parts: + existing = await db.execute( + select(EquipmentPart).where( + EquipmentPart.tenant_id == tenant_id, + EquipmentPart.machine_id == machine_id, + EquipmentPart.name == name, + ) + ) + if existing.scalar_one_or_none(): + print( + f" Part '{name}' ({tenant_id}/{machine_name}) already exists, skipping" + ) + continue + part_id = uuid.uuid4() + db.add( + EquipmentPart( + id=part_id, + tenant_id=tenant_id, + machine_id=machine_id, + name=name, + part_number=part_number, + category=category, + lifecycle_type=lt, + lifecycle_limit=ll, + alarm_threshold=at, + counter_source=cs, + installed_at=NOW - timedelta(days=180), + is_active=True, + ) + ) + lifecycle_pct = round((cv / ll) * 100, 1) + db.add( + PartCounter( + id=uuid.uuid4(), + tenant_id=tenant_id, + equipment_part_id=part_id, + current_value=cv, + lifecycle_pct=lifecycle_pct, + last_reset_at=NOW - timedelta(days=180), + last_updated_at=NOW - timedelta(hours=2), + version=1, + ) + ) + print( + f" + Part '{name}' ({tenant_id}/{machine_name}) — {cv}/{ll} ({lifecycle_pct}%)" + ) + await db.commit() + + # ── 5. Inspection Templates ── + for tenant_id, templates in TEMPLATES.items(): + for tmpl in templates: + existing = await db.execute( + select(InspectionTemplate).where( + InspectionTemplate.tenant_id == tenant_id, + InspectionTemplate.name == tmpl["name"], + ) + ) + if existing.scalar_one_or_none(): + print( + f" Template '{tmpl['name']}' ({tenant_id}) already exists, skipping" + ) + continue + + template_id = uuid.uuid4() + machine_id_for_tmpl = None + if tmpl.get("machine_name"): + machine_id_for_tmpl = machine_map.get( + (tenant_id, tmpl["machine_name"]) + ) + + db.add( + InspectionTemplate( + id=template_id, + tenant_id=tenant_id, + name=tmpl["name"], + subject_type=tmpl["subject_type"], + machine_id=machine_id_for_tmpl, + product_code=tmpl.get("product_code"), + schedule_type=tmpl["schedule_type"], + inspection_mode=tmpl["inspection_mode"], + version=1, + is_active=True, + ) + ) + + for idx, item in enumerate(tmpl["items"]): + db.add( + InspectionTemplateItem( + id=uuid.uuid4(), + template_id=template_id, + sort_order=idx + 1, + name=item["name"], + category=item.get("category"), + data_type=item["data_type"], + unit=item.get("unit"), + spec_min=item.get("spec_min"), + spec_max=item.get("spec_max"), + warning_min=item.get("warning_min"), + warning_max=item.get("warning_max"), + select_options=item.get("select_options"), + is_required=item.get("is_required", True), + ) + ) + + print( + f" + Template '{tmpl['name']}' ({tenant_id}) — {len(tmpl['items'])} items" + ) + await db.commit() + await engine.dispose() print("\nSeed complete.") diff --git a/tests/test_e2e_integration.py b/tests/test_e2e_integration.py new file mode 100644 index 0000000..ed73280 --- /dev/null +++ b/tests/test_e2e_integration.py @@ -0,0 +1,353 @@ +import pytest +from httpx import AsyncClient +from tests.conftest import get_auth_headers + + +async def _login( + client: AsyncClient, email: str = "super@test.com", pw: str = "pass1234" +): + return await get_auth_headers(client, email, pw) + + +@pytest.mark.asyncio +async def test_full_lifecycle_flow(client: AsyncClient, seeded_db): + h = await _login(client) + tid = "test-co" + + r = await client.post( + f"/api/{tid}/machines", + json={ + "name": "E2E 통합 설비", + "equipment_code": "E2E-001", + "model": "Test Model X", + }, + headers=h, + ) + assert r.status_code == 200 + machine = r.json() + machine_id = machine["id"] + assert machine["name"] == "E2E 통합 설비" + + r = await client.post( + f"/api/{tid}/machines/{machine_id}/parts", + json={ + "name": "E2E 부품", + "part_number": "E2E-P-001", + "category": "소모품", + "lifecycle_type": "count", + "lifecycle_limit": 1000, + "alarm_threshold": 80, + "counter_source": "manual", + }, + headers=h, + ) + assert r.status_code == 200 + part = r.json() + part_id = part["id"] + assert part["counter"]["current_value"] == 0 + + r = await client.put( + f"/api/{tid}/parts/{part_id}/counter", json={"value": 500}, headers=h + ) + assert r.status_code == 200 + assert r.json()["counter"]["current_value"] == 500 + assert r.json()["counter"]["lifecycle_pct"] == 50.0 + + r = await client.get(f"/api/{tid}/alarms?is_acknowledged=false", headers=h) + assert r.status_code == 200 + assert len(r.json()["alarms"]) == 0 + + r = await client.put( + f"/api/{tid}/parts/{part_id}/counter", json={"value": 850}, headers=h + ) + assert r.status_code == 200 + assert r.json()["counter"]["lifecycle_pct"] == 85.0 + + r = await client.get(f"/api/{tid}/alarms?is_acknowledged=false", headers=h) + assert r.status_code == 200 + alarms = r.json()["alarms"] + assert len(alarms) == 1 + warning_alarm = alarms[0] + assert warning_alarm["severity"] == "warning" + assert warning_alarm["alarm_type"] == "threshold" + warning_alarm_id = warning_alarm["id"] + + r = await client.put( + f"/api/{tid}/parts/{part_id}/counter", json={"value": 1050}, headers=h + ) + assert r.status_code == 200 + assert r.json()["counter"]["lifecycle_pct"] == 105.0 + + r = await client.get(f"/api/{tid}/alarms?is_acknowledged=false", headers=h) + assert r.status_code == 200 + active = r.json()["alarms"] + assert len(active) == 2 + severities = {a["severity"] for a in active} + assert severities == {"warning", "critical"} + + r = await client.post( + f"/api/{tid}/alarms/{warning_alarm_id}/acknowledge", headers=h + ) + assert r.status_code == 200 + assert r.json()["is_acknowledged"] is True + + r = await client.get(f"/api/{tid}/alarms/summary", headers=h) + assert r.status_code == 200 + summary = r.json() + assert summary["active_count"] == 1 + assert summary["critical_count"] == 1 + assert summary["warning_count"] == 0 + + r = await client.post( + f"/api/{tid}/parts/{part_id}/replace", + json={ + "reason": "수명 초과", + "notes": "E2E 테스트 교체", + }, + headers=h, + ) + assert r.status_code == 200 + replaced = r.json() + assert replaced["status"] == "success" + assert replaced["part"]["counter"]["current_value"] == 0 + assert replaced["part"]["counter"]["lifecycle_pct"] == 0.0 + + r = await client.get(f"/api/{tid}/parts/{part_id}/replacements", headers=h) + assert r.status_code == 200 + logs = r.json()["replacements"] + assert len(logs) == 1 + assert logs[0]["counter_at_replacement"] == 1050 + assert logs[0]["reason"] == "수명 초과" + + r = await client.post( + f"/api/{tid}/templates", + json={ + "name": "E2E 설비 점검", + "subject_type": "equipment", + "machine_id": machine_id, + "schedule_type": "daily", + "inspection_mode": "measurement", + "items": [ + { + "name": "온도", + "data_type": "numeric", + "unit": "°C", + "spec_min": 10, + "spec_max": 50, + "is_required": True, + }, + {"name": "가동 상태", "data_type": "boolean", "is_required": True}, + { + "name": "상태", + "data_type": "select", + "select_options": ["정상", "주의", "이상"], + "is_required": True, + }, + ], + }, + headers=h, + ) + assert r.status_code == 200 + template = r.json() + template_id = template["id"] + assert len(template["items"]) == 3 + + r = await client.post( + f"/api/{tid}/inspections", + json={ + "template_id": template_id, + }, + headers=h, + ) + assert r.status_code == 200 + session = r.json() + session_id = session["id"] + assert session["status"] == "in_progress" + assert len(session["records"]) == 3 + + detail_r = await client.get(f"/api/{tid}/inspections/{session_id}", headers=h) + assert detail_r.status_code == 200 + items_detail = detail_r.json()["items_detail"] + + record_data = [] + for item_d in items_detail: + item = item_d["item"] + record = item_d["record"] + if item["name"] == "온도": + record_data.append( + {"template_item_id": record["template_item_id"], "value_numeric": 25.5} + ) + elif item["name"] == "가동 상태": + record_data.append( + {"template_item_id": record["template_item_id"], "value_boolean": True} + ) + elif item["name"] == "상태": + record_data.append( + {"template_item_id": record["template_item_id"], "value_select": "정상"} + ) + + r = await client.put( + f"/api/{tid}/inspections/{session_id}/records", + json={ + "records": record_data, + }, + headers=h, + ) + assert r.status_code == 200 + + r = await client.post(f"/api/{tid}/inspections/{session_id}/complete", headers=h) + assert r.status_code == 200 + completed = r.json() + assert completed["status"] == "success" + assert completed["summary"]["pass_count"] >= 2 + + r = await client.get(f"/api/{tid}/machines/{machine_id}", headers=h) + assert r.status_code == 200 + assert r.json()["parts_count"] == 1 + + r = await client.get(f"/api/{tid}/inspections", headers=h) + assert r.status_code == 200 + all_sessions = r.json()["inspections"] + completed_sessions = [s for s in all_sessions if s["status"] == "completed"] + assert len(completed_sessions) >= 1 + + +@pytest.mark.asyncio +async def test_tenant_isolation_e2e(client: AsyncClient, seeded_db): + h_super = await _login(client) + h_tenant = await _login(client, "admin@test-co.com", "pass1234") + tid = "test-co" + other = "other-co" + + r = await client.post( + f"/api/{tid}/machines", + json={ + "name": "격리 테스트 설비", + "equipment_code": "ISO-001", + }, + headers=h_super, + ) + assert r.status_code == 200 + machine_id = r.json()["id"] + + r = await client.post( + f"/api/{tid}/machines/{machine_id}/parts", + json={ + "name": "격리 부품", + "lifecycle_type": "hours", + "lifecycle_limit": 5000, + }, + headers=h_super, + ) + assert r.status_code == 200 + + r = await client.post( + f"/api/{tid}/templates", + json={ + "name": "격리 템플릿", + "subject_type": "equipment", + "schedule_type": "daily", + "inspection_mode": "checklist", + "items": [{"name": "체크", "data_type": "boolean", "is_required": True}], + }, + headers=h_super, + ) + assert r.status_code == 200 + + r = await client.get(f"/api/{other}/machines", headers=h_super) + assert r.status_code == 200 + assert len(r.json()["machines"]) == 0 + + r = await client.get(f"/api/{other}/templates", headers=h_super) + assert r.status_code == 200 + assert len(r.json()["templates"]) == 0 + + r = await client.get(f"/api/{other}/alarms", headers=h_super) + assert r.status_code == 200 + assert len(r.json()["alarms"]) == 0 + + r = await client.get(f"/api/{other}/inspections", headers=h_super) + assert r.status_code == 200 + assert len(r.json()["inspections"]) == 0 + + +@pytest.mark.asyncio +async def test_inspection_with_spec_validation(client: AsyncClient, seeded_db): + h = await _login(client) + tid = "test-co" + + r = await client.post( + f"/api/{tid}/machines", + json={ + "name": "규격 검증 설비", + "equipment_code": "SPEC-001", + }, + headers=h, + ) + machine_id = r.json()["id"] + + r = await client.post( + f"/api/{tid}/templates", + json={ + "name": "규격 검증 점검", + "subject_type": "equipment", + "machine_id": machine_id, + "schedule_type": "ad_hoc", + "inspection_mode": "measurement", + "items": [ + { + "name": "치수 A", + "data_type": "numeric", + "unit": "mm", + "spec_min": 10.0, + "spec_max": 20.0, + "is_required": True, + }, + { + "name": "치수 B", + "data_type": "numeric", + "unit": "mm", + "spec_min": 5.0, + "spec_max": 8.0, + "is_required": True, + }, + ], + }, + headers=h, + ) + template_id = r.json()["id"] + + r = await client.post( + f"/api/{tid}/inspections", json={"template_id": template_id}, headers=h + ) + session = r.json() + session_id = session["id"] + + detail_r = await client.get(f"/api/{tid}/inspections/{session_id}", headers=h) + items_detail = detail_r.json()["items_detail"] + + record_data = [] + for item_d in items_detail: + item = item_d["item"] + record = item_d["record"] + if item["name"] == "치수 A": + record_data.append( + {"template_item_id": record["template_item_id"], "value_numeric": 15.0} + ) + elif item["name"] == "치수 B": + record_data.append( + {"template_item_id": record["template_item_id"], "value_numeric": 9.5} + ) + + await client.put( + f"/api/{tid}/inspections/{session_id}/records", + json={"records": record_data}, + headers=h, + ) + + r = await client.post(f"/api/{tid}/inspections/{session_id}/complete", headers=h) + assert r.status_code == 200 + completed = r.json() + assert completed["status"] == "success" + assert completed["summary"]["pass_count"] == 1 + assert completed["summary"]["fail_count"] == 1