feat: Phase 7 — enriched seed data and E2E integration tests
All checks were successful
Deploy to Production / deploy (push) Successful in 49s
All checks were successful
Deploy to Production / deploy (push) Successful in 49s
- 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 <noreply@anthropic.com>
This commit is contained in:
709
scripts/seed.py
709
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.")
|
||||
|
||||
|
||||
353
tests/test_e2e_integration.py
Normal file
353
tests/test_e2e_integration.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user