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

412 lines
16 KiB
Python

"""Materials router — CRITICAL: EAV flattening, M:N brands, dynamic attr_ filters.
GET /materials supports `?attr_{attribute_key}=value` dynamic EAV filters plus
`expand=category,brands,canonical_unit,attributes`. Nested sub-resources
(material_units, attribute-values) live here too (P1-34 / P2-17..22).
"""
from datetime import date
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, 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 (
AttributeDefinition,
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialUnit,
Unit,
)
from app.models.procurement import LineItem
from app.schemas.catalog import (
MaterialAttributeValuesPut,
MaterialCreate,
MaterialUnitCreate,
MaterialUnitUpdate,
MaterialUpdate,
)
from app.services import material_service
from app.services.eav_service import flatten_eav_values
from app.services.expand_service import (
parse_expand,
serialize_material,
serialize_materials,
unit_to_dict,
)
from app.utils.errors import ConflictError, NotFoundError, ValidationError
from app.utils.pagination import paginate, sort_query
router = APIRouter(prefix="/materials", tags=["materials"])
_procurement_writer = require_role("admin", "procurement_manager")
def _coerce_filter_value(data_type: str, raw: str):
"""Typed coercion for `attr_{key}=value` query filters."""
try:
if data_type in ("number",):
return float(raw)
if data_type in ("integer",):
return int(raw)
if data_type == "boolean":
return raw.lower() in ("true", "1")
if data_type == "date":
return date.fromisoformat(raw)
return raw # string / enum
except ValueError:
raise ValidationError("مقدار فیلتر برای ویژگی نامعتبر است", field=f"attr_{raw}")
async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
"""Apply every `attr_{key}=value` query param as a typed EAV filter."""
for key, raw_value in request.query_params.multi_items():
if not key.startswith("attr_"):
continue
attr_key = key[len("attr_"):]
definition = await db.scalar(
select(AttributeDefinition).where(AttributeDefinition.attribute_key == attr_key)
)
if definition is None:
raise ValidationError(f"ویژگی ناشناخته است: {attr_key}", field=key)
value = _coerce_filter_value(definition.data_type, raw_value)
column_name = {
"number": "value_number",
"integer": "value_number",
"string": "value_string",
"enum": "value_string",
"boolean": "value_boolean",
"date": "value_date",
}[definition.data_type]
column = getattr(MaterialAttributeValue, column_name)
sub = (
select(1)
.where(
MaterialAttributeValue.material_id == Material.id,
MaterialAttributeValue.attribute_definition_id == definition.id,
column == value,
)
)
stmt = stmt.where(exists(sub))
return stmt
# ---------------------------------------------------------------------------
# Materials CRUD
# ---------------------------------------------------------------------------
@router.get("")
async def list_materials(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
brand_id=None,
status: str | None = None,
q: str | None = None,
sort: str | None = None,
expand: str | None = None,
request: Request = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(Material)
if brand_id:
# WHERE EXISTS brand_materials with that brand_id
sub = select(1).where(
BrandMaterial.material_id == Material.id, BrandMaterial.brand_id == brand_id
)
stmt = stmt.where(exists(sub))
if status:
stmt = stmt.where(Material.status == status)
if q:
stmt = stmt.where(Material.name.ilike(f"%{q}%"))
stmt = await _apply_attr_filters(db, stmt, request)
stmt = sort_query(stmt, Material, sort)
items, meta = await paginate(stmt, page, page_size, db)
data = await serialize_materials(db, items, parse_expand(expand))
return {"data": data, "meta": meta}
@router.post("", status_code=201)
async def create_material(
payload: MaterialCreate,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await material_service.create_material(db, payload)
return {"data": await serialize_material(db, material, parse_expand(expand))}
@router.get("/{material_id}")
async def get_material(material_id, expand: str | None = None, db: AsyncSession = Depends(get_db)):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
return {"data": await serialize_material(db, material, parse_expand(expand))}
@router.patch("/{material_id}")
async def update_material(
material_id,
payload: MaterialUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
material = await material_service.update_material(db, material, payload)
return {"data": await serialize_material(db, material, parse_expand(None))}
@router.delete("/{material_id}")
async def delete_material(
material_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
referenced = await db.scalar(
select(1).where(LineItem.material_id == material_id).limit(1)
)
if referenced:
raise ConflictError("این متریال در خطوط آیتم استفاده شده و قابل حذف نیست")
await db.delete(material) # attribute_values / brand_materials / material_units cascade via DDL
await db.flush()
return {"data": {"id": str(material_id), "deleted": True}}
# ---------------------------------------------------------------------------
# Material units (packaging / conversion overrides) — P1-34, P2-17..20
# ---------------------------------------------------------------------------
def material_unit_to_dict(mu: MaterialUnit, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(mu.id),
"material_id": str(mu.material_id),
"brand_material_id": str(mu.brand_material_id) if mu.brand_material_id else None,
"unit_id": mu.unit_id,
"conversion_factor": float(mu.conversion_factor),
"is_default": mu.is_default,
}
if "unit" in expand and hasattr(mu, "_unit"):
d["unit"] = unit_to_dict(mu._unit)
if "brand_material" in expand and hasattr(mu, "_brand_material"):
bm = mu._brand_material
d["brand_material"] = {
"id": str(bm.id),
"brand_id": str(bm.brand_id),
"material_id": str(bm.material_id),
"catalog_code": bm.catalog_code,
}
return d
async def _material_or_404(db: AsyncSession, material_id) -> Material:
material = await db.get(Material, material_id)
if material is None:
raise NotFoundError("متریال یافت نشد")
return material
def _bm_cond(brand_material_id):
"""PG «IS» فقط با NULL معتبر است؛ برای مقداردار از «==» استفاده می‌شود."""
if brand_material_id is None:
return MaterialUnit.brand_material_id.is_(None)
return MaterialUnit.brand_material_id == brand_material_id
async def _set_default_unit(db: AsyncSession, material_id, brand_material_id, exclude_id) -> None:
"""If is_default=True: demote all other units in the same group."""
await db.execute(
MaterialUnit.__table__.update()
.where(
MaterialUnit.material_id == material_id,
_bm_cond(brand_material_id),
MaterialUnit.id != exclude_id,
)
.values(is_default=False)
)
@router.get("/{material_id}/units")
async def list_material_units(
material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
brand_material_id=None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
await _material_or_404(db, material_id)
stmt = select(MaterialUnit).where(MaterialUnit.material_id == material_id)
if brand_material_id:
stmt = stmt.where(MaterialUnit.brand_material_id == brand_material_id)
items, meta = await paginate(stmt, page, page_size, db)
tokens = parse_expand(expand)
if "unit" in tokens:
unit_ids = {mu.unit_id for mu in items}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
for mu in items:
mu._unit = units.get(mu.unit_id)
if "brand_material" in tokens:
bm_ids = {mu.brand_material_id for mu in items if mu.brand_material_id}
bms = {}
if bm_ids:
bms = {b.id: b for b in (await db.execute(select(BrandMaterial).where(BrandMaterial.id.in_(bm_ids)))).scalars()}
for mu in items:
mu._brand_material = bms.get(mu.brand_material_id)
return {"data": [material_unit_to_dict(mu, tokens) for mu in items], "meta": meta}
@router.post("/{material_id}/units", status_code=201)
async def create_material_unit(
material_id,
payload: MaterialUnitCreate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
await _material_or_404(db, material_id)
if await db.get(Unit, payload.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
if payload.brand_material_id is not None:
bm = await db.get(BrandMaterial, payload.brand_material_id)
if bm is None:
raise NotFoundError("برند-متریال یافت نشد", field="brand_material_id")
if bm.material_id != material_id:
raise ConflictError("brand_material_id متعلق به این متریال نیست", field="brand_material_id")
# App-level uniqueness (PG treats NULL brand_material_id as distinct)
duplicate = await db.scalar(
select(MaterialUnit.id).where(
MaterialUnit.material_id == material_id,
MaterialUnit.unit_id == payload.unit_id,
_bm_cond(payload.brand_material_id),
)
)
if duplicate is not None:
raise ConflictError("این واحد قبلاً برای این متریال ثبت شده است")
if payload.is_default:
await _set_default_unit(db, material_id, payload.brand_material_id, exclude_id=None)
mu = MaterialUnit(
material_id=material_id,
brand_material_id=payload.brand_material_id,
unit_id=payload.unit_id,
conversion_factor=payload.conversion_factor,
is_default=payload.is_default,
)
db.add(mu)
await db.flush()
return {"data": material_unit_to_dict(mu)}
@router.patch("/{material_id}/units/{mu_id}")
async def update_material_unit(
material_id,
mu_id,
payload: MaterialUnitUpdate,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
async with db.begin():
mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
)
if mu is None:
raise NotFoundError("واحد متریال یافت نشد")
if payload.unit_id is not None and payload.unit_id != mu.unit_id:
if await db.get(Unit, payload.unit_id) is None:
raise NotFoundError("واحد یافت نشد", field="unit_id")
duplicate = await db.scalar(
select(MaterialUnit.id).where(
MaterialUnit.material_id == material_id,
MaterialUnit.unit_id == payload.unit_id,
_bm_cond(mu.brand_material_id),
MaterialUnit.id != mu.id,
)
)
if duplicate is not None:
raise ConflictError("این واحد قبلاً برای این متریال ثبت شده است")
mu.unit_id = payload.unit_id
if payload.conversion_factor is not None:
mu.conversion_factor = payload.conversion_factor
if payload.is_default is not None:
if payload.is_default:
await _set_default_unit(db, material_id, mu.brand_material_id, exclude_id=mu.id)
mu.is_default = payload.is_default
return {"data": material_unit_to_dict(mu)}
@router.delete("/{material_id}/units/{mu_id}")
async def delete_material_unit(
material_id,
mu_id,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
mu = await db.scalar(
select(MaterialUnit).where(MaterialUnit.id == mu_id, MaterialUnit.material_id == material_id)
)
if mu is None:
raise NotFoundError("واحد متریال یافت نشد")
await db.delete(mu)
await db.flush()
return {"data": {"deleted": True}}
# ---------------------------------------------------------------------------
# Material attribute values (EAV) — P2-21/22
# ---------------------------------------------------------------------------
@router.get("/{material_id}/attribute-values")
async def get_material_attribute_values(
material_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
await _material_or_404(db, material_id)
stmt = (
select(MaterialAttributeValue)
.where(MaterialAttributeValue.material_id == material_id)
.order_by(MaterialAttributeValue.attribute_definition_id)
)
items, meta = await paginate(stmt, page, page_size, db)
def_ids = {row.attribute_definition_id for row in items}
defs = {}
if def_ids:
defs = {
d.id: d
for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars()
}
unit_ids = {d.unit_id for d in defs.values() if d.unit_id is not None}
units = {}
if unit_ids:
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
data = flatten_eav_values(items, defs, units, prefer_unit_id_at_time=False, include_definition_id=True)
return {"data": data, "meta": meta}
@router.put("/{material_id}/attribute-values")
async def put_material_attribute_values(
material_id,
payload: MaterialAttributeValuesPut,
db: AsyncSession = Depends(get_db),
_auth=Depends(_procurement_writer),
):
"""FULL replacement of a material's EAV values (empty array deletes all)."""
async with db.begin():
await _material_or_404(db, material_id)
validated = await material_service.validate_attributes(db, payload.attributes)
existing = (
(await db.execute(select(MaterialAttributeValue).where(MaterialAttributeValue.material_id == material_id))).scalars().all()
)
for row in existing:
await db.delete(row)
await db.flush()
for ad, values in validated:
db.add(MaterialAttributeValue(material_id=material_id, attribute_definition_id=ad.id, **values))
return {"data": {"updated": len(validated)}}