chore(pm): vendor the Aramesh/PM platform under pm/ (squash of Aramesh/PM @ 91f3271)
The PM panel (PmPanelPage -> iframe /pm/) needs the project-management platform served at /pm/ on the ERP domain. Source: org repo Aramesh/PM, branch feature/project-management-platform. Update via: git subtree pull --prefix=pm aramesh-pm feature/project-management-platform --squash
This commit is contained in:
@@ -0,0 +1,446 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user