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:
MMD
2026-10-07 15:14:20 +03:30
87 changed files with 23922 additions and 0 deletions
+446
View File
@@ -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())