"""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_procurement_writer, 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_procurement_writer() 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)}}