Files
ERP/backend/app/routers/suppliers.py
T
Milad 4a3c226518 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
2026-09-29 13:37:59 +03:30

149 lines
5.3 KiB
Python

"""Suppliers CRUD — v2.6: 1 supplier → exactly 1 category (category_id FK)."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import require_procurement_writer, require_role
from app.db.engine import get_db
from app.models.catalog import MaterialCategory
from app.models.procurement import Quote, Supplier
from app.schemas.procurement import SupplierCreate, SupplierUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
from app.utils.serialization import to_iso
router = APIRouter(prefix="/suppliers", tags=["suppliers"])
_procurement_writer = require_procurement_writer()
def supplier_to_dict(supplier: Supplier, category: MaterialCategory | None = None) -> dict:
return {
"id": str(supplier.id),
"name": supplier.name,
"category_id": str(supplier.category_id),
"category": {
"id": str(category.id),
"slug": category.slug,
"name": category.name,
} if category else None,
"location": supplier.location,
"phone": supplier.phone,
"contact_person": supplier.contact_person,
"tax_id": supplier.tax_id,
"created_at": to_iso(supplier.created_at),
}
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
return category
@router.get("")
async def list_suppliers(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
q: str | None = None,
category_id=None,
sort: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Supplier)
if q:
stmt = stmt.where(
or_(
Supplier.name.ilike(f"%{q}%"),
Supplier.location.ilike(f"%{q}%"),
Supplier.phone.ilike(f"%{q}%"),
)
)
if category_id:
stmt = stmt.where(Supplier.category_id == category_id)
stmt = sort_query(stmt, Supplier, sort)
items, meta = await paginate(stmt, page, page_size, db)
cat_ids = {s.category_id for s in items}
cats = {}
if cat_ids:
cats = {
c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()
}
return {"data": [supplier_to_dict(s, cats.get(s.category_id)) for s in items], "meta": meta}
@router.post("", status_code=201)
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,
category_id=payload.category_id,
location=payload.location,
phone=payload.phone,
contact_person=payload.contact_person,
tax_id=payload.tax_id,
)
db.add(supplier)
await db.flush()
return {"data": supplier_to_dict(supplier, category)}
@router.get("/{supplier_id}")
async def get_supplier(supplier_id, db: AsyncSession = Depends(get_db)):
supplier = await db.get(Supplier, supplier_id)
if supplier is None:
raise NotFoundError("تأمین‌کننده یافت نشد")
category = await db.get(MaterialCategory, supplier.category_id)
return {"data": supplier_to_dict(supplier, category)}
@router.patch("/{supplier_id}")
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("تأمین‌کننده یافت نشد")
if payload.name is not None:
supplier.name = payload.name
if payload.category_id is not None:
if payload.category_id != supplier.category_id:
await _category_or_404(db, payload.category_id)
supplier.category_id = payload.category_id
if payload.location is not None:
supplier.location = payload.location
if payload.phone is not None:
supplier.phone = payload.phone
if payload.contact_person is not None:
supplier.contact_person = payload.contact_person
if payload.tax_id is not None:
supplier.tax_id = payload.tax_id
await db.flush()
category = await db.get(MaterialCategory, supplier.category_id)
return {"data": supplier_to_dict(supplier, category)}
@router.delete("/{supplier_id}")
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("تأمین‌کننده یافت نشد")
# quotes.supplier_id is NOT NULL RESTRICT — block if any quote exists.
linked = await db.scalar(select(func.count()).select_from(Quote).where(Quote.supplier_id == supplier_id))
if linked:
raise ConflictError("این تأمین‌کننده دارای پیشنهاد است و قابل حذف نیست")
await db.delete(supplier) # supplier_materials cascade via DDL
await db.flush()
return {"data": {"id": str(supplier_id), "deleted": True}}