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
149 lines
5.3 KiB
Python
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}}
|