From 253c922280098b9050363c27b3a9f889b2c89b2f Mon Sep 17 00:00:00 2001 From: ZCode Date: Sat, 26 Sep 2026 17:41:07 +0000 Subject: [PATCH] revert: restore production to the state before 730d0bd (PM/RBAC work moved to 'pm') MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Their three commits (730d0bd, 5a9b543, 387edfb) reached production by push and auto-deployed. 387edfb expects a users.username column that the live database does not have, so POST /api/v1/auth/login was returning HTTP 500. Their schema changes never applied either (projects still has only id, name, description, start_date, pm_user_id, created_at), so nothing depends on the reverted code. This commit sets the tree back to exactly a02b3ea — verified with git diff against it — while keeping history linear: the work itself is preserved on 'pm' at 387edfb. --- backend/app/auth.py | 9 +- backend/app/db/init_db.py | 2 +- backend/app/models/procurement.py | 14 +- backend/app/routers/attribute_definitions.py | 19 +- backend/app/routers/auth.py | 4 +- backend/app/routers/brand_materials.py | 21 +- backend/app/routers/brands.py | 21 +- backend/app/routers/files.py | 7 +- backend/app/routers/inventory.py | 46 +-- backend/app/routers/item_receipts.py | 18 +- backend/app/routers/item_termsheets.py | 21 +- backend/app/routers/line_item_specs.py | 10 +- backend/app/routers/line_items.py | 22 +- backend/app/routers/material_categories.py | 21 +- backend/app/routers/materials.py | 43 +-- backend/app/routers/projects.py | 70 +--- backend/app/routers/quote_events.py | 9 +- backend/app/routers/quotes.py | 61 +-- backend/app/routers/schedule_steps.py | 23 +- backend/app/routers/standard_conversions.py | 21 +- backend/app/routers/supplier_materials.py | 14 +- backend/app/routers/suppliers.py | 21 +- backend/app/routers/termsheets.py | 28 +- backend/app/routers/units.py | 21 +- backend/app/routers/users.py | 61 +-- backend/app/schemas/procurement.py | 9 +- docker-compose.yml | 3 - frontend/src/App.tsx | 124 +----- frontend/src/components/AppSwitcherMenu.tsx | 88 ++--- frontend/src/components/PanelMenu.tsx | 17 - frontend/src/components/RequirePmProjects.tsx | 6 +- frontend/src/components/layout/Header.tsx | 23 +- .../src/components/layout/MobileTabBar.tsx | 9 +- frontend/src/hooks/index.ts | 2 +- frontend/src/hooks/useProjects.ts | 12 - frontend/src/lib/api.ts | 53 +-- frontend/src/lib/types.ts | 6 - frontend/src/pages/Admin/AdminPanel.tsx | 59 +-- frontend/src/pages/Auth/LoginPage.tsx | 8 +- .../src/pages/Dashboard/DashboardPage.tsx | 39 +- frontend/src/pages/Dashboard/LedgerTable.tsx | 3 - frontend/src/pages/Dashboard/QuoteCard.tsx | 35 +- .../pages/Inventory/ChangeInventoryPage.tsx | 19 +- .../src/pages/Inventory/InventoryPage.tsx | 28 +- .../src/pages/Inventory/InventoryTopNav.tsx | 34 +- .../src/pages/Inventory/OrganizationPage.tsx | 354 ++++++++++++++++++ .../src/pages/Inventory/WarehousesPage.tsx | 48 ++- frontend/src/pages/Inventory/panelMenu.ts | 50 +-- .../src/pages/Inventory/useInventoryData.ts | 4 +- .../pages/Organization/OrganizationPage.tsx | 291 -------------- frontend/src/pages/Projects/ProjectsPage.tsx | 229 +++-------- frontend/src/pages/Suppliers/CategoryPage.tsx | 15 +- .../pages/Suppliers/SupplierDetailPage.tsx | 41 +- frontend/src/store/auth.ts | 3 +- frontend/src/store/index.ts | 2 - sql/migration.sql | 11 - 56 files changed, 753 insertions(+), 1479 deletions(-) create mode 100644 frontend/src/pages/Inventory/OrganizationPage.tsx delete mode 100644 frontend/src/pages/Organization/OrganizationPage.tsx diff --git a/backend/app/auth.py b/backend/app/auth.py index b284525..e5972fa 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -119,12 +119,10 @@ 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") @@ -137,12 +135,9 @@ 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 in ("supervisor", "pm"): + if user.role == "pm": projects = ( await db.execute( select(Project) @@ -164,7 +159,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/supervisor user (oldest first).""" + """Every project id managed by the given PM 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 789872a..018ff34 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', 'supervisor'); + CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin'); EXCEPTION WHEN duplicate_object THEN NULL; END $$ """, """ diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py index 523649c..f41b355 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", "supervisor", + "pm", "procurement_manager", "admin", name="user_role", native_enum=True, ) quote_status_enum = Enum( @@ -58,7 +58,6 @@ 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) @@ -149,23 +148,12 @@ 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 6124262..8e0c0ad 100644 --- a/backend/app/routers/attribute_definitions.py +++ b/backend/app/routers/attribute_definitions.py @@ -3,7 +3,6 @@ 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 @@ -14,7 +13,6 @@ 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: @@ -71,11 +69,7 @@ async def list_attribute_definitions( @router.post("", status_code=201) -async def create_attribute_definition( - payload: AttributeDefinitionCreate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)): 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") @@ -108,10 +102,7 @@ async def get_attribute_definition( @router.patch("/{definition_id}") async def update_attribute_definition( - definition_id, - payload: AttributeDefinitionUpdate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), + definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db) ): ad = await db.get(AttributeDefinition, definition_id) if ad is None: @@ -145,11 +136,7 @@ async def update_attribute_definition( @router.delete("/{definition_id}") -async def delete_attribute_definition( - definition_id, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)): 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 b3bb3c6..2005e66 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 ForbiddenError, ServiceUnavailableError, UnauthorizedError +from app.utils.errors import ServiceUnavailableError, UnauthorizedError from app.utils.ratelimit import limiter logger = logging.getLogger("procurement.auth") @@ -78,8 +78,6 @@ 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 13f96d8..1200f96 100644 --- a/backend/app/routers/brand_materials.py +++ b/backend/app/routers/brand_materials.py @@ -3,7 +3,6 @@ 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 @@ -13,7 +12,6 @@ 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: @@ -72,11 +70,7 @@ async def list_brand_materials( @router.post("", status_code=201) -async def create_brand_material( - payload: BrandMaterialCreate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)): 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: @@ -113,12 +107,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)): bm = await db.get(BrandMaterial, bm_id) if bm is None: raise NotFoundError("ارتباط برند-متریال یافت نشد") @@ -131,11 +120,7 @@ async def update_brand_material( @router.delete("/{bm_id}") -async def delete_brand_material( - bm_id, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)): 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 1112cc0..9173c48 100644 --- a/backend/app/routers/brands.py +++ b/backend/app/routers/brands.py @@ -3,7 +3,6 @@ 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 @@ -12,7 +11,6 @@ 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: @@ -44,11 +42,7 @@ async def list_brands( @router.post("", status_code=201) -async def create_brand( - payload: BrandCreate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)): 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("برندی با این نام و کشور قبلاً ثبت شده است") @@ -67,12 +61,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)): brand = await db.get(Brand, brand_id) if brand is None: raise NotFoundError("برند یافت نشد") @@ -90,11 +79,7 @@ async def update_brand( @router.delete("/{brand_id}") -async def delete_brand( - brand_id, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)): 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 ea5b4ac..791dd16 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -29,10 +29,9 @@ 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", @@ -126,7 +125,7 @@ async def _attachment_or_404( # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # --------------------------------------------------------------------------- -@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)]) +@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)]) 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) @@ -209,7 +208,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(_attachment_writer)]) +@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)]) 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 416c4c3..468998e 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -77,21 +77,23 @@ async def inventory_scope( if user is not None: if user.role is None: raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") - # مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی - if user.role in ("procurement_manager", "pm"): - return Scope(role="manager", project_id=project_id, actor=user.display_name) - if user.role == "supervisor": + # 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": 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="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") + 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) # Dev fallback: header-based identity (fork's placeholder mode) — only # when DEV_LOGIN_ENABLED is explicitly turned on (local testing without @@ -126,9 +128,9 @@ async def inventory_scope( return Scope(role=role, project_id=project_id, actor=actor) -def _require_inventory_modifier(scope: Scope) -> None: - if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM): - raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند") +def _require_pm(scope: Scope) -> None: + if scope.role != ROLE_PM: + raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست") # --------------------------------------------------------------------------- @@ -213,8 +215,8 @@ async def list_warehouses( db: AsyncSession = Depends(get_db), ): stmt = select(Warehouse) - if scope.role in (ROLE_PM, "supervisor"): - stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope + if scope.role == ROLE_PM: + stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope elif project_id is not None: stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.order_by(Warehouse.name) @@ -321,7 +323,7 @@ async def list_inventory( db: AsyncSession = Depends(get_db), ): stmt = select(InventoryItem) - if scope.role in (ROLE_PM, "supervisor"): + if scope.role == ROLE_PM: stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( Warehouse.project_id == scope.project_id ) @@ -386,7 +388,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 in (ROLE_PM, "supervisor"): + if scope.role == ROLE_PM: stmt = stmt.where(Warehouse.project_id == scope.project_id) elif project_id is not None: stmt = stmt.where(Warehouse.project_id == project_id) @@ -452,7 +454,7 @@ 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: + if scope.role == ROLE_PM and wh.project_id != scope.project_id: raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") txs = (await db.execute( select(InventoryTransaction) @@ -472,17 +474,17 @@ async def create_transaction( scope: Scope = Depends(inventory_scope), db: AsyncSession = Depends(get_db), ): - _require_inventory_modifier(scope) + _require_pm(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 scope.project_id is not None and wh.project_id != scope.project_id: + if 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: + if 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 12d2d6d..f29254e 100644 --- a/backend/app/routers/item_receipts.py +++ b/backend/app/routers/item_receipts.py @@ -3,7 +3,6 @@ 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 @@ -15,7 +14,6 @@ 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: @@ -47,13 +45,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)): 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)) @@ -73,13 +65,7 @@ async def create_receipt( @router.patch("") -async def update_receipt( - quote_id, - line_item_id, - payload: ReceiptUpdate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)): 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 701e25c..2d2977c 100644 --- a/backend/app/routers/item_termsheets.py +++ b/backend/app/routers/item_termsheets.py @@ -7,7 +7,6 @@ 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, @@ -24,7 +23,6 @@ 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: @@ -60,11 +58,7 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db) ): async with db.begin(): li = await _line_item_or_404(db, quote_id, line_item_id) @@ -99,12 +93,7 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db) ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await db.get(Quote, quote_id) @@ -125,11 +114,7 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db) ): 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 a00f535..9f49e95 100644 --- a/backend/app/routers/line_item_specs.py +++ b/backend/app/routers/line_item_specs.py @@ -3,7 +3,6 @@ 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 @@ -13,7 +12,6 @@ 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: @@ -55,13 +53,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)): """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 e9f6c79..963d1f5 100644 --- a/backend/app/routers/line_items.py +++ b/backend/app/routers/line_items.py @@ -3,7 +3,6 @@ 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 @@ -15,7 +14,6 @@ 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: @@ -52,12 +50,7 @@ async def list_line_items( @router.post("", status_code=201) -async def create_line_item( - quote_id, - payload: LineItemCreate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": @@ -96,11 +89,7 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db) ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await _quote_or_404(db, quote_id) @@ -142,12 +131,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): 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 ccd4ce5..8611a74 100644 --- a/backend/app/routers/material_categories.py +++ b/backend/app/routers/material_categories.py @@ -3,7 +3,6 @@ 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 @@ -13,7 +12,6 @@ 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: @@ -48,11 +46,7 @@ async def list_categories( @router.post("", status_code=201) -async def create_category( - payload: MaterialCategoryCreate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)): duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug)) if duplicate is not None: raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug") @@ -76,12 +70,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)): category = await db.get(MaterialCategory, category_id) if category is None: raise NotFoundError("دسته‌بندی یافت نشد") @@ -103,11 +92,7 @@ async def update_category( @router.delete("/{category_id}") -async def delete_category( - category_id, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_category(category_id, db: AsyncSession = Depends(get_db)): 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 c78fcdd..c128f9c 100644 --- a/backend/app/routers/materials.py +++ b/backend/app/routers/materials.py @@ -9,7 +9,6 @@ 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, @@ -39,7 +38,6 @@ 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): @@ -127,10 +125,7 @@ async def list_materials( @router.post("", status_code=201) async def create_material( - payload: MaterialCreate, - expand: str | None = None, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), + payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db) ): material = await material_service.create_material(db, payload) return {"data": await serialize_material(db, material, parse_expand(expand))} @@ -145,12 +140,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)): material = await db.get(Material, material_id) if material is None: raise NotFoundError("متریال یافت نشد") @@ -159,11 +149,7 @@ async def update_material( @router.delete("/{material_id}") -async def delete_material( - material_id, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_material(material_id, db: AsyncSession = Depends(get_db)): material = await db.get(Material, material_id) if material is None: raise NotFoundError("متریال یافت نشد") @@ -262,10 +248,7 @@ 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), - _auth=Depends(_procurement_writer), + material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db) ): async with db.begin(): await _material_or_404(db, material_id) @@ -304,11 +287,7 @@ 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), - _auth=Depends(_procurement_writer), + material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db) ): async with db.begin(): mu = await db.scalar( @@ -340,12 +319,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)): mu = await db.scalar( select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id) ) @@ -391,10 +365,7 @@ 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), - _auth=Depends(_procurement_writer), + material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db) ): """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 7e6b69d..2ebd785 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 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,6 @@ 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: @@ -34,12 +33,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict: d = { "id": str(project.id), "name": project.name, - "location": project.location or "", - "project_manager": project.project_manager or "", - "description": project.description or "", + "description": project.description, "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), } @@ -47,8 +42,6 @@ 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 @@ -62,14 +55,7 @@ async def list_projects( ): stmt = select(Project) if 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 = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.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) @@ -78,38 +64,24 @@ async def list_projects( d = { "id": str(p.id), "name": p.name, - "location": p.location or "", - "project_manager": p.project_manager or "", - "description": p.description or "", + "description": p.description, "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), - _auth=Depends(_procurement_writer), -): +async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)): project = Project( - 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 "", + name=payload.name, + description=payload.description, start_date=payload.start_date, - planned_start=payload.start_date, - status="ACTIVE", ) db.add(project) await db.flush() @@ -131,51 +103,37 @@ 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.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() + project.name = payload.name if payload.description is not None: - project.description = payload.description.strip() + project.description = payload.description 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", "pm"): - raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است") + if role not in ("admin", "procurement_manager"): + 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 not in ("supervisor", "pm"): - raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id") + if pm_user.role != "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), - _auth=Depends(_procurement_writer), -): +async def delete_project(project_id, db: AsyncSession = Depends(get_db)): 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 962bb0b..1f91c41 100644 --- a/backend/app/routers/quote_events.py +++ b/backend/app/routers/quote_events.py @@ -3,7 +3,6 @@ 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 @@ -12,7 +11,6 @@ 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("") @@ -36,12 +34,7 @@ async def list_events( @router.post("", status_code=201) -async def create_event( - quote_id, - payload: NoteEventIn, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)): 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 2849be3..489921c 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -8,7 +8,6 @@ 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 ( @@ -43,9 +42,6 @@ from app.utils.security import get_actor router = APIRouter(prefix="/quotes", tags=["quotes"]) -# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد) -_procurement_writer = require_role("admin", "procurement_manager") - # --- list filter helpers --------------------------------------------------- @@ -160,12 +156,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): quote = await quote_service.create_quote(db, payload, actor) return {"data": await serialize_quote_with_children(db, quote)} @@ -176,11 +167,7 @@ async def create_quote( @router.patch("/{quote_id}") async def update_quote( - quote_id, - payload: QuoteUpdate, - db: AsyncSession = Depends(get_db), - actor: str = Depends(get_actor), - _auth=Depends(_procurement_writer), + quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) ): quote = await quote_service.update_quote(db, quote_id, payload, actor) return {"data": await serialize_quote(db, quote, set())} @@ -188,11 +175,7 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) ): """Full replacement of a quote's line items + termsheets (edit modal).""" quote = await quote_service.replace_quote_children(db, quote_id, payload, actor) @@ -205,11 +188,7 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) ): quote = await quote_service.accept_quote(db, quote_id, payload, actor) return {"data": await _accept_response(db, quote)} @@ -245,12 +224,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): quote = await quote_service.arrive_quote(db, quote_id, actor) return { "data": { @@ -263,11 +237,7 @@ async def arrive_quote( @router.patch("/{quote_id}/arrive-items") async def arrive_items( - quote_id, - payload: ArriveItemsIn, - db: AsyncSession = Depends(get_db), - actor: str = Depends(get_actor), - _auth=Depends(_procurement_writer), + quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) ): quote = await quote_service.arrive_items(db, quote_id, payload, actor) return { @@ -289,23 +259,14 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) ): 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), - _auth=Depends(_procurement_writer), -): +async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): quote = await quote_service.expire_quote(db, quote_id, actor) return {"data": {"id": str(quote.id), "status": quote.status}} @@ -315,10 +276,6 @@ async def expire_quote( # --------------------------------------------------------------------------- @router.delete("/{quote_id}") -async def delete_quote( - quote_id, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)): 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 d2c03d4..fc265e7 100644 --- a/backend/app/routers/schedule_steps.py +++ b/backend/app/routers/schedule_steps.py @@ -3,7 +3,6 @@ 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 @@ -16,7 +15,6 @@ 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: @@ -59,11 +57,7 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db) ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) step_no = payload.step_no @@ -90,12 +84,7 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db) ): step = await _step_or_404(db, termsheet_id, step_id) if payload.step_no is not None and payload.step_no != step.step_no: @@ -120,13 +109,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)): 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 6469399..b63434a 100644 --- a/backend/app/routers/standard_conversions.py +++ b/backend/app/routers/standard_conversions.py @@ -3,7 +3,6 @@ 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 @@ -12,7 +11,6 @@ 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: @@ -43,11 +41,7 @@ async def list_standard_conversions( @router.post("", status_code=201) -async def create_standard_conversion( - payload: StandardConversionCreate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)): duplicate = await db.scalar( select(StandardConversion.id).where( StandardConversion.from_unit == payload.from_unit, @@ -73,12 +67,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)): sc = await db.get(StandardConversion, sc_id) if sc is None: raise NotFoundError("تبدیل استاندارد یافت نشد") @@ -105,11 +94,7 @@ async def update_standard_conversion( @router.delete("/{sc_id}") -async def delete_standard_conversion( - sc_id, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)): 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 02d5a55..26e4026 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -8,7 +8,6 @@ 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 @@ -19,7 +18,6 @@ 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, @@ -65,10 +63,7 @@ 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), - _auth=Depends(_procurement_writer), + supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db) ): async with db.begin(): link = await link_supplier_material(db, supplier_id, payload.material_id) @@ -77,12 +72,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)): 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 070a9e6..3ab5175 100644 --- a/backend/app/routers/suppliers.py +++ b/backend/app/routers/suppliers.py @@ -3,7 +3,6 @@ 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 @@ -13,7 +12,6 @@ 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: @@ -73,11 +71,7 @@ async def list_suppliers( @router.post("", status_code=201) -async def create_supplier( - payload: SupplierCreate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)): category = await _category_or_404(db, payload.category_id) supplier = Supplier( name=payload.name, @@ -102,12 +96,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)): supplier = await db.get(Supplier, supplier_id) if supplier is None: raise NotFoundError("تأمین‌کننده یافت نشد") @@ -131,11 +120,7 @@ async def update_supplier( @router.delete("/{supplier_id}") -async def delete_supplier( - supplier_id, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)): 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 b1d5ffd..16c8f98 100644 --- a/backend/app/routers/termsheets.py +++ b/backend/app/routers/termsheets.py @@ -3,7 +3,6 @@ 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, @@ -27,7 +26,6 @@ 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: @@ -82,12 +80,7 @@ async def list_termsheets( @router.post("", status_code=201) -async def create_termsheet( - quote_id, - payload: TermsheetCreate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": @@ -117,11 +110,7 @@ async def get_termsheet( @router.patch("/{termsheet_id}") async def update_termsheet( - quote_id, - termsheet_id, - payload: TermsheetUpdate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), + quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db) ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) if ts.status != "offered": @@ -138,11 +127,7 @@ 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), - _auth=Depends(_procurement_writer), + quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db) ): async with db.begin(): ts = await _termsheet_or_404(db, quote_id, termsheet_id) @@ -154,12 +139,7 @@ async def update_termsheet_status( @router.delete("/{termsheet_id}") -async def delete_termsheet( - quote_id, - termsheet_id, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)): 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 5004f7a..7152ecd 100644 --- a/backend/app/routers/units.py +++ b/backend/app/routers/units.py @@ -3,7 +3,6 @@ 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 @@ -12,7 +11,6 @@ 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: @@ -47,11 +45,7 @@ async def list_units( @router.post("", status_code=201) -async def create_unit( - payload: UnitCreate, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)): duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code)) if duplicate is not None: raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code") @@ -76,12 +70,7 @@ 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), - _auth=Depends(_procurement_writer), -): +async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)): unit = await db.get(Unit, unit_id) if unit is None: raise NotFoundError("واحد یافت نشد") @@ -103,11 +92,7 @@ async def update_unit( @router.delete("/{unit_id}") -async def delete_unit( - unit_id: int, - db: AsyncSession = Depends(get_db), - _auth=Depends(_procurement_writer), -): +async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)): 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 ecd0eef..702484d 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, text +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_current_user, require_role @@ -41,8 +41,6 @@ USER_ROLE_LABELS = { "pm": "مدیر پروژه", "procurement_manager": "مدیر تدارکات", "admin": "ادمین", - # کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد - "supervisor": "سرپرست پروژه", } @@ -54,7 +52,6 @@ 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, @@ -68,15 +65,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, procurement manager, or project manager — organization management.""" - if ctx.role not in ("admin", "procurement_manager", "pm"): + """Admin OR procurement manager (the same person) — organization management.""" + if ctx.role not in ("admin", "procurement_manager"): raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") return ctx async def _user_projects(db: AsyncSession, user: User) -> list[Project]: - """Every project assigned to the user (supervisor or PM) (oldest first).""" - if user.role not in ("supervisor", "pm"): + """Every project the user manages as PM (oldest first; [] for non-PMs).""" + if user.role != "pm": return [] rows = ( await db.execute( @@ -151,13 +148,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() - supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} + pm_ids = {u.id for u in users if u.role == "pm"} by_user: dict = {} - if supervisor_ids: + if pm_ids: projs = ( await db.execute( select(Project) - .where(Project.pm_user_id.in_(supervisor_ids)) + .where(Project.pm_user_id.in_(pm_ids)) .order_by(Project.created_at, Project.id) ) ).scalars().all() @@ -207,7 +204,7 @@ async def update_user_role( if user is None: raise NotFoundError("کاربر یافت نشد") previous_role = user.role - if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"): + if user.role == "pm" and payload.role != "pm": await _unassign_projects(db, user.id) user.role = payload.role await db.flush() @@ -234,15 +231,8 @@ 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") @@ -253,20 +243,16 @@ 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() - supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} + pm_ids = {u.id for u in users if u.role == "pm"} by_user: dict = {} - if supervisor_ids: + if pm_ids: projs = ( await db.execute( select(Project) - .where(Project.pm_user_id.in_(supervisor_ids)) + .where(Project.pm_user_id.in_(pm_ids)) .order_by(Project.created_at, Project.id) ) ).scalars().all() @@ -296,16 +282,11 @@ async def assign_user_project( user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") - if user.role not in ("supervisor", "pm"): - raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role") + if user.role != "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)} @@ -313,19 +294,5 @@ 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 77e39e7..2d6f0b6 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -44,19 +44,14 @@ 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 @@ -208,13 +203,13 @@ class UserCreate(APIModel): last_name: str = "" phone: str | None = None casdoor_id: str | None = None - role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None + role: Literal["pm", "procurement_manager", "admin"] | None = None class UserRoleUpdate(APIModel): """role=None revokes the role (and any project assignment).""" - role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None + role: Literal["pm", "procurement_manager", "admin"] | None = None class UserProjectUpdate(APIModel): diff --git a/docker-compose.yml b/docker-compose.yml index fbf2548..b2187e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -248,9 +248,6 @@ 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 384f91d..4c1b872 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from "react"; -import { Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { Navigate, Route, Routes } from "react-router-dom"; import { useAuthStore } from "./store/auth"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { RequirePmProjects } from "./components/RequirePmProjects"; @@ -38,7 +38,7 @@ const WarehousesPage = lazy(() => import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })), ); const OrganizationPage = lazy(() => - import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), + import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), ); const AdminPanelPage = lazy(() => import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), @@ -55,102 +55,17 @@ function Loading() { // همه‌ی صفحات پشت ورود هستند — بدون نشست، به صفحه‌ی ورود می‌رویم. function RequireAuth({ children }: { children: React.ReactNode }) { const user = useAuthStore((s) => s.user); - const { pathname } = useLocation(); if (!user) return ; - if (user.role === "supervisor") { - // اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود - if (!user.projects || user.projects.length === 0) { - return ( -
-
-

