151 lines
5.7 KiB
Python
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}}
|