Files
ERP/backend/app/services/material_service.py
whqreza 7f31fb5105 fix(materials): remove nested db.begin — material PATCH was 500
The router loads the material row (starting a transaction) before calling
material_service.update_material, which then opened its own
async with db.begin() → SQLAlchemy 'A transaction is already begun on
this Session' → INTERNAL_ERROR on every PATCH (incl. changing the
reference unit from ضرایب تبدیل). Mutations now run inside the request
transaction committed by the get_db dependency.
2026-09-08 14:41:57 +03:30

202 lines
8.7 KiB
Python

"""Material domain services: EAV value coercion/validation + atomic creation."""
from datetime import date
from decimal import Decimal, InvalidOperation
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.catalog import (
AttributeDefinition,
Brand,
BrandMaterial,
Material,
MaterialAttributeValue,
MaterialCategory,
Unit,
)
from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate
from app.utils.errors import ConflictError, NotFoundError, ValidationError
def coerce_eav_value(data_type: str, value, field: str = "value"):
"""Coerce a client value against attribute_definitions.data_type.
Returns the Python value to store in the matching typed column.
Raises 400 VALIDATION_ERROR on mismatch (contract error shape).
"""
if data_type == "number":
try:
return Decimal(str(value))
except (ValueError, InvalidOperation, TypeError):
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected number", field=field)
if data_type == "integer":
try:
if isinstance(value, str):
if "." in value or "e" in value.lower() or "E" in value:
raise ValueError
return int(value)
if isinstance(value, bool): # bool is an int subclass — reject it explicitly
raise ValueError
if isinstance(value, (int, float, Decimal)):
dec = Decimal(str(value))
if dec != dec.to_integral_value():
raise ValueError
return int(dec)
raise ValueError
except (ValueError, InvalidOperation, TypeError):
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected integer", field=field)
if data_type in ("string", "enum"):
if value is None:
raise ValidationError("مقدار نمی‌تواند خالی باشد", field=field)
return str(value)
if data_type == "boolean":
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1", "yes"):
return True
if lowered in ("false", "0", "no"):
return False
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected boolean", field=field)
if data_type == "date":
if isinstance(value, date):
return value
try:
return date.fromisoformat(str(value))
except ValueError:
raise ValidationError("نوع مقدار با data_type سازگار نیست — expected date (YYYY-MM-DD)", field=field)
raise ValidationError(f"data_type نامعتبر است: {data_type}", field="data_type")
def typed_column_values(data_type: str, coerced):
"""Map a coerced value to the EAV typed column dict."""
if data_type in ("number", "integer"):
return {"value_number": coerced}
if data_type in ("string", "enum"):
return {"value_string": coerced}
if data_type == "boolean":
return {"value_boolean": coerced}
if data_type == "date":
return {"value_date": coerced}
raise ValidationError(f"data_type نامعتبر است: {data_type}", field="data_type")
async def validate_attributes(
db: AsyncSession, attributes: list[MaterialAttributeIn]
) -> list[tuple[AttributeDefinition, dict]]:
"""Validate an EAV payload; returns [(definition, typed-column-dict)]."""
if not attributes:
return []
def_ids = {a.attribute_definition_id for a in attributes}
defs = {d.id: d for d in (await db.execute(select(AttributeDefinition).where(AttributeDefinition.id.in_(def_ids)))).scalars()}
result: list[tuple[AttributeDefinition, dict]] = []
for index, attr in enumerate(attributes):
ad = defs.get(attr.attribute_definition_id)
if ad is None:
raise NotFoundError("ویژگی یافت نشد", field=f"attributes[{index}].attribute_definition_id")
field = f"attributes[{index}].value"
coerced = coerce_eav_value(ad.data_type, attr.value, field)
if ad.data_type == "enum":
allowed = ad.enum_values or []
if allowed and str(attr.value) not in {str(v) for v in allowed}:
raise ValidationError("مقدار در گزینه‌های مجاز این ویژگی نیست", field=field)
result.append((ad, typed_column_values(ad.data_type, coerced)))
return result
async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material:
"""Create a material + brand_materials rows + EAV values in ONE transaction.
v2.5/2.6: category_id is OPTIONAL — a global material may be created
without a category (it gets derived automatically from the first linked
supplier). name is GLOBALLY unique.
"""
async with db.begin():
duplicate = await db.scalar(
select(Material.id).where(Material.name == payload.name)
)
if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.category_id is not None:
if await db.get(MaterialCategory, payload.category_id) is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material = Material(
category_id=payload.category_id,
name=payload.name,
topic=payload.topic,
canonical_unit_id=payload.canonical_unit_id,
status=payload.status,
)
db.add(material)
await db.flush()
if payload.brand_ids:
brands = (await db.execute(select(Brand).where(Brand.id.in_(payload.brand_ids)))).scalars().all()
found = {b.id for b in brands}
for brand_id in payload.brand_ids:
if brand_id not in found:
raise NotFoundError("برند یافت نشد", field="brand_ids")
db.add(BrandMaterial(brand_id=brand_id, material_id=material.id))
validated = await validate_attributes(db, payload.attributes)
for ad, values in validated:
db.add(
MaterialAttributeValue(
material_id=material.id, attribute_definition_id=ad.id, **values
)
)
return material
async def update_material(db: AsyncSession, material: Material, payload: MaterialUpdate) -> Material:
"""Partial update (name, canonical_unit_id, status). EAV/brands are NOT edited here.
v2.5/2.6: category is DERIVED (from the first linked supplier) — it is
no longer part of the update payload.
NOTE: no ``db.begin()`` here — the router already starts the transaction
(it loads the row first), and a nested begin raises
"A transaction is already begun on this Session" (500). The request
session commits via the get_db dependency.
"""
if payload.name is not None:
duplicate = await db.scalar(
select(Material.id).where(
Material.name == payload.name,
Material.id != material.id,
)
)
if duplicate is not None:
raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name")
if payload.name is not None:
material.name = payload.name
if payload.topic is not None:
material.topic = payload.topic
if payload.canonical_unit_id is not None and payload.canonical_unit_id != material.canonical_unit_id:
if not await db.get(Unit, payload.canonical_unit_id):
raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id")
material.canonical_unit_id = payload.canonical_unit_id
if payload.status is not None:
material.status = payload.status
return material
async def ensure_brand_material_belong(
db: AsyncSession, brand_material_id: UUID | None, material_id: UUID
) -> None:
"""App-layer integrity check: brand_material_id must belong to material_id (migration design note)."""
if brand_material_id is None:
return
bm = await db.get(BrandMaterial, 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")