feat(org): panel-based access system replacing roles (procurement/inventory)

- new tables user_panels (panel: procurement/inventory/pm + level full/limited)
  and user_project_members (M:N membership, separate from pm responsibility)
- UserContext carries panels + member_project_ids; /users/me and the users
  list include them
- admin endpoints: PUT /users/{id}/panels, PUT /users/{id}/panel-projects
- inventory scope: panel inventory 'full' -> manager scope (all projects, NO
  stock change); 'limited' -> supervisor scope (own memberships, stock change
  allowed); legacy roles kept as migration fallback; admin untouched
- procurement writes: _procurement_writer is now panel-aware (admin, legacy
  procurement_manager, or procurement 'full') across all 18 write routers;
  quote CREATE additionally allows 'limited' users restricted to their member
  projects (validated server-side)
- frontend: panel-aware HomeRoute (inventory -> warehouse panel, procurement
  limited -> minimal RequesterHome), wizard project options restricted for
  limited users, change-inventory button/route gated to inventory 'limited',
  organization panel gains the panels & access-levels management section
This commit is contained in:
2026-09-29 13:37:59 +03:30
parent c7d860f431
commit 4a3c226518
35 changed files with 591 additions and 72 deletions
+41
View File
@@ -66,6 +66,47 @@ class User(Base):
created_at: Mapped[datetime] = _timestamptz()
class UserPanel(Base):
"""user_panels — تخصیص پنل + سطح دسترسی به کاربر (جایگزین مدل نقش).
panel: 'procurement' | 'inventory' | 'pm'
level: 'full' — تدارکات: تغییر کامل در کل پنل؛ انبار: همه پروژه‌ها
(به‌جز تغییر موجودی)
'limited' — تدارکات: فقط افزودن استعلام برای پروژه‌های عضویت؛
انبار: فقط پروژه‌های عضویت + امکان تغییر موجودی
"""
__tablename__ = "user_panels"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
panel: Mapped[str] = mapped_column(Text, nullable=False)
level: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "panel", name="uq_user_panel"),)
class UserProjectMembership(Base):
"""user_project_members — عضویت M:N کاربر در پروژه‌ها، جدا از مسئولیت
پروژه (projects.pm_user_id). محدوده‌ی داده‌ی سطح limited را تعیین می‌کند."""
__tablename__ = "user_project_members"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
project_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = _timestamptz()
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_user_project_member"),)
class Supplier(Base):
__tablename__ = "suppliers"