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:
0
src/api/__init__.py
Normal file
0
src/api/__init__.py
Normal file
299
src/api/equipment_parts.py
Normal file
299
src/api/equipment_parts.py
Normal 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
230
src/api/machines.py
Normal 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": "설비가 삭제되었습니다."}
|
||||
Reference in New Issue
Block a user