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/db/init_db.py b/backend/app/db/init_db.py
index f1dd67b..14b96a2 100644
--- a/backend/app/db/init_db.py
+++ b/backend/app/db/init_db.py
@@ -174,7 +174,7 @@ _V26_DDL = [
""",
"""
DO $$ BEGIN
- CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin');
+ CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor');
EXCEPTION WHEN duplicate_object THEN NULL; END $$
""",
"""
diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py
index f41b355..523649c 100644
--- a/backend/app/models/procurement.py
+++ b/backend/app/models/procurement.py
@@ -26,7 +26,7 @@ from app.db.base import Base
from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk
user_role_enum = Enum(
- "pm", "procurement_manager", "admin",
+ "pm", "procurement_manager", "admin", "supervisor",
name="user_role", native_enum=True,
)
quote_status_enum = Enum(
@@ -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/attribute_definitions.py b/backend/app/routers/attribute_definitions.py
index 8e0c0ad..6124262 100644
--- a/backend/app/routers/attribute_definitions.py
+++ b/backend/app/routers/attribute_definitions.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit
from app.models.procurement import LineItemSpec
@@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"])
+_procurement_writer = require_role("admin", "procurement_manager")
def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict:
@@ -69,7 +71,11 @@ async def list_attribute_definitions(
@router.post("", status_code=201)
-async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)):
+async def create_attribute_definition(
+ payload: AttributeDefinitionCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key))
if duplicate is not None:
raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key")
@@ -102,7 +108,10 @@ async def get_attribute_definition(
@router.patch("/{definition_id}")
async def update_attribute_definition(
- definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db)
+ definition_id,
+ payload: AttributeDefinitionUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
ad = await db.get(AttributeDefinition, definition_id)
if ad is None:
@@ -136,7 +145,11 @@ async def update_attribute_definition(
@router.delete("/{definition_id}")
-async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)):
+async def delete_attribute_definition(
+ definition_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
ad = await db.get(AttributeDefinition, definition_id)
if ad is None:
raise NotFoundError("تعریف ویژگی یافت نشد")
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/brand_materials.py b/backend/app/routers/brand_materials.py
index 1200f96..13f96d8 100644
--- a/backend/app/routers/brand_materials.py
+++ b/backend/app/routers/brand_materials.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial, Material
from app.models.procurement import LineItem
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/brand-materials", tags=["brand-materials"])
+_procurement_writer = require_role("admin", "procurement_manager")
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
@@ -70,7 +72,11 @@ async def list_brand_materials(
@router.post("", status_code=201)
-async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)):
+async def create_brand_material(
+ payload: BrandMaterialCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
if await db.get(Brand, payload.brand_id) is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
if await db.get(Material, payload.material_id) is None:
@@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession
@router.patch("/{bm_id}")
-async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)):
+async def update_brand_material(
+ bm_id,
+ payload: BrandMaterialUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
@@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe
@router.delete("/{bm_id}")
-async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)):
+async def delete_brand_material(
+ bm_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
diff --git a/backend/app/routers/brands.py b/backend/app/routers/brands.py
index 9173c48..1112cc0 100644
--- a/backend/app/routers/brands.py
+++ b/backend/app/routers/brands.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import Brand, BrandMaterial
from app.schemas.catalog import BrandCreate, BrandUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/brands", tags=["brands"])
+_procurement_writer = require_role("admin", "procurement_manager")
def brand_to_dict(brand: Brand) -> dict:
@@ -42,7 +44,11 @@ async def list_brands(
@router.post("", status_code=201)
-async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)):
+async def create_brand(
+ payload: BrandCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all()
if any(b.country == payload.country for b in rows):
raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است")
@@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{brand_id}")
-async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)):
+async def update_brand(
+ brand_id,
+ payload: BrandUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
brand = await db.get(Brand, brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد")
@@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend
@router.delete("/{brand_id}")
-async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)):
+async def delete_brand(
+ brand_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
brand = await db.get(Brand, brand_id)
if brand is None:
raise NotFoundError("برند یافت نشد")
diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py
index 791dd16..ea5b4ac 100644
--- a/backend/app/routers/files.py
+++ b/backend/app/routers/files.py
@@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token
router = APIRouter(prefix="/quotes", tags=["files"])
-# حذف پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود
+# حذف و بارگذاری پیوستها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود
# برای هر کاربر واردشده آزاد است.
_attachment_deleter = require_role("procurement_manager", "admin")
+_attachment_writer = require_role("procurement_manager", "admin")
ALLOWED_MIME_TYPES = {
"image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf",
@@ -125,7 +126,7 @@ async def _attachment_or_404(
# Invoice — قابل بارگذاری در هر وضعیتی (از صفحهی ویرایش)
# ---------------------------------------------------------------------------
-@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)])
+@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)])
async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
mime = _validate_upload(file)
@@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn
# Proforma — no status restriction
# ---------------------------------------------------------------------------
-@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)])
+@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)])
async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)):
await _quote_or_404(db, quote_id)
mime = _validate_upload(file)
diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py
index 8aa9370..9816cd7 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("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند")
# ---------------------------------------------------------------------------
@@ -216,8 +214,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)
@@ -324,7 +322,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
)
@@ -389,7 +387,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)
@@ -455,7 +453,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)
@@ -475,17 +473,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/item_receipts.py b/backend/app/routers/item_receipts.py
index f29254e..12d2d6d 100644
--- a/backend/app/routers/item_receipts.py
+++ b/backend/app/routers/item_receipts.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import ItemDecision, ItemReceipt, LineItem
from app.schemas.procurement import ReceiptCreate, ReceiptUpdate
@@ -14,6 +15,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt",
tags=["item-receipts"],
)
+_procurement_writer = require_role("admin", "procurement_manager")
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db)
@router.post("", status_code=201)
-async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)):
+async def create_receipt(
+ quote_id,
+ line_item_id,
+ payload: ReceiptCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await _decision_or_404(db, line_item_id)
existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id))
@@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy
@router.patch("")
-async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)):
+async def update_receipt(
+ quote_id,
+ line_item_id,
+ payload: ReceiptUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
await _line_item_or_404(db, quote_id, line_item_id)
decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id))
if decision is None:
diff --git a/backend/app/routers/item_termsheets.py b/backend/app/routers/item_termsheets.py
index 2d2977c..701e25c 100644
--- a/backend/app/routers/item_termsheets.py
+++ b/backend/app/routers/item_termsheets.py
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import (
ItemDecision,
@@ -23,6 +24,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets",
tags=["item-termsheets"],
)
+_procurement_writer = require_role("admin", "procurement_manager")
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend
@router.post("", status_code=201)
async def create_item_termsheet(
- quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db)
+ quote_id,
+ line_item_id,
+ payload: ItemTermsheetCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id)
@@ -93,7 +99,12 @@ async def create_item_termsheet(
@router.patch("/{item_termsheet_id}")
async def update_item_termsheet(
- quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db)
+ quote_id,
+ line_item_id,
+ item_termsheet_id,
+ payload: ItemTermsheetUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await db.get(Quote, quote_id)
@@ -114,7 +125,11 @@ async def update_item_termsheet(
@router.delete("/{item_termsheet_id}")
async def delete_item_termsheet(
- quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db)
+ quote_id,
+ line_item_id,
+ item_termsheet_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
it = await db.scalar(
select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id)
diff --git a/backend/app/routers/line_item_specs.py b/backend/app/routers/line_item_specs.py
index 9f49e95..a00f535 100644
--- a/backend/app/routers/line_item_specs.py
+++ b/backend/app/routers/line_item_specs.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Unit
from app.models.procurement import LineItem, LineItemSpec, Quote
@@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values
from app.utils.errors import ConflictError, NotFoundError
router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"])
+_procurement_writer = require_role("admin", "procurement_manager")
async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem:
@@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends
@router.put("")
-async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)):
+async def put_line_item_specs(
+ quote_id,
+ line_item_id,
+ payload: SpecsPutIn,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
"""FULL replacement — specs not in the payload are DELETED (P1-19)."""
async with db.begin():
li = await _line_item_or_404(db, quote_id, line_item_id)
diff --git a/backend/app/routers/line_items.py b/backend/app/routers/line_items.py
index 963d1f5..e9f6c79 100644
--- a/backend/app/routers/line_items.py
+++ b/backend/app/routers/line_items.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import Material, Unit
from app.models.procurement import ItemDecision, LineItem, Quote
@@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"])
+_procurement_writer = require_role("admin", "procurement_manager")
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -50,7 +52,12 @@ async def list_line_items(
@router.post("", status_code=201)
-async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)):
+async def create_line_item(
+ quote_id,
+ payload: LineItemCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
async with db.begin():
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
@@ -89,7 +96,11 @@ async def get_line_item(
@router.patch("/{line_item_id}")
async def update_line_item(
- quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db)
+ quote_id,
+ line_item_id,
+ payload: LineItemUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id)
@@ -131,7 +142,12 @@ async def update_line_item(
@router.delete("/{line_item_id}")
-async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)):
+async def delete_line_item(
+ quote_id,
+ line_item_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
li = await _line_item_or_404(db, quote_id, line_item_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
diff --git a/backend/app/routers/material_categories.py b/backend/app/routers/material_categories.py
index 8611a74..ccd4ce5 100644
--- a/backend/app/routers/material_categories.py
+++ b/backend/app/routers/material_categories.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/material-categories", tags=["material-categories"])
+_procurement_writer = require_role("admin", "procurement_manager")
def category_to_dict(category: MaterialCategory) -> dict:
@@ -46,7 +48,11 @@ async def list_categories(
@router.post("", status_code=201)
-async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)):
+async def create_category(
+ payload: MaterialCategoryCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug))
if duplicate is not None:
raise ConflictError("دستهبندی با این slug قبلاً ثبت شده است", field="slug")
@@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{category_id}")
-async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)):
+async def update_category(
+ category_id,
+ payload: MaterialCategoryUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دستهبندی یافت نشد")
@@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn
@router.delete("/{category_id}")
-async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
+async def delete_category(
+ category_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دستهبندی یافت نشد")
diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py
index c128f9c..c78fcdd 100644
--- a/backend/app/routers/materials.py
+++ b/backend/app/routers/materials.py
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import (
AttributeDefinition,
@@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"])
+_procurement_writer = require_role("admin", "procurement_manager")
def _coerce_filter_value(data_type: str, raw: str):
@@ -125,7 +127,10 @@ async def list_materials(
@router.post("", status_code=201)
async def create_material(
- payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db)
+ payload: MaterialCreate,
+ expand: str | None = None,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
material = await material_service.create_material(db, payload)
return {"data": await serialize_material(db, material, parse_expand(expand))}
@@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession
@router.patch("/{material_id}")
-async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)):
+async def update_material(
+ material_id,
+ payload: MaterialUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
@@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession
@router.delete("/{material_id}")
-async def delete_material(material_id, db: AsyncSession = Depends(get_db)):
+async def delete_material(
+ material_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
@@ -248,7 +262,10 @@ async def list_material_units(
@router.post("/{material_id}/units", status_code=201)
async def create_material_unit(
- material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db)
+ material_id,
+ payload: MaterialUnitCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
async with db.begin():
await _material_or_404(db, material_id)
@@ -287,7 +304,11 @@ async def create_material_unit(
@router.patch("/{material_id}/units/{mu_id}")
async def update_material_unit(
- material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db)
+ material_id,
+ mu_id,
+ payload: MaterialUnitUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
async with db.begin():
mu = await db.scalar(
@@ -319,7 +340,12 @@ async def update_material_unit(
@router.delete("/{material_id}/units/{mu_id}")
-async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)):
+async def delete_material_unit(
+ material_id,
+ mu_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
)
@@ -365,7 +391,10 @@ async def get_material_attribute_values(
@router.put("/{material_id}/attribute-values")
async def put_material_attribute_values(
- material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db)
+ material_id,
+ payload: MaterialAttributeValuesPut,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
"""FULL replacement of a material's EAV values (empty array deletes all)."""
async with db.begin():
diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py
index 2ebd785..7e6b69d 100644
--- a/backend/app/routers/projects.py
+++ b/backend/app/routers/projects.py
@@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
-from app.auth import UserContext, get_optional_user
+from app.auth import UserContext, get_optional_user, require_role
from app.db.engine import get_db
from app.models.procurement import Project, Quote, User
from app.schemas.procurement import ProjectCreate, ProjectUpdate
@@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_date, to_iso
router = APIRouter(prefix="/projects", tags=["projects"])
+_procurement_writer = require_role("admin", "procurement_manager")
async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None:
@@ -33,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),
}
@@ -42,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
@@ -55,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)
@@ -64,24 +78,38 @@ 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}
@router.post("", status_code=201)
-async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)):
+async def create_project(
+ payload: ProjectCreate,
+ db: AsyncSession = Depends(get_db),
+ _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()
@@ -103,37 +131,51 @@ async def update_project(
request: Request,
ctx: UserContext | None = Depends(_optional_ctx),
db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
project = await db.get(Project, project_id)
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)}
@router.delete("/{project_id}")
-async def delete_project(project_id, db: AsyncSession = Depends(get_db)):
+async def delete_project(
+ project_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
project = await db.get(Project, project_id)
if project is None:
raise NotFoundError("پروژه یافت نشد")
diff --git a/backend/app/routers/quote_events.py b/backend/app/routers/quote_events.py
index 1f91c41..962bb0b 100644
--- a/backend/app/routers/quote_events.py
+++ b/backend/app/routers/quote_events.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import Quote, QuoteEvent
from app.schemas.procurement import NoteEventIn
@@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"])
+_procurement_writer = require_role("admin", "procurement_manager")
@router.get("")
@@ -34,7 +36,12 @@ async def list_events(
@router.post("", status_code=201)
-async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)):
+async def create_event(
+ quote_id,
+ payload: NoteEventIn,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
quote = await db.get(Quote, quote_id)
if quote is None:
raise NotFoundError("پیشنهاد (استعلام) یافت نشد")
diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py
index 489921c..2849be3 100644
--- a/backend/app/routers/quotes.py
+++ b/backend/app/routers/quotes.py
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import exists, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import Material
from app.models.procurement import (
@@ -42,6 +43,9 @@ from app.utils.security import get_actor
router = APIRouter(prefix="/quotes", tags=["quotes"])
+# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد)
+_procurement_writer = require_role("admin", "procurement_manager")
+
# --- list filter helpers ---------------------------------------------------
@@ -156,7 +160,12 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe
# ---------------------------------------------------------------------------
@router.post("", status_code=201)
-async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
+async def create_quote(
+ payload: QuoteCreate,
+ db: AsyncSession = Depends(get_db),
+ actor: str = Depends(get_actor),
+ _auth=Depends(_procurement_writer),
+):
quote = await quote_service.create_quote(db, payload, actor)
return {"data": await serialize_quote_with_children(db, quote)}
@@ -167,7 +176,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db),
@router.patch("/{quote_id}")
async def update_quote(
- quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
+ quote_id,
+ payload: QuoteUpdate,
+ db: AsyncSession = Depends(get_db),
+ actor: str = Depends(get_actor),
+ _auth=Depends(_procurement_writer),
):
quote = await quote_service.update_quote(db, quote_id, payload, actor)
return {"data": await serialize_quote(db, quote, set())}
@@ -175,7 +188,11 @@ async def update_quote(
@router.patch("/{quote_id}/children")
async def replace_quote_children(
- quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
+ quote_id,
+ payload: QuoteChildrenReplaceIn,
+ db: AsyncSession = Depends(get_db),
+ actor: str = Depends(get_actor),
+ _auth=Depends(_procurement_writer),
):
"""Full replacement of a quote's line items + termsheets (edit modal)."""
quote = await quote_service.replace_quote_children(db, quote_id, payload, actor)
@@ -188,7 +205,11 @@ async def replace_quote_children(
@router.patch("/{quote_id}/accept")
async def accept_quote(
- quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
+ quote_id,
+ payload: AcceptQuoteIn,
+ db: AsyncSession = Depends(get_db),
+ actor: str = Depends(get_actor),
+ _auth=Depends(_procurement_writer),
):
quote = await quote_service.accept_quote(db, quote_id, payload, actor)
return {"data": await _accept_response(db, quote)}
@@ -224,7 +245,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict:
@router.patch("/{quote_id}/arrive")
-async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
+async def arrive_quote(
+ quote_id,
+ db: AsyncSession = Depends(get_db),
+ actor: str = Depends(get_actor),
+ _auth=Depends(_procurement_writer),
+):
quote = await quote_service.arrive_quote(db, quote_id, actor)
return {
"data": {
@@ -237,7 +263,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
@router.patch("/{quote_id}/arrive-items")
async def arrive_items(
- quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
+ quote_id,
+ payload: ArriveItemsIn,
+ db: AsyncSession = Depends(get_db),
+ actor: str = Depends(get_actor),
+ _auth=Depends(_procurement_writer),
):
quote = await quote_service.arrive_items(db, quote_id, payload, actor)
return {
@@ -259,14 +289,23 @@ async def arrive_items(
@router.patch("/{quote_id}/reject")
async def reject_quote(
- quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)
+ quote_id,
+ payload: RejectQuoteIn | None = None,
+ db: AsyncSession = Depends(get_db),
+ actor: str = Depends(get_actor),
+ _auth=Depends(_procurement_writer),
):
quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor)
return {"data": {"id": str(quote.id), "status": quote.status}}
@router.patch("/{quote_id}/expire")
-async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)):
+async def expire_quote(
+ quote_id,
+ db: AsyncSession = Depends(get_db),
+ actor: str = Depends(get_actor),
+ _auth=Depends(_procurement_writer),
+):
quote = await quote_service.expire_quote(db, quote_id, actor)
return {"data": {"id": str(quote.id), "status": quote.status}}
@@ -276,6 +315,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str
# ---------------------------------------------------------------------------
@router.delete("/{quote_id}")
-async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)):
+async def delete_quote(
+ quote_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
result = await quote_service.delete_quote(db, quote_id)
return {"data": result}
diff --git a/backend/app/routers/schedule_steps.py b/backend/app/routers/schedule_steps.py
index fc265e7..d2c03d4 100644
--- a/backend/app/routers/schedule_steps.py
+++ b/backend/app/routers/schedule_steps.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import Termsheet, TermsheetScheduleStep
from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate
@@ -15,6 +16,7 @@ router = APIRouter(
prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps",
tags=["schedule-steps"],
)
+_procurement_writer = require_role("admin", "procurement_manager")
async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet:
@@ -57,7 +59,11 @@ async def list_schedule_steps(
@router.post("", status_code=201)
async def create_schedule_step(
- quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db)
+ quote_id,
+ termsheet_id,
+ payload: ScheduleStepCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
step_no = payload.step_no
@@ -84,7 +90,12 @@ async def create_schedule_step(
@router.patch("/{step_id}")
async def update_schedule_step(
- quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db)
+ quote_id,
+ termsheet_id,
+ step_id,
+ payload: ScheduleStepUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
step = await _step_or_404(db, termsheet_id, step_id)
if payload.step_no is not None and payload.step_no != step.step_no:
@@ -109,7 +120,13 @@ async def update_schedule_step(
@router.delete("/{step_id}")
-async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)):
+async def delete_schedule_step(
+ quote_id,
+ termsheet_id,
+ step_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
step = await _step_or_404(db, termsheet_id, step_id)
count = await db.scalar(
select(func.count()).select_from(TermsheetScheduleStep).where(
diff --git a/backend/app/routers/standard_conversions.py b/backend/app/routers/standard_conversions.py
index b63434a..6469399 100644
--- a/backend/app/routers/standard_conversions.py
+++ b/backend/app/routers/standard_conversions.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import StandardConversion
from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate
@@ -11,6 +12,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"])
+_procurement_writer = require_role("admin", "procurement_manager")
def sc_to_dict(sc: StandardConversion) -> dict:
@@ -41,7 +43,11 @@ async def list_standard_conversions(
@router.post("", status_code=201)
-async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)):
+async def create_standard_conversion(
+ payload: StandardConversionCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
duplicate = await db.scalar(
select(StandardConversion.id).where(
StandardConversion.from_unit == payload.from_unit,
@@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{sc_id}")
-async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)):
+async def update_standard_conversion(
+ sc_id,
+ payload: StandardConversionUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
sc = await db.get(StandardConversion, sc_id)
if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد")
@@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d
@router.delete("/{sc_id}")
-async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)):
+async def delete_standard_conversion(
+ sc_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
sc = await db.get(StandardConversion, sc_id)
if sc is None:
raise NotFoundError("تبدیل استاندارد یافت نشد")
diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py
index 26e4026..02d5a55 100644
--- a/backend/app/routers/supplier_materials.py
+++ b/backend/app/routers/supplier_materials.py
@@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory
from app.models.procurement import Supplier, SupplierMaterial
@@ -18,6 +19,7 @@ from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(tags=["supplier-materials"])
+_procurement_writer = require_role("admin", "procurement_manager")
def link_to_dict(link: SupplierMaterial, material: Material | None = None,
@@ -63,7 +65,10 @@ async def list_supplier_materials(
@router.post("/suppliers/{supplier_id}/materials", status_code=201)
async def create_supplier_material(
- supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db)
+ supplier_id,
+ payload: SupplierMaterialCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
async with db.begin():
link = await link_supplier_material(db, supplier_id, payload.material_id)
@@ -72,7 +77,12 @@ async def create_supplier_material(
@router.delete("/suppliers/{supplier_id}/materials/{material_id}")
-async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)):
+async def delete_supplier_material(
+ supplier_id,
+ material_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
async with db.begin():
await unlink_supplier_material(db, supplier_id, material_id)
return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}}
diff --git a/backend/app/routers/suppliers.py b/backend/app/routers/suppliers.py
index 3ab5175..070a9e6 100644
--- a/backend/app/routers/suppliers.py
+++ b/backend/app/routers/suppliers.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier
@@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
+_procurement_writer = require_role("admin", "procurement_manager")
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
@@ -71,7 +73,11 @@ async def list_suppliers(
@router.post("", status_code=201)
-async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)):
+async def create_supplier(
+ payload: SupplierCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
category = await _category_or_404(db, payload.category_id)
supplier = Supplier(
name=payload.name,
@@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
@router.patch("/{supplier_id}")
-async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)):
+async def update_supplier(
+ supplier_id,
+ payload: SupplierUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمینکننده یافت نشد")
@@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession
@router.delete("/{supplier_id}")
-async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
+async def delete_supplier(
+ supplier_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمینکننده یافت نشد")
diff --git a/backend/app/routers/termsheets.py b/backend/app/routers/termsheets.py
index 16c8f98..b1d5ffd 100644
--- a/backend/app/routers/termsheets.py
+++ b/backend/app/routers/termsheets.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.procurement import (
ItemDecision,
@@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"])
+_procurement_writer = require_role("admin", "procurement_manager")
async def _quote_or_404(db: AsyncSession, quote_id) -> Quote:
@@ -80,7 +82,12 @@ async def list_termsheets(
@router.post("", status_code=201)
-async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)):
+async def create_termsheet(
+ quote_id,
+ payload: TermsheetCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
async with db.begin():
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
@@ -110,7 +117,11 @@ async def get_termsheet(
@router.patch("/{termsheet_id}")
async def update_termsheet(
- quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db)
+ quote_id,
+ termsheet_id,
+ payload: TermsheetUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
if ts.status != "offered":
@@ -127,7 +138,11 @@ async def update_termsheet(
@router.patch("/{termsheet_id}/status")
async def update_termsheet_status(
- quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db)
+ quote_id,
+ termsheet_id,
+ payload: TermsheetStatusUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
):
async with db.begin():
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
@@ -139,7 +154,12 @@ async def update_termsheet_status(
@router.delete("/{termsheet_id}")
-async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)):
+async def delete_termsheet(
+ quote_id,
+ termsheet_id,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
ts = await _termsheet_or_404(db, quote_id, termsheet_id)
quote = await _quote_or_404(db, quote_id)
if quote.status != "offered":
diff --git a/backend/app/routers/units.py b/backend/app/routers/units.py
index 7152ecd..5004f7a 100644
--- a/backend/app/routers/units.py
+++ b/backend/app/routers/units.py
@@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
+from app.auth import require_role
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit
from app.models.procurement import LineItem, LineItemSpec
@@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/units", tags=["units"])
+_procurement_writer = require_role("admin", "procurement_manager")
def unit_full_to_dict(unit: Unit) -> dict:
@@ -45,7 +47,11 @@ async def list_units(
@router.post("", status_code=201)
-async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)):
+async def create_unit(
+ payload: UnitCreate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code))
if duplicate is not None:
raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code")
@@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
@router.patch("/{unit_id}")
-async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)):
+async def update_unit(
+ unit_id: int,
+ payload: UnitUpdate,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
unit = await db.get(Unit, unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد")
@@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe
@router.delete("/{unit_id}")
-async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)):
+async def delete_unit(
+ unit_id: int,
+ db: AsyncSession = Depends(get_db),
+ _auth=Depends(_procurement_writer),
+):
unit = await db.get(Unit, unit_id)
if unit is None:
raise NotFoundError("واحد یافت نشد")
diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py
index 702484d..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
@@ -41,6 +41,8 @@ USER_ROLE_LABELS = {
"pm": "مدیر پروژه",
"procurement_manager": "مدیر تدارکات",
"admin": "ادمین",
+ # کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد
+ "supervisor": "سرپرست پروژه",
}
@@ -52,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,
@@ -65,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(
@@ -148,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()
@@ -204,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()
@@ -231,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")
@@ -243,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()
@@ -282,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)}
@@ -294,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 372131b..9bc549a 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
@@ -209,13 +214,13 @@ class UserCreate(APIModel):
last_name: str = ""
phone: str | None = None
casdoor_id: str | None = None
- role: Literal["pm", "procurement_manager", "admin"] | None = None
+ role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserRoleUpdate(APIModel):
"""role=None revokes the role (and any project assignment)."""
- role: Literal["pm", "procurement_manager", "admin"] | None = None
+ role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None
class UserProjectUpdate(APIModel):
diff --git a/docker-compose.yml b/docker-compose.yml
index 2c01cb0..4dc93b9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -249,6 +249,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 d9eb7f0..346568b 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 })),
@@ -68,16 +68,101 @@ function RequireAuth({ children }: { children: React.ReactNode }) {
const returnUrl = encodeURIComponent(location.pathname + location.search);
return
+ شما به عنوان سرپرست پروژه در سیستم ثبت شدهاید اما هنوز هیچ پروژهای به شما تخصیص داده نشده است. + تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید. +
+ +