feat: Phase 0-2 complete — auth, machines, equipment parts with full CRUD

Multi-tenant factory inspection system (SpiFox, Enkid, Alpet):
- FastAPI backend with JWT auth, PostgreSQL (asyncpg)
- Next.js 16 frontend with App Router, SWR data fetching
- Machines CRUD with equipment parts management
- Part lifecycle tracking (hours/count/date) with counters
- Partial unique index for soft-delete support
- 24 pytest tests passing, E2E verified

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-02-10 12:05:22 +09:00
commit ab2a3e35b2
75 changed files with 13327 additions and 0 deletions

0
src/api/__init__.py Normal file
View File

299
src/api/equipment_parts.py Normal file
View File

@@ -0,0 +1,299 @@
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, Path
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.database.config import get_db
from src.database.models import Machine, EquipmentPart, PartCounter
from src.auth.models import TokenData
from src.auth.dependencies import require_auth, verify_tenant_access
router = APIRouter(tags=["equipment_parts"])
class PartCreate(BaseModel):
name: str
part_number: Optional[str] = None
category: Optional[str] = None
lifecycle_type: str # hours | count | date
lifecycle_limit: float
alarm_threshold: float = 90.0
counter_source: str = "manual" # auto_plc | auto_time | manual
installed_at: Optional[str] = None
class PartUpdate(BaseModel):
name: Optional[str] = None
part_number: Optional[str] = None
category: Optional[str] = None
lifecycle_type: Optional[str] = None
lifecycle_limit: Optional[float] = None
alarm_threshold: Optional[float] = None
counter_source: Optional[str] = None
def _format_ts(val) -> Optional[str]:
if val is None:
return None
return val.isoformat() if hasattr(val, "isoformat") else str(val)
def _part_to_dict(p: EquipmentPart, counter: Optional[PartCounter] = None) -> dict:
result = {
"id": str(p.id),
"tenant_id": str(p.tenant_id),
"machine_id": str(p.machine_id),
"name": str(p.name),
"part_number": str(p.part_number) if p.part_number else None,
"category": str(p.category) if p.category else None,
"lifecycle_type": str(p.lifecycle_type),
"lifecycle_limit": float(p.lifecycle_limit),
"alarm_threshold": float(p.alarm_threshold or 90.0),
"counter_source": str(p.counter_source or "manual"),
"installed_at": _format_ts(p.installed_at),
"is_active": bool(p.is_active),
"created_at": _format_ts(p.created_at),
"updated_at": _format_ts(p.updated_at),
}
if counter:
result["counter"] = {
"current_value": float(counter.current_value or 0),
"lifecycle_pct": float(counter.lifecycle_pct or 0),
"last_reset_at": _format_ts(counter.last_reset_at),
"last_updated_at": _format_ts(counter.last_updated_at),
"version": int(counter.version or 0),
}
return result
async def _verify_machine(
db: AsyncSession, tenant_id: str, machine_id: UUID
) -> Machine:
stmt = select(Machine).where(
Machine.id == machine_id, Machine.tenant_id == tenant_id
)
result = await db.execute(stmt)
machine = result.scalar_one_or_none()
if not machine:
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.")
return machine
@router.get("/api/{tenant_id}/machines/{machine_id}/parts")
async def list_parts(
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
await _verify_machine(db, tenant_id, machine_id)
stmt = (
select(EquipmentPart)
.options(selectinload(EquipmentPart.counter))
.where(
EquipmentPart.machine_id == machine_id,
EquipmentPart.tenant_id == tenant_id,
EquipmentPart.is_active == True,
)
.order_by(EquipmentPart.name)
)
result = await db.execute(stmt)
parts = result.scalars().all()
return {"parts": [_part_to_dict(p, p.counter) for p in parts]}
@router.post("/api/{tenant_id}/machines/{machine_id}/parts")
async def create_part(
body: PartCreate,
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
await _verify_machine(db, tenant_id, machine_id)
if body.lifecycle_type not in ("hours", "count", "date"):
raise HTTPException(
status_code=400,
detail="lifecycle_type은 hours, count, date 중 하나여야 합니다.",
)
if body.counter_source not in ("auto_plc", "auto_time", "manual"):
raise HTTPException(
status_code=400,
detail="counter_source는 auto_plc, auto_time, manual 중 하나여야 합니다.",
)
dupe_stmt = select(EquipmentPart).where(
EquipmentPart.tenant_id == tenant_id,
EquipmentPart.machine_id == machine_id,
EquipmentPart.name == body.name,
EquipmentPart.is_active == True,
)
if (await db.execute(dupe_stmt)).scalar_one_or_none():
raise HTTPException(
status_code=409, detail=f"같은 이름의 부품 '{body.name}'이 이미 존재합니다."
)
installed_dt = None
if body.installed_at:
try:
installed_dt = datetime.fromisoformat(
body.installed_at.replace("Z", "+00:00")
)
except ValueError:
raise HTTPException(
status_code=400,
detail="installed_at 형식이 올바르지 않습니다. ISO 8601 형식을 사용하세요.",
)
part = EquipmentPart(
tenant_id=tenant_id,
machine_id=machine_id,
name=body.name,
part_number=body.part_number,
category=body.category,
lifecycle_type=body.lifecycle_type,
lifecycle_limit=body.lifecycle_limit,
alarm_threshold=body.alarm_threshold,
counter_source=body.counter_source,
installed_at=installed_dt,
)
db.add(part)
await db.flush()
now = datetime.now(timezone.utc)
counter = PartCounter(
tenant_id=tenant_id,
equipment_part_id=part.id,
current_value=0,
lifecycle_pct=0,
last_reset_at=installed_dt or now,
last_updated_at=now,
)
db.add(counter)
await db.commit()
await db.refresh(part)
await db.refresh(counter)
return _part_to_dict(part, counter)
@router.get("/api/{tenant_id}/parts/{part_id}")
async def get_part(
tenant_id: str = Path(...),
part_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(EquipmentPart)
.options(selectinload(EquipmentPart.counter))
.where(EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id)
)
result = await db.execute(stmt)
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.")
return _part_to_dict(part, part.counter)
@router.put("/api/{tenant_id}/parts/{part_id}")
async def update_part(
body: PartUpdate,
tenant_id: str = Path(...),
part_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(EquipmentPart).where(
EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id
)
result = await db.execute(stmt)
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.")
if body.name is not None:
dupe_stmt = select(EquipmentPart).where(
EquipmentPart.tenant_id == tenant_id,
EquipmentPart.machine_id == part.machine_id,
EquipmentPart.name == body.name,
EquipmentPart.is_active == True,
EquipmentPart.id != part_id,
)
if (await db.execute(dupe_stmt)).scalar_one_or_none():
raise HTTPException(
status_code=409,
detail=f"같은 이름의 부품 '{body.name}'이 이미 존재합니다.",
)
part.name = body.name
if body.part_number is not None:
part.part_number = body.part_number
if body.category is not None:
part.category = body.category
if body.lifecycle_type is not None:
if body.lifecycle_type not in ("hours", "count", "date"):
raise HTTPException(
status_code=400,
detail="lifecycle_type은 hours, count, date 중 하나여야 합니다.",
)
part.lifecycle_type = body.lifecycle_type
if body.lifecycle_limit is not None:
part.lifecycle_limit = body.lifecycle_limit
if body.alarm_threshold is not None:
part.alarm_threshold = body.alarm_threshold
if body.counter_source is not None:
if body.counter_source not in ("auto_plc", "auto_time", "manual"):
raise HTTPException(
status_code=400,
detail="counter_source는 auto_plc, auto_time, manual 중 하나여야 합니다.",
)
part.counter_source = body.counter_source
await db.commit()
await db.refresh(part)
return _part_to_dict(part)
@router.delete("/api/{tenant_id}/parts/{part_id}")
async def delete_part(
tenant_id: str = Path(...),
part_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(EquipmentPart).where(
EquipmentPart.id == part_id, EquipmentPart.tenant_id == tenant_id
)
result = await db.execute(stmt)
part = result.scalar_one_or_none()
if not part:
raise HTTPException(status_code=404, detail="부품을 찾을 수 없습니다.")
part.is_active = False
await db.commit()
return {"status": "success", "message": "부품이 비활성화되었습니다."}

230
src/api/machines.py Normal file
View File

@@ -0,0 +1,230 @@
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, HTTPException, Depends, Path
from pydantic import BaseModel
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from src.database.config import get_db
from src.database.models import Machine, EquipmentPart
from src.auth.models import TokenData
from src.auth.dependencies import require_auth, verify_tenant_access
router = APIRouter(prefix="/api/{tenant_id}/machines", tags=["machines"])
class MachineCreate(BaseModel):
name: str
equipment_code: str = ""
model: Optional[str] = None
class MachineUpdate(BaseModel):
name: Optional[str] = None
equipment_code: Optional[str] = None
model: Optional[str] = None
class MachineResponse(BaseModel):
id: str
tenant_id: str
name: str
equipment_code: str
model: Optional[str]
parts_count: int = 0
created_at: Optional[str] = None
updated_at: Optional[str] = None
model_config = {"from_attributes": True}
class MachineDetailResponse(MachineResponse):
parts: List[dict] = []
def _format_ts(val) -> Optional[str]:
if val is None:
return None
return val.isoformat() if hasattr(val, "isoformat") else str(val)
def _machine_to_response(m: Machine, parts_count: int = 0) -> MachineResponse:
return MachineResponse(
id=str(m.id),
tenant_id=str(m.tenant_id),
name=str(m.name),
equipment_code=str(m.equipment_code or ""),
model=str(m.model) if m.model else None,
parts_count=parts_count,
created_at=_format_ts(m.created_at),
updated_at=_format_ts(m.updated_at),
)
@router.get("")
async def list_machines(
tenant_id: str = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(Machine, func.count(EquipmentPart.id).label("parts_count"))
.outerjoin(
EquipmentPart,
(EquipmentPart.machine_id == Machine.id)
& (EquipmentPart.is_active == True),
)
.where(Machine.tenant_id == tenant_id)
.group_by(Machine.id)
.order_by(Machine.name)
)
result = await db.execute(stmt)
rows = result.all()
return {"machines": [_machine_to_response(m, count) for m, count in rows]}
@router.post("")
async def create_machine(
body: MachineCreate,
tenant_id: str = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
machine = Machine(
tenant_id=tenant_id,
name=body.name,
equipment_code=body.equipment_code,
model=body.model,
)
db.add(machine)
await db.commit()
await db.refresh(machine)
return _machine_to_response(machine, 0)
@router.get("/{machine_id}")
async def get_machine(
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = (
select(Machine)
.options(selectinload(Machine.parts))
.where(Machine.id == machine_id, Machine.tenant_id == tenant_id)
)
result = await db.execute(stmt)
machine = result.scalar_one_or_none()
if not machine:
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.")
active_parts = [p for p in machine.parts if p.is_active]
parts_data = [
{
"id": str(p.id),
"name": str(p.name),
"part_number": str(p.part_number) if p.part_number else None,
"category": str(p.category) if p.category else None,
"lifecycle_type": str(p.lifecycle_type),
"lifecycle_limit": float(p.lifecycle_limit),
"alarm_threshold": float(p.alarm_threshold or 90.0),
"counter_source": str(p.counter_source or "manual"),
"installed_at": _format_ts(p.installed_at),
"is_active": bool(p.is_active),
}
for p in active_parts
]
resp = MachineDetailResponse(
id=str(machine.id),
tenant_id=str(machine.tenant_id),
name=str(machine.name),
equipment_code=str(machine.equipment_code or ""),
model=str(machine.model) if machine.model else None,
parts_count=len(active_parts),
parts=parts_data,
created_at=_format_ts(machine.created_at),
updated_at=_format_ts(machine.updated_at),
)
return resp
@router.put("/{machine_id}")
async def update_machine(
body: MachineUpdate,
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(Machine).where(
Machine.id == machine_id, Machine.tenant_id == tenant_id
)
result = await db.execute(stmt)
machine = result.scalar_one_or_none()
if not machine:
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.")
if body.name is not None:
machine.name = body.name
if body.equipment_code is not None:
machine.equipment_code = body.equipment_code
if body.model is not None:
machine.model = body.model
await db.commit()
await db.refresh(machine)
return _machine_to_response(machine)
@router.delete("/{machine_id}")
async def delete_machine(
tenant_id: str = Path(...),
machine_id: UUID = Path(...),
current_user: TokenData = Depends(require_auth),
db: AsyncSession = Depends(get_db),
):
verify_tenant_access(tenant_id, current_user)
stmt = select(Machine).where(
Machine.id == machine_id, Machine.tenant_id == tenant_id
)
result = await db.execute(stmt)
machine = result.scalar_one_or_none()
if not machine:
raise HTTPException(status_code=404, detail="설비를 찾을 수 없습니다.")
parts_stmt = select(func.count(EquipmentPart.id)).where(
EquipmentPart.machine_id == machine_id,
EquipmentPart.is_active == True,
)
parts_count = (await db.execute(parts_stmt)).scalar() or 0
if parts_count > 0:
raise HTTPException(
status_code=409,
detail=f"활성 부품이 {parts_count}개 있어 삭제할 수 없습니다. 먼저 부품을 제거해주세요.",
)
await db.delete(machine)
await db.commit()
return {"status": "success", "message": "설비가 삭제되었습니다."}