Files
ERP/backend/app/routers/brand_materials.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

151 lines
5.7 KiB
Python

"""Brand-material M:N junction CRUD. (brand_id, material_id) is UNIQUE."""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, 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 Brand, BrandMaterial, Material
from app.models.procurement import LineItem
from app.schemas.catalog import BrandMaterialCreate, BrandMaterialUpdate
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="/brand-materials", tags=["brand-materials"])
_procurement_writer = require_role("admin", "procurement_manager")
def bm_to_dict(bm: BrandMaterial, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(bm.id),
"brand_id": str(bm.brand_id),
"material_id": str(bm.material_id),
"catalog_code": bm.catalog_code,
"is_active": bm.is_active,
"created_at": to_iso(bm.created_at),
}
if "brand" in expand and hasattr(bm, "_brand"):
d["brand"] = {"id": str(bm._brand.id), "name": bm._brand.name, "country": bm._brand.country}
if "material" in expand and hasattr(bm, "_material"):
d["material"] = {"id": str(bm._material.id), "name": bm._material.name}
return d
@router.get("")
async def list_brand_materials(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
brand_id=None,
material_id=None,
is_active: bool | None = None,
sort: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(BrandMaterial)
if brand_id:
stmt = stmt.where(BrandMaterial.brand_id == brand_id)
if material_id:
stmt = stmt.where(BrandMaterial.material_id == material_id)
if is_active is not None:
stmt = stmt.where(BrandMaterial.is_active == is_active)
stmt = sort_query(stmt, BrandMaterial, sort)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "brand" in tokens or "material" in tokens:
brand_ids = {bm.brand_id for bm in items}
material_ids = {bm.material_id for bm in items}
brands = {}
materials = {}
if brand_ids:
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
if material_ids:
materials = {
m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars()
}
for bm in items:
bm._brand = brands.get(bm.brand_id)
bm._material = materials.get(bm.material_id)
return {"data": [bm_to_dict(bm, tokens) for bm in items], "meta": meta}
@router.post("", status_code=201)
async def create_brand_material(
payload: BrandMaterialCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
if await db.get(Brand, payload.brand_id) is None:
raise NotFoundError("برند یافت نشد", field="brand_id")
if await db.get(Material, payload.material_id) is None:
raise NotFoundError("متریال یافت نشد", field="material_id")
duplicate = await db.scalar(
select(BrandMaterial.id).where(
BrandMaterial.brand_id == payload.brand_id, BrandMaterial.material_id == payload.material_id
)
)
if duplicate is not None:
raise ConflictError("این ارتباط برند-متریال قبلاً ثبت شده است")
bm = BrandMaterial(
brand_id=payload.brand_id,
material_id=payload.material_id,
catalog_code=payload.catalog_code,
is_active=payload.is_active,
)
db.add(bm)
await db.flush()
return {"data": bm_to_dict(bm)}
@router.get("/{bm_id}")
async def get_brand_material(bm_id, expand: str | None = None, db: AsyncSession = Depends(get_db)):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "brand" in tokens:
bm._brand = await db.get(Brand, bm.brand_id)
if "material" in tokens:
bm._material = await db.get(Material, bm.material_id)
return {"data": bm_to_dict(bm, tokens)}
@router.patch("/{bm_id}")
async def update_brand_material(
bm_id,
payload: BrandMaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
if payload.catalog_code is not None:
bm.catalog_code = payload.catalog_code
if payload.is_active is not None:
bm.is_active = payload.is_active
await db.flush()
return {"data": bm_to_dict(bm)}
@router.delete("/{bm_id}")
async def delete_brand_material(
bm_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
bm = await db.get(BrandMaterial, bm_id)
if bm is None:
raise NotFoundError("ارتباط برند-متریال یافت نشد")
# Recommendation (P2-16): block if line items reference it (SET NULL would lose context).
referenced = await db.scalar(
select(func.count()).select_from(LineItem).where(LineItem.brand_material_id == bm_id)
)
if referenced:
raise ConflictError("این ارتباط توسط خطوط آیتم استفاده شده و قابل حذف نیست")
await db.delete(bm)
await db.flush()
return {"data": {"id": str(bm_id), "deleted": True}}