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
@@ -0,0 +1,94 @@
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db import get_db
from app.main import app
from app.models import Base, PMUser, Project, Task
from app.security import create_token, hash_password
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.asyncio
async def test_gantt_and_relations_api():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
admin = PMUser(
username="admin",
password_hash=hash_password("admin123"),
display_name="مدیر ارشد",
role="project_manager",
)
session.add(admin)
await session.flush()
project = Project(name="پروژه تست گانت", description="تست")
session.add(project)
await session.flush()
task1 = Task(project_id=project.id, title="فونداسیون", duration=5)
task2 = Task(project_id=project.id, title="اسکلت بتنی", duration=10)
session.add_all([task1, task2])
await session.commit()
admin_id = admin.id
p_id = str(project.id)
t1_id = str(task1.id)
t2_id = str(task2.id)
token = create_token(admin_id)
headers = {"Authorization": f"Bearer {token}"}
async def override_get_db():
async with async_session() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
try:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=headers) as client:
# 1. Add relation: task1 -> task2 (FS + 2 days lag)
rel_res = await client.post(
f"/api/v1/projects/{p_id}/relations",
json={"from_task_id": t1_id, "to_task_id": t2_id, "relation_type": "FS", "lag_days": 2},
)
assert rel_res.status_code == 200
rel_data = rel_res.json()["data"]
assert rel_data["relation_type"] == "FS"
assert rel_data["lag_days"] == 2
rel_id = rel_data["id"]
# 2. Add reverse relation: task2 -> task1 (should detect cycle and reject with 400)
cycle_res = await client.post(
f"/api/v1/projects/{p_id}/relations",
json={"from_task_id": t2_id, "to_task_id": t1_id, "relation_type": "FS"},
)
assert cycle_res.status_code == 400
assert "چرخه" in cycle_res.json()["error"]["message"]
# 3. Query Gantt dataset
gantt_res = await client.get(f"/api/v1/projects/{p_id}/gantt")
assert gantt_res.status_code == 200
gantt_data = gantt_res.json()["data"]
assert len(gantt_data["tasks"]) == 2
assert len(gantt_data["relations"]) == 1
assert len(gantt_data["critical_path"]) >= 1
# 4. Delete relation
del_rel = await client.delete(f"/api/v1/projects/{p_id}/relations/{rel_id}")
assert del_rel.status_code == 200
assert del_rel.json()["data"]["deleted"] is True
finally:
app.dependency_overrides.clear()
await engine.dispose()
+207
View File
@@ -0,0 +1,207 @@
"""API tests for native Projects and Tasks endpoints."""
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db import get_db
from app.main import app
from app.models import Base, PMUser, Project, ProjectStatus, Task
from app.security import create_token, hash_password
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.asyncio
async def test_native_project_and_task_api():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
# Seed admin user
async with async_session() as session:
admin = PMUser(
username="admin",
password_hash=hash_password("admin123"),
display_name="مدیر ارشد",
role="project_manager",
)
session.add(admin)
await session.commit()
admin_id = admin.id
token = create_token(admin_id)
headers = {"Authorization": f"Bearer {token}"}
async def override_get_db():
async with async_session() as s:
yield s
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
# 1. Create a native project
resp = await client.post(
"/api/v1/projects",
json={
"name": "پروژه بیمارستان",
"description": "احداث فاز ۲ بیمارستان",
"location": "تهران",
"status": "ACTIVE",
"planned_start": "1405/01/01",
"planned_end": "1405/12/29",
},
headers=headers,
)
assert resp.status_code == 200, resp.text
proj_data = resp.json()["data"]
project_id = proj_data["id"]
assert proj_data["name"] == "پروژه بیمارستان"
assert proj_data["status"] == "ACTIVE"
# 2. List projects
resp = await client.get("/api/v1/projects", headers=headers)
assert resp.status_code == 200
projects_list = resp.json()["data"]
assert len(projects_list) == 1
assert projects_list[0]["id"] == project_id
# 3. Create a root task (Phase)
resp = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "فاز ۱: اسکلت‌بندی",
"priority": "high",
"planned_start": "1405/01/01",
"duration": 60,
},
headers=headers,
)
assert resp.status_code == 200, resp.text
phase_task = resp.json()["data"]
phase_id = phase_task["id"]
assert phase_task["title"] == "فاز ۱: اسکلت‌بندی"
# 4. Create a child task (Subtask)
resp = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"parent_id": phase_id,
"title": "فعالیت ۱.۱: آرماتوربندی ستون‌ها",
"priority": "urgent",
"planned_start": "1405/01/05",
"duration": 15,
},
headers=headers,
)
assert resp.status_code == 200, resp.text
subtask = resp.json()["data"]
assert subtask["parent_id"] == phase_id
# 5. Get Phase task detail and verify children
resp = await client.get(f"/api/v1/tasks/{phase_id}", headers=headers)
assert resp.status_code == 200
detail = resp.json()["data"]
assert len(detail["children"]) == 1
assert detail["children"][0]["id"] == subtask["id"]
# 6. Update task progress with QC invariant rule
# Since no QC questions exist for this task, progress can increase directly
resp = await client.patch(
f"/api/v1/tasks/{subtask['id']}/progress",
json={"percentage": 50},
headers=headers,
)
assert resp.status_code == 200
assert resp.json()["data"]["progress"] == 50
assert resp.json()["data"]["status"] == "IN_PROGRESS"
# 7. Verify mandatory start/end dates: task without dates must be rejected
resp_no_dates = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "وظیفه بدون تاریخ",
},
headers=headers,
)
assert resp_no_dates.status_code in (400, 422)
# 8. Verify project creation with simplified fields (no code, client, dates required)
resp_simple_proj = await client.post(
"/api/v1/projects",
json={
"name": "پروژه ساده نوساز",
},
headers=headers,
)
assert resp_simple_proj.status_code == 200
simple_data = resp_simple_proj.json()["data"]
assert simple_data["name"] == "پروژه ساده نوساز"
assert "id" in simple_data
# 9. Verify task creation and update with Human Resources and Materials (including equipment)
resp_resources_task = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "بتن‌ریزی فونداسیون با منابع کامل",
"planned_start": "1405/01/10",
"duration": 5,
"human_resources": [
{"role": "سرکارگر بتن‌ریزی", "count": 1},
{"role": "کارگر ساده", "count": 6},
],
"materials": [
{
"name": "بتن آماده عیار ۳۵۰",
"unit": "متر مکعب",
"specifications": ["تیپ ۲", "اسلامپ ۸"],
"count": 150.5,
},
{
"name": "پمپ بتن دکلی",
"unit": "دستگاه",
"specifications": ["دکل ۳۶ متری"],
"count": 1,
},
],
},
headers=headers,
)
assert resp_resources_task.status_code == 200
res_data = resp_resources_task.json()["data"]
assert len(res_data["human_resources"]) == 2
assert res_data["human_resources"][0]["role"] == "سرکارگر بتن‌ریزی"
assert len(res_data["materials"]) == 2
assert res_data["materials"][0]["unit"] == "متر مکعب"
assert res_data["materials"][0]["specifications"] == ["تیپ ۲", "اسلامپ ۸"]
assert res_data["materials"][1]["name"] == "پمپ بتن دکلی"
# Update resources via PATCH
task_id = res_data["id"]
patch_resp = await client.patch(
f"/api/v1/tasks/{task_id}",
json={
"human_resources": [
{"role": "سرکارگر بتن‌ریزی", "count": 2},
],
},
headers=headers,
)
assert patch_resp.status_code == 200
patched_data = patch_resp.json()["data"]
assert len(patched_data["human_resources"]) == 1
assert patched_data["human_resources"][0]["count"] == 2
app.dependency_overrides.clear()
await engine.dispose()
+48
View File
@@ -0,0 +1,48 @@
"""Unit tests for unified DateService."""
from datetime import date
import pytest
from app.utils.date_service import date_service
def test_date_service_parse_iso():
d = date_service.parse("2026-09-16")
assert d == date(2026, 9, 16)
def test_date_service_parse_jalali():
# 1405/06/25 -> 2026-09-16
d = date_service.parse("1405/06/25")
assert d == date(2026, 9, 16)
def test_date_service_to_jalali():
d = date(2026, 9, 16)
assert date_service.to_jalali(d) == "1405/06/25"
def test_date_service_duration_calculation():
d1 = date(2026, 9, 10)
d2 = date(2026, 9, 15)
# gap between the dates: the end date is not counted
assert date_service.calculate_duration(d1, d2) == 5
assert date_service.calculate_duration(d1, d1) == 0
assert date_service.calculate_duration(None, d2) == 0
# inverted range is not a duration
assert date_service.calculate_duration(d2, d1) == 0
def test_date_service_compute_end_date():
d1 = date(2026, 9, 10)
# 0 days means start and end are the same day
assert date_service.compute_end_date(d1, 0) == d1
# 5 days is the gap, so the end date is 5 days later
assert date_service.compute_end_date(d1, 5) == date(2026, 9, 15)
def test_duration_and_end_date_are_inverses():
"""The pair must round-trip, otherwise saved values drift on reload."""
d1 = date(2026, 9, 10)
for gap in (0, 1, 4, 5, 30):
end = date_service.compute_end_date(d1, gap)
assert date_service.calculate_duration(d1, end) == gap
+155
View File
@@ -0,0 +1,155 @@
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db import get_db
from app.main import app
from app.models import Base, PMUser
from app.security import create_token, hash_password
from app.utils.date_service import date_service
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.mark.asyncio
async def test_automated_lifecycle_dates():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
admin = PMUser(
username="admin",
password_hash=hash_password("admin123"),
display_name="مدیر ارشد",
role="project_manager",
)
session.add(admin)
await session.commit()
admin_id = admin.id
token = create_token(admin_id)
headers = {"Authorization": f"Bearer {token}"}
async def override_get_db():
async with async_session() as s:
yield s
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
# 1. Create a project
p_res = await client.post(
"/api/v1/projects",
json={"name": "پروژه بیمارستان البرز"},
headers=headers,
)
assert p_res.status_code == 200
project_id = p_res.json()["data"]["id"]
assert p_res.json()["data"]["planned_start"] is None
assert p_res.json()["data"]["planned_end"] is None
# 2. Create Task 1 (earliest: 2026-10-01 to 2026-10-10)
t1_res = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "گودبرداری و خاکبرداری",
"planned_start": "2026-10-01",
"planned_end": "2026-10-10",
},
headers=headers,
)
assert t1_res.status_code == 200
t1_id = t1_res.json()["data"]["id"]
assert t1_res.json()["data"]["actual_start"] is None
assert t1_res.json()["data"]["actual_end"] is None
# Verify project planned dates matched Task 1
p_get1 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get1.json()["data"]["planned_start"] == "2026-10-01"
assert p_get1.json()["data"]["planned_end"] == "2026-10-10"
assert p_get1.json()["data"]["actual_start"] is None
assert p_get1.json()["data"]["actual_end"] is None
# 3. Create Task 2 (later: 2026-10-11 to 2026-10-25)
t2_res = await client.post(
"/api/v1/tasks",
json={
"project_id": project_id,
"title": "اجرای فونداسیون",
"planned_start": "2026-10-11",
"planned_end": "2026-10-25",
},
headers=headers,
)
assert t2_res.status_code == 200
t2_id = t2_res.json()["data"]["id"]
# Verify project planned dates span from Task 1 (first) to Task 2 (last)
p_get2 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get2.json()["data"]["planned_start"] == "2026-10-01"
assert p_get2.json()["data"]["planned_end"] == "2026-10-25"
# 4. Progress on Task 1 moves from 0 to 25%
today_iso = date_service.today().isoformat()
prog1_res = await client.patch(
f"/api/v1/tasks/{t1_id}/progress",
json={"percentage": 25},
headers=headers,
)
assert prog1_res.status_code == 200
assert prog1_res.json()["data"]["actual_start"] == today_iso
assert prog1_res.json()["data"]["actual_end"] is None
# Check project dates: project.actual_start is now set, actual_end is None
p_get3 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get3.json()["data"]["actual_start"] == today_iso
assert p_get3.json()["data"]["actual_end"] is None
# 5. Complete Task 1 (100%)
prog1_100 = await client.patch(
f"/api/v1/tasks/{t1_id}/progress",
json={"percentage": 100},
headers=headers,
)
assert prog1_100.status_code == 200
assert prog1_100.json()["data"]["actual_end"] == today_iso
# Project actual_end should still be None because Task 2 (the last task) hasn't finished
p_get4 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get4.json()["data"]["actual_end"] is None
# 6. Complete Task 2 (last task) (100%)
prog2_100 = await client.patch(
f"/api/v1/tasks/{t2_id}/progress",
json={"percentage": 100},
headers=headers,
)
assert prog2_100.status_code == 200
assert prog2_100.json()["data"]["actual_end"] == today_iso
# Now project actual_end should match Task 2's actual_end
p_get5 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get5.json()["data"]["actual_end"] == today_iso
assert p_get5.json()["data"]["progress"] == 100.0
# 7. Test reset to 0% on Task 2: actual_end and actual_start are cleared
prog2_0 = await client.patch(
f"/api/v1/tasks/{t2_id}/progress",
json={"percentage": 0},
headers=headers,
)
assert prog2_0.status_code == 200
assert prog2_0.json()["data"]["actual_start"] is None
assert prog2_0.json()["data"]["actual_end"] is None
p_get6 = await client.get(f"/api/v1/projects/{project_id}", headers=headers)
assert p_get6.json()["data"]["actual_end"] is None
+200
View File
@@ -0,0 +1,200 @@
"""Integration tests for native Projects, Tasks hierarchy, and QC gating invariants."""
import uuid
from datetime import date
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
import pytest_asyncio
from app.models import (
Base,
PMUser,
Project,
ProjectMember,
ProjectStatus,
QCAnswer,
QCQuestionPhase,
Task,
TaskPriority,
TaskQCDefinition,
TaskQCQuestion,
TaskQCResult,
TaskStatus,
)
from app.qc_service import build_state, load_definitions
@pytest_asyncio.fixture
async def async_db():
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_project_and_members_creation(async_db: AsyncSession):
# 1. Create a user
user = PMUser(
username="manager1",
password_hash="fake",
display_name="مهندس مدیر",
role="project_manager",
)
async_db.add(user)
await async_db.commit()
# 2. Create a project
project = Project(
name="پروژه برج بهارستان",
description="ساخت مجتمع ۲۰ طبقه",
location="تهران",
project_manager="مهندس مدیر",
status=ProjectStatus.ACTIVE.value,
planned_start=date(2026, 9, 1),
planned_end=date(2027, 9, 1),
)
async_db.add(project)
await async_db.flush()
# 3. Add a project member
member = ProjectMember(
project_id=project.id,
user_id=user.id,
role="project_manager",
)
async_db.add(member)
await async_db.commit()
# Verify project
loaded = await async_db.scalar(select(Project).where(Project.id == project.id))
assert loaded is not None
assert loaded.name == "پروژه برج بهارستان"
assert loaded.status == "ACTIVE"
@pytest.mark.asyncio
async def test_hierarchical_tasks(async_db: AsyncSession):
# Setup project
project = Project(name="پروژه تست ساختار شکست")
async_db.add(project)
await async_db.commit()
# 1. Phase 1 (Root Task)
phase1 = Task(
project_id=project.id,
parent_id=None,
title="فاز ۱: اسکلت فلزی",
status=TaskStatus.TODO.value,
)
async_db.add(phase1)
await async_db.flush()
# 2. Activity (Subtask under Phase 1)
activity1 = Task(
project_id=project.id,
parent_id=phase1.id,
title="فعالیت ۱.۱: نصب ستون‌های طبقه اول",
status=TaskStatus.TODO.value,
)
async_db.add(activity1)
await async_db.flush()
# 3. Subtask under Activity
subtask1 = Task(
project_id=project.id,
parent_id=activity1.id,
title="زیرتسک ۱.۱.۱: بولت‌گذاری و تراز پایه",
status=TaskStatus.TODO.value,
)
async_db.add(subtask1)
await async_db.commit()
# Query hierarchy
root = await async_db.scalar(select(Task).where(Task.id == phase1.id))
assert root is not None
assert root.parent_id is None
leaf = await async_db.scalar(select(Task).where(Task.id == subtask1.id))
assert leaf is not None
assert leaf.parent_id == activity1.id
@pytest.mark.asyncio
async def test_qc_gating_invariants(async_db: AsyncSession):
project = Project(name="پروژه تست کیفیت")
async_db.add(project)
await async_db.commit()
task = Task(
project_id=project.id,
title="بتن‌ریزی فونداسیون",
status=TaskStatus.TODO.value,
)
async_db.add(task)
await async_db.flush()
# Create task QC definition with 1 pre-execution question and 1 execution question
qc_def = TaskQCDefinition(task_id=task.id, name="چک‌لیست بتن‌ریزی")
async_db.add(qc_def)
await async_db.flush()
q_pre = TaskQCQuestion(
definition_id=qc_def.id,
phase=QCQuestionPhase.pre,
text="آیا آرماتوربندی و قالب‌بندی بازرسی و تأیید شد؟",
required=True,
sort_order=1,
)
q_exec = TaskQCQuestion(
definition_id=qc_def.id,
phase=QCQuestionPhase.during,
text="آیا اسلامپ بتن در محدوده استاندارد است؟",
required=True,
sort_order=2,
)
async_db.add_all([q_pre, q_exec])
await async_db.commit()
task_id = task.id
# Invariant Rule 1 check: Before answering pre-QC, can_start must be FALSE
defs = await load_definitions(async_db, task_id)
state = build_state(defs)
assert state.can_start is False
assert state.can_complete is False
# Now pass the pre-QC question
res_pre = TaskQCResult(question_id=q_pre.id, question=q_pre, answer=QCAnswer.pass_)
async_db.add(res_pre)
await async_db.commit()
# Load fresh definitions in new query
defs = await load_definitions(async_db, task_id)
state = build_state(defs)
# can_start is now TRUE! Progress can become > 0
assert state.can_start is True
# But can_complete is still FALSE because execution question is not yet answered!
assert state.can_complete is False
# Now pass the execution question as well
res_exec = TaskQCResult(question_id=q_exec.id, question=q_exec, answer=QCAnswer.pass_)
async_db.add(res_exec)
await async_db.commit()
defs = await load_definitions(async_db, task_id)
state = build_state(defs)
# Both can_start and can_complete are now TRUE!
assert state.can_start is True
assert state.can_complete is True
@@ -0,0 +1,64 @@
import uuid
from datetime import date
from types import SimpleNamespace
import pytest
from app.scheduling_service import calculate_schedule, check_for_cycle
def test_cycle_detection():
id_a = uuid.uuid4()
id_b = uuid.uuid4()
id_c = uuid.uuid4()
edges = [
(id_a, id_b),
(id_b, id_c),
]
# Adding C -> A should detect cycle
assert check_for_cycle(edges, id_c, id_a) is True
# Adding A -> A (self-loop) should detect cycle
assert check_for_cycle(edges, id_a, id_a) is True
# Adding C -> D (new node) should not detect cycle
id_d = uuid.uuid4()
assert check_for_cycle(edges, id_c, id_d) is False
def test_cpm_forward_and_backward_pass():
id_a = uuid.uuid4()
id_b = uuid.uuid4()
id_c = uuid.uuid4()
t_a = SimpleNamespace(id=id_a, duration=3, planned_start=date(2026, 1, 1), parent_id=None, progress=0)
t_b = SimpleNamespace(id=id_b, duration=4, planned_start=None, parent_id=None, progress=0)
t_c = SimpleNamespace(id=id_c, duration=2, planned_start=None, parent_id=None, progress=0)
# A (3d) -[FS + 1 lag]-> B (4d) -[FS + 0 lag]-> C (2d)
rel_ab = SimpleNamespace(from_task_id=id_a, to_task_id=id_b, relation_type="FS", lag_days=1)
rel_bc = SimpleNamespace(from_task_id=id_b, to_task_id=id_c, relation_type="FS", lag_days=0)
res = calculate_schedule([t_a, t_b, t_c], [rel_ab, rel_bc], project_start=date(2026, 1, 1))
sched = res["tasks_schedule"]
# Task A: 2026-01-01, duration 3 is a gap -> ends 2026-01-04
assert sched[id_a]["planned_start"] == date(2026, 1, 1)
assert sched[id_a]["planned_end"] == date(2026, 1, 4)
# Task B starts right after A finishes (Jan 4) plus 1 lag day = Jan 5
assert sched[id_b]["planned_start"] == date(2026, 1, 5)
# B: duration 4 gap -> Jan 5 + 4 = Jan 9
assert sched[id_b]["planned_end"] == date(2026, 1, 9)
# Task C starts Jan 9, duration 2 gap -> ends Jan 11
assert sched[id_c]["planned_start"] == date(2026, 1, 9)
assert sched[id_c]["planned_end"] == date(2026, 1, 11)
# All three are on critical path
assert id_a in res["critical_path"]
assert id_b in res["critical_path"]
assert id_c in res["critical_path"]