"""PM data model — Native Construction Project Management Platform. - Project and Task entities are owned directly by PM with hierarchical WBS, CPM scheduling, dependencies, and milestones. - QC domain (templates, definitions, inspections, results, defects) is PM-owned. - Material requirements and reservations integrate directly with ERP stock. """ import enum import uuid from datetime import date, datetime from sqlalchemy import ( JSON, Boolean, Date, DateTime, Enum, Float, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func, ) from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship class Base(DeclarativeBase): pass def _uuid() -> uuid.UUID: return uuid.uuid4() # --- Native PM Platform Models ----------------------------------------------- class ProjectStatus(str, enum.Enum): PLANNED = "PLANNED" ACTIVE = "ACTIVE" ON_HOLD = "ON_HOLD" COMPLETED = "COMPLETED" CANCELLED = "CANCELLED" class TaskStatus(str, enum.Enum): TODO = "TODO" IN_PROGRESS = "IN_PROGRESS" BLOCKED = "BLOCKED" COMPLETED = "COMPLETED" CANCELLED = "CANCELLED" class TaskPriority(str, enum.Enum): LOW = "low" MEDIUM = "medium" HIGH = "high" URGENT = "urgent" class TaskRelationType(str, enum.Enum): FS = "FS" # Finish-to-Start (پایان به شروع) SS = "SS" # Start-to-Start (شروع به شروع) FF = "FF" # Finish-to-Finish (پایان به پایان) SF = "SF" # Start-to-Finish (شروع به پایان) class Project(Base): """Native Construction Project entity owned directly by PM.""" __tablename__ = "projects" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) name: Mapped[str] = mapped_column(String(255), index=True) description: Mapped[str] = mapped_column(Text, default="") location: Mapped[str] = mapped_column(String(255), default="") project_manager: Mapped[str] = mapped_column(String(255), default="") planned_start: Mapped[date | None] = mapped_column(Date, nullable=True) planned_end: Mapped[date | None] = mapped_column(Date, nullable=True) actual_start: Mapped[date | None] = mapped_column(Date, nullable=True) actual_end: Mapped[date | None] = mapped_column(Date, nullable=True) status: Mapped[str] = mapped_column( String(32), default=ProjectStatus.ACTIVE.value, server_default=ProjectStatus.ACTIVE.value, index=True, ) progress: Mapped[float] = mapped_column(Float, default=0.0) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) members: Mapped[list["ProjectMember"]] = relationship( back_populates="project", cascade="all, delete-orphan" ) tasks: Mapped[list["Task"]] = relationship( back_populates="project", cascade="all, delete-orphan" ) relations: Mapped[list["TaskRelation"]] = relationship( back_populates="project", cascade="all, delete-orphan" ) class ProjectMember(Base): """Team members assigned to a project with designated roles.""" __tablename__ = "project_members" __table_args__ = (UniqueConstraint("project_id", "user_id", name="uq_project_member"),) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) project_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True ) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True ) role: Mapped[str] = mapped_column(String(32), default="site_engineer") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) project: Mapped["Project"] = relationship(back_populates="members") user: Mapped["PMUser"] = relationship(lazy="selectin") class Task(Base): """Hierarchical Construction Task / Activity entity owned directly by PM. Supports arbitrary depth: Project -> Phase -> Activity -> Subtask through self-referencing `parent_id`. """ __tablename__ = "tasks" __table_args__ = ( Index("ix_tasks_project", "project_id"), Index("ix_tasks_parent", "parent_id"), ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) project_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True ) parent_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), nullable=True, index=True ) title: Mapped[str] = mapped_column(String(512)) description: Mapped[str] = mapped_column(Text, default="") status: Mapped[str] = mapped_column( String(32), default=TaskStatus.TODO.value, server_default=TaskStatus.TODO.value, index=True, ) priority: Mapped[str] = mapped_column( String(32), default=TaskPriority.MEDIUM.value, server_default=TaskPriority.MEDIUM.value, ) assignee_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="SET NULL"), nullable=True ) assignee_name: Mapped[str | None] = mapped_column(String(255), nullable=True) planned_start: Mapped[date | None] = mapped_column(Date, nullable=True) planned_end: Mapped[date | None] = mapped_column(Date, nullable=True) actual_start: Mapped[date | None] = mapped_column(Date, nullable=True) actual_end: Mapped[date | None] = mapped_column(Date, nullable=True) duration: Mapped[int] = mapped_column(Integer, default=1, server_default="1") progress: Mapped[int] = mapped_column(Integer, default=0, server_default="0") qc_status: Mapped[str] = mapped_column( String(32), default="none", server_default="none", comment="none|pre_qc_pending|pre_qc_failed|ready|exec_qc_pending|in_progress|qc_failed_rework|final_passed", ) human_resources: Mapped[list[dict]] = mapped_column( JSON, default=list, server_default="[]" ) materials: Mapped[list[dict]] = mapped_column( JSON, default=list, server_default="[]" ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) project: Mapped["Project"] = relationship(back_populates="tasks", lazy="selectin") parent: Mapped["Task | None"] = relationship( back_populates="children", remote_side=[id], lazy="selectin" ) children: Mapped[list["Task"]] = relationship( back_populates="parent", cascade="all, delete-orphan", lazy="selectin" ) assignee: Mapped["PMUser | None"] = relationship(lazy="selectin") outgoing_relations: Mapped[list["TaskRelation"]] = relationship( foreign_keys="[TaskRelation.from_task_id]", back_populates="from_task", cascade="all, delete-orphan", ) incoming_relations: Mapped[list["TaskRelation"]] = relationship( foreign_keys="[TaskRelation.to_task_id]", back_populates="to_task", cascade="all, delete-orphan", ) progress_logs: Mapped[list["TaskProgressLog"]] = relationship( back_populates="task", cascade="all, delete-orphan", lazy="selectin" ) class TaskProgressLog(Base): """ثبت گزارش پیشرفت مقطعی/روزانه وظیفه به همراه مصرف واقعی نیروی انسانی و مصالح.""" __tablename__ = "task_progress_logs" __table_args__ = ( Index("ix_task_progress_logs_task", "task_id"), Index("ix_task_progress_logs_created", "created_at"), ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) task_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True ) progress_before: Mapped[int] = mapped_column(Integer, default=0, server_default="0") progress_after: Mapped[int] = mapped_column(Integer) human_resources_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]") materials_used: Mapped[list[dict]] = mapped_column(JSON, default=list, server_default="[]") description: Mapped[str] = mapped_column(Text, default="", server_default="") logged_by: Mapped[str] = mapped_column(String(255), default="", server_default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) task: Mapped["Task"] = relationship(back_populates="progress_logs") class TaskRelation(Base): """Dependency / precedence between two tasks in a project (FS, SS, FF, SF + lag).""" __tablename__ = "task_relations" __table_args__ = ( UniqueConstraint("from_task_id", "to_task_id", name="uq_task_relations_pair"), Index("ix_task_relations_project", "project_id"), Index("ix_task_relations_from", "from_task_id"), Index("ix_task_relations_to", "to_task_id"), ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) project_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True ) from_task_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True ) to_task_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True ) relation_type: Mapped[str] = mapped_column( String(8), default=TaskRelationType.FS.value, server_default=TaskRelationType.FS.value ) lag_days: Mapped[int] = mapped_column(Integer, default=0, server_default="0") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) project: Mapped["Project"] = relationship(back_populates="relations") from_task: Mapped["Task"] = relationship( foreign_keys=[from_task_id], back_populates="outgoing_relations" ) to_task: Mapped["Task"] = relationship( foreign_keys=[to_task_id], back_populates="incoming_relations" ) # --- PM-owned QC domain ------------------------------------------------------ class QCQuestionPhase(str, enum.Enum): """مرحله‌ای که یک سوال QC به آن تعلق دارد. pre = پیش از اجرا (آمادگی شروع کار) during = هنگام اجرا after = پس از اجرا """ pre = "pre" during = "during" after = "after" class QCAnswer(str, enum.Enum): pass_ = "pass" fail = "fail" na = "na" class QCTemplate(Base): __tablename__ = "qc_templates" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) name: Mapped[str] = mapped_column(String(255)) activity_type: Mapped[str] = mapped_column(String(64), default="", index=True) description: Mapped[str] = mapped_column(Text, default="") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) questions: Mapped[list["QCQuestion"]] = relationship( back_populates="template", cascade="all, delete-orphan", order_by="QCQuestion.sort_order" ) class QCQuestion(Base): __tablename__ = "qc_questions" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) template_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("qc_templates.id", ondelete="CASCADE"), index=True, ) phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase")) text: Mapped[str] = mapped_column(String(512)) sort_order: Mapped[int] = mapped_column(Integer, default=0) required: Mapped[bool] = mapped_column(Boolean, default=True) template: Mapped[QCTemplate] = relationship(back_populates="questions") class TaskQCDefinition(Base): __tablename__ = "task_qc_definitions" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) task_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True ) name: Mapped[str] = mapped_column(String(255), default="") # provenance only — deleting/editing the template never affects this copy source_template_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("qc_templates.id", ondelete="SET NULL"), nullable=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) questions: Mapped[list["TaskQCQuestion"]] = relationship( back_populates="definition", cascade="all, delete-orphan", order_by="TaskQCQuestion.sort_order", ) class TaskQCQuestion(Base): __tablename__ = "task_qc_questions" __table_args__ = (Index("ix_task_qc_q_def", "definition_id"),) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) definition_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("task_qc_definitions.id", ondelete="CASCADE") ) phase: Mapped[QCQuestionPhase] = mapped_column(Enum(QCQuestionPhase, name="qc_phase")) text: Mapped[str] = mapped_column(String(512)) sort_order: Mapped[int] = mapped_column(Integer, default=0) required: Mapped[bool] = mapped_column(Boolean, default=True) definition: Mapped[TaskQCDefinition] = relationship(back_populates="questions") results: Mapped[list["TaskQCResult"]] = relationship( back_populates="question", cascade="all, delete-orphan", order_by="TaskQCResult.evaluated_at" ) class TaskQCResult(Base): """Evaluation of ONE task-specific question — append-only history. The current answer is the latest row for the question; keeping history is what makes rework → re-inspection → pass traceable. """ __tablename__ = "task_qc_results" __table_args__ = (Index("ix_task_qc_res_q", "question_id"),) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) question_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="CASCADE") ) answer: Mapped[QCAnswer] = mapped_column(Enum(QCAnswer, name="qc_answer")) comment: Mapped[str] = mapped_column(Text, default="") photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True) evaluated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) evaluated_by: Mapped[str] = mapped_column(String(255), default="") question: Mapped[TaskQCQuestion] = relationship(back_populates="results") class Defect(Base): """عیب ثبت‌شده روی یک تسک (اختیاری متصل به سؤال QC مردود).""" __tablename__ = "defects" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) task_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="CASCADE"), index=True ) question_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("task_qc_questions.id", ondelete="SET NULL"), nullable=True ) title: Mapped[str] = mapped_column(String(512)) description: Mapped[str] = mapped_column(Text, default="") severity: Mapped[str] = mapped_column(String(16), default="medium", server_default="medium") status: Mapped[str] = mapped_column(String(16), default="open", server_default="open") corrective_action: Mapped[str] = mapped_column(Text, default="") resolution_notes: Mapped[str] = mapped_column(Text, default="") photo_path: Mapped[str | None] = mapped_column(String(512), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # --- Auth & access control ------------------------------------------------------ class PMUser(Base): __tablename__ = "pm_users" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) username: Mapped[str] = mapped_column(String(64), unique=True, index=True) password_hash: Mapped[str] = mapped_column(String(255)) display_name: Mapped[str] = mapped_column(String(255), default="") # Values live in app/authz.py (Role); stored as text so new roles need no # Postgres enum migration. role: Mapped[str] = mapped_column( String(32), default="project_manager", server_default="project_manager", comment="project_manager|project_supervisor", ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) class ProjectAssignment(Base): """Projects a scoped role may access (e.g. a supervisor's assigned projects). Unscoped roles (project_manager) see every project and need no rows here; scoped roles are limited to exactly the rows present for them. Source of truth for the scoping done in app/authz.py. """ __tablename__ = "project_assignments" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=_uuid) user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("pm_users.id", ondelete="CASCADE"), index=True ) project_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), index=True ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())