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,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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user