diff --git a/backend/app/auth.py b/backend/app/auth.py index e5972fa..b284525 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -119,10 +119,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: if not casdoor_id: raise UnauthorizedError("توکن فاقد شناسه کاربر است", field="authorization") + username = str(claims.get("name") or claims.get("preferred_username") or "") user = await db.scalar(select(User).where(User.casdoor_id == casdoor_id)) if user is None: user = User( casdoor_id=casdoor_id, + username=username or None, first_name=str( claims.get("firstName") or claims.get("firstname") @@ -135,9 +137,12 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: ) db.add(user) await db.flush() + elif username and not user.username: + user.username = username + await db.flush() project_ids: list = [] - if user.role == "pm": + if user.role in ("supervisor", "pm"): projects = ( await db.execute( select(Project) @@ -159,7 +164,7 @@ async def context_from_claims(claims: dict, db: AsyncSession) -> UserContext: async def pm_project_ids(db: AsyncSession, user: User) -> list: - """Every project id managed by the given PM user (oldest first).""" + """Every project id managed by the given PM/supervisor user (oldest first).""" rows = ( await db.execute( select(Project.id) diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index f1dd67b..14b96a2 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -174,7 +174,7 @@ _V26_DDL = [ """, """ DO $$ BEGIN - CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin'); + CREATE TYPE user_role AS ENUM ('pm', 'procurement_manager', 'admin', 'supervisor'); EXCEPTION WHEN duplicate_object THEN NULL; END $$ """, """ diff --git a/backend/app/models/procurement.py b/backend/app/models/procurement.py index f41b355..523649c 100644 --- a/backend/app/models/procurement.py +++ b/backend/app/models/procurement.py @@ -26,7 +26,7 @@ from app.db.base import Base from app.models.catalog import Material, Unit, _timestamptz, _uuid_pk user_role_enum = Enum( - "pm", "procurement_manager", "admin", + "pm", "procurement_manager", "admin", "supervisor", name="user_role", native_enum=True, ) quote_status_enum = Enum( @@ -58,6 +58,7 @@ class User(Base): id: Mapped[uuid.UUID] = _uuid_pk() casdoor_id: Mapped[str] = mapped_column(Text, nullable=False, unique=True) + username: Mapped[str | None] = mapped_column(Text) first_name: Mapped[str] = mapped_column(Text, nullable=False) last_name: Mapped[str] = mapped_column(Text, nullable=False) phone: Mapped[str | None] = mapped_column(Text) @@ -148,12 +149,23 @@ class Project(Base): id: Mapped[uuid.UUID] = _uuid_pk() name: Mapped[str] = mapped_column(Text, nullable=False) description: Mapped[str | None] = mapped_column(Text) + location: Mapped[str | None] = mapped_column(Text, server_default=text("''")) + project_manager: Mapped[str | None] = mapped_column(Text, server_default=text("''")) start_date: Mapped[date | None] = mapped_column(Date) + planned_start: Mapped[date | None] = mapped_column(Date) + planned_end: Mapped[date | None] = mapped_column(Date) + actual_start: Mapped[date | None] = mapped_column(Date) + actual_end: Mapped[date | None] = mapped_column(Date) + status: Mapped[str | None] = mapped_column(Text, server_default=text("'ACTIVE'")) + progress: Mapped[float | None] = mapped_column(Numeric(5, 2), server_default=text("0.0")) # v2.6: the single PM responsible for this project (confirms arrivals). pm_user_id: Mapped[uuid.UUID | None] = mapped_column( ForeignKey("users.id", ondelete="SET NULL") ) created_at: Mapped[datetime] = _timestamptz() + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) class Quote(Base): diff --git a/backend/app/routers/attribute_definitions.py b/backend/app/routers/attribute_definitions.py index 8e0c0ad..6124262 100644 --- a/backend/app/routers/attribute_definitions.py +++ b/backend/app/routers/attribute_definitions.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, MaterialAttributeValue, Unit from app.models.procurement import LineItemSpec @@ -13,6 +14,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/attribute-definitions", tags=["attribute-definitions"]) +_procurement_writer = require_role("admin", "procurement_manager") def attr_def_to_dict(ad: AttributeDefinition, unit: Unit | None = None) -> dict: @@ -69,7 +71,11 @@ async def list_attribute_definitions( @router.post("", status_code=201) -async def create_attribute_definition(payload: AttributeDefinitionCreate, db: AsyncSession = Depends(get_db)): +async def create_attribute_definition( + payload: AttributeDefinitionCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar(select(AttributeDefinition.id).where(AttributeDefinition.attribute_key == payload.attribute_key)) if duplicate is not None: raise ConflictError("ویژگی با این attribute_key قبلاً ثبت شده است", field="attribute_key") @@ -102,7 +108,10 @@ async def get_attribute_definition( @router.patch("/{definition_id}") async def update_attribute_definition( - definition_id, payload: AttributeDefinitionUpdate, db: AsyncSession = Depends(get_db) + definition_id, + payload: AttributeDefinitionUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): ad = await db.get(AttributeDefinition, definition_id) if ad is None: @@ -136,7 +145,11 @@ async def update_attribute_definition( @router.delete("/{definition_id}") -async def delete_attribute_definition(definition_id, db: AsyncSession = Depends(get_db)): +async def delete_attribute_definition( + definition_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): ad = await db.get(AttributeDefinition, definition_id) if ad is None: raise NotFoundError("تعریف ویژگی یافت نشد") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 2005e66..b3bb3c6 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -22,7 +22,7 @@ from app.config import settings from app.db.engine import get_db from app.routers.users import _user_projects, user_to_dict from app.services import analytics -from app.utils.errors import ServiceUnavailableError, UnauthorizedError +from app.utils.errors import ForbiddenError, ServiceUnavailableError, UnauthorizedError from app.utils.ratelimit import limiter logger = logging.getLogger("procurement.auth") @@ -78,6 +78,8 @@ async def login(request: Request, payload: LoginRequest, db: AsyncSession = Depe claims = await _verify_token(access_token) ctx = await context_from_claims(claims, db) projects = await _user_projects(db, ctx.user) + if ctx.role == "supervisor" and not projects: + raise ForbiddenError("پروژه تخصیص داده نشده است — تا زمان تخصیص پروژه، اجازه ورود به سامانه را ندارید") await analytics.track( "login_success", {"username": payload.username, "role": ctx.role}, diff --git a/backend/app/routers/brand_materials.py b/backend/app/routers/brand_materials.py index 1200f96..13f96d8 100644 --- a/backend/app/routers/brand_materials.py +++ b/backend/app/routers/brand_materials.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial, Material from app.models.procurement import LineItem @@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/brand-materials", tags=["brand-materials"]) +_procurement_writer = require_role("admin", "procurement_manager") def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict: @@ -70,7 +72,11 @@ async def list_brand_materials( @router.post("", status_code=201) -async def create_brand_material(payload: BrandMaterialCreate, db: AsyncSession = Depends(get_db)): +async def create_brand_material( + payload: BrandMaterialCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): if await db.get(Brand, payload.brand_id) is None: raise NotFoundError("برند یافت نشد", field="brand_id") if await db.get(Material, payload.material_id) is None: @@ -107,7 +113,12 @@ async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession @router.patch("/{bm_id}") -async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSession = Depends(get_db)): +async def update_brand_material( + bm_id, + payload: BrandMaterialUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): bm = await db.get(BrandMaterial, bm_id) if bm is None: raise NotFoundError("ارتباط برند-متریال یافت نشد") @@ -120,7 +131,11 @@ async def update_brand_material(bm_id, payload: BrandMaterialUpdate, db: AsyncSe @router.delete("/{bm_id}") -async def delete_brand_material(bm_id, db: AsyncSession = Depends(get_db)): +async def delete_brand_material( + bm_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): bm = await db.get(BrandMaterial, bm_id) if bm is None: raise NotFoundError("ارتباط برند-متریال یافت نشد") diff --git a/backend/app/routers/brands.py b/backend/app/routers/brands.py index 9173c48..1112cc0 100644 --- a/backend/app/routers/brands.py +++ b/backend/app/routers/brands.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Brand, BrandMaterial from app.schemas.catalog import BrandCreate, BrandUpdate @@ -11,6 +12,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/brands", tags=["brands"]) +_procurement_writer = require_role("admin", "procurement_manager") def brand_to_dict(brand: Brand) -> dict: @@ -42,7 +44,11 @@ async def list_brands( @router.post("", status_code=201) -async def create_brand(payload: BrandCreate, db: AsyncSession = Depends(get_db)): +async def create_brand( + payload: BrandCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): rows = (await db.execute(select(Brand).where(Brand.name == payload.name))).scalars().all() if any(b.country == payload.country for b in rows): raise ConflictError("برندی با این نام و کشور قبلاً ثبت شده است") @@ -61,7 +67,12 @@ async def get_brand(brand_id, db: AsyncSession = Depends(get_db)): @router.patch("/{brand_id}") -async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depends(get_db)): +async def update_brand( + brand_id, + payload: BrandUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): brand = await db.get(Brand, brand_id) if brand is None: raise NotFoundError("برند یافت نشد") @@ -79,7 +90,11 @@ async def update_brand(brand_id, payload: BrandUpdate, db: AsyncSession = Depend @router.delete("/{brand_id}") -async def delete_brand(brand_id, db: AsyncSession = Depends(get_db)): +async def delete_brand( + brand_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): brand = await db.get(Brand, brand_id) if brand is None: raise NotFoundError("برند یافت نشد") diff --git a/backend/app/routers/files.py b/backend/app/routers/files.py index 791dd16..ea5b4ac 100644 --- a/backend/app/routers/files.py +++ b/backend/app/routers/files.py @@ -29,9 +29,10 @@ from app.utils.preview_token import make_preview_token, verify_preview_token router = APIRouter(prefix="/quotes", tags=["files"]) -# حذف پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ بارگذاری و دانلود +# حذف و بارگذاری پیوست‌ها = عملیات تدارکات (ادمین / مدیر تدارکات)؛ دانلود # برای هر کاربر واردشده آزاد است. _attachment_deleter = require_role("procurement_manager", "admin") +_attachment_writer = require_role("procurement_manager", "admin") ALLOWED_MIME_TYPES = { "image/png", "image/jpeg", "image/webp", "image/gif", "application/pdf", @@ -125,7 +126,7 @@ async def _attachment_or_404( # Invoice — قابل بارگذاری در هر وضعیتی (از صفحه‌ی ویرایش) # --------------------------------------------------------------------------- -@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(get_current_user)]) +@router.post("/{quote_id}/invoice", status_code=201, dependencies=[Depends(_attachment_writer)]) async def upload_invoice(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) mime = _validate_upload(file) @@ -208,7 +209,7 @@ async def delete_invoice(quote_id: uuid.UUID, attachment_id: uuid.UUID, db: Asyn # Proforma — no status restriction # --------------------------------------------------------------------------- -@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(get_current_user)]) +@router.post("/{quote_id}/proforma", status_code=201, dependencies=[Depends(_attachment_writer)]) async def upload_proforma(quote_id: uuid.UUID, file: UploadFile = File(...), db: AsyncSession = Depends(get_db)): await _quote_or_404(db, quote_id) mime = _validate_upload(file) diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 8aa9370..9816cd7 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -77,23 +77,21 @@ async def inventory_scope( if user is not None: if user.role is None: raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است") - # v2.6: مدیر تدارکات = ادمین دفتر — دسترسی فقط‌خواندنی به پنل انبار. - if user.role == "procurement_manager": - return Scope(role="admin", project_id=project_id, actor=user.display_name) - if user.role not in ("pm", "admin"): - raise ValidationError("نقش نامعتبر است — فقط pm یا admin برای موجودی", field="role") - if user.role == "pm": + # مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقط‌خواندنی در تغییر موجودی + if user.role in ("procurement_manager", "pm"): + return Scope(role="manager", project_id=project_id, actor=user.display_name) + if user.role == "supervisor": allowed = list(user.project_ids or []) + if not allowed: + raise ForbiddenError("پروژه تخصیص داده نشده است") if project_id is not None: if project_id not in allowed: raise ForbiddenError("این پروژه به شما تخصیص داده نشده است") - return Scope(role="pm", project_id=project_id, actor=user.display_name) - if not allowed: - raise ValidationError( - "کاربر PM به هیچ پروژه‌ای متصل نیست (projects.pm_user_id)", field="project_id" - ) - return Scope(role="pm", project_id=allowed[0], actor=user.display_name) - return Scope(role="admin", project_id=project_id, actor=user.display_name) + return Scope(role="supervisor", project_id=project_id, actor=user.display_name) + return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name) + if user.role == "admin": + return Scope(role="admin", project_id=project_id, actor=user.display_name) + raise ValidationError("نقش نامعتبر است", field="role") # Dev fallback: header-based identity (fork's placeholder mode) — only # when DEV_LOGIN_ENABLED is explicitly turned on (local testing without @@ -128,9 +126,9 @@ async def inventory_scope( return Scope(role=role, project_id=project_id, actor=actor) -def _require_pm(scope: Scope) -> None: - if scope.role != ROLE_PM: - raise ForbiddenError("دسترسی ادمین فقط خواندنی است — ثبت/تغییر موجودی مجاز نیست") +def _require_inventory_modifier(scope: Scope) -> None: + if scope.role not in (ROLE_ADMIN, "admin", "supervisor", ROLE_PM): + raise ForbiddenError("مدیر تدارکات و مدیر پروژه امکان تغییر موجودی انبار را ندارند — فقط سرپرست پروژه و ادمین مجاز هستند") # --------------------------------------------------------------------------- @@ -216,8 +214,8 @@ async def list_warehouses( db: AsyncSession = Depends(get_db), ): stmt = select(Warehouse) - if scope.role == ROLE_PM: - stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM scope + if scope.role in (ROLE_PM, "supervisor"): + stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope elif project_id is not None: stmt = stmt.where(Warehouse.project_id == project_id) stmt = stmt.order_by(Warehouse.name) @@ -324,7 +322,7 @@ async def list_inventory( db: AsyncSession = Depends(get_db), ): stmt = select(InventoryItem) - if scope.role == ROLE_PM: + if scope.role in (ROLE_PM, "supervisor"): stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( Warehouse.project_id == scope.project_id ) @@ -389,7 +387,7 @@ async def list_transactions( stmt = select(InventoryTransaction).join( InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id ).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id) - if scope.role == ROLE_PM: + if scope.role in (ROLE_PM, "supervisor"): stmt = stmt.where(Warehouse.project_id == scope.project_id) elif project_id is not None: stmt = stmt.where(Warehouse.project_id == project_id) @@ -455,7 +453,7 @@ async def inventory_history( if item is None: raise NotFoundError("موجودی یافت نشد") wh = await get_warehouse_or_404(db, item.warehouse_id) - if scope.role == ROLE_PM and wh.project_id != scope.project_id: + if scope.role in (ROLE_PM, "supervisor") and wh.project_id != scope.project_id: raise ForbiddenError("این موجودی متعلق به پروژه شما نیست") txs = (await db.execute( select(InventoryTransaction) @@ -475,17 +473,17 @@ async def create_transaction( scope: Scope = Depends(inventory_scope), db: AsyncSession = Depends(get_db), ): - _require_pm(scope) + _require_inventory_modifier(scope) # No explicit db.begin(): the session may already be inside an implicit # transaction (inventory_scope read from it in the JWT path); get_db # commits on success / rolls back on failure. with nullcontext(): wh = await get_warehouse_or_404(db, payload.warehouse_id) - if wh.project_id != scope.project_id: + if scope.project_id is not None and wh.project_id != scope.project_id: raise ForbiddenError("این انبار متعلق به پروژه شما نیست") if payload.dest_warehouse_id is not None: dest = await get_warehouse_or_404(db, payload.dest_warehouse_id) - if dest.project_id != scope.project_id: + if scope.project_id is not None and dest.project_id != scope.project_id: raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست") txs = await apply_transaction(db, payload, performed_by=scope.actor) await audit_record( diff --git a/backend/app/routers/item_receipts.py b/backend/app/routers/item_receipts.py index f29254e..12d2d6d 100644 --- a/backend/app/routers/item_receipts.py +++ b/backend/app/routers/item_receipts.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import ItemDecision, ItemReceipt, LineItem from app.schemas.procurement import ReceiptCreate, ReceiptUpdate @@ -14,6 +15,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/receipt", tags=["item-receipts"], ) +_procurement_writer = require_role("admin", "procurement_manager") async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: @@ -45,7 +47,13 @@ async def get_receipt(quote_id, line_item_id, db: AsyncSession = Depends(get_db) @router.post("", status_code=201) -async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: AsyncSession = Depends(get_db)): +async def create_receipt( + quote_id, + line_item_id, + payload: ReceiptCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): await _line_item_or_404(db, quote_id, line_item_id) decision = await _decision_or_404(db, line_item_id) existing = await db.scalar(select(ItemReceipt).where(ItemReceipt.item_decision_id == decision.id)) @@ -65,7 +73,13 @@ async def create_receipt(quote_id, line_item_id, payload: ReceiptCreate, db: Asy @router.patch("") -async def update_receipt(quote_id, line_item_id, payload: ReceiptUpdate, db: AsyncSession = Depends(get_db)): +async def update_receipt( + quote_id, + line_item_id, + payload: ReceiptUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): await _line_item_or_404(db, quote_id, line_item_id) decision = await db.scalar(select(ItemDecision).where(ItemDecision.line_item_id == line_item_id)) if decision is None: diff --git a/backend/app/routers/item_termsheets.py b/backend/app/routers/item_termsheets.py index 2d2977c..701e25c 100644 --- a/backend/app/routers/item_termsheets.py +++ b/backend/app/routers/item_termsheets.py @@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -23,6 +24,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/line-items/{line_item_id}/termsheets", tags=["item-termsheets"], ) +_procurement_writer = require_role("admin", "procurement_manager") async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: @@ -58,7 +60,11 @@ async def list_item_termsheets(quote_id, line_item_id, db: AsyncSession = Depend @router.post("", status_code=201) async def create_item_termsheet( - quote_id, line_item_id, payload: ItemTermsheetCreate, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + payload: ItemTermsheetCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): li = await _line_item_or_404(db, quote_id, line_item_id) @@ -93,7 +99,12 @@ async def create_item_termsheet( @router.patch("/{item_termsheet_id}") async def update_item_termsheet( - quote_id, line_item_id, item_termsheet_id, payload: ItemTermsheetUpdate, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + item_termsheet_id, + payload: ItemTermsheetUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await db.get(Quote, quote_id) @@ -114,7 +125,11 @@ async def update_item_termsheet( @router.delete("/{item_termsheet_id}") async def delete_item_termsheet( - quote_id, line_item_id, item_termsheet_id, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + item_termsheet_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): it = await db.scalar( select(ItemTermsheet).where(ItemTermsheet.id == item_termsheet_id, ItemTermsheet.line_item_id == line_item_id) diff --git a/backend/app/routers/line_item_specs.py b/backend/app/routers/line_item_specs.py index 9f49e95..a00f535 100644 --- a/backend/app/routers/line_item_specs.py +++ b/backend/app/routers/line_item_specs.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, Unit from app.models.procurement import LineItem, LineItemSpec, Quote @@ -12,6 +13,7 @@ from app.services.eav_service import flatten_eav_values from app.utils.errors import ConflictError, NotFoundError router = APIRouter(prefix="/quotes/{quote_id}/line-items/{line_item_id}/specs", tags=["line-item-specs"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _line_item_or_404(db: AsyncSession, quote_id, line_item_id) -> LineItem: @@ -53,7 +55,13 @@ async def get_line_item_specs(quote_id, line_item_id, db: AsyncSession = Depends @router.put("") -async def put_line_item_specs(quote_id, line_item_id, payload: SpecsPutIn, db: AsyncSession = Depends(get_db)): +async def put_line_item_specs( + quote_id, + line_item_id, + payload: SpecsPutIn, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): """FULL replacement — specs not in the payload are DELETED (P1-19).""" async with db.begin(): li = await _line_item_or_404(db, quote_id, line_item_id) diff --git a/backend/app/routers/line_items.py b/backend/app/routers/line_items.py index 963d1f5..e9f6c79 100644 --- a/backend/app/routers/line_items.py +++ b/backend/app/routers/line_items.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Material, Unit from app.models.procurement import ItemDecision, LineItem, Quote @@ -14,6 +15,7 @@ from app.utils.errors import ConflictError, NotFoundError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/line-items", tags=["line-items"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: @@ -50,7 +52,12 @@ async def list_line_items( @router.post("", status_code=201) -async def create_line_item(quote_id, payload: LineItemCreate, db: AsyncSession = Depends(get_db)): +async def create_line_item( + quote_id, + payload: LineItemCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": @@ -89,7 +96,11 @@ async def get_line_item( @router.patch("/{line_item_id}") async def update_line_item( - quote_id, line_item_id, payload: LineItemUpdate, db: AsyncSession = Depends(get_db) + quote_id, + line_item_id, + payload: LineItemUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await _quote_or_404(db, quote_id) @@ -131,7 +142,12 @@ async def update_line_item( @router.delete("/{line_item_id}") -async def delete_line_item(quote_id, line_item_id, db: AsyncSession = Depends(get_db)): +async def delete_line_item( + quote_id, + line_item_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): li = await _line_item_or_404(db, quote_id, line_item_id) quote = await _quote_or_404(db, quote_id) if quote.status != "offered": diff --git a/backend/app/routers/material_categories.py b/backend/app/routers/material_categories.py index 8611a74..ccd4ce5 100644 --- a/backend/app/routers/material_categories.py +++ b/backend/app/routers/material_categories.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory from app.models.procurement import Supplier @@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/material-categories", tags=["material-categories"]) +_procurement_writer = require_role("admin", "procurement_manager") def category_to_dict(category: MaterialCategory) -> dict: @@ -46,7 +48,11 @@ async def list_categories( @router.post("", status_code=201) -async def create_category(payload: MaterialCategoryCreate, db: AsyncSession = Depends(get_db)): +async def create_category( + payload: MaterialCategoryCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar(select(MaterialCategory.id).where(MaterialCategory.slug == payload.slug)) if duplicate is not None: raise ConflictError("دسته‌بندی با این slug قبلاً ثبت شده است", field="slug") @@ -70,7 +76,12 @@ async def get_category(category_id, db: AsyncSession = Depends(get_db)): @router.patch("/{category_id}") -async def update_category(category_id, payload: MaterialCategoryUpdate, db: AsyncSession = Depends(get_db)): +async def update_category( + category_id, + payload: MaterialCategoryUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): category = await db.get(MaterialCategory, category_id) if category is None: raise NotFoundError("دسته‌بندی یافت نشد") @@ -92,7 +103,11 @@ async def update_category(category_id, payload: MaterialCategoryUpdate, db: Asyn @router.delete("/{category_id}") -async def delete_category(category_id, db: AsyncSession = Depends(get_db)): +async def delete_category( + category_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): category = await db.get(MaterialCategory, category_id) if category is None: raise NotFoundError("دسته‌بندی یافت نشد") diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py index c128f9c..c78fcdd 100644 --- a/backend/app/routers/materials.py +++ b/backend/app/routers/materials.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import exists, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import ( AttributeDefinition, @@ -38,6 +39,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/materials", tags=["materials"]) +_procurement_writer = require_role("admin", "procurement_manager") def _coerce_filter_value(data_type: str, raw: str): @@ -125,7 +127,10 @@ async def list_materials( @router.post("", status_code=201) async def create_material( - payload: MaterialCreate, expand: str | None = None, db: AsyncSession = Depends(get_db) + payload: MaterialCreate, + expand: str | None = None, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): material = await material_service.create_material(db, payload) return {"data": await serialize_material(db, material, parse_expand(expand))} @@ -140,7 +145,12 @@ async def get_material(material_id, expand: str | None = None, db: AsyncSession @router.patch("/{material_id}") -async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession = Depends(get_db)): +async def update_material( + material_id, + payload: MaterialUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): material = await db.get(Material, material_id) if material is None: raise NotFoundError("متریال یافت نشد") @@ -149,7 +159,11 @@ async def update_material(material_id, payload: MaterialUpdate, db: AsyncSession @router.delete("/{material_id}") -async def delete_material(material_id, db: AsyncSession = Depends(get_db)): +async def delete_material( + material_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): material = await db.get(Material, material_id) if material is None: raise NotFoundError("متریال یافت نشد") @@ -248,7 +262,10 @@ async def list_material_units( @router.post("/{material_id}/units", status_code=201) async def create_material_unit( - material_id, payload: MaterialUnitCreate, db: AsyncSession = Depends(get_db) + material_id, + payload: MaterialUnitCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): await _material_or_404(db, material_id) @@ -287,7 +304,11 @@ async def create_material_unit( @router.patch("/{material_id}/units/{mu_id}") async def update_material_unit( - material_id, mu_id, payload: MaterialUnitUpdate, db: AsyncSession = Depends(get_db) + material_id, + mu_id, + payload: MaterialUnitUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): mu = await db.scalar( @@ -319,7 +340,12 @@ async def update_material_unit( @router.delete("/{material_id}/units/{mu_id}") -async def delete_material_unit(material_id, mu_id, db: AsyncSession = Depends(get_db)): +async def delete_material_unit( + material_id, + mu_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): mu = await db.scalar( select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id) ) @@ -365,7 +391,10 @@ async def get_material_attribute_values( @router.put("/{material_id}/attribute-values") async def put_material_attribute_values( - material_id, payload: MaterialAttributeValuesPut, db: AsyncSession = Depends(get_db) + material_id, + payload: MaterialAttributeValuesPut, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): """FULL replacement of a material's EAV values (empty array deletes all).""" async with db.begin(): diff --git a/backend/app/routers/projects.py b/backend/app/routers/projects.py index 2ebd785..7e6b69d 100644 --- a/backend/app/routers/projects.py +++ b/backend/app/routers/projects.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from app.auth import UserContext, get_optional_user +from app.auth import UserContext, get_optional_user, require_role from app.db.engine import get_db from app.models.procurement import Project, Quote, User from app.schemas.procurement import ProjectCreate, ProjectUpdate @@ -13,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_date, to_iso router = APIRouter(prefix="/projects", tags=["projects"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _optional_ctx(request: Request, db: AsyncSession = Depends(get_db)) -> UserContext | None: @@ -33,8 +34,12 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict: d = { "id": str(project.id), "name": project.name, - "description": project.description, + "location": project.location or "", + "project_manager": project.project_manager or "", + "description": project.description or "", "start_date": to_date(project.start_date), + "status": project.status or "ACTIVE", + "progress": float(project.progress) if project.progress is not None else 0.0, "pm_user_id": str(project.pm_user_id) if project.pm_user_id else None, "created_at": to_iso(project.created_at), } @@ -42,6 +47,8 @@ async def project_to_dict(db: AsyncSession, project: Project) -> dict: pm = await db.get(User, project.pm_user_id) if pm: d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id + elif project.project_manager: + d["pm_name"] = project.project_manager return d @@ -55,7 +62,14 @@ async def list_projects( ): stmt = select(Project) if q: - stmt = stmt.where(or_(Project.name.ilike(f"%{q}%"), Project.description.ilike(f"%{q}%"))) + stmt = stmt.where( + or_( + Project.name.ilike(f"%{q}%"), + Project.description.ilike(f"%{q}%"), + Project.location.ilike(f"%{q}%"), + Project.project_manager.ilike(f"%{q}%"), + ) + ) stmt = sort_query(stmt, Project, sort) items, meta = await paginate(stmt, page, page_size, db) pm_map = await _pm_map(db, items) @@ -64,24 +78,38 @@ async def list_projects( d = { "id": str(p.id), "name": p.name, - "description": p.description, + "location": p.location or "", + "project_manager": p.project_manager or "", + "description": p.description or "", "start_date": to_date(p.start_date), + "status": p.status or "ACTIVE", + "progress": float(p.progress) if p.progress is not None else 0.0, "pm_user_id": str(p.pm_user_id) if p.pm_user_id else None, "created_at": to_iso(p.created_at), } pm = pm_map.get(p.pm_user_id) if pm: d["pm_name"] = f"{pm.first_name} {pm.last_name}".strip() or pm.casdoor_id + elif p.project_manager: + d["pm_name"] = p.project_manager data.append(d) return {"data": data, "meta": meta} @router.post("", status_code=201) -async def create_project(payload: ProjectCreate, db: AsyncSession = Depends(get_db)): +async def create_project( + payload: ProjectCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): project = Project( - name=payload.name, - description=payload.description, + name=payload.name.strip(), + location=payload.location.strip() if payload.location else "", + project_manager=payload.project_manager.strip() if payload.project_manager else "", + description=payload.description.strip() if payload.description else "", start_date=payload.start_date, + planned_start=payload.start_date, + status="ACTIVE", ) db.add(project) await db.flush() @@ -103,37 +131,51 @@ async def update_project( request: Request, ctx: UserContext | None = Depends(_optional_ctx), db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): project = await db.get(Project, project_id) if project is None: raise NotFoundError("پروژه یافت نشد") if payload.name is not None: - project.name = payload.name + project.name = payload.name.strip() + if payload.location is not None: + project.location = payload.location.strip() + if payload.project_manager is not None: + project.project_manager = payload.project_manager.strip() if payload.description is not None: - project.description = payload.description + project.description = payload.description.strip() if payload.start_date is not None: project.start_date = payload.start_date + project.planned_start = payload.start_date + if payload.status is not None: + project.status = payload.status # PM assignment (1:N): one PM per project, a PM may own many projects. # Admin / procurement manager only (same rule as the Organization panel). if "pm_user_id" in payload.model_fields_set: role = ctx.role if ctx else (request.headers.get("x-user-role") or "").strip().lower() - if role not in ("admin", "procurement_manager"): - raise ForbiddenError("تعیین مدیر پروژه فقط برای ادمین / مدیر تدارکات مجاز است") + if role not in ("admin", "procurement_manager", "pm"): + raise ForbiddenError("تعیین سرپرست پروژه فقط برای مدیر تدارکات، مدیر پروژه و ادمین مجاز است") if payload.pm_user_id is None: project.pm_user_id = None else: pm_user = await db.get(User, payload.pm_user_id) if pm_user is None: raise NotFoundError("کاربر یافت نشد", field="pm_user_id") - if pm_user.role != "pm": - raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="pm_user_id") + if pm_user.role not in ("supervisor", "pm"): + raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="pm_user_id") project.pm_user_id = pm_user.id + if not project.project_manager: + project.project_manager = f"{pm_user.first_name} {pm_user.last_name}".strip() await db.flush() return {"data": await project_to_dict(db, project)} @router.delete("/{project_id}") -async def delete_project(project_id, db: AsyncSession = Depends(get_db)): +async def delete_project( + project_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): project = await db.get(Project, project_id) if project is None: raise NotFoundError("پروژه یافت نشد") diff --git a/backend/app/routers/quote_events.py b/backend/app/routers/quote_events.py index 1f91c41..962bb0b 100644 --- a/backend/app/routers/quote_events.py +++ b/backend/app/routers/quote_events.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import Quote, QuoteEvent from app.schemas.procurement import NoteEventIn @@ -11,6 +12,7 @@ from app.utils.errors import NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/events", tags=["quote-events"]) +_procurement_writer = require_role("admin", "procurement_manager") @router.get("") @@ -34,7 +36,12 @@ async def list_events( @router.post("", status_code=201) -async def create_event(quote_id, payload: NoteEventIn, db: AsyncSession = Depends(get_db)): +async def create_event( + quote_id, + payload: NoteEventIn, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): quote = await db.get(Quote, quote_id) if quote is None: raise NotFoundError("پیشنهاد (استعلام) یافت نشد") diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py index 489921c..2849be3 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import exists, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Material from app.models.procurement import ( @@ -42,6 +43,9 @@ from app.utils.security import get_actor router = APIRouter(prefix="/quotes", tags=["quotes"]) +# عملیات نوشتنی تدارکات — فقط ادمین و مدیر تدارکات (مدیر پروژه فقط خواندن دارد) +_procurement_writer = require_role("admin", "procurement_manager") + # --- list filter helpers --------------------------------------------------- @@ -156,7 +160,12 @@ async def get_quote(quote_id, expand: str | None = None, db: AsyncSession = Depe # --------------------------------------------------------------------------- @router.post("", status_code=201) -async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): +async def create_quote( + payload: QuoteCreate, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), +): quote = await quote_service.create_quote(db, payload, actor) return {"data": await serialize_quote_with_children(db, quote)} @@ -167,7 +176,11 @@ async def create_quote(payload: QuoteCreate, db: AsyncSession = Depends(get_db), @router.patch("/{quote_id}") async def update_quote( - quote_id, payload: QuoteUpdate, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: QuoteUpdate, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.update_quote(db, quote_id, payload, actor) return {"data": await serialize_quote(db, quote, set())} @@ -175,7 +188,11 @@ async def update_quote( @router.patch("/{quote_id}/children") async def replace_quote_children( - quote_id, payload: QuoteChildrenReplaceIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: QuoteChildrenReplaceIn, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): """Full replacement of a quote's line items + termsheets (edit modal).""" quote = await quote_service.replace_quote_children(db, quote_id, payload, actor) @@ -188,7 +205,11 @@ async def replace_quote_children( @router.patch("/{quote_id}/accept") async def accept_quote( - quote_id, payload: AcceptQuoteIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: AcceptQuoteIn, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.accept_quote(db, quote_id, payload, actor) return {"data": await _accept_response(db, quote)} @@ -224,7 +245,12 @@ async def _accept_response(db: AsyncSession, quote: Quote) -> dict: @router.patch("/{quote_id}/arrive") -async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): +async def arrive_quote( + quote_id, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), +): quote = await quote_service.arrive_quote(db, quote_id, actor) return { "data": { @@ -237,7 +263,11 @@ async def arrive_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str @router.patch("/{quote_id}/arrive-items") async def arrive_items( - quote_id, payload: ArriveItemsIn, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: ArriveItemsIn, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.arrive_items(db, quote_id, payload, actor) return { @@ -259,14 +289,23 @@ async def arrive_items( @router.patch("/{quote_id}/reject") async def reject_quote( - quote_id, payload: RejectQuoteIn | None = None, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor) + quote_id, + payload: RejectQuoteIn | None = None, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), ): quote = await quote_service.reject_quote(db, quote_id, payload or RejectQuoteIn(), actor) return {"data": {"id": str(quote.id), "status": quote.status}} @router.patch("/{quote_id}/expire") -async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str = Depends(get_actor)): +async def expire_quote( + quote_id, + db: AsyncSession = Depends(get_db), + actor: str = Depends(get_actor), + _auth=Depends(_procurement_writer), +): quote = await quote_service.expire_quote(db, quote_id, actor) return {"data": {"id": str(quote.id), "status": quote.status}} @@ -276,6 +315,10 @@ async def expire_quote(quote_id, db: AsyncSession = Depends(get_db), actor: str # --------------------------------------------------------------------------- @router.delete("/{quote_id}") -async def delete_quote(quote_id, db: AsyncSession = Depends(get_db)): +async def delete_quote( + quote_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): result = await quote_service.delete_quote(db, quote_id) return {"data": result} diff --git a/backend/app/routers/schedule_steps.py b/backend/app/routers/schedule_steps.py index fc265e7..d2c03d4 100644 --- a/backend/app/routers/schedule_steps.py +++ b/backend/app/routers/schedule_steps.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import Termsheet, TermsheetScheduleStep from app.schemas.procurement import ScheduleStepCreate, ScheduleStepUpdate @@ -15,6 +16,7 @@ router = APIRouter( prefix="/quotes/{quote_id}/termsheets/{termsheet_id}/schedule-steps", tags=["schedule-steps"], ) +_procurement_writer = require_role("admin", "procurement_manager") async def _termsheet_or_404(db: AsyncSession, quote_id, termsheet_id) -> Termsheet: @@ -57,7 +59,11 @@ async def list_schedule_steps( @router.post("", status_code=201) async def create_schedule_step( - quote_id, termsheet_id, payload: ScheduleStepCreate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + payload: ScheduleStepCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) step_no = payload.step_no @@ -84,7 +90,12 @@ async def create_schedule_step( @router.patch("/{step_id}") async def update_schedule_step( - quote_id, termsheet_id, step_id, payload: ScheduleStepUpdate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + step_id, + payload: ScheduleStepUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): step = await _step_or_404(db, termsheet_id, step_id) if payload.step_no is not None and payload.step_no != step.step_no: @@ -109,7 +120,13 @@ async def update_schedule_step( @router.delete("/{step_id}") -async def delete_schedule_step(quote_id, termsheet_id, step_id, db: AsyncSession = Depends(get_db)): +async def delete_schedule_step( + quote_id, + termsheet_id, + step_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): step = await _step_or_404(db, termsheet_id, step_id) count = await db.scalar( select(func.count()).select_from(TermsheetScheduleStep).where( diff --git a/backend/app/routers/standard_conversions.py b/backend/app/routers/standard_conversions.py index b63434a..6469399 100644 --- a/backend/app/routers/standard_conversions.py +++ b/backend/app/routers/standard_conversions.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import StandardConversion from app.schemas.catalog import StandardConversionCreate, StandardConversionUpdate @@ -11,6 +12,7 @@ from app.utils.pagination import paginate from app.utils.serialization import to_iso router = APIRouter(prefix="/standard-conversions", tags=["standard-conversions"]) +_procurement_writer = require_role("admin", "procurement_manager") def sc_to_dict(sc: StandardConversion) -> dict: @@ -41,7 +43,11 @@ async def list_standard_conversions( @router.post("", status_code=201) -async def create_standard_conversion(payload: StandardConversionCreate, db: AsyncSession = Depends(get_db)): +async def create_standard_conversion( + payload: StandardConversionCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar( select(StandardConversion.id).where( StandardConversion.from_unit == payload.from_unit, @@ -67,7 +73,12 @@ async def get_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)): @router.patch("/{sc_id}") -async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, db: AsyncSession = Depends(get_db)): +async def update_standard_conversion( + sc_id, + payload: StandardConversionUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): sc = await db.get(StandardConversion, sc_id) if sc is None: raise NotFoundError("تبدیل استاندارد یافت نشد") @@ -94,7 +105,11 @@ async def update_standard_conversion(sc_id, payload: StandardConversionUpdate, d @router.delete("/{sc_id}") -async def delete_standard_conversion(sc_id, db: AsyncSession = Depends(get_db)): +async def delete_standard_conversion( + sc_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): sc = await db.get(StandardConversion, sc_id) if sc is None: raise NotFoundError("تبدیل استاندارد یافت نشد") diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py index 26e4026..02d5a55 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import Material, MaterialCategory from app.models.procurement import Supplier, SupplierMaterial @@ -18,6 +19,7 @@ from app.utils.pagination import paginate from app.utils.serialization import to_iso router = APIRouter(tags=["supplier-materials"]) +_procurement_writer = require_role("admin", "procurement_manager") def link_to_dict(link: SupplierMaterial, material: Material | None = None, @@ -63,7 +65,10 @@ async def list_supplier_materials( @router.post("/suppliers/{supplier_id}/materials", status_code=201) async def create_supplier_material( - supplier_id, payload: SupplierMaterialCreate, db: AsyncSession = Depends(get_db) + supplier_id, + payload: SupplierMaterialCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): link = await link_supplier_material(db, supplier_id, payload.material_id) @@ -72,7 +77,12 @@ async def create_supplier_material( @router.delete("/suppliers/{supplier_id}/materials/{material_id}") -async def delete_supplier_material(supplier_id, material_id, db: AsyncSession = Depends(get_db)): +async def delete_supplier_material( + supplier_id, + material_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): async with db.begin(): await unlink_supplier_material(db, supplier_id, material_id) return {"data": {"supplier_id": str(supplier_id), "material_id": str(material_id), "deleted": True}} diff --git a/backend/app/routers/suppliers.py b/backend/app/routers/suppliers.py index 3ab5175..070a9e6 100644 --- a/backend/app/routers/suppliers.py +++ b/backend/app/routers/suppliers.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import MaterialCategory from app.models.procurement import Quote, Supplier @@ -12,6 +13,7 @@ from app.utils.pagination import paginate, sort_query from app.utils.serialization import to_iso router = APIRouter(prefix="/suppliers", tags=["suppliers"]) +_procurement_writer = require_role("admin", "procurement_manager") def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict: @@ -71,7 +73,11 @@ async def list_suppliers( @router.post("", status_code=201) -async def create_supplier(payload: SupplierCreate, db: AsyncSession = Depends(get_db)): +async def create_supplier( + payload: SupplierCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): category = await _category_or_404(db, payload.category_id) supplier = Supplier( name=payload.name, @@ -96,7 +102,12 @@ async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)): @router.patch("/{supplier_id}") -async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession = Depends(get_db)): +async def update_supplier( + supplier_id, + payload: SupplierUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): supplier = await db.get(Supplier, supplier_id) if supplier is None: raise NotFoundError("تأمین‌کننده یافت نشد") @@ -120,7 +131,11 @@ async def update_supplier(supplier_id, payload: SupplierUpdate, db: AsyncSession @router.delete("/{supplier_id}") -async def delete_supplier(supplier_id, db: AsyncSession = Depends(get_db)): +async def delete_supplier( + supplier_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): supplier = await db.get(Supplier, supplier_id) if supplier is None: raise NotFoundError("تأمین‌کننده یافت نشد") diff --git a/backend/app/routers/termsheets.py b/backend/app/routers/termsheets.py index 16c8f98..b1d5ffd 100644 --- a/backend/app/routers/termsheets.py +++ b/backend/app/routers/termsheets.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.procurement import ( ItemDecision, @@ -26,6 +27,7 @@ from app.utils.errors import ConflictError, NotFoundError, ValidationError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/quotes/{quote_id}/termsheets", tags=["termsheets"]) +_procurement_writer = require_role("admin", "procurement_manager") async def _quote_or_404(db: AsyncSession, quote_id) -> Quote: @@ -80,7 +82,12 @@ async def list_termsheets( @router.post("", status_code=201) -async def create_termsheet(quote_id, payload: TermsheetCreate, db: AsyncSession = Depends(get_db)): +async def create_termsheet( + quote_id, + payload: TermsheetCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): async with db.begin(): quote = await _quote_or_404(db, quote_id) if quote.status != "offered": @@ -110,7 +117,11 @@ async def get_termsheet( @router.patch("/{termsheet_id}") async def update_termsheet( - quote_id, termsheet_id, payload: TermsheetUpdate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + payload: TermsheetUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): ts = await _termsheet_or_404(db, quote_id, termsheet_id) if ts.status != "offered": @@ -127,7 +138,11 @@ async def update_termsheet( @router.patch("/{termsheet_id}/status") async def update_termsheet_status( - quote_id, termsheet_id, payload: TermsheetStatusUpdate, db: AsyncSession = Depends(get_db) + quote_id, + termsheet_id, + payload: TermsheetStatusUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), ): async with db.begin(): ts = await _termsheet_or_404(db, quote_id, termsheet_id) @@ -139,7 +154,12 @@ async def update_termsheet_status( @router.delete("/{termsheet_id}") -async def delete_termsheet(quote_id, termsheet_id, db: AsyncSession = Depends(get_db)): +async def delete_termsheet( + quote_id, + termsheet_id, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): ts = await _termsheet_or_404(db, quote_id, termsheet_id) quote = await _quote_or_404(db, quote_id) if quote.status != "offered": diff --git a/backend/app/routers/units.py b/backend/app/routers/units.py index 7152ecd..5004f7a 100644 --- a/backend/app/routers/units.py +++ b/backend/app/routers/units.py @@ -3,6 +3,7 @@ from fastapi import APIRouter, Depends, Query from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from app.auth import require_role from app.db.engine import get_db from app.models.catalog import AttributeDefinition, Material, MaterialUnit, Unit from app.models.procurement import LineItem, LineItemSpec @@ -11,6 +12,7 @@ from app.utils.errors import ConflictError, NotFoundError from app.utils.pagination import paginate, sort_query router = APIRouter(prefix="/units", tags=["units"]) +_procurement_writer = require_role("admin", "procurement_manager") def unit_full_to_dict(unit: Unit) -> dict: @@ -45,7 +47,11 @@ async def list_units( @router.post("", status_code=201) -async def create_unit(payload: UnitCreate, db: AsyncSession = Depends(get_db)): +async def create_unit( + payload: UnitCreate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): duplicate = await db.scalar(select(Unit.id).where(Unit.code == payload.code)) if duplicate is not None: raise ConflictError("واحدی با این کد قبلاً ثبت شده است", field="code") @@ -70,7 +76,12 @@ async def get_unit(unit_id: int, db: AsyncSession = Depends(get_db)): @router.patch("/{unit_id}") -async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depends(get_db)): +async def update_unit( + unit_id: int, + payload: UnitUpdate, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): unit = await db.get(Unit, unit_id) if unit is None: raise NotFoundError("واحد یافت نشد") @@ -92,7 +103,11 @@ async def update_unit(unit_id: int, payload: UnitUpdate, db: AsyncSession = Depe @router.delete("/{unit_id}") -async def delete_unit(unit_id: int, db: AsyncSession = Depends(get_db)): +async def delete_unit( + unit_id: int, + db: AsyncSession = Depends(get_db), + _auth=Depends(_procurement_writer), +): unit = await db.get(Unit, unit_id) if unit is None: raise NotFoundError("واحد یافت نشد") diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 702484d..ecd0eef 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -17,7 +17,7 @@ import uuid import httpx from fastapi import APIRouter, Depends -from sqlalchemy import select, update +from sqlalchemy import select, update, text from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_current_user, require_role @@ -41,6 +41,8 @@ USER_ROLE_LABELS = { "pm": "مدیر پروژه", "procurement_manager": "مدیر تدارکات", "admin": "ادمین", + # کاربر فقطِ پنل مدیریت پروژه (PM) — به انبار/تدارکات دسترسی ندارد + "supervisor": "سرپرست پروژه", } @@ -52,6 +54,7 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: return { "id": str(user.id), "casdoor_id": user.casdoor_id, + "username": user.username, "first_name": user.first_name, "last_name": user.last_name, "phone": user.phone, @@ -65,15 +68,15 @@ def user_to_dict(user: User, projects: list[Project] | None = None) -> dict: async def _org_manager(ctx: UserContext = Depends(get_current_user)) -> UserContext: - """Admin OR procurement manager (the same person) — organization management.""" - if ctx.role not in ("admin", "procurement_manager"): + """Admin, procurement manager, or project manager — organization management.""" + if ctx.role not in ("admin", "procurement_manager", "pm"): raise ForbiddenError("دسترسی به این بخش برای نقش شما مجاز نیست") return ctx async def _user_projects(db: AsyncSession, user: User) -> list[Project]: - """Every project the user manages as PM (oldest first; [] for non-PMs).""" - if user.role != "pm": + """Every project assigned to the user (supervisor or PM) (oldest first).""" + if user.role not in ("supervisor", "pm"): return [] rows = ( await db.execute( @@ -148,13 +151,13 @@ async def list_users( ): """Every user (with role + assigned projects) — Organization panel.""" users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() - pm_ids = {u.id for u in users if u.role == "pm"} + supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} by_user: dict = {} - if pm_ids: + if supervisor_ids: projs = ( await db.execute( select(Project) - .where(Project.pm_user_id.in_(pm_ids)) + .where(Project.pm_user_id.in_(supervisor_ids)) .order_by(Project.created_at, Project.id) ) ).scalars().all() @@ -204,7 +207,7 @@ async def update_user_role( if user is None: raise NotFoundError("کاربر یافت نشد") previous_role = user.role - if user.role == "pm" and payload.role != "pm": + if user.role in ("supervisor", "pm") and payload.role not in ("supervisor", "pm"): await _unassign_projects(db, user.id) user.role = payload.role await db.flush() @@ -231,8 +234,15 @@ async def sync_casdoor_users( continue existing = await db.scalar(select(User).where(User.casdoor_id == cid)) if existing is None: + uname = str(cu.get("name") or cu.get("preferred_username") or "") or None + if uname: + by_uname = await db.scalar(select(User).where(User.username == uname)) + if by_uname is not None: + by_uname.casdoor_id = cid + continue db.add(User( casdoor_id=cid, + username=uname, first_name=str( cu.get("firstName") or cu.get("displayName") @@ -243,16 +253,20 @@ async def sync_casdoor_users( phone=str(cu.get("phone") or "") or None, )) created += 1 + elif not existing.username: + uname = str(cu.get("name") or cu.get("preferred_username") or "") or None + if uname: + existing.username = uname await db.flush() users = (await db.execute(select(User).order_by(User.created_at))).scalars().all() - pm_ids = {u.id for u in users if u.role == "pm"} + supervisor_ids = {u.id for u in users if u.role in ("supervisor", "pm")} by_user: dict = {} - if pm_ids: + if supervisor_ids: projs = ( await db.execute( select(Project) - .where(Project.pm_user_id.in_(pm_ids)) + .where(Project.pm_user_id.in_(supervisor_ids)) .order_by(Project.created_at, Project.id) ) ).scalars().all() @@ -282,11 +296,16 @@ async def assign_user_project( user = await db.get(User, user_id) if user is None: raise NotFoundError("کاربر یافت نشد") - if user.role != "pm": - raise ValidationError("ابتدا نقش این کاربر را «مدیر پروژه» کنید", field="role") + if user.role not in ("supervisor", "pm"): + raise ValidationError("ابتدا نقش این کاربر را «سرپرست پروژه» کنید", field="role") if payload.project_id is None: await _unassign_projects(db, user.id) + if user.username: + await db.execute( + text("DELETE FROM project_assignments WHERE user_id IN (SELECT id FROM pm_users WHERE username = :uname)"), + {"uname": user.username} + ) await db.flush() return {"data": user_to_dict(user)} @@ -294,5 +313,19 @@ async def assign_user_project( if project is None: raise NotFoundError("پروژه یافت نشد", field="project_id") project.pm_user_id = user.id + if user.username: + await db.execute( + text(""" + INSERT INTO project_assignments (id, user_id, project_id) + SELECT gen_random_uuid(), pu.id, :pid + FROM pm_users pu + WHERE pu.username = :uname + AND NOT EXISTS ( + SELECT 1 FROM project_assignments pa + WHERE pa.user_id = pu.id AND pa.project_id = :pid + ) + """), + {"pid": project.id, "uname": user.username} + ) await db.flush() return {"data": user_to_dict(user, await _user_projects(db, user))} diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index 372131b..9bc549a 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -44,14 +44,19 @@ class SupplierMaterialCreate(APIModel): class ProjectCreate(APIModel): name: str = Field(min_length=1) + location: str | None = None + project_manager: str | None = None description: str | None = None start_date: date | None = None class ProjectUpdate(APIModel): name: str | None = Field(default=None, min_length=1) + location: str | None = None + project_manager: str | None = None description: str | None = None start_date: date | None = None + status: str | None = None # PM assignment (1:N — one PM per project, a PM may own many projects). # Admin / procurement manager only; None unassigns this project's PM. pm_user_id: UUID | None = None @@ -209,13 +214,13 @@ class UserCreate(APIModel): last_name: str = "" phone: str | None = None casdoor_id: str | None = None - role: Literal["pm", "procurement_manager", "admin"] | None = None + role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None class UserRoleUpdate(APIModel): """role=None revokes the role (and any project assignment).""" - role: Literal["pm", "procurement_manager", "admin"] | None = None + role: Literal["pm", "procurement_manager", "admin", "supervisor"] | None = None class UserProjectUpdate(APIModel): diff --git a/docker-compose.yml b/docker-compose.yml index 2c01cb0..4dc93b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -249,6 +249,9 @@ services: ports: - "8000:8000" + volumes: + - ./backend/app:/srv/backend/app + networks: - traefik_network - default diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9eb7f0..346568b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,7 +38,7 @@ const WarehousesPage = lazy(() => import("./pages/Inventory/WarehousesPage").then((m) => ({ default: m.WarehousesPage })), ); const OrganizationPage = lazy(() => - import("./pages/Inventory/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), + import("./pages/Organization/OrganizationPage").then((m) => ({ default: m.OrganizationPage })), ); const AdminPanelPage = lazy(() => import("./pages/Admin/AdminPanel").then((m) => ({ default: m.AdminPanel })), @@ -68,16 +68,101 @@ function RequireAuth({ children }: { children: React.ReactNode }) { const returnUrl = encodeURIComponent(location.pathname + location.search); return ; } + if (user.role === "supervisor") { + // اگر سرپرست پروژه‌ای نداشته باشد، دسترسی مسدود می‌شود + if (!user.projects || user.projects.length === 0) { + return ( +
+
+

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

+

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

+ +
+
+ ); + } + // سرپرست پروژه فقط به انبار و PM دسترسی دارد + if (!location.pathname.startsWith("/inventory") && location.pathname !== "/panel/pm") { + return ; + } + } + } return <>{children}; } -// صفحه‌ی اصلی بر اساس نقش: مدیر پروژه → پنل انبار؛ مدیر تدارکات/ادمین → دفتر +// صفحه‌ی اصلی بر اساس نقش: سرپرست پروژه → پنل مدیریت پروژه (PM)؛ مدیران و ادمین → دفتر تدارکات function HomeRoute() { const user = useAuthStore((s) => s.user); - if (user?.role === "pm") return ; + if (user?.role === "supervisor") 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 ( +