feat(inventory): limit supervisor access to own project inventory and remove rectangular change inventory icon
This commit is contained in:
@@ -58,6 +58,7 @@ class Scope:
|
|||||||
role: str
|
role: str
|
||||||
project_id: uuid.UUID | None
|
project_id: uuid.UUID | None
|
||||||
actor: str | None
|
actor: str | None
|
||||||
|
project_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
async def inventory_scope(
|
async def inventory_scope(
|
||||||
@@ -79,7 +80,7 @@ async def inventory_scope(
|
|||||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||||
# مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
|
# مدیر تدارکات و مدیر پروژه: دسترسی کامل به پنل انبار، اما فقطخواندنی در تغییر موجودی
|
||||||
if user.role in ("procurement_manager", "pm"):
|
if user.role in ("procurement_manager", "pm"):
|
||||||
return Scope(role="manager", project_id=project_id, actor=user.display_name)
|
return Scope(role="manager", project_id=project_id, actor=user.display_name, project_ids=list(user.project_ids or []))
|
||||||
if user.role == "supervisor":
|
if user.role == "supervisor":
|
||||||
allowed = list(user.project_ids or [])
|
allowed = list(user.project_ids or [])
|
||||||
if not allowed:
|
if not allowed:
|
||||||
@@ -87,8 +88,8 @@ async def inventory_scope(
|
|||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
if project_id not in allowed:
|
if project_id not in allowed:
|
||||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return Scope(role="supervisor", project_id=project_id, actor=user.display_name)
|
return Scope(role="supervisor", project_id=project_id, actor=user.display_name, project_ids=allowed)
|
||||||
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name)
|
return Scope(role="supervisor", project_id=allowed[0], actor=user.display_name, project_ids=allowed)
|
||||||
if user.role == "admin":
|
if user.role == "admin":
|
||||||
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
return Scope(role="admin", project_id=project_id, actor=user.display_name)
|
||||||
raise ValidationError("نقش نامعتبر است", field="role")
|
raise ValidationError("نقش نامعتبر است", field="role")
|
||||||
@@ -214,7 +215,15 @@ async def list_warehouses(
|
|||||||
):
|
):
|
||||||
stmt = select(Warehouse)
|
stmt = select(Warehouse)
|
||||||
if scope.role in (ROLE_PM, "supervisor"):
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id) # force PM / supervisor scope
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
stmt = stmt.order_by(Warehouse.name)
|
stmt = stmt.order_by(Warehouse.name)
|
||||||
@@ -246,9 +255,11 @@ async def create_warehouse(
|
|||||||
scope: Scope = Depends(inventory_scope),
|
scope: Scope = Depends(inventory_scope),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# PM: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
# PM / سرپرست: فقط پروژه خودش — Admin: ساختار را مدیریت میکند (هر پروژه)
|
||||||
if scope.role == ROLE_PM and payload.project_id != scope.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
raise ValidationError("PM فقط میتواند برای پروژه خودش انبار بسازد", field="project_id")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if payload.project_id not in allowed:
|
||||||
|
raise ValidationError("شما فقط میتوانید برای پروژه تخصیصیافته به خود انبار بسازید", field="project_id")
|
||||||
if await db.get(Project, payload.project_id) is None:
|
if await db.get(Project, payload.project_id) is None:
|
||||||
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
raise NotFoundError("پروژه یافت نشد", field="project_id")
|
||||||
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
wh = Warehouse(project_id=payload.project_id, name=payload.name, description=payload.description)
|
||||||
@@ -265,8 +276,9 @@ async def update_warehouse(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
if scope.project_id != wh.project_id:
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
if payload.name is not None:
|
if payload.name is not None:
|
||||||
wh.name = payload.name
|
wh.name = payload.name
|
||||||
@@ -290,8 +302,10 @@ async def delete_warehouse(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM and scope.project_id != wh.project_id:
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
# inventory rows cascade via DDL; transactions are RESTRICTed through
|
||||||
# inventory cascade — block deletion if any stock history exists.
|
# inventory cascade — block deletion if any stock history exists.
|
||||||
linked = await db.scalar(
|
linked = await db.scalar(
|
||||||
@@ -322,9 +336,21 @@ async def list_inventory(
|
|||||||
):
|
):
|
||||||
stmt = select(InventoryItem)
|
stmt = select(InventoryItem)
|
||||||
if scope.role in (ROLE_PM, "supervisor"):
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
Warehouse.project_id == scope.project_id
|
if project_id is not None:
|
||||||
)
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id == project_id
|
||||||
|
)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id.in_(allowed)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
|
Warehouse.project_id == scope.project_id
|
||||||
|
)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
|
||||||
Warehouse.project_id == project_id
|
Warehouse.project_id == project_id
|
||||||
@@ -387,7 +413,15 @@ async def list_transactions(
|
|||||||
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
InventoryItem, InventoryItem.id == InventoryTransaction.inventory_id
|
||||||
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
).join(Warehouse, Warehouse.id == InventoryItem.warehouse_id)
|
||||||
if scope.role in (ROLE_PM, "supervisor"):
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if project_id is not None:
|
||||||
|
if project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
|
elif allowed:
|
||||||
|
stmt = stmt.where(Warehouse.project_id.in_(allowed))
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(Warehouse.project_id == scope.project_id)
|
||||||
elif project_id is not None:
|
elif project_id is not None:
|
||||||
stmt = stmt.where(Warehouse.project_id == project_id)
|
stmt = stmt.where(Warehouse.project_id == project_id)
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
@@ -452,8 +486,10 @@ async def inventory_history(
|
|||||||
if item is None:
|
if item is None:
|
||||||
raise NotFoundError("موجودی یافت نشد")
|
raise NotFoundError("موجودی یافت نشد")
|
||||||
wh = await get_warehouse_or_404(db, item.warehouse_id)
|
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 in (ROLE_PM, "supervisor"):
|
||||||
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
|
if wh.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این موجودی متعلق به پروژه شما نیست")
|
||||||
txs = (await db.execute(
|
txs = (await db.execute(
|
||||||
select(InventoryTransaction)
|
select(InventoryTransaction)
|
||||||
.where(InventoryTransaction.inventory_id == inventory_id)
|
.where(InventoryTransaction.inventory_id == inventory_id)
|
||||||
@@ -478,12 +514,14 @@ async def create_transaction(
|
|||||||
# commits on success / rolls back on failure.
|
# commits on success / rolls back on failure.
|
||||||
with nullcontext():
|
with nullcontext():
|
||||||
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
wh = await get_warehouse_or_404(db, payload.warehouse_id)
|
||||||
if scope.project_id is not None and wh.project_id != scope.project_id:
|
allowed = scope.project_ids or ([scope.project_id] if scope.project_id else [])
|
||||||
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
if scope.role in (ROLE_PM, "supervisor"):
|
||||||
if payload.dest_warehouse_id is not None:
|
if wh.project_id not in allowed:
|
||||||
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
|
||||||
if scope.project_id is not None and dest.project_id != scope.project_id:
|
if payload.dest_warehouse_id is not None:
|
||||||
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
dest = await get_warehouse_or_404(db, payload.dest_warehouse_id)
|
||||||
|
if dest.project_id not in allowed:
|
||||||
|
raise ForbiddenError("انبار مقصد متعلق به پروژه شما نیست")
|
||||||
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
txs = await apply_transaction(db, payload, performed_by=scope.actor)
|
||||||
await audit_record(
|
await audit_record(
|
||||||
db,
|
db,
|
||||||
|
|||||||
+39
-18
@@ -71,16 +71,17 @@ class ArrivalScope:
|
|||||||
user_id: uuid.UUID | None
|
user_id: uuid.UUID | None
|
||||||
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
project_id: uuid.UUID | None # PM's active project; admin: optional filter
|
||||||
display_name: str
|
display_name: str
|
||||||
|
project_ids: list[uuid.UUID] | None = None
|
||||||
|
|
||||||
|
|
||||||
async def arrivals_scope(
|
async def arrivals_scope(
|
||||||
request: Request,
|
request: Request,
|
||||||
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM: الزامی برای دادههای پروژه)"),
|
project_id: uuid.UUID | None = Query(default=None, description="پروژه فعال (PM / سرپرست: دادههای پروژه)"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> ArrivalScope:
|
) -> ArrivalScope:
|
||||||
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
"""Resolve the arrivals caller — Casdoor JWT first, dev headers as fallback.
|
||||||
|
|
||||||
PM → scoped to the requested project, which must be one they manage
|
PM / supervisor → scoped to the requested project, which must be one they manage
|
||||||
(projects.pm_user_id, 1:N); without the param, their first project.
|
(projects.pm_user_id, 1:N); without the param, their first project.
|
||||||
Admin / procurement manager → all projects (param = optional filter).
|
Admin / procurement manager → all projects (param = optional filter).
|
||||||
"""
|
"""
|
||||||
@@ -89,19 +90,19 @@ async def arrivals_scope(
|
|||||||
if ctx is not None:
|
if ctx is not None:
|
||||||
if ctx.role is None:
|
if ctx.role is None:
|
||||||
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
raise ForbiddenError("نقش شما هنوز در سیستم تعیین نشده است")
|
||||||
if ctx.role == "pm":
|
if ctx.role in ("pm", "supervisor"):
|
||||||
allowed = list(ctx.project_ids or [])
|
allowed = list(ctx.project_ids or [])
|
||||||
if project_id is not None:
|
if project_id is not None:
|
||||||
if project_id not in allowed:
|
if project_id not in allowed:
|
||||||
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name, project_ids=allowed)
|
||||||
if not allowed:
|
if not allowed:
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name)
|
return ArrivalScope(role="pm", user_id=ctx.user.id, project_id=allowed[0], display_name=ctx.display_name, project_ids=allowed)
|
||||||
if ctx.role in ("admin", "procurement_manager"):
|
if ctx.role in ("admin", "procurement_manager"):
|
||||||
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
return ArrivalScope(role="admin", user_id=ctx.user.id, project_id=project_id, display_name=ctx.display_name)
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است")
|
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است")
|
||||||
# Dev fallback: X-User-Role: pm | admin (procurement_manager maps to
|
# Dev fallback: X-User-Role: pm | supervisor | admin (procurement_manager maps to
|
||||||
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
# admin) — ONLY when DEV_LOGIN_ENABLED (local testing without Casdoor).
|
||||||
# Disabled by default: an ungated header identity in production would
|
# Disabled by default: an ungated header identity in production would
|
||||||
# let anyone confirm receipts as PM.
|
# let anyone confirm receipts as PM.
|
||||||
@@ -110,8 +111,10 @@ async def arrivals_scope(
|
|||||||
role = (request.headers.get("x-user-role") or "").strip().lower()
|
role = (request.headers.get("x-user-role") or "").strip().lower()
|
||||||
if role == "procurement_manager":
|
if role == "procurement_manager":
|
||||||
role = "admin"
|
role = "admin"
|
||||||
|
elif role == "supervisor":
|
||||||
|
role = "pm"
|
||||||
if role not in ("pm", "admin"):
|
if role not in ("pm", "admin"):
|
||||||
raise ForbiddenError("این بخش فقط برای مدیر پروژه یا مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
raise ForbiddenError("این بخش فقط برای مدیر یا سرپرست پروژه و مدیر تدارکات است — هدر X-User-Role را بفرستید")
|
||||||
# The explicit query param wins; otherwise the dev header carries it.
|
# The explicit query param wins; otherwise the dev header carries it.
|
||||||
if project_id is None and role == "pm":
|
if project_id is None and role == "pm":
|
||||||
raw_project = request.headers.get("x-project-id")
|
raw_project = request.headers.get("x-project-id")
|
||||||
@@ -189,10 +192,17 @@ async def pm_on_the_way(
|
|||||||
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
.outerjoin(Project, Project.id == ItemDecision.project_id)
|
||||||
.where(ItemDecision.status == "chosen")
|
.where(ItemDecision.status == "chosen")
|
||||||
)
|
)
|
||||||
if ctx.role == "pm" and ctx.project_id is None:
|
if ctx.role == "pm":
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
if ctx.project_id is not None:
|
if not allowed:
|
||||||
# PM: active project; admin/manager: optional project filter
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
if ctx.project_id is not None:
|
||||||
|
if ctx.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||||
|
elif ctx.project_id is not None:
|
||||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
decisions = (await db.execute(stmt.order_by(ItemDecision.decided_at.desc().nulls_last()))).all()
|
||||||
|
|
||||||
@@ -243,8 +253,12 @@ async def pm_confirm_receipt(
|
|||||||
decision = await db.get(ItemDecision, payload.item_decision_id)
|
decision = await db.get(ItemDecision, payload.item_decision_id)
|
||||||
if decision is None or decision.status != "chosen":
|
if decision is None or decision.status != "chosen":
|
||||||
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
raise NotFoundError("تصمیم خرید یافت نشد", field="item_decision_id")
|
||||||
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
if ctx.role == "pm":
|
||||||
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
|
if decision.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این قلم متعلق به پروژه شما نیست")
|
||||||
|
if ctx.project_id is not None and decision.project_id != ctx.project_id:
|
||||||
|
raise ForbiddenError("این قلم متعلق به پروژه فعال نیست")
|
||||||
|
|
||||||
li = await db.get(LineItem, decision.line_item_id)
|
li = await db.get(LineItem, decision.line_item_id)
|
||||||
quote = await db.get(Quote, li.quote_id)
|
quote = await db.get(Quote, li.quote_id)
|
||||||
@@ -451,10 +465,17 @@ async def pm_receipts(
|
|||||||
.join(Quote, Quote.id == LineItem.quote_id)
|
.join(Quote, Quote.id == LineItem.quote_id)
|
||||||
.join(Project, Project.id == ItemDecision.project_id)
|
.join(Project, Project.id == ItemDecision.project_id)
|
||||||
)
|
)
|
||||||
if ctx.role == "pm" and ctx.project_id is None:
|
if ctx.role == "pm":
|
||||||
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
allowed = ctx.project_ids or ([ctx.project_id] if ctx.project_id else [])
|
||||||
if ctx.project_id is not None:
|
if not allowed:
|
||||||
# PM: active project; admin/manager: optional project filter
|
raise ForbiddenError("شما به هیچ پروژهای متصل نیستید (projects.pm_user_id)")
|
||||||
|
if ctx.project_id is not None:
|
||||||
|
if ctx.project_id not in allowed:
|
||||||
|
raise ForbiddenError("این پروژه به شما تخصیص داده نشده است")
|
||||||
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(ItemDecision.project_id.in_(allowed))
|
||||||
|
elif ctx.project_id is not None:
|
||||||
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
stmt = stmt.where(ItemDecision.project_id == ctx.project_id)
|
||||||
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
rows = (await db.execute(stmt.order_by(ItemReceipt.received_at.desc()))).all()
|
||||||
receipts = [r.ItemReceipt for r in rows]
|
receipts = [r.ItemReceipt for r in rows]
|
||||||
|
|||||||
@@ -142,10 +142,11 @@ function RequireInventoryChange({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// دسترسی مدیریت انبارها: فقط مدیران و ادمین (سرپرست پروژه فقط میتواند موجودی تغییر دهد)
|
// دسترسی مدیریت انبارها: مدیران، ادمین و سرپرست پروژه (سرپرست پروژه منحصراً انبارهای پروژه خودش را میبیند)
|
||||||
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
|
function RequireWarehouseManage({ children }: { children: React.ReactNode }) {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
if (user?.role === "supervisor") {
|
const allowed = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || user?.role === "supervisor";
|
||||||
|
if (!allowed) {
|
||||||
return <Navigate to="/inventory" replace />;
|
return <Navigate to="/inventory" replace />;
|
||||||
}
|
}
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -82,16 +82,17 @@ export function MobileTabBar() {
|
|||||||
if (pathname === "/login" || pathname === "/admin") return null;
|
if (pathname === "/login" || pathname === "/admin") return null;
|
||||||
// پنل مدیریت پروژه: iframe تمامصفحهی PM است و خودش تببار داخلی دارد
|
// پنل مدیریت پروژه: iframe تمامصفحهی PM است و خودش تببار داخلی دارد
|
||||||
if (pathname === "/panel/pm") return null;
|
if (pathname === "/panel/pm") return null;
|
||||||
// سرپرست پروژه فقطِ پنل PM است و هیچ تب ERP ندارد
|
|
||||||
if (role === "supervisor") return null;
|
|
||||||
const isPM = role === "pm";
|
const isPM = role === "pm";
|
||||||
|
const isSupervisor = role === "supervisor";
|
||||||
const isOffice = role === "admin" || role === "procurement_manager";
|
const isOffice = role === "admin" || role === "procurement_manager";
|
||||||
|
|
||||||
if (pathname.startsWith("/inventory")) {
|
if (pathname.startsWith("/inventory")) {
|
||||||
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
|
if (isPM) return <TabBar tabs={PM_INV_TABS} />;
|
||||||
if (isOffice) return <TabBar tabs={ADMIN_INV_TABS} />;
|
if (isOffice || isSupervisor) return <TabBar tabs={ADMIN_INV_TABS} />;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// سرپرست پروژه به بخشهای تدارکات/دفتر دسترسی ندارد
|
||||||
|
if (isSupervisor) return null;
|
||||||
if (pathname === "/wizard") return null;
|
if (pathname === "/wizard") return null;
|
||||||
const onOfficePage =
|
const onOfficePage =
|
||||||
pathname === "/" ||
|
pathname === "/" ||
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export function ArrivalsPage() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
const isAdmin = user?.role === "admin" || user?.role === "procurement_manager";
|
||||||
const isPM = user?.role === "pm";
|
const isPM = user?.role === "pm";
|
||||||
|
const isSupervisor = user?.role === "supervisor";
|
||||||
|
const canAccess = isAdmin || isPM || isSupervisor;
|
||||||
|
|
||||||
const [items, setItems] = useState<PendingArrival[]>([]);
|
const [items, setItems] = useState<PendingArrival[]>([]);
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
|
||||||
@@ -33,9 +35,9 @@ export function ArrivalsPage() {
|
|||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const activeProject = isPM ? user?.project_id || undefined : undefined;
|
const activeProject = (isPM || isSupervisor) ? user?.project_id || undefined : undefined;
|
||||||
const [arr, whs] = await Promise.all([
|
const [arr, whs] = await Promise.all([
|
||||||
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM را بررسی میکند
|
// پروژهی فعال صریحاً ارسال میشود؛ بکاند تخصیص PM / سرپرست را بررسی میکند
|
||||||
apiV26.pm.onTheWay(activeProject),
|
apiV26.pm.onTheWay(activeProject),
|
||||||
apiV26.warehouses.list(activeProject),
|
apiV26.warehouses.list(activeProject),
|
||||||
]);
|
]);
|
||||||
@@ -46,13 +48,13 @@ export function ArrivalsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [isPM, user?.project_id]);
|
}, [isPM, isSupervisor, user?.project_id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin && !isPM) return;
|
if (!canAccess) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
void load();
|
void load();
|
||||||
}, [isAdmin, isPM, load]);
|
}, [canAccess, load]);
|
||||||
|
|
||||||
// سفارشها: گروهبندی اقلام بهازای هر پیشنهاد (فقط سفارشهای دارای قلمِ در راه/ناقص)
|
// سفارشها: گروهبندی اقلام بهازای هر پیشنهاد (فقط سفارشهای دارای قلمِ در راه/ناقص)
|
||||||
const orders = useMemo(() => {
|
const orders = useMemo(() => {
|
||||||
@@ -212,12 +214,12 @@ export function ArrivalsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isAdmin && !isPM) {
|
if (!canAccess) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
<Header title="کالاهای در راه" backTo="/inventory" backLabel="بازگشت به داشبورد" />
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<EmptyState emoji="🔒" text="این بخش فقط برای مدیر پروژه و مدیر تدارکات است." />
|
<EmptyState emoji="🔒" text="این بخش برای نقش کاربری شما مجاز نیست." />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -41,10 +41,12 @@ export function DailyControlPage() {
|
|||||||
|
|
||||||
const txByDay = useMemo(() => {
|
const txByDay = useMemo(() => {
|
||||||
const map = new Map<string, InventoryTransaction[]>();
|
const map = new Map<string, InventoryTransaction[]>();
|
||||||
|
const allowedWhIds = new Set(data.warehouses.map((w) => w.id));
|
||||||
const projWhIds = projFilter
|
const projWhIds = projFilter
|
||||||
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
|
? new Set(data.warehouses.filter((w) => String(w.project_id) === String(projFilter)).map((w) => w.id))
|
||||||
: null;
|
: null;
|
||||||
data.transactions.forEach((t) => {
|
data.transactions.forEach((t) => {
|
||||||
|
if (!isAdmin && !allowedWhIds.has(t.warehouse_id)) return;
|
||||||
if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
|
if (projWhIds && !projWhIds.has(t.warehouse_id)) return;
|
||||||
const k = gregKey(new Date(t.transaction_date));
|
const k = gregKey(new Date(t.transaction_date));
|
||||||
const arr = map.get(k) || [];
|
const arr = map.get(k) || [];
|
||||||
@@ -53,7 +55,7 @@ export function DailyControlPage() {
|
|||||||
});
|
});
|
||||||
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
|
map.forEach((arr) => arr.sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()));
|
||||||
return map;
|
return map;
|
||||||
}, [data.transactions, data.warehouses, projFilter]);
|
}, [data.transactions, data.warehouses, projFilter, isAdmin]);
|
||||||
|
|
||||||
const cells = useMemo(() => {
|
const cells = useMemo(() => {
|
||||||
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
const [ty, tm] = gregorianToJalali(anchor.getFullYear(), anchor.getMonth() + 1, anchor.getDate());
|
||||||
@@ -172,11 +174,15 @@ export function DailyControlPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<span className="font-semibold text-slate-800">پروژه فعال</span>
|
<span className="font-semibold text-slate-800">
|
||||||
|
{user?.project_name ? `پروژه: ${user.project_name}` : "پروژه فعال"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<span className="text-[11.5px] text-slate-400">
|
||||||
|
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-[11.5px] text-slate-400">
|
|
||||||
{projFilter ? "نمایش برای پروژه انتخابی" : "نمایش تمام پروژهها"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -12,17 +12,12 @@ export function InventoryTopNav({
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === "admin";
|
||||||
const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
|
const isManager = user?.role === "procurement_manager" || user?.role === "pm" || isAdmin;
|
||||||
|
const isSupervisor = user?.role === "supervisor";
|
||||||
|
const canViewTabs = isManager || isSupervisor;
|
||||||
|
|
||||||
const items: { label: string; to: string; active: boolean }[] = [];
|
const items: { label: string; to: string; active: boolean }[] = [];
|
||||||
// سرپرست پروژه از دکمه شناور گرد (+) در پایین صفحه استفاده میکند و دکمه مستطیلی بالای صفحه برای او نمایش داده نمیشود
|
// برای تغییر موجودی از همان دکمه شناور دایرهای آبی (+) استفاده میشود و دکمه مستطیلی در بالای صفحه حذف شد
|
||||||
if (isAdmin) {
|
if (canViewTabs) {
|
||||||
items.push({
|
|
||||||
label: "تغییر موجودی",
|
|
||||||
to: "/inventory/change",
|
|
||||||
active: pathname.startsWith("/inventory/change"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isManager) {
|
|
||||||
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
||||||
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
||||||
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export function WarehousesPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const data = useInventoryData();
|
const data = useInventoryData();
|
||||||
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm";
|
const isSupervisor = user?.role === "supervisor";
|
||||||
|
const canManage = user?.role === "admin" || user?.role === "procurement_manager" || user?.role === "pm" || isSupervisor;
|
||||||
|
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [newDesc, setNewDesc] = useState("");
|
const [newDesc, setNewDesc] = useState("");
|
||||||
@@ -32,7 +33,7 @@ export function WarehousesPage() {
|
|||||||
<div className="text-3xl">🔒</div>
|
<div className="text-3xl">🔒</div>
|
||||||
<h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3>
|
<h3 className="mt-2 text-[15px] font-bold">دسترسی محدود</h3>
|
||||||
<p className="mt-1 text-[12.5px] text-muted">
|
<p className="mt-1 text-[12.5px] text-muted">
|
||||||
مدیریت و تعریف انبارها مخصوص مدیران سیستم است. سرپرست پروژه فقط به ثبت تغییرات موجودی دسترسی دارد.
|
دسترسی به این بخش برای حساب کاربری شما فعال نیست.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@@ -43,7 +44,7 @@ export function WarehousesPage() {
|
|||||||
const addWarehouse = async () => {
|
const addWarehouse = async () => {
|
||||||
const name = newName.trim();
|
const name = newName.trim();
|
||||||
if (!name) return toast.error("نام انبار الزامی است");
|
if (!name) return toast.error("نام انبار الزامی است");
|
||||||
const projectId = selectedProjectId || user?.project_id || data.lookup.projects[0]?.id;
|
const projectId = isSupervisor ? user?.project_id : (selectedProjectId || user?.project_id || data.lookup.projects[0]?.id);
|
||||||
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
|
if (!projectId) return toast.error("پروژه انبار را انتخاب کنید");
|
||||||
try {
|
try {
|
||||||
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
|
await apiV26.warehouses.create({ project_id: projectId, name, description: newDesc.trim() || null });
|
||||||
@@ -92,7 +93,7 @@ export function WarehousesPage() {
|
|||||||
<div className="rounded-xl border border-line bg-panel p-3">
|
<div className="rounded-xl border border-line bg-panel p-3">
|
||||||
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
|
<h3 className="mb-2 text-[13px] font-bold">+ افزودن انبار جدید</h3>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{data.lookup.projects.length > 1 && (
|
{!isSupervisor && data.lookup.projects.length > 1 && (
|
||||||
<select
|
<select
|
||||||
className="h-9 rounded-lg border border-line bg-panel px-3 text-sm"
|
className="h-9 rounded-lg border border-line bg-panel px-3 text-sm"
|
||||||
value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""}
|
value={selectedProjectId || user?.project_id || data.lookup.projects[0]?.id || ""}
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ export function panelMenuItems(pathname: string, role: string | null | undefined
|
|||||||
if (isSupervisor) {
|
if (isSupervisor) {
|
||||||
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
|
items.push({ label: "تغییر موجودی", to: "/inventory/change", active: pathname.startsWith("/inventory/change") });
|
||||||
}
|
}
|
||||||
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران
|
// کنترل روزانه و انبارها و کالاهای در راه برای مدیران و سرپرست پروژه
|
||||||
if (isManager) {
|
if (isManager || isSupervisor) {
|
||||||
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
items.push({ label: "کنترل روزانه", to: "/inventory/daily", active: pathname.startsWith("/inventory/daily") });
|
||||||
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
items.push({ label: "انبارها", to: "/inventory/warehouses", active: pathname.startsWith("/inventory/warehouses") });
|
||||||
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
items.push({ label: "کالاهای در راه", to: "/inventory/arrivals", active: pathname.startsWith("/inventory/arrivals") });
|
||||||
|
|||||||
Reference in New Issue
Block a user