پروژه تخصیص داده نشده است

-

- شما به عنوان سرپرست پروژه در سیستم ثبت شده‌اید اما هنوز هیچ پروژه‌ای به شما تخصیص داده نشده است. - تا زمان تخصیص پروژه توسط مدیر سیستم، اجازه ورود به سامانه را ندارید. -

- -
-
- ); - } - // سرپرست پروژه فقط به انبار و PM دسترسی دارد - if (!pathname.startsWith("/inventory") && pathname !== "/panel/pm") { - return ; - } - } return <>{children}; } -// صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات +// صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر function HomeRoute() { const user = useAuthStore((s) => s.user); - if (user?.role === "supervisor") return ; + if (user?.role === "pm") return ; return ; } -// دسترسی ایجاد استعلام/پیشنهاد (ویزارد): مدیر تدارکات، مدیر پروژه و ادمین -function RequireProcurementWrite({ children }: { children: React.ReactNode }) { - const user = useAuthStore((s) => s.user); - if (user?.role !== "admin" && user?.role !== "procurement_manager" && user?.role !== "pm") { - return ; - } - return <>{children}; -} - -// پنل «مدیریت پروژه (PM)» — اپ PM روی همان دامنه زیر مسیر /pm سرو می‌شود -function PmPanelPage() { - return ( -