feat: CONAI Phase 1 MVP 초기 구현

소형 건설업체(100억 미만)를 위한 AI 기반 토목공사 통합관리 플랫폼

Backend (FastAPI):
- SQLAlchemy 모델 13개 (users, projects, wbs, tasks, daily_reports, reports, inspections, quality, weather, permits, rag, settings)
- API 라우터 11개 (auth, projects, tasks, daily_reports, reports, inspections, weather, rag, kakao, permits, settings)
- Services: Claude AI 래퍼, CPM Gantt 계산, 기상청 API, RAG(pgvector), 카카오 Skill API
- Alembic 마이그레이션 (pgvector 포함)
- pytest 테스트 (CPM, 날씨 경보)

Frontend (Next.js 15):
- 11개 페이지 (대시보드, 프로젝트, Gantt, 일보, 검측, 품질, 날씨, 인허가, RAG, 설정)
- TanStack Query + Zustand + Tailwind CSS

인프라:
- Docker Compose (PostgreSQL pgvector + backend + frontend)
- 한국어 README 및 설치 가이드

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sinmb79
2026-03-24 20:06:36 +09:00
commit 2a4950d8a0
99 changed files with 7447 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
from .user import User
from .project import Project, WBSItem
from .task import Task, TaskDependency
from .daily_report import DailyReport, DailyReportPhoto
from .report import Report
from .inspection import InspectionRequest
from .quality import QualityTest
from .weather import WeatherData, WeatherAlert
from .permit import PermitItem
from .rag import RagSource, RagChunk
from .settings import ClientProfile, AlertRule, WorkTypeLibrary
__all__ = [
"User",
"Project", "WBSItem",
"Task", "TaskDependency",
"DailyReport", "DailyReportPhoto",
"Report",
"InspectionRequest",
"QualityTest",
"WeatherData", "WeatherAlert",
"PermitItem",
"RagSource", "RagChunk",
"ClientProfile", "AlertRule", "WorkTypeLibrary",
]
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
class UUIDMixin:
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
+64
View File
@@ -0,0 +1,64 @@
import uuid
from sqlalchemy import String, Integer, Boolean, Date, Text, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
from datetime import datetime
import enum
class ReportStatus(str, enum.Enum):
DRAFT = "draft"
CONFIRMED = "confirmed"
SUBMITTED = "submitted"
class InputSource(str, enum.Enum):
KAKAO = "kakao"
WEB = "web"
API = "api"
class DailyReport(Base, UUIDMixin, TimestampMixin):
__tablename__ = "daily_reports"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
report_date: Mapped[str] = mapped_column(Date, nullable=False, index=True)
weather_summary: Mapped[str | None] = mapped_column(String(100), nullable=True)
temperature_high: Mapped[float | None] = mapped_column(nullable=True)
temperature_low: Mapped[float | None] = mapped_column(nullable=True)
workers_count: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # {"concrete": 5, ...}
equipment_list: Mapped[list | None] = mapped_column(JSONB, nullable=True) # [{"type": "backhoe", ...}]
work_content: Mapped[str | None] = mapped_column(Text, nullable=True)
issues: Mapped[str | None] = mapped_column(Text, nullable=True)
input_source: Mapped[InputSource] = mapped_column(
SAEnum(InputSource, name="input_source"), default=InputSource.WEB, nullable=False
)
raw_kakao_input: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_generated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
status: Mapped[ReportStatus] = mapped_column(
SAEnum(ReportStatus, name="daily_report_status"), default=ReportStatus.DRAFT, nullable=False
)
confirmed_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
confirmed_at: Mapped[datetime | None] = mapped_column(nullable=True)
pdf_s3_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
# relationships
project: Mapped["Project"] = relationship("Project", back_populates="daily_reports")
photos: Mapped[list["DailyReportPhoto"]] = relationship(
"DailyReportPhoto", back_populates="daily_report", cascade="all, delete-orphan"
)
confirmed_user: Mapped["User | None"] = relationship("User", foreign_keys=[confirmed_by])
class DailyReportPhoto(Base, UUIDMixin, TimestampMixin):
__tablename__ = "daily_report_photos"
daily_report_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("daily_reports.id"), nullable=False)
s3_key: Mapped[str] = mapped_column(String(500), nullable=False)
caption: Mapped[str | None] = mapped_column(String(200), nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# relationships
daily_report: Mapped["DailyReport"] = relationship("DailyReport", back_populates="photos")
+44
View File
@@ -0,0 +1,44 @@
import uuid
from sqlalchemy import String, Boolean, Date, Text, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
import enum
class InspectionResult(str, enum.Enum):
PASS = "pass"
FAIL = "fail"
CONDITIONAL_PASS = "conditional_pass"
class InspectionStatus(str, enum.Enum):
DRAFT = "draft"
SENT = "sent"
COMPLETED = "completed"
class InspectionRequest(Base, UUIDMixin, TimestampMixin):
__tablename__ = "inspection_requests"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
wbs_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("wbs_items.id"), nullable=True)
inspection_type: Mapped[str] = mapped_column(String(50), nullable=False) # rebar, formwork, pipe_burial, etc.
requested_date: Mapped[str] = mapped_column(Date, nullable=False)
location_detail: Mapped[str | None] = mapped_column(String(200), nullable=True)
checklist_items: Mapped[list | None] = mapped_column(JSONB, nullable=True)
result: Mapped[InspectionResult | None] = mapped_column(
SAEnum(InspectionResult, name="inspection_result"), nullable=True
)
inspector_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_generated: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
status: Mapped[InspectionStatus] = mapped_column(
SAEnum(InspectionStatus, name="inspection_status"), default=InspectionStatus.DRAFT, nullable=False
)
pdf_s3_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
# relationships
project: Mapped["Project"] = relationship("Project", back_populates="inspection_requests")
wbs_item: Mapped["WBSItem | None"] = relationship("WBSItem")
+36
View File
@@ -0,0 +1,36 @@
import uuid
from sqlalchemy import String, Boolean, Date, Text, Integer, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
import enum
class PermitStatus(str, enum.Enum):
NOT_STARTED = "not_started"
SUBMITTED = "submitted"
IN_REVIEW = "in_review"
APPROVED = "approved"
REJECTED = "rejected"
class PermitItem(Base, UUIDMixin, TimestampMixin):
__tablename__ = "permit_items"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
permit_type: Mapped[str] = mapped_column(String(100), nullable=False) # 도로점용허가, 하천점용허가, etc.
authority: Mapped[str | None] = mapped_column(String(100), nullable=True)
required: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
deadline: Mapped[str | None] = mapped_column(Date, nullable=True)
status: Mapped[PermitStatus] = mapped_column(
SAEnum(PermitStatus, name="permit_status"), default=PermitStatus.NOT_STARTED, nullable=False
)
submitted_date: Mapped[str | None] = mapped_column(Date, nullable=True)
approved_date: Mapped[str | None] = mapped_column(Date, nullable=True)
document_s3_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# relationships
project: Mapped["Project"] = relationship("Project", back_populates="permit_items")
+79
View File
@@ -0,0 +1,79 @@
import uuid
from sqlalchemy import String, Integer, BigInteger, Date, Float, ForeignKey, Enum as SAEnum, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
import enum
class ProjectStatus(str, enum.Enum):
PLANNING = "planning"
ACTIVE = "active"
SUSPENDED = "suspended"
COMPLETED = "completed"
class ConstructionType(str, enum.Enum):
ROAD = "road"
SEWER = "sewer"
WATER = "water"
BRIDGE = "bridge"
SITE_WORK = "site_work"
OTHER = "other"
class Project(Base, UUIDMixin, TimestampMixin):
__tablename__ = "projects"
name: Mapped[str] = mapped_column(String(200), nullable=False)
code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
client_profile_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("client_profiles.id"), nullable=True)
construction_type: Mapped[ConstructionType] = mapped_column(
SAEnum(ConstructionType, name="construction_type"), default=ConstructionType.OTHER, nullable=False
)
contract_amount: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
start_date: Mapped[str | None] = mapped_column(Date, nullable=True)
end_date: Mapped[str | None] = mapped_column(Date, nullable=True)
location_address: Mapped[str | None] = mapped_column(Text, nullable=True)
location_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
location_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
weather_grid_x: Mapped[int | None] = mapped_column(Integer, nullable=True)
weather_grid_y: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[ProjectStatus] = mapped_column(
SAEnum(ProjectStatus, name="project_status"), default=ProjectStatus.PLANNING, nullable=False
)
owner_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
# relationships
owner: Mapped["User"] = relationship("User", back_populates="owned_projects", foreign_keys=[owner_id])
wbs_items: Mapped[list["WBSItem"]] = relationship("WBSItem", back_populates="project", cascade="all, delete-orphan")
tasks: Mapped[list["Task"]] = relationship("Task", back_populates="project", cascade="all, delete-orphan")
daily_reports: Mapped[list["DailyReport"]] = relationship("DailyReport", back_populates="project", cascade="all, delete-orphan")
inspection_requests: Mapped[list["InspectionRequest"]] = relationship("InspectionRequest", back_populates="project", cascade="all, delete-orphan")
quality_tests: Mapped[list["QualityTest"]] = relationship("QualityTest", back_populates="project", cascade="all, delete-orphan")
weather_data: Mapped[list["WeatherData"]] = relationship("WeatherData", back_populates="project", cascade="all, delete-orphan")
weather_alerts: Mapped[list["WeatherAlert"]] = relationship("WeatherAlert", back_populates="project", cascade="all, delete-orphan")
permit_items: Mapped[list["PermitItem"]] = relationship("PermitItem", back_populates="project", cascade="all, delete-orphan")
reports: Mapped[list["Report"]] = relationship("Report", back_populates="project", cascade="all, delete-orphan")
client_profile: Mapped["ClientProfile | None"] = relationship("ClientProfile", back_populates="projects")
class WBSItem(Base, UUIDMixin, TimestampMixin):
__tablename__ = "wbs_items"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
parent_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("wbs_items.id"), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(200), nullable=False)
level: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
unit: Mapped[str | None] = mapped_column(String(20), nullable=True)
design_qty: Mapped[float | None] = mapped_column(Float, nullable=True)
unit_price: Mapped[float | None] = mapped_column(Float, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# relationships
project: Mapped["Project"] = relationship("Project", back_populates="wbs_items")
parent: Mapped["WBSItem | None"] = relationship("WBSItem", remote_side="WBSItem.id", back_populates="children")
children: Mapped[list["WBSItem"]] = relationship("WBSItem", back_populates="parent")
tasks: Mapped[list["Task"]] = relationship("Task", back_populates="wbs_item")
+35
View File
@@ -0,0 +1,35 @@
import uuid
from sqlalchemy import String, Date, Text, Float, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
import enum
class QualityResult(str, enum.Enum):
PASS = "pass"
FAIL = "fail"
class QualityTest(Base, UUIDMixin, TimestampMixin):
__tablename__ = "quality_tests"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
wbs_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("wbs_items.id"), nullable=True)
test_type: Mapped[str] = mapped_column(String(50), nullable=False) # compression_strength, slump, compaction, etc.
test_date: Mapped[str] = mapped_column(Date, nullable=False)
location_detail: Mapped[str | None] = mapped_column(String(200), nullable=True)
design_value: Mapped[float | None] = mapped_column(Float, nullable=True)
measured_value: Mapped[float] = mapped_column(Float, nullable=False)
unit: Mapped[str] = mapped_column(String(20), nullable=False)
result: Mapped[QualityResult] = mapped_column(
SAEnum(QualityResult, name="quality_result"), nullable=False
)
lab_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
report_number: Mapped[str | None] = mapped_column(String(100), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# relationships
project: Mapped["Project"] = relationship("Project", back_populates="quality_tests")
wbs_item: Mapped["WBSItem | None"] = relationship("WBSItem")
+41
View File
@@ -0,0 +1,41 @@
import uuid
from sqlalchemy import String, Integer, Text, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
import enum
class RagSourceType(str, enum.Enum):
KCS = "kcs"
LAW = "law"
REGULATION = "regulation"
GUIDELINE = "guideline"
class RagSource(Base, UUIDMixin, TimestampMixin):
__tablename__ = "rag_sources"
title: Mapped[str] = mapped_column(String(300), nullable=False)
source_type: Mapped[RagSourceType] = mapped_column(
SAEnum(RagSourceType, name="rag_source_type"), nullable=False
)
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
file_s3_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
# relationships
chunks: Mapped[list["RagChunk"]] = relationship("RagChunk", back_populates="source", cascade="all, delete-orphan")
class RagChunk(Base, UUIDMixin, TimestampMixin):
__tablename__ = "rag_chunks"
source_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("rag_sources.id"), nullable=False)
chunk_index: Mapped[int] = mapped_column(Integer, nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
# Note: embedding column (VECTOR) added via Alembic migration with pgvector extension
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
# relationships
source: Mapped["RagSource"] = relationship("RagSource", back_populates="chunks")
+38
View File
@@ -0,0 +1,38 @@
import uuid
from sqlalchemy import String, Date, Text, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
import enum
class ReportType(str, enum.Enum):
WEEKLY = "weekly"
MONTHLY = "monthly"
class ReportStatus(str, enum.Enum):
DRAFT = "draft"
REVIEWED = "reviewed"
SUBMITTED = "submitted"
class Report(Base, UUIDMixin, TimestampMixin):
__tablename__ = "reports"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
report_type: Mapped[ReportType] = mapped_column(
SAEnum(ReportType, name="report_type"), nullable=False
)
period_start: Mapped[str] = mapped_column(Date, nullable=False)
period_end: Mapped[str] = mapped_column(Date, nullable=False)
content_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
ai_draft_text: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[ReportStatus] = mapped_column(
SAEnum(ReportStatus, name="report_status"), default=ReportStatus.DRAFT, nullable=False
)
pdf_s3_key: Mapped[str | None] = mapped_column(String(500), nullable=True)
# relationships
project: Mapped["Project"] = relationship("Project", back_populates="reports")
+41
View File
@@ -0,0 +1,41 @@
import uuid
from sqlalchemy import String, Boolean, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
class ClientProfile(Base, UUIDMixin, TimestampMixin):
__tablename__ = "client_profiles"
name: Mapped[str] = mapped_column(String(100), nullable=False)
report_frequency: Mapped[str] = mapped_column(String(20), default="weekly", nullable=False)
template_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
contact_info: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# relationships
projects: Mapped[list["Project"]] = relationship("Project", back_populates="client_profile")
class AlertRule(Base, UUIDMixin, TimestampMixin):
__tablename__ = "alert_rules"
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True)
rule_name: Mapped[str] = mapped_column(String(100), nullable=False)
condition: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
channels: Mapped[list | None] = mapped_column(JSONB, nullable=True)
recipients: Mapped[list | None] = mapped_column(JSONB, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class WorkTypeLibrary(Base, UUIDMixin, TimestampMixin):
__tablename__ = "work_type_library"
code: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
category: Mapped[str] = mapped_column(String(50), nullable=False)
weather_constraints: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
default_checklist: Mapped[list | None] = mapped_column(JSONB, nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+61
View File
@@ -0,0 +1,61 @@
import uuid
from sqlalchemy import String, Integer, Date, Boolean, Float, ForeignKey, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
import enum
class DependencyType(str, enum.Enum):
FS = "FS" # Finish-to-Start
SS = "SS" # Start-to-Start
FF = "FF" # Finish-to-Finish
SF = "SF" # Start-to-Finish
class Task(Base, UUIDMixin, TimestampMixin):
__tablename__ = "tasks"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
wbs_item_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("wbs_items.id"), nullable=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
planned_start: Mapped[str | None] = mapped_column(Date, nullable=True)
planned_end: Mapped[str | None] = mapped_column(Date, nullable=True)
actual_start: Mapped[str | None] = mapped_column(Date, nullable=True)
actual_end: Mapped[str | None] = mapped_column(Date, nullable=True)
progress_pct: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
is_milestone: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_critical: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
early_start: Mapped[str | None] = mapped_column(Date, nullable=True) # CPM
early_finish: Mapped[str | None] = mapped_column(Date, nullable=True)
late_start: Mapped[str | None] = mapped_column(Date, nullable=True)
late_finish: Mapped[str | None] = mapped_column(Date, nullable=True)
total_float: Mapped[int | None] = mapped_column(Integer, nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# relationships
project: Mapped["Project"] = relationship("Project", back_populates="tasks")
wbs_item: Mapped["WBSItem | None"] = relationship("WBSItem", back_populates="tasks")
predecessors: Mapped[list["TaskDependency"]] = relationship(
"TaskDependency", foreign_keys="TaskDependency.successor_id", back_populates="successor"
)
successors: Mapped[list["TaskDependency"]] = relationship(
"TaskDependency", foreign_keys="TaskDependency.predecessor_id", back_populates="predecessor"
)
weather_alerts: Mapped[list["WeatherAlert"]] = relationship("WeatherAlert", back_populates="task")
class TaskDependency(Base, UUIDMixin, TimestampMixin):
__tablename__ = "task_dependencies"
predecessor_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False)
successor_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=False)
dependency_type: Mapped[DependencyType] = mapped_column(
SAEnum(DependencyType, name="dependency_type"), default=DependencyType.FS, nullable=False
)
lag_days: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
# relationships
predecessor: Mapped["Task"] = relationship("Task", foreign_keys=[predecessor_id], back_populates="successors")
successor: Mapped["Task"] = relationship("Task", foreign_keys=[successor_id], back_populates="predecessors")
+31
View File
@@ -0,0 +1,31 @@
import uuid
from sqlalchemy import String, Boolean, Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
import enum
class UserRole(str, enum.Enum):
ADMIN = "admin"
SITE_MANAGER = "site_manager"
SUPERVISOR = "supervisor"
WORKER = "worker"
class User(Base, UUIDMixin, TimestampMixin):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(100), nullable=False)
role: Mapped[UserRole] = mapped_column(
SAEnum(UserRole, name="user_role"), default=UserRole.SITE_MANAGER, nullable=False
)
phone: Mapped[str | None] = mapped_column(String(20), nullable=True)
kakao_user_key: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
# relationships
owned_projects: Mapped[list["Project"]] = relationship("Project", back_populates="owner", foreign_keys="Project.owner_id")
+57
View File
@@ -0,0 +1,57 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, Date, Float, Integer, ForeignKey, Enum as SAEnum, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID, JSONB
from app.core.database import Base
from app.models.base import TimestampMixin, UUIDMixin
import enum
class ForecastType(str, enum.Enum):
SHORT_TERM = "short_term"
MEDIUM_TERM = "medium_term"
OBSERVED = "observed"
class AlertSeverity(str, enum.Enum):
WARNING = "warning"
CRITICAL = "critical"
class WeatherData(Base, UUIDMixin, TimestampMixin):
__tablename__ = "weather_data"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
forecast_date: Mapped[str] = mapped_column(Date, nullable=False)
forecast_type: Mapped[ForecastType] = mapped_column(
SAEnum(ForecastType, name="forecast_type"), nullable=False
)
temperature_high: Mapped[float | None] = mapped_column(Float, nullable=True)
temperature_low: Mapped[float | None] = mapped_column(Float, nullable=True)
precipitation_mm: Mapped[float | None] = mapped_column(Float, nullable=True)
wind_speed_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
weather_code: Mapped[str | None] = mapped_column(String(20), nullable=True)
raw_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
fetched_at: Mapped[datetime] = mapped_column(nullable=False)
# relationships
project: Mapped["Project"] = relationship("Project", back_populates="weather_data")
class WeatherAlert(Base, UUIDMixin, TimestampMixin):
__tablename__ = "weather_alerts"
project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=False)
task_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("tasks.id"), nullable=True)
alert_date: Mapped[str] = mapped_column(Date, nullable=False)
alert_type: Mapped[str] = mapped_column(String(50), nullable=False) # rain_concrete, wind_highwork, etc.
severity: Mapped[AlertSeverity] = mapped_column(
SAEnum(AlertSeverity, name="alert_severity"), nullable=False
)
message: Mapped[str] = mapped_column(Text, nullable=False)
is_acknowledged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# relationships
project: Mapped["Project"] = relationship("Project", back_populates="weather_alerts")
task: Mapped["Task | None"] = relationship("Task", back_populates="weather_alerts")