diff --git a/backend/app/auth.py b/backend/app/auth.py index b284525..98b8691 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -27,7 +27,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.db.engine import AsyncSessionLocal -from app.models.procurement import Project, User +from app.models.procurement import Project, User, UserPanel, UserProjectMembership from app.utils.errors import ( ForbiddenError, ServiceUnavailableError, @@ -106,6 +106,10 @@ class UserContext: project_id: object | None # uuid.UUID | None — default (first) project of a PM project_ids: list # every project this PM manages (1:N — empty for non-PMs) display_name: str + # لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', 'inventory': ...} + panels: dict | None = None + # عضویت M:N در پروژه‌ها — محدوده داده سطح limited (جدا از مسئولیت PM) + member_project_ids: list | None = None async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: @@ -153,6 +157,26 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: project_ids = [p.id for p in projects] project_id = project_ids[0] if project_ids else None + # لایه پنل‌ها: تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (جدا از نقش) + panels: dict = {} + panel_rows = ( + await db.execute( + select(UserPanel).where(UserPanel.user_id == user.id).order_by(UserPanel.created_at) + ) + ).scalars().all() + for row in panel_rows: + panels[row.panel] = row.level + member_project_ids: list = [ + row.project_id + for row in ( + await db.execute( + select(UserProjectMembership) + .where(UserProjectMembership.user_id == user.id) + .order_by(UserProjectMembership.created_at) + ) + ).scalars().all() + ] + display_name = f"{user.first_name} {user.last_name}".strip() or user.casdoor_id return UserContext( user=user, @@ -160,6 +184,8 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: project_id=project_id, project_ids=project_ids, display_name=display_name, + panels=panels, + member_project_ids=member_project_ids, ) @@ -222,3 +248,31 @@ def require_role(*roles: str): raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") return ctx return _dep + + +def require_procurement_writer(): + """نوشتن در پنل تدارکات — ادمین، پنل تدارکات 'full'، یا نقش میراث مدیر تدارکات + (دوره مهاجرت از مدل نقش به مدل پنل).""" + + async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext: + if ctx.role in ("admin", "procurement_manager"): + return ctx + if (ctx.panels or {}).get("procurement") == "full": + return ctx + raise ForbiddenError("دسترسی نوشتن در پنل تدارکات را ندارید") + + return _dep + + +def allow_quote_create(): + """افزودن استعلام — علاوه بر «نوشتن»، پنل تدارکات 'limited' هم مجاز است + (محدود به پروژه‌های عضویت؛ اعتبارسنجی عضویت در خود endpoint انجام می‌شود).""" + + async def _dep(ctx: UserContext = Depends(get_current_user)) -> UserContext: + if ctx.role in ("admin", "procurement_manager"): + return ctx + if (ctx.panels or {}).get("procurement") in ("full", "limited"): + return ctx + raise ForbiddenError("دسترسی افزودن استعلام ندارید") + + return _dep diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py index 523649c..0bbe904 100644 --- a/backend/app/models/procurement.py +++ b/backend/app/models/procurement.py @@ -66,6 +66,47 @@ class User(Base): created_at: Mapped[datetime] = _timestamptz() +class UserPanel(Base): + """user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش). + + panel: 'procurement' | 'inventory' | 'pm' + level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژه‌ها + (به‌جز تغییر موجودی) + 'limited' — تدارکات: فقط افزودن استعلام برای پروژه‌های عضویت؛ + انبار: فقط پروژه‌های عضویت + امکان تغییر موجودی + """ + + __tablename__ = "user_panels" + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + panel: Mapped[str] = mapped_column(Text, nullable=False) + level: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = _timestamptz() + + __table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),) + + +class UserProjectMembership(Base): + """user_project_members — عضویت M:N کاربر در پروژه‌ها، جدا از مسئولیت + پروژه (projects.pm_user_id). محدوده‌ی داده‌ی سطح limited را تعیین می‌کند.""" + + __tablename__ = "user_project_members" + + id: Mapped[uuid.UUID] = _uuid_pk() + user_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + project_id: Mapped[uuid.UUID] = mapped_column( + ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True + ) + created_at: Mapped[datetime] = _timestamptz() + + __table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),) + + class Supplier(Base): __tablename__ = "suppliers" diff --git a/backend/app/routers/attribute_definitions.py b/backend/app/routers/attribute_definitions.py index 6124262..47f12a3 100644 --- a/backend/app/routers/attribute_definitions.py +++ b/backend/app/routers/attribute_definitions.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.procurement import LineItemSpec @@ -14,7 +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") +_procurement_writer = require_procurement_writer() def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b3bb3c6..560b7f1 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -94,6 +94,6 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe "access_token": access_token, "token_type": "bearer", "expires_in": body.get("expires_in"), - "user": user_to_dict(ctx.user, projects), + "user": await user_to_dict(ctx.user, projects, db), } } diff --git a/backend/app/routers/brand_materials.py b/backend/app/routers/brand_materials.py index 13f96d8..0d680e4 100644 --- a/backend/app/routers/brand_materials.py +++ b/backend/app/routers/brand_materials.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial, Material from app.models.procurement import LineItem @@ -13,7 +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") +_procurement_writer = require_procurement_writer() def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: diff --git a/backend/app/routers/brands.py b/backend/app/routers/brands.py index 1112cc0..330c0a5 100644 --- a/backend/app/routers/brands.py +++ b/backend/app/routers/brands.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial from app.schemas.catalog import BrandCreate, BrandUpdate @@ -12,7 +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") +_procurement_writer = require_procurement_writer() def brand_to_dict(brand: Brand) -> dict: diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 9816cd7..c2bb04a 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -58,6 +58,7 @@ class Scope: role: str project_id: uuid.UUID | None actor: str | None + project_ids: list[uuid.UUID] | None = None async def inventory_scope( @@ -75,11 +76,29 @@ async def inventory_scope( if request.headers.get("authorization"): user: UserContext = await get_optional_user(request, db) if user is not None: - if user.role is None: - raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") - # مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی + panels = user.panels or {} + if user.role is None and not panels: + raise ForbiddenError("دسترسی شما هنوز در سیستم تعیین نشده است") + # ادمین: دست‌نخورده — همه‌چیز از جمله تغییر موجودی + if user.role == "admin": + return Scope(role="admin", project_id=project_id, actor=user.display_name) + # لایه پنل‌ها (جایگزین نقش‌ها): پنل انبار اولویت دارد + if "inventory" in panels: + if panels["inventory"] == "full": + # کل سیستم و تمام پروژه‌ها — به‌جز تغییر موجودی + return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.member_project_ids or [])) + # limited: فقط پروژه‌های عضویت + امکان تغییر موجودی + allowed = list(user.member_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="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed) + return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed) + # مدیر تدارکات و مدیر پروژه (میراث): دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی if user.role in ("procurement_manager", "pm"): - return Scope(role="manager", project_id=project_id, actor=user.display_name) + return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or [])) if user.role == "supervisor": allowed = list(user.project_ids or []) if not allowed: @@ -87,8 +106,8 @@ async def inventory_scope( if project_id is not None: if project_id not in allowed: raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") - return Scope(role="supervisor", project_id=project_id, actor=user.display_name) - return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name) + return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed) + return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed) if user.role == "admin": return Scope(role="admin", project_id=project_id, actor=user.display_name) raise ValidationError("نقش نامعتبر است", field="role") @@ -215,7 +234,15 @@ async def list_warehouses( ): stmt = select(Warehouse) if scope.role in (ROLE_PM, "supervisor"): - stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope + allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) + if project_id is not None: + if project_id not in allowed: + raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") + stmt = stmt.where(Warehouse.project_id == project_id) + elif allowed: + stmt = stmt.where(Warehouse.project_id.in_(allowed)) + else: + stmt = stmt.where(Warehouse.project_id == scope.project_id) elif project_id is not None: stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.order_by(Warehouse.name) @@ -247,9 +274,11 @@ async def create_warehouse( scope: Scope = Depends(inventory_scope), db: AsyncSession = Depends(get_db), ): - # PM: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه) - if scope.role == ROLE_PM and payload.project_id != scope.project_id: - raise ValidationError("PM فقط می‌تواند برای پروژه خودش انبار بسازد", field="project_id") + # PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت می‌کند (هر پروژه) + if scope.role in (ROLE_PM, "supervisor"): + allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) + if payload.project_id not in allowed: + raise ValidationError("شما فقط می‌توانید برای پروژه تخصیص‌یافته به خود انبار بسازید", field="project_id") if await db.get(Project, payload.project_id) is None: raise NotFoundError("پروژه یافت نشد", field="project_id") wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description) @@ -266,8 +295,9 @@ async def update_warehouse( db: AsyncSession = Depends(get_db), ): wh = await get_warehouse_or_404(db, warehouse_id) - if scope.role == ROLE_PM: - if scope.project_id != wh.project_id: + if scope.role in (ROLE_PM, "supervisor"): + allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) + if wh.project_id not in allowed: raise ForbiddenError("این انبار متعلق به پروژه شما نیست") if payload.name is not None: wh.name = payload.name @@ -291,8 +321,10 @@ async def delete_warehouse( db: AsyncSession = Depends(get_db), ): wh = await get_warehouse_or_404(db, warehouse_id) - if scope.role == ROLE_PM and scope.project_id != wh.project_id: - raise ForbiddenError("این انبار متعلق به پروژه شما نیست") + if scope.role in (ROLE_PM, "supervisor"): + allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) + if wh.project_id not in allowed: + raise ForbiddenError("این انبار متعلق به پروژه شما نیست") # inventory rows cascade via DDL; transactions are RESTRICTed through # inventory cascade — block deletion if any stock history exists. linked = await db.scalar( @@ -323,9 +355,21 @@ async def list_inventory( ): stmt = select(InventoryItem) if scope.role in (ROLE_PM, "supervisor"): - stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( - Warehouse.project_id == scope.project_id - ) + allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) + if project_id is not None: + if project_id not in allowed: + raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") + stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( + Warehouse.project_id == project_id + ) + elif allowed: + stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( + Warehouse.project_id.in_(allowed) + ) + else: + stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( + Warehouse.project_id == scope.project_id + ) elif project_id is not None: stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( Warehouse.project_id == project_id @@ -388,7 +432,15 @@ async def list_transactions( InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id ).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) if scope.role in (ROLE_PM, "supervisor"): - stmt = stmt.where(Warehouse.project_id == scope.project_id) + allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) + if project_id is not None: + if project_id not in allowed: + raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") + stmt = stmt.where(Warehouse.project_id == project_id) + elif allowed: + stmt = stmt.where(Warehouse.project_id.in_(allowed)) + else: + stmt = stmt.where(Warehouse.project_id == scope.project_id) elif project_id is not None: stmt = stmt.where(Warehouse.project_id == project_id) if warehouse_id is not None: @@ -453,8 +505,10 @@ async def inventory_history( if item is None: raise NotFoundError("موجودی یافت نشد") wh = await get_warehouse_or_404(db, item.warehouse_id) - if scope.role in (ROLE_PM, "supervisor") and wh.project_id != scope.project_id: - raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") + if scope.role in (ROLE_PM, "supervisor"): + allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) + if wh.project_id not in allowed: + raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") txs = (await db.execute( select(InventoryTransaction) .where(InventoryTransaction.inventory_id == inventory_id) @@ -479,12 +533,14 @@ async def create_transaction( # commits on success / rolls back on failure. with nullcontext(): wh = await get_warehouse_or_404(db, payload.warehouse_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 scope.project_id is not None and dest.project_id != scope.project_id: - raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") + allowed = scope.project_ids or ([scope.project_id] if scope.project_id else []) + if scope.role in (ROLE_PM, "supervisor"): + if wh.project_id not in allowed: + 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 not in allowed: + raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") txs = await apply_transaction(db, payload, performed_by=scope.actor) await audit_record( db, diff --git a/backend/app/routers/item_receipts.py b/backend/app/routers/item_receipts.py index 12d2d6d..61b2646 100644 --- a/backend/app/routers/item_receipts.py +++ b/backend/app/routers/item_receipts.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.schemas.procurement import ReceiptCreate, ReceiptUpdate @@ -15,7 +15,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", tags=["item-receipts"], ) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: diff --git a/backend/app/routers/item_termsheets.py b/backend/app/routers/item_termsheets.py index 701e25c..0b2469d 100644 --- a/backend/app/routers/item_termsheets.py +++ b/backend/app/routers/item_termsheets.py @@ -7,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -24,7 +24,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", tags=["item-termsheets"], ) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: diff --git a/backend/app/routers/line_item_specs.py b/backend/app/routers/line_item_specs.py index a00f535..dc00633 100644 --- a/backend/app/routers/line_item_specs.py +++ b/backend/app/routers/line_item_specs.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, Unit from app.models.procurement import LineItem, LineItemSpec, Quote @@ -13,7 +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") +_procurement_writer = require_procurement_writer() async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: diff --git a/backend/app/routers/line_items.py b/backend/app/routers/line_items.py index e9f6c79..4c9c5f4 100644 --- a/backend/app/routers/line_items.py +++ b/backend/app/routers/line_items.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Material, Unit from app.models.procurement import ItemDecision, LineItem, Quote @@ -15,7 +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") +_procurement_writer = require_procurement_writer() async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: diff --git a/backend/app/routers/material_categories.py b/backend/app/routers/material_categories.py index ccd4ce5..7d76c10 100644 --- a/backend/app/routers/material_categories.py +++ b/backend/app/routers/material_categories.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory from app.models.procurement import Supplier @@ -13,7 +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") +_procurement_writer = require_procurement_writer() def category_to_dict(category: MaterialCategory) -> dict: diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py index c78fcdd..b703d22 100644 --- a/backend/app/routers/materials.py +++ b/backend/app/routers/materials.py @@ -9,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import ( AttributeDefinition, @@ -39,7 +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") +_procurement_writer = require_procurement_writer() def _coerce_filter_value(data_type: str, raw: str): diff --git a/backend/app/routers/pm.py b/backend/app/routers/pm.py index f8c1c3f..b075ffb 100644 --- a/backend/app/routers/pm.py +++ b/backend/app/routers/pm.py @@ -71,16 +71,17 @@ class ArrivalScope: user_id: uuid.UUID | None project_id: uuid.UUID | None # PM's active project; admin: optional filter display_name: str + project_ids: list[uuid.UUID] | None = None async def arrivals_scope( request: Request, - project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای داده‌های پروژه)"), + project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: داده‌های پروژه)"), db: AsyncSession = Depends(get_db), ) -> ArrivalScope: """Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback. - PM → scoped to the requested project, which must be one they manage + PM / supervisor → scoped to the requested project, which must be one they manage (projects.pm_user_id, 1:N); without the param, their first project. Admin / procurement manager → all projects (param = optional filter). """ @@ -89,19 +90,19 @@ async def arrivals_scope( if ctx is not None: if ctx.role is None: raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") - if ctx.role == "pm": + if ctx.role in ("pm", "supervisor"): allowed = list(ctx.project_ids or []) if project_id is not None: if project_id not in allowed: raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") - return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name) + return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed) if not allowed: raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") - return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name) + return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed) if ctx.role in ("admin", "procurement_manager"): return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name) - raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است") - # Dev fallback: X-User-Role: pm | admin (procurement_manager maps to + raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است") + # Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to # admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor). # Disabled by default: an ungated header identity in production would # let anyone confirm receipts as PM. @@ -110,8 +111,10 @@ async def arrivals_scope( role = (request.headers.get("x-user-role") or "").strip().lower() if role == "procurement_manager": role = "admin" + elif role == "supervisor": + role = "pm" if role not in ("pm", "admin"): - raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید") + raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید") # The explicit query param wins; otherwise the dev header carries it. if project_id is None and role == "pm": raw_project = request.headers.get("x-project-id") @@ -189,10 +192,17 @@ async def pm_on_the_way( .outerjoin(Project, Project.id == ItemDecision.project_id) .where(ItemDecision.status == "chosen") ) - if ctx.role == "pm" and ctx.project_id is None: - raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") - if ctx.project_id is not None: - # PM: active project; admin/manager: optional project filter + if ctx.role == "pm": + allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else []) + if not allowed: + raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") + if ctx.project_id is not None: + if ctx.project_id not in allowed: + raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") + stmt = stmt.where(ItemDecision.project_id == ctx.project_id) + else: + stmt = stmt.where(ItemDecision.project_id.in_(allowed)) + elif ctx.project_id is not None: stmt = stmt.where(ItemDecision.project_id == ctx.project_id) decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all() @@ -243,8 +253,12 @@ async def pm_confirm_receipt( decision = await db.get(ItemDecision, payload.item_decision_id) if decision is None or decision.status != "chosen": raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id") - if ctx.project_id is not None and decision.project_id != ctx.project_id: - raise ForbiddenError("این قلم متعلق به پروژه فعال نیست") + if ctx.role == "pm": + allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else []) + if decision.project_id not in allowed: + raise ForbiddenError("این قلم متعلق به پروژه شما نیست") + if ctx.project_id is not None and decision.project_id != ctx.project_id: + raise ForbiddenError("این قلم متعلق به پروژه فعال نیست") li = await db.get(LineItem, decision.line_item_id) quote = await db.get(Quote, li.quote_id) @@ -451,10 +465,17 @@ async def pm_receipts( .join(Quote, Quote.id == LineItem.quote_id) .join(Project, Project.id == ItemDecision.project_id) ) - if ctx.role == "pm" and ctx.project_id is None: - raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") - if ctx.project_id is not None: - # PM: active project; admin/manager: optional project filter + if ctx.role == "pm": + allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else []) + if not allowed: + raise ForbiddenError("شما به هیچ پروژه‌ای متصل نیستید (projects.pm_user_id)") + if ctx.project_id is not None: + if ctx.project_id not in allowed: + raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") + stmt = stmt.where(ItemDecision.project_id == ctx.project_id) + else: + stmt = stmt.where(ItemDecision.project_id.in_(allowed)) + elif ctx.project_id is not None: stmt = stmt.where(ItemDecision.project_id == ctx.project_id) rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all() receipts = [r.ItemReceipt for r in rows] diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 7e6b69d..47e8e6b 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, require_role +from app.auth import UserContext, get_optional_user, require_procurement_writer, 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,7 +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") +_procurement_writer = require_procurement_writer() async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: diff --git a/backend/app/routers/quote_events.py b/backend/app/routers/quote_events.py index 962bb0b..e6cd9d3 100644 --- a/backend/app/routers/quote_events.py +++ b/backend/app/routers/quote_events.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import Quote, QuoteEvent from app.schemas.procurement import NoteEventIn @@ -12,7 +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") +_procurement_writer = require_procurement_writer() @router.get("") diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py index 2849be3..f8a03e6 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -8,7 +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.auth import UserContext, allow_quote_create, require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Material from app.models.procurement import ( @@ -37,14 +37,14 @@ from app.services.expand_service import ( serialize_quote_with_children, serialize_quotes, ) -from app.utils.errors import NotFoundError +from app.utils.errors import ForbiddenError, NotFoundError, ValidationError from app.utils.pagination import sort_query from app.utils.security import get_actor router = APIRouter(prefix="/quotes", tags=["quotes"]) # عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() # --- list filter helpers --------------------------------------------------- @@ -164,8 +164,17 @@ async def create_quote( payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor), - _auth=Depends(_procurement_writer), + ctx: UserContext = Depends(allow_quote_create()), + _auth=None, ): + # سطح limited: استعلام فقط برای پروژه‌های عضویت خودش + if (ctx.panels or {}).get("procurement") == "limited" and ctx.role != "admin": + member = set(ctx.member_project_ids or []) + wanted = {pid for pid in ([payload.project_id] + list(payload.project_ids or [])) if pid} + if not wanted: + raise ValidationError("برای افزودن استعلام، پروژه الزامی است") + if not wanted <= member: + raise ForbiddenError("استعلام فقط برای پروژه‌های تخصیص‌یافته به شما مجاز است") quote = await quote_service.create_quote(db, payload, actor) return {"data": await serialize_quote_with_children(db, quote)} diff --git a/backend/app/routers/schedule_steps.py b/backend/app/routers/schedule_steps.py index d2c03d4..5e8a857 100644 --- a/backend/app/routers/schedule_steps.py +++ b/backend/app/routers/schedule_steps.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import Termsheet, TermsheetScheduleStep from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate @@ -16,7 +16,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", tags=["schedule-steps"], ) -_procurement_writer = require_role("admin", "procurement_manager") +_procurement_writer = require_procurement_writer() async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: diff --git a/backend/app/routers/standard_conversions.py b/backend/app/routers/standard_conversions.py index 6469399..73deeaf 100644 --- a/backend/app/routers/standard_conversions.py +++ b/backend/app/routers/standard_conversions.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import StandardConversion from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate @@ -12,7 +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") +_procurement_writer = require_procurement_writer() def sc_to_dict(sc: StandardConversion) -> dict: diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py index 02d5a55..3a9a31e 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -8,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import Material, MaterialCategory from app.models.procurement import Supplier, SupplierMaterial @@ -19,7 +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") +_procurement_writer = require_procurement_writer() def link_to_dict(link: SupplierMaterial, material: Material | None = None, diff --git a/backend/app/routers/suppliers.py b/backend/app/routers/suppliers.py index 070a9e6..b25d8d7 100644 --- a/backend/app/routers/suppliers.py +++ b/backend/app/routers/suppliers.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory from app.models.procurement import Quote, Supplier @@ -13,7 +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") +_procurement_writer = require_procurement_writer() def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: diff --git a/backend/app/routers/termsheets.py b/backend/app/routers/termsheets.py index b1d5ffd..6226838 100644 --- a/backend/app/routers/termsheets.py +++ b/backend/app/routers/termsheets.py @@ -3,7 +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.auth import require_procurement_writer, require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -27,7 +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") +_procurement_writer = require_procurement_writer() async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: diff --git a/backend/app/routers/units.py b/backend/app/routers/units.py index 5004f7a..5a78bea 100644 --- a/backend/app/routers/units.py +++ b/backend/app/routers/units.py @@ -3,7 +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.auth import require_procurement_writer, 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 @@ -12,7 +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") +_procurement_writer = require_procurement_writer() def unit_full_to_dict(unit: Unit) -> dict: diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index ecd0eef..180daf9 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -17,15 +17,21 @@ import uuid import httpx from fastapi import APIRouter, Depends -from sqlalchemy import select, update, text +from sqlalchemy import delete, select, update, text from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_current_user, require_role from app.utils.audit import record as audit_record from app.config import settings from app.db.engine import get_db -from app.models.procurement import Project, User -from app.schemas.procurement import UserCreate, UserProjectUpdate, UserRoleUpdate +from app.models.procurement import Project, User, UserPanel, UserProjectMembership +from app.schemas.procurement import ( + UserCreate, + UserPanelsUpdate, + UserPanelProjectsUpdate, + UserProjectUpdate, + UserRoleUpdate, +) from app.utils.errors import ( ConflictError, ForbiddenError, @@ -46,12 +52,39 @@ USER_ROLE_LABELS = { } -def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: +PANEL_LEVELS = ("full", "limited") +PANELS = ("procurement", "inventory", "pm") + + +async def _panels_of(db: AsyncSession, user_id) -> dict: + """panel -> level map for the user (لایه پنل‌ها — جایگزین نقش‌ها).""" + rows = ( + await db.execute( + select(UserPanel).where(UserPanel.user_id == user_id).order_by(UserPanel.created_at) + ) + ).scalars().all() + return {row.panel: row.level for row in rows} + + +async def _member_project_ids_of(db: AsyncSession, user_id) -> list[str]: + """عضویت M:N کاربر در پروژه‌ها (جدا از مسئولیت) — رشته.""" + rows = ( + await db.execute( + select(UserProjectMembership.project_id) + .where(UserProjectMembership.user_id == user_id) + .order_by(UserProjectMembership.created_at) + ) + ).scalars().all() + return [str(pid) for pid in rows] + + +async def user_to_dict(user: User, projects: list[Project] | None = None, db: AsyncSession | None = None) -> dict: """Serialize a user. `projects` = every project the PM manages (1:N); - project_id/project_name stay as the first (oldest) one for compatibility.""" + project_id/project_name stay as the first (oldest) one for compatibility. + با db، پنل‌های تخصیصی و عضویت پروژه‌ای هم برمی‌گردند.""" projects = projects or [] first = projects[0] if projects else None - return { + out = { "id": str(user.id), "casdoor_id": user.casdoor_id, "username": user.username, @@ -65,6 +98,10 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: "projects": [{"id": str(p.id), "name": p.name} for p in projects], "created_at": to_iso(user.created_at), } + if db is not None: + out["panels"] = await _panels_of(db, user.id) + out["member_project_ids"] = await _member_project_ids_of(db, user.id) + return out async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: @@ -130,7 +167,7 @@ async def _casdoor_users() -> list[dict]: @router.get("/me") async def me(ctx: UserContext = Depends(get_current_user), db: AsyncSession = Depends(get_db)): projects = await _user_projects(db, ctx.user) - return {"data": user_to_dict(ctx.user, projects)} + return {"data": await user_to_dict(ctx.user, projects, db)} @router.get("/me/projects") @@ -141,6 +178,18 @@ async def my_projects(ctx: UserContext = Depends(get_current_user), db: AsyncSes active project against this list and falls back gracefully when a project is revoked.""" projects = await _user_projects(db, ctx.user) + # عضویت‌های M:N (لایه پنل‌ها) هم به فهرست اضافه می‌شود + member_ids = set(ctx.member_project_ids or []) + have = {p.id for p in projects} + if member_ids: + extra = ( + await db.execute( + select(Project) + .where(Project.id.in_(member_ids)) + .order_by(Project.created_at, Project.id) + ) + ).scalars().all() + projects = list(projects) + [p for p in extra if p.id not in have] return {"data": [{"id": str(p.id), "name": p.name} for p in projects]} @@ -164,7 +213,7 @@ async def list_users( for p in projs: by_user.setdefault(p.pm_user_id, []).append(p) return { - "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], + "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users], "meta": {"total": len(users)}, } @@ -190,7 +239,7 @@ async def create_user( db.add(user) await db.flush() await audit_record(db, actor=ctx.display_name, action="user.created", entity="user", entity_id=str(user.id), details={"role": payload.role}) - return {"data": user_to_dict(user)} + return {"data": await user_to_dict(user)} @router.patch("/{user_id}") @@ -213,7 +262,7 @@ async def update_user_role( await db.flush() await audit_record(db, actor=ctx.display_name, action="user.role_changed", entity="user", entity_id=str(user.id), details={"from": previous_role, "to": payload.role}) projects = await _user_projects(db, user) - return {"data": user_to_dict(user, projects)} + return {"data": await user_to_dict(user, projects)} @router.post("/sync") @@ -273,7 +322,7 @@ async def sync_casdoor_users( for p in projs: by_user.setdefault(p.pm_user_id, []).append(p) return { - "data": [user_to_dict(u, by_user.get(u.id, [])) for u in users], + "data": [await user_to_dict(u, by_user.get(u.id, []), db) for u in users], "meta": {"total": len(users), "created": created}, } @@ -307,7 +356,7 @@ async def assign_user_project( {"uname": user.username} ) await db.flush() - return {"data": user_to_dict(user)} + return {"data": await user_to_dict(user)} project = await db.get(Project, payload.project_id) if project is None: @@ -328,4 +377,63 @@ async def assign_user_project( {"pid": project.id, "uname": user.username} ) await db.flush() - return {"data": user_to_dict(user, await _user_projects(db, user))} + return {"data": await user_to_dict(user, await _user_projects(db, user))} + + +# --------------------------------------------------------------------------- +# پنل‌ها و سطوح دسترسی — جایگزین مدل نقش برای کاربران عادی +# --------------------------------------------------------------------------- + + +@router.put("/{user_id}/panels") +async def set_user_panels( + user_id, + payload: UserPanelsUpdate, + _: UserContext = Depends(require_role("admin")), + db: AsyncSession = Depends(get_db), +): + """تخصیص پنل + سطح دسترسی به کاربر — فقط ادمین.""" + user = await db.get(User, user_id) + if user is None: + raise NotFoundError("کاربر یافت نشد") + for panel, level in payload.panels.items(): + if panel not in PANELS: + raise ValidationError(f"پنل نامعتبر: {panel}", field="panels") + if level not in PANEL_LEVELS: + raise ValidationError(f"سطح دسترسی نامعتبر: {level}", field="panels") + await db.execute(delete(UserPanel).where(UserPanel.user_id == user.id)) + for panel, level in payload.panels.items(): + db.add(UserPanel(user_id=user.id, panel=panel, level=level)) + await db.flush() + return {"data": await user_to_dict(user, await _user_projects(db, user), db)} + + +@router.put("/{user_id}/panel-projects") +async def set_user_panel_projects( + user_id, + payload: UserPanelProjectsUpdate, + _: UserContext = Depends(require_role("admin")), + db: AsyncSession = Depends(get_db), +): + """عضویت پروژه‌ای کاربر (M:N) — محدوده داده‌ی سطح limited.""" + user = await db.get(User, user_id) + if user is None: + raise NotFoundError("کاربر یافت نشد") + wanted = list(dict.fromkeys(payload.project_ids)) + if wanted: + found = ( + await db.execute(select(Project).where(Project.id.in_(wanted))) + ).scalars().all() + found_ids = {p.id for p in found} + unknown = [str(pid) for pid in wanted if pid not in found_ids] + if unknown: + raise ValidationError( + "پروژه(های) نامعتبر: " + "، ".join(unknown), field="project_ids" + ) + await db.execute( + delete(UserProjectMembership).where(UserProjectMembership.user_id == user.id) + ) + for pid in wanted: + db.add(UserProjectMembership(user_id=user.id, project_id=pid)) + await db.flush() + return {"data": await user_to_dict(user, await _user_projects(db, user), db)} diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index 9bc549a..1735fa9 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -352,3 +352,16 @@ class ReceiptUpdate(APIModel): received_qty: Decimal | None = Field(default=None, gt=0) on_time: bool | None = None quality_note: str | None = None + + +class UserPanelsUpdate(APIModel): + """Replace the user's panel assignments. هر پنل: 'full' | 'limited' — + پنل غایب یعنی تخصیص نداشتن و کلید آن حذف می‌شود.""" + + panels: dict[str, str] = Field(default_factory=dict) + + +class UserPanelProjectsUpdate(APIModel): + """Replace the user's project memberships (M:N — جدا از مسئولیت پروژه).""" + + project_ids: list[UUID] = Field(default_factory=list) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 346568b..71b2c2b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,9 @@ const OrganizationPage = lazy(() => const AdminPanelPage = lazy(() => import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), ); +const RequesterHome = lazy(() => + import("./pages/Dashboard/RequesterHome").then((m) => ({ default: m.RequesterHome })), +); function Loading() { return ( @@ -102,20 +105,34 @@ function RequireAuth({ children }: { children: React.ReactNode }) { return <>{children}; } -// صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات +// صفحه‌ی اصلی بر اساس لایه پنل‌ها (جایگزین نقش‌ها): +// انبار → پنل انبار · تدارکات limited → خانه حداقلی استعلام · بقیه → دفتر تدارکات +// نقش‌های میراث (سرپرست → PM، مدیر پروژه → انبار) برای دوره مهاجرت حفظ شده‌اند. function HomeRoute() { const user = useAuthStore((s) => s.user); + const panels = user?.panels ?? {}; + if (panels.inventory) return ; + if (panels.procurement === "limited") return ; + // فقط پنل PM: مستقیم به پنل مدیریت پروژه + if (panels.pm && !panels.inventory && !panels.procurement) { + return ; + } if (user?.role === "supervisor") return ; + if (user?.role === "pm") return ; return ; } -// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین +// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): ادمین، نقش‌های میراث، تدارکات full +// و تدارکات limited (محدود به پروژه‌های عضویت — صفحه /requester) function RequireProcurementWrite({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") { - return ; + const panels = user?.panels ?? {}; + if (user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm") { + return <>{children}; } - return <>{children}; + if (panels.procurement === "full") return <>{children}; + if (panels.procurement === "limited") return ; + return ; } // پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود @@ -129,10 +146,16 @@ function PmPanelPage() { ); } -// دسترسی پنل PM: همه نقش‌ها دسترسی دارند +// دسترسی پنل PM: ادمین، پنل pm (full/limited) و نقش‌های میراث pm/supervisor — +// مدیر تدارکات (یا هر کاربر بدون پنل pm) دسترسی ندارد function RequirePmPanel({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor"; + const panels = user?.panels ?? {}; + const allowed = + user?.role === "admin" || + Boolean(panels.pm) || + user?.role === "pm" || + user?.role === "supervisor"; if (!allowed) return ; return <>{children}; } @@ -145,19 +168,25 @@ function RequireOrganization({ children }: { children: React.ReactNode }) { return <>{children}; } -// دسترسی تغییر موجودی انبار: فقط سرپرست پروژه و ادمین (مدیر تدارکات و مدیر پروژه نمی‌توانند موجودی تغییر دهند) +// دسترسی تغییر موجودی انبار: ادمین، سرپرست پروژه (میراث) و پنل انبار limited — +// پنل انبار full طبق طراحی به تغییر موجودی دسترسی ندارد function RequireInventoryChange({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - if (user?.role !== "admin" && user?.role !== "supervisor") { + const allowed = + user?.role === "admin" || + user?.role === "supervisor" || + user?.panels?.inventory === "limited"; + if (!allowed) { return ; } return <>{children}; } -// دسترسی مدیریت انبارها: فقط مدیران و ادمین (سرپرست پروژه فقط می‌تواند موجودی تغییر دهد) +// دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را می‌بیند) function RequireWarehouseManage({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - if (user?.role === "supervisor") { + const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor"; + if (!allowed) { return ; } return <>{children}; @@ -315,6 +344,15 @@ export default function App() { } /> + {/* خانه حداقلی استعلام — تدارکات سطح limited */} + + + + } + /> {/* پنل مدیریت پروژه — فقط ادمین و مدیر پروژه */} s.setProjects); const logout = useLogout(); - const isSupervisor = user?.role === "supervisor"; + // پنل انبار limited هم مثل سرپرست: پروژه‌های فعالش از عضویت‌ها اعتبارسنجی می‌شود + const isSupervisor = user?.role === "supervisor" || user?.panels?.inventory === "limited"; const [state, setState] = useState<"loading" | "ok" | "empty">(isSupervisor ? "loading" : "ok"); useEffect(() => { - if (!user || user.role !== "supervisor") { + const scoped = user?.role === "supervisor" || user?.panels?.inventory === "limited"; + if (!user || !scoped) { setState("ok"); return; } @@ -45,7 +47,7 @@ export function RequirePmProjects({ children }: { children: ReactNode }) { alive = false; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user?.id, user?.role, isDev]); + }, [user?.id, user?.role, user?.panels, isDev]); if (state === "loading") { return ( diff --git a/frontend/src/components/layout/MobileTabBar.tsx b/frontend/src/components/layout/MobileTabBar.tsx index b6b4073..665553e 100644 --- a/frontend/src/components/layout/MobileTabBar.tsx +++ b/frontend/src/components/layout/MobileTabBar.tsx @@ -80,18 +80,24 @@ export function MobileTabBar() { const { pathname } = useLocation(); if (pathname === "/login" || pathname === "/admin") return null; + // خانه حداقلی استعلام (تدارکات limited): بدون تب ERP — PM هم تب‌بار خودش را دارد + if (pathname === "/requester" || pathname === "/panel/pm") return null; + // کاربر فقطِ تدارکات-limited: تب‌های انبار/دفتر ندارد + const panels = useAuthStore.getState().user?.panels ?? {}; + if (panels.procurement === "limited" && !panels.inventory && !panels.pm) return null; // پنل مدیریت پروژه: iframe تمام‌صفحه‌ی PM است و خودش تب‌بار داخلی دارد if (pathname === "/panel/pm") return null; - // سرپرست پروژه فقطِ پنل PM است و هیچ تب ERP ندارد - if (role === "supervisor") return null; const isPM = role === "pm"; + const isSupervisor = role === "supervisor"; const isOffice = role === "admin" || role === "procurement_manager"; if (pathname.startsWith("/inventory")) { if (isPM) return ; - if (isOffice) return ; + if (isOffice || isSupervisor) return ; return null; } + // سرپرست پروژه به بخش‌های تدارکات/دفتر دسترسی ندارد + if (isSupervisor) return null; if (pathname === "/wizard" || pathname.startsWith("/review")) return null; const onOfficePage = pathname === "/" || diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 70f22bb..690eb52 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1662,6 +1662,10 @@ export interface UserRow { phone: string | null; role: "pm" | "procurement_manager" | "admin" | "supervisor" | null; role_label: string | null; + // لایه پنل‌ها (جایگزین نقش‌ها): {'procurement': 'full'|'limited', ...} + panels?: Record; + // عضویت M:N پروژه‌ای — محدوده داده سطح limited + member_project_ids?: string[]; project_id: string | null; project_name: string | null; // 1:N — every project this PM manages (oldest first). @@ -1757,6 +1761,11 @@ export const apiV26 = { }): Promise => postData("/users", body), updateRole: async (id: string, role: "pm" | "procurement_manager" | "admin" | "supervisor" | null): Promise => patchData(`/users/${id}`, { role }), + // لایه پنل‌ها — تخصیص پنل + سطح دسترسی و عضویت پروژه‌ای (فقط ادمین) + setPanels: async (id: string, panels: Record): Promise => + patchData(`/users/${id}/panels`, { panels }), + setPanelProjects: async (id: string, project_ids: string[]): Promise => + patchData(`/users/${id}/panel-projects`, { project_ids }), assignProject: async (id: string, projectId: string | null): Promise => postData(`/users/${id}/project`, { project_id: projectId }), // همگام‌سازی هویت‌ها از Casdoor (ادمین/مدیر تدارکات) — نقش‌ها دست‌نخورده می‌مانند diff --git a/frontend/src/pages/Dashboard/RequesterHome.tsx b/frontend/src/pages/Dashboard/RequesterHome.tsx new file mode 100644 index 0000000..64032b6 --- /dev/null +++ b/frontend/src/pages/Dashboard/RequesterHome.tsx @@ -0,0 +1,62 @@ +import { Link } from "react-router-dom"; +import { FilePlus2, Info } from "lucide-react"; +import { Header, PageLayout } from "../../components/layout/Header"; +import { useAuthStore } from "../../store/auth"; +import { useProjects } from "../../hooks"; +import { faNum } from "../../lib/formatters"; + +// خانه حداقلی کاربرِ «تدارکات — سطح limited»: فقط افزودن استعلام برای +// پروژه‌های عضویت خودش. هیچ سربرگ دیگری از پنل تدارکات دیده نمی‌شود. +export function RequesterHome() { + const user = useAuthStore((s) => s.user); + const projects = useProjects(); + const memberIds = new Set(user?.member_project_ids ?? []); + const myProjects = memberIds.size + ? projects.filter((p) => memberIds.has(p.id)) + : []; + + return ( +
+
+ +
+ + + شما فقط مجاز به ثبت استعلام برای پروژه‌های تخصیص‌یافته‌ی خود هستید. + +
+ + {myProjects.length === 0 ? ( +
+ هنوز پروژه‌ای به شما تخصیص داده نشده است — با مدیر سیستم تماس بگیرید. +
+ ) : ( + <> +
+ {myProjects.map((p) => ( + +
+ 🏢 {p.name} +

ثبت استعلام خرید برای این پروژه

+
+ + + + + ))} +
+ + + + افزودن استعلام جدید ({faNum(myProjects.length)} پروژه فعال) + + + )} +
+
+ ); +} diff --git a/frontend/src/pages/Inventory/ArrivalsPage.tsx b/frontend/src/pages/Inventory/ArrivalsPage.tsx index b8a0ad7..f31e938 100644 --- a/frontend/src/pages/Inventory/ArrivalsPage.tsx +++ b/frontend/src/pages/Inventory/ArrivalsPage.tsx @@ -23,6 +23,8 @@ export function ArrivalsPage() { const user = useAuthStore((s) => s.user); const isAdmin = user?.role === "admin" || user?.role === "procurement_manager"; const isPM = user?.role === "pm"; + const isSupervisor = user?.role === "supervisor"; + const canAccess = isAdmin || isPM || isSupervisor; const [items, setItems] = useState([]); const [incomingTransfers, setIncomingTransfers] = useState([]); @@ -36,9 +38,9 @@ export function ArrivalsPage() { const load = useCallback(async () => { try { - const activeProject = isPM ? user?.project_id || undefined : undefined; + const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined; const [arr, whs, incoming] = await Promise.all([ - // پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM را بررسی می‌کند + // پروژه‌ی فعال صریحاً ارسال می‌شود؛ بک‌اند تخصیص PM / سرپرست را بررسی می‌کند apiV26.pm.onTheWay(activeProject), apiV26.warehouses.list(activeProject), apiV26.projectTransfers.onTheWay(activeProject), @@ -53,13 +55,13 @@ export function ArrivalsPage() { } finally { setLoading(false); } - }, [isPM, user?.project_id]); + }, [isPM, isSupervisor, user?.project_id]); useEffect(() => { - if (!isAdmin && !isPM) return; + if (!canAccess) return; setLoading(true); void load(); - }, [isAdmin, isPM, load]); + }, [canAccess, load]); // سفارش‌ها: گروه‌بندی اقلام به‌ازای هر پیشنهاد (فقط سفارش‌های دارای قلمِ در راه/ناقص) const orders = useMemo(() => { @@ -267,11 +269,14 @@ export function ArrivalsPage() { }; if (!isAdmin && !isPM) { + + if (!canAccess) { + return (
- +
); diff --git a/frontend/src/pages/Inventory/DailyControlPage.tsx b/frontend/src/pages/Inventory/DailyControlPage.tsx index c87aa54..11ccbae 100644 --- a/frontend/src/pages/Inventory/DailyControlPage.tsx +++ b/frontend/src/pages/Inventory/DailyControlPage.tsx @@ -41,10 +41,12 @@ export function DailyControlPage() { const txByDay = useMemo(() => { const map = new Map(); + const allowedWhIds = new Set(data.warehouses.map((w) => w.id)); const projWhIds = projFilter ? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id)) : null; data.transactions.forEach((t) => { + if (!isAdmin && !allowedWhIds.has(t.warehouse_id)) return; if (projWhIds && !projWhIds.has(t.warehouse_id)) return; const k = gregKey(new Date(t.transaction_date)); const arr = map.get(k) || []; @@ -53,7 +55,7 @@ export function DailyControlPage() { }); map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime())); return map; - }, [data.transactions, data.warehouses, projFilter]); + }, [data.transactions, data.warehouses, projFilter, isAdmin]); const cells = useMemo(() => { const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate()); @@ -173,11 +175,15 @@ export function DailyControlPage() { ))} ) : ( - پروژه فعال + + {user?.project_name ? `پروژه: ${user.project_name}` : "پروژه فعال"} + + )} + {isAdmin && ( + + {projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"} + )} - - {projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژه‌ها"} - ({ diff --git a/frontend/src/pages/Inventory/InventoryTopNav.tsx b/frontend/src/pages/Inventory/InventoryTopNav.tsx index 627ebb7..fb58848 100644 --- a/frontend/src/pages/Inventory/InventoryTopNav.tsx +++ b/frontend/src/pages/Inventory/InventoryTopNav.tsx @@ -12,17 +12,12 @@ export function InventoryTopNav({ const user = useAuthStore((s) => s.user); const isAdmin = user?.role === "admin"; const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin; + const isSupervisor = user?.role === "supervisor"; + const canViewTabs = isManager || isSupervisor; const items: { label: string; to: string; active: boolean }[] = []; - // سرپرست پروژه از دکمه شناور گرد (+) در پایین صفحه استفاده می‌کند و دکمه مستطیلی بالای صفحه برای او نمایش داده نمی‌شود - if (isAdmin) { - items.push({ - label: "تغییر موجودی", - to: "/inventory/change", - active: pathname.startsWith("/inventory/change"), - }); - } - if (isManager) { + // برای تغییر موجودی از همان دکمه شناور دایره‌ای آبی (+) استفاده می‌شود و دکمه مستطیلی در بالای صفحه حذف شد + if (canViewTabs) { items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") }); items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") }); items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") }); diff --git a/frontend/src/pages/Inventory/WarehousesPage.tsx b/frontend/src/pages/Inventory/WarehousesPage.tsx index bbebe8e..4e4e0c2 100644 --- a/frontend/src/pages/Inventory/WarehousesPage.tsx +++ b/frontend/src/pages/Inventory/WarehousesPage.tsx @@ -14,7 +14,8 @@ export function WarehousesPage() { const navigate = useNavigate(); const user = useAuthStore((s) => s.user); const data = useInventoryData(); - const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm"; + const isSupervisor = user?.role === "supervisor"; + const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || isSupervisor; const [newName, setNewName] = useState(""); const [newDesc, setNewDesc] = useState(""); @@ -32,7 +33,7 @@ export function WarehousesPage() {
🔒

دسترسی محدود

- مدیریت و تعریف انبارها مخصوص مدیران سیستم است. سرپرست پروژه فقط به ثبت تغییرات موجودی دسترسی دارد. + دسترسی به این بخش برای حساب کاربری شما فعال نیست.

@@ -43,7 +44,7 @@ export function WarehousesPage() { const addWarehouse = async () => { const name = newName.trim(); if (!name) return toast.error("نام انبار الزامی است"); - const projectId = selectedProjectId || user?.project_id || data.lookup.projects[0]?.id; + const projectId = isSupervisor ? user?.project_id : (selectedProjectId || user?.project_id || data.lookup.projects[0]?.id); if (!projectId) return toast.error("پروژه انبار را انتخاب کنید"); try { await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null }); @@ -92,7 +93,7 @@ export function WarehousesPage() {

+ افزودن انبار جدید

- {data.lookup.projects.length > 1 && ( + {!isSupervisor && data.lookup.projects.length > 1 && ( void setPanel(u, "procurement", e.target.value as "" | "full" | "limited")} + > + + + + + ) : ( + {panelLabel(u.panels?.procurement)} + )} + + + {canChangeRoles ? ( + + ) : ( + {panelLabel(u.panels?.inventory)} + )} + + + {hasPanel ? ( +
+ ({ id: p.id, name: p.name }))} + values={u.member_project_ids ?? []} + onChange={(nextIds) => void setMemberProjects(u, nextIds)} + disabled={busyId === u.id} + /> +
+ ) : ( + — ابتدا پنل تخصیص دهید — + )} + + + ); + })} + + +
+
); diff --git a/frontend/src/pages/Wizard/Step4Projects.tsx b/frontend/src/pages/Wizard/Step4Projects.tsx index 0b1d290..5e6690a 100644 --- a/frontend/src/pages/Wizard/Step4Projects.tsx +++ b/frontend/src/pages/Wizard/Step4Projects.tsx @@ -14,6 +14,8 @@ export interface Step4Props { onAssign: (tempId: string, proj: string) => void; onUnassign: (tempId: string, proj: string) => void; onAddProject: (name: string) => void; + /** سطح limited تدارکات: افزودن پروژه جدید ممنوع */ + canAddProject?: boolean; } const ALL_KEY = "__all__"; @@ -111,7 +113,7 @@ function DroppableProject({ } // مرحله ۴ — تخصیص پروژه‌ها با درگ‌اندروپ (یا کلیک-قلم بعد کلیک-پروژه) -export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject }: Step4Props) { +export function Step4Projects({ items, projects, assignment, onAssign, onUnassign, onAddProject, canAddProject = true }: Step4Props) { const [picked, setPicked] = useState(null); const [newProj, setNewProj] = useState(""); @@ -213,8 +215,8 @@ export function Step4Projects({ items, projects, assignment, onAssign, onUnassig {!projects.length && (
هنوز پروژه‌ای تعریف نشده است — از پایین یک پروژه اضافه کنید.
)} - {/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها */} -
+ {/* افزودن پروژه به‌صورت درجا — زیر لیست پروژه‌ها (سطح limited: ممنوع) */} +