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/database/__init__.py
Normal file
0
src/database/__init__.py
Normal file
39
src/database/config.py
Normal file
39
src/database/config.py
Normal 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
166
src/database/models.py
Normal 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"),
|
||||
)
|
||||
Reference in New Issue
Block a user