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/database/__init__.py Normal file
View File

39
src/database/config.py Normal file
View File

@@ -0,0 +1,39 @@
"""PostgreSQL async 데이터베이스 설정"""
import os
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://factoryops:factoryops@localhost:5432/factoryops_v2",
)
engine = create_async_engine(
DATABASE_URL,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
echo=os.getenv("SQL_ECHO", "false").lower() == "true",
)
AsyncSessionLocal = async_sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db():
from src.database.models import Base as _Base # noqa: F811
async with engine.begin() as conn:
await conn.run_sync(_Base.metadata.create_all)

166
src/database/models.py Normal file
View File

@@ -0,0 +1,166 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
Column,
String,
Boolean,
Float,
Integer,
ForeignKey,
Index,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.dialects.postgresql import UUID, JSONB, TIMESTAMP
from sqlalchemy.orm import relationship
from src.database.config import Base
def utcnow():
return datetime.now(timezone.utc)
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False)
password_hash = Column(String(255), nullable=False)
name = Column(String(100), nullable=False)
role = Column(String(20), nullable=False) # superadmin | tenant_admin | user
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
tenant = relationship("Tenant", back_populates="users")
class Tenant(Base):
__tablename__ = "tenants"
id = Column(String(50), primary_key=True)
name = Column(String(100), nullable=False)
industry_type = Column(String(50), default="general")
is_active = Column(Boolean, default=True)
enabled_modules = Column(JSONB, nullable=True)
workflow_config = Column(JSONB, nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
users = relationship("User", back_populates="tenant")
machines = relationship("Machine", back_populates="tenant")
class Machine(Base):
__tablename__ = "machines"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
name = Column(String(100), nullable=False)
equipment_code = Column(String(50), default="")
model = Column(String(100), nullable=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
tenant = relationship("Tenant", back_populates="machines")
parts = relationship(
"EquipmentPart", back_populates="machine", cascade="all, delete-orphan"
)
__table_args__ = (Index("ix_machines_tenant_id", "tenant_id"),)
class EquipmentPart(Base):
__tablename__ = "equipment_parts"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(String(50), ForeignKey("tenants.id"), nullable=False)
machine_id = Column(UUID(as_uuid=True), ForeignKey("machines.id"), nullable=False)
name = Column(String(100), nullable=False)
part_number = Column(String(50), nullable=True)
category = Column(String(50), nullable=True)
lifecycle_type = Column(String(20), nullable=False) # hours | count | date
lifecycle_limit = Column(Float, nullable=False)
alarm_threshold = Column(Float, default=90.0)
counter_source = Column(
String(20), default="manual"
) # auto_plc | auto_time | manual
installed_at = Column(TIMESTAMP(timezone=True), nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(TIMESTAMP(timezone=True), default=utcnow)
updated_at = Column(TIMESTAMP(timezone=True), default=utcnow, onupdate=utcnow)
tenant = relationship("Tenant")
machine = relationship("Machine", back_populates="parts")
counter = relationship(
"PartCounter",
back_populates="equipment_part",
uselist=False,
cascade="all, delete-orphan",
)
replacement_logs = relationship(
"PartReplacementLog",
back_populates="equipment_part",
cascade="all, delete-orphan",
)
__table_args__ = (
Index(
"uq_equipment_part_active_tenant_machine_name",
"tenant_id",
"machine_id",
"name",
unique=True,
postgresql_where=text("is_active = true"),
),
Index("ix_equipment_parts_tenant_id", "tenant_id"),
Index("ix_equipment_parts_tenant_machine", "tenant_id", "machine_id"),
)
class PartCounter(Base):
__tablename__ = "part_counters"
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"),
unique=True,
nullable=False,
)
current_value = Column(Float, default=0)
lifecycle_pct = Column(Float, default=0)
last_reset_at = Column(TIMESTAMP(timezone=True), nullable=False)
last_updated_at = Column(TIMESTAMP(timezone=True), nullable=False)
version = Column(Integer, default=0)
equipment_part = relationship("EquipmentPart", back_populates="counter")
__table_args__ = (Index("ix_part_counters_tenant_id", "tenant_id"),)
class PartReplacementLog(Base):
__tablename__ = "part_replacement_logs"
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
)
replaced_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
replaced_at = Column(TIMESTAMP(timezone=True), nullable=False)
counter_at_replacement = Column(Float, nullable=False)
lifecycle_pct_at_replacement = Column(Float, nullable=False)
reason = Column(String(200), nullable=True)
notes = Column(Text, nullable=True)
equipment_part = relationship("EquipmentPart", back_populates="replacement_logs")
__table_args__ = (
Index("ix_part_replacement_tenant_part", "tenant_id", "equipment_part_id"),
Index("ix_part_replacement_tenant_date", "tenant_id", "replaced_at"),
)