diff --git a/backend/app/auth.py b/backend/app/auth.py
index e5972fa..b284525 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -119,10 +119,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
if not casdoor_id:
raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization")
+ username = str(claims.get("name") or claims.get("preferred_username") or "")
user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id))
if user is None:
user = User(
casdoor_id=casdoor_id,
+ username=username or None,
first_name=str(
claims.get("firstName")
or claims.get("firstname")
@@ -135,9 +137,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
)
db.add(user)
await db.flush()
+ elif username and not user.username:
+ user.username = username
+ await db.flush()
project_ids: list = []
- if user.role == "pm":
+ if user.role in ("supervisor", "pm"):
projects = (
await db.execute(
select(Project)
@@ -159,7 +164,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext:
async def pm_project_ids(db: AsyncSession, user: User) -> list:
- """Every project id managed by the given PM user (oldest first)."""
+ """Every project id managed by the given PM/supervisor user (oldest first)."""
rows = (
await db.execute(
select(Project.id)
diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py
index 3860c87..523649c 100644
--- a/backend/app/models/procurement.py
+++ b/backend/app/models/procurement.py
@@ -58,6 +58,7 @@ class User(Base):
id: Mapped[uuid.UUID] = _uuid_pk()
casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
+ username: Mapped[str | None] = mapped_column(Text)
first_name: Mapped[str] = mapped_column(Text, nullable=False)
last_name: Mapped[str] = mapped_column(Text, nullable=False)
phone: Mapped[str | None] = mapped_column(Text)
@@ -148,12 +149,23 @@ class Project(Base):
id: Mapped[uuid.UUID] = _uuid_pk()
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text)
+ location: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
+ project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''"))
start_date: Mapped[date | None] = mapped_column(Date)
+ planned_start: Mapped[date | None] = mapped_column(Date)
+ planned_end: Mapped[date | None] = mapped_column(Date)
+ actual_start: Mapped[date | None] = mapped_column(Date)
+ actual_end: Mapped[date | None] = mapped_column(Date)
+ status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'"))
+ progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0"))
# v2.6: the single PM responsible for this project (confirms arrivals).
pm_user_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL")
)
created_at: Mapped[datetime] = _timestamptz()
+ updated_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
class Quote(Base):
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 2005e66..b3bb3c6 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -22,7 +22,7 @@ from app.config import settings
from app.db.engine import get_db
from app.routers.users import _user_projects, user_to_dict
from app.services import analytics
-from app.utils.errors import ServiceUnavailableError, UnauthorizedError
+from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError
from app.utils.ratelimit import limiter
logger = logging.getLogger("procurement.auth")
@@ -78,6 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe
claims = await _verify_token(access_token)
ctx = await context_from_claims(claims, db)
projects = await _user_projects(db, ctx.user)
+ if ctx.role == "supervisor" and not projects:
+ raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید")
await analytics.track(
"login_success",
{"username": payload.username, "role": ctx.role},
diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py
index 468998e..416c4c3 100644
--- a/backend/app/routers/inventory.py
+++ b/backend/app/routers/inventory.py
@@ -77,23 +77,21 @@ async def inventory_scope(
if user is not None:
if user.role is None:
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
- # v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقطخواندنی به پنل انبار.
- if user.role == "procurement_manager":
- return Scope(role="admin", project_id=project_id, actor=user.display_name)
- if user.role not in ("pm", "admin"):
- raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role")
- if user.role == "pm":
+ # مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
+ if user.role in ("procurement_manager", "pm"):
+ return Scope(role="manager", project_id=project_id, actor=user.display_name)
+ if user.role == "supervisor":
allowed = list(user.project_ids or [])
+ if not allowed:
+ raise ForbiddenError("پروژه تخصیص داده نشده است")
if project_id is not None:
if project_id not in allowed:
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
- return Scope(role="pm", project_id=project_id, actor=user.display_name)
- if not allowed:
- raise ValidationError(
- "کاربر PM به هیچ پروژهای متصل نیست (projects.pm_user_id)", field="project_id"
- )
- return Scope(role="pm", project_id=allowed[0], actor=user.display_name)
- return Scope(role="admin", project_id=project_id, actor=user.display_name)
+ return Scope(role="supervisor", project_id=project_id, actor=user.display_name)
+ return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name)
+ if user.role == "admin":
+ return Scope(role="admin", project_id=project_id, actor=user.display_name)
+ raise ValidationError("نقش نامعتبر است", field="role")
# Dev fallback: header-based identity (fork's placeholder mode) — only
# when DEV_LOGIN_ENABLED is explicitly turned on (local testing without
@@ -128,9 +126,9 @@ async def inventory_scope(
return Scope(role=role, project_id=project_id, actor=actor)
-def _require_pm(scope: Scope) -> None:
- if scope.role != ROLE_PM:
- raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست")
+def _require_inventory_modifier(scope: Scope) -> None:
+ if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM):
+ raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
# ---------------------------------------------------------------------------
@@ -215,8 +213,8 @@ async def list_warehouses(
db: AsyncSession = Depends(get_db),
):
stmt = select(Warehouse)
- if scope.role == ROLE_PM:
- stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope
+ if scope.role in (ROLE_PM, "supervisor"):
+ stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope
elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
stmt = stmt.order_by(Warehouse.name)
@@ -323,7 +321,7 @@ async def list_inventory(
db: AsyncSession = Depends(get_db),
):
stmt = select(InventoryItem)
- if scope.role == ROLE_PM:
+ if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == scope.project_id
)
@@ -388,7 +386,7 @@ async def list_transactions(
stmt = select(InventoryTransaction).join(
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
- if scope.role == ROLE_PM:
+ if scope.role in (ROLE_PM, "supervisor"):
stmt = stmt.where(Warehouse.project_id == scope.project_id)
elif project_id is not None:
stmt = stmt.where(Warehouse.project_id == project_id)
@@ -454,7 +452,7 @@ async def inventory_history(
if item is None:
raise NotFoundError("موجودی یافت نشد")
wh = await get_warehouse_or_404(db, item.warehouse_id)
- if scope.role == ROLE_PM and wh.project_id != scope.project_id:
+ if scope.role in (ROLE_PM, "supervisor") and wh.project_id != scope.project_id:
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
txs = (await db.execute(
select(InventoryTransaction)
@@ -474,17 +472,17 @@ async def create_transaction(
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
- _require_pm(scope)
+ _require_inventory_modifier(scope)
# No explicit db.begin(): the session may already be inside an implicit
# transaction (inventory_scope read from it in the JWT path); get_db
# commits on success / rolls back on failure.
with nullcontext():
wh = await get_warehouse_or_404(db, payload.warehouse_id)
- if wh.project_id != scope.project_id:
+ if scope.project_id is not None and wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
if payload.dest_warehouse_id is not None:
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
- if dest.project_id != scope.project_id:
+ if scope.project_id is not None and dest.project_id != scope.project_id:
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
txs = await apply_transaction(db, payload, performed_by=scope.actor)
await audit_record(
diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py
index abf791a..7e6b69d 100644
--- a/backend/app/routers/projects.py
+++ b/backend/app/routers/projects.py
@@ -34,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
d = {
"id": str(project.id),
"name": project.name,
- "description": project.description,
+ "location": project.location or "",
+ "project_manager": project.project_manager or "",
+ "description": project.description or "",
"start_date": to_date(project.start_date),
+ "status": project.status or "ACTIVE",
+ "progress": float(project.progress) if project.progress is not None else 0.0,
"pm_user_id": str(project.pm_user_id) if project.pm_user_id else None,
"created_at": to_iso(project.created_at),
}
@@ -43,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict:
pm = await db.get(User, project.pm_user_id)
if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
+ elif project.project_manager:
+ d["pm_name"] = project.project_manager
return d
@@ -56,7 +62,14 @@ async def list_projects(
):
stmt = select(Project)
if q:
- stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%")))
+ stmt = stmt.where(
+ or_(
+ Project.name.ilike(f"%{q}%"),
+ Project.description.ilike(f"%{q}%"),
+ Project.location.ilike(f"%{q}%"),
+ Project.project_manager.ilike(f"%{q}%"),
+ )
+ )
stmt = sort_query(stmt, Project, sort)
items, meta = await paginate(stmt, page, page_size, db)
pm_map = await _pm_map(db, items)
@@ -65,14 +78,20 @@ async def list_projects(
d = {
"id": str(p.id),
"name": p.name,
- "description": p.description,
+ "location": p.location or "",
+ "project_manager": p.project_manager or "",
+ "description": p.description or "",
"start_date": to_date(p.start_date),
+ "status": p.status or "ACTIVE",
+ "progress": float(p.progress) if p.progress is not None else 0.0,
"pm_user_id": str(p.pm_user_id) if p.pm_user_id else None,
"created_at": to_iso(p.created_at),
}
pm = pm_map.get(p.pm_user_id)
if pm:
d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id
+ elif p.project_manager:
+ d["pm_name"] = p.project_manager
data.append(d)
return {"data": data, "meta": meta}
@@ -84,9 +103,13 @@ async def create_project(
_auth=Depends(_procurement_writer),
):
project = Project(
- name=payload.name,
- description=payload.description,
+ name=payload.name.strip(),
+ location=payload.location.strip() if payload.location else "",
+ project_manager=payload.project_manager.strip() if payload.project_manager else "",
+ description=payload.description.strip() if payload.description else "",
start_date=payload.start_date,
+ planned_start=payload.start_date,
+ status="ACTIVE",
)
db.add(project)
await db.flush()
@@ -114,26 +137,35 @@ async def update_project(
if project is None:
raise NotFoundError("پروژه یافت نشد")
if payload.name is not None:
- project.name = payload.name
+ project.name = payload.name.strip()
+ if payload.location is not None:
+ project.location = payload.location.strip()
+ if payload.project_manager is not None:
+ project.project_manager = payload.project_manager.strip()
if payload.description is not None:
- project.description = payload.description
+ project.description = payload.description.strip()
if payload.start_date is not None:
project.start_date = payload.start_date
+ project.planned_start = payload.start_date
+ if payload.status is not None:
+ project.status = payload.status
# PM assignment (1:N): one PM per project, a PM may own many projects.
# Admin / procurement manager only (same rule as the Organization panel).
if "pm_user_id" in payload.model_fields_set:
role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower()
- if role not in ("admin", "procurement_manager"):
- raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است")
+ if role not in ("admin", "procurement_manager", "pm"):
+ raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است")
if payload.pm_user_id is None:
project.pm_user_id = None
else:
pm_user = await db.get(User, payload.pm_user_id)
if pm_user is None:
raise NotFoundError("کاربر یافت نشد", field="pm_user_id")
- if pm_user.role != "pm":
- raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id")
+ if pm_user.role not in ("supervisor", "pm"):
+ raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id")
project.pm_user_id = pm_user.id
+ if not project.project_manager:
+ project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip()
await db.flush()
return {"data": await project_to_dict(db, project)}
diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py
index f6517e6..ecd0eef 100644
--- a/backend/app/routers/users.py
+++ b/backend/app/routers/users.py
@@ -17,7 +17,7 @@ import uuid
import httpx
from fastapi import APIRouter, Depends
-from sqlalchemy import select, update
+from sqlalchemy import select, update, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_current_user, require_role
@@ -54,6 +54,7 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
return {
"id": str(user.id),
"casdoor_id": user.casdoor_id,
+ "username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"phone": user.phone,
@@ -67,15 +68,15 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict:
async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext:
- """Admin OR procurement manager (the same person) — organization management."""
- if ctx.role not in ("admin", "procurement_manager"):
+ """Admin, procurement manager, or project manager — organization management."""
+ if ctx.role not in ("admin", "procurement_manager", "pm"):
raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست")
return ctx
async def _user_projects(db: AsyncSession, user: User) -> list[Project]:
- """Every project the user manages as PM (oldest first; [] for non-PMs)."""
- if user.role != "pm":
+ """Every project assigned to the user (supervisor or PM) (oldest first)."""
+ if user.role not in ("supervisor", "pm"):
return []
rows = (
await db.execute(
@@ -150,13 +151,13 @@ async def list_users(
):
"""Every user (with role + assigned projects) — Organization panel."""
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
- pm_ids = {u.id for u in users if u.role == "pm"}
+ supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {}
- if pm_ids:
+ if supervisor_ids:
projs = (
await db.execute(
select(Project)
- .where(Project.pm_user_id.in_(pm_ids))
+ .where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
@@ -206,7 +207,7 @@ async def update_user_role(
if user is None:
raise NotFoundError("کاربر یافت نشد")
previous_role = user.role
- if user.role == "pm" and payload.role != "pm":
+ if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"):
await _unassign_projects(db, user.id)
user.role = payload.role
await db.flush()
@@ -233,8 +234,15 @@ async def sync_casdoor_users(
continue
existing = await db.scalar(select(User).where(User.casdoor_id == cid))
if existing is None:
+ uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
+ if uname:
+ by_uname = await db.scalar(select(User).where(User.username == uname))
+ if by_uname is not None:
+ by_uname.casdoor_id = cid
+ continue
db.add(User(
casdoor_id=cid,
+ username=uname,
first_name=str(
cu.get("firstName")
or cu.get("displayName")
@@ -245,16 +253,20 @@ async def sync_casdoor_users(
phone=str(cu.get("phone") or "") or None,
))
created += 1
+ elif not existing.username:
+ uname = str(cu.get("name") or cu.get("preferred_username") or "") or None
+ if uname:
+ existing.username = uname
await db.flush()
users = (await db.execute(select(User).order_by(User.created_at))).scalars().all()
- pm_ids = {u.id for u in users if u.role == "pm"}
+ supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")}
by_user: dict = {}
- if pm_ids:
+ if supervisor_ids:
projs = (
await db.execute(
select(Project)
- .where(Project.pm_user_id.in_(pm_ids))
+ .where(Project.pm_user_id.in_(supervisor_ids))
.order_by(Project.created_at, Project.id)
)
).scalars().all()
@@ -284,11 +296,16 @@ async def assign_user_project(
user = await db.get(User, user_id)
if user is None:
raise NotFoundError("کاربر یافت نشد")
- if user.role != "pm":
- raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role")
+ if user.role not in ("supervisor", "pm"):
+ raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role")
if payload.project_id is None:
await _unassign_projects(db, user.id)
+ if user.username:
+ await db.execute(
+ text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"),
+ {"uname": user.username}
+ )
await db.flush()
return {"data": user_to_dict(user)}
@@ -296,5 +313,19 @@ async def assign_user_project(
if project is None:
raise NotFoundError("پروژه یافت نشد", field="project_id")
project.pm_user_id = user.id
+ if user.username:
+ await db.execute(
+ text("""
+ INSERT INTO project_assignments (id, user_id, project_id)
+ SELECT gen_random_uuid(), pu.id, :pid
+ FROM pm_users pu
+ WHERE pu.username = :uname
+ AND NOT EXISTS (
+ SELECT 1 FROM project_assignments pa
+ WHERE pa.user_id = pu.id AND pa.project_id = :pid
+ )
+ """),
+ {"pid": project.id, "uname": user.username}
+ )
await db.flush()
return {"data": user_to_dict(user, await _user_projects(db, user))}
diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py
index 666c669..77e39e7 100644
--- a/backend/app/schemas/procurement.py
+++ b/backend/app/schemas/procurement.py
@@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel):
class ProjectCreate(APIModel):
name: str = Field(min_length=1)
+ location: str | None = None
+ project_manager: str | None = None
description: str | None = None
start_date: date | None = None
class ProjectUpdate(APIModel):
name: str | None = Field(default=None, min_length=1)
+ location: str | None = None
+ project_manager: str | None = None
description: str | None = None
start_date: date | None = None
+ status: str | None = None
# PM assignment (1:N — one PM per project, a PM may own many projects).
# Admin / procurement manager only; None unassigns this project's PM.
pm_user_id: UUID | None = None
diff --git a/docker-compose.yml b/docker-compose.yml
index b2187e0..fbf2548 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -248,6 +248,9 @@ services:
ports:
- "8000:8000"
+ volumes:
+ - ./backend/app:/srv/backend/app
+
networks:
- traefik_network
- default
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8794d12..384f91d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -38,7 +38,7 @@ const WarehousesPage = lazy(() =>
import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })),
);
const OrganizationPage = lazy(() =>
- import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
+ import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })),
);
const AdminPanelPage = lazy(() =>
import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })),
@@ -53,35 +53,60 @@ function Loading() {
}
// همهی صفحات پشت ورود هستند — بدون نشست، به صفحهی ورود میرویم.
-// سرپرست پروژه (فقطِ پنل PM) از هر مسیری به پنل PM هدایت میشود.
function RequireAuth({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user);
const { pathname } = useLocation();
if (!user) return
+ شما به عنوان سرپرست پروژه در سیستم ثبت شدهاید اما هنوز هیچ پروژهای به شما تخصیص داده نشده است. + تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید. +
+ +