Files
ERP/backend/app/routers/suppliers.py
T
MMD 0ea0322967 Revert 253c922: restore the PM/RBAC work to the integration
Reverting the production revert so the pm branch can merge cleanly on
top of the restored RBAC base. Conflicts against newer integration work
resolved by combining:
- App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside
  the supervisor gating (no-project block screen, panel scoping)
- LoginPage: returnUrl navigation kept, supervisor routing added
- migration.sql: bot_drafts table kept, unified-projects ALTERs restored
2026-10-01 15:24:56 +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_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_role("admin", "procurement_manager")
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}}