Files
ERP/backend/app/routers/materials.py
T
whqreza 8adfd2267f refactor(catalog)!: decouple materials from categories (v2.7)
Materials are now global and category-free. The category lives only on the supplier (suppliers.category_id); every category-facing query goes through the supplier edge instead of the material row.

Schema (migration.sql + init_db DDL):
- drop the materials.category_id column, its FK, and the v2.5 first-link-wins automation (fn_derive_material_category + trg_supplier_materials_derive_category) — the DDL runs idempotently at API startup

Backend:
- GET /materials?category_id=… now filters via EXISTS(supplier_materials ⋈ suppliers.category_id)
- quote category filters (quotes list) use the quote's supplier category
- /pm/on-the-way derives the display category from the supplier of the quote
- supplier_materials links no longer derive/return a material category
- expand_service: material expand=category resolves to null (kept for contract compatibility)
- material create/update no longer validates or stores category_id; seed.py aligned

Frontend:
- material creation no longer sends category_id
- formatMaterialTitle reworked to a strict order with « . » separator: [Brand] . [Material] . [Spec 1] . [Spec 2], brand deduped when the stored name already starts with it; specs separator switched from « · » to « . »

Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
2026-09-19 13:19:26 +03:30

395 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.db.engine import get_db
from app.models.catalog import (
AttributeDefinition,
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialUnit,
Unit,
)
from app.models.procurement import LineItem, Supplier, SupplierMaterial
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"])
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),
category_id=None,
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 category_id:
sub = (
select(1)
.select_from(SupplierMaterial)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(
SupplierMaterial.material_id == Material.id,
Supplier.category_id == category_id,
)
)
stmt = stmt.where(exists(sub))
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)
):
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)):
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)):
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)
):
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)
):
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)):
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)
):
"""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)}}