refactor(catalog): reduce the category taxonomy to a supplier label

Categories are kept only as the label each supplier carries (suppliers.category_id
+ the material_categories list it points at). Everything that treated category as
a shared key across materials, inventory, quotes and unit conversions is removed —
including two features that were never populated (0 rows on every volume).

Removed (backend):
- routers category_attributes, category_ref_units, unit_conversions + registration
- ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one
  stays — it filters suppliers by their own label)
- GET /inventory/materials/options: required a category and had no caller
- the material-payload category derivation (expand_service.category_by_material,
  the inventory serializer lookup) and the material category on /pm/on-the-way;
  the wire keys stay, now always null, so the payload shape is unchanged
- startup DDL: DROP the empty category_attributes / category_reference_units /
  unit_conversions tables (idempotent) and their CREATE statements; seed.py
  truncate list updated

Removed (frontend):
- the category-keyed conversion path: refUnitForCategory / findConversion /
  refConverted, the categoryUnits + unitConversions store slices, api clients,
  hooks and the "≈ converted quantity" line on quote detail and project pages
  (it needed rows in those tables to render, and there were none)
- material.supplier_category and its read sites (the edit modal grew one
  yesterday to restore labels; the label lives on the supplier now)
- the inventory page's category chips, the row filter and the «دسته» column,
  plus the lookup's categories fetch and catName helper

Kept: supplier label + its list and picker, /suppliers?category_id=, the global
standard_conversions feature and the units page (material_units conversions).

Verified against a restore of the production dump: startup DDL drops exactly the
three tables, suppliers keep their label (3/3), material_categories still serves
its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/
standard-conversions all return 200 with quote line items intact.
This commit is contained in:
ZCode
2026-09-20 19:44:23 +00:00
parent 2fd72fecf7
commit b469238153
29 changed files with 73 additions and 947 deletions
-6
View File
@@ -4,8 +4,6 @@ from app.routers import (
auth,
brand_materials,
brands,
category_attributes,
category_ref_units,
files,
inventory,
item_decisions,
@@ -24,7 +22,6 @@ from app.routers import (
supplier_materials,
suppliers,
termsheets,
unit_conversions,
units,
users,
)
@@ -35,7 +32,6 @@ ROUTERS = [
brands.router,
material_categories.router,
attribute_definitions.router,
category_attributes.router,
materials.router,
brand_materials.router,
suppliers.router,
@@ -51,8 +47,6 @@ ROUTERS = [
item_receipts.router,
quote_events.router,
files.router,
category_ref_units.router,
unit_conversions.router,
standard_conversions.router,
inventory.router,
pm.router,
-136
View File
@@ -1,136 +0,0 @@
"""Category attributes — scoped attribute definitions per category (P2 set).
Identified by composite key {category_id}/{attribute_definition_id}.
The nested `attribute` object is ALWAYS included (not expand-based).
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory
from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
router = APIRouter(
prefix="/material-categories/{category_id}/attributes",
tags=["category-attributes"],
)
def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict:
return {
"id": str(ca.id),
"category_id": str(ca.category_id),
"attribute_definition_id": str(ca.attribute_definition_id),
"is_required": ca.is_required,
"is_filterable": ca.is_filterable,
"conditional_rules": ca.conditional_rules,
"sort_order": ca.sort_order,
"attribute": {
"attribute_key": attribute.attribute_key,
"label": attribute.label,
"data_type": attribute.data_type,
"unit_id": attribute.unit_id,
"enum_values": attribute.enum_values,
},
}
async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory:
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
return category
async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict:
attribute = await db.get(AttributeDefinition, ca.attribute_definition_id)
return ca_to_dict(ca, attribute)
@router.get("")
async def list_category_attributes(
category_id,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
await _category_or_404(db, category_id)
stmt = (
select(CategoryAttribute)
.where(CategoryAttribute.category_id == category_id)
.order_by(CategoryAttribute.sort_order.asc())
)
items, meta = await paginate(stmt, page, page_size, db)
return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta}
@router.post("", status_code=201)
async def create_category_attribute(
category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db)
):
await _category_or_404(db, category_id)
if await db.get(AttributeDefinition, payload.attribute_definition_id) is None:
raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id")
duplicate = await db.scalar(
select(CategoryAttribute.id).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == payload.attribute_definition_id,
)
)
if duplicate is not None:
raise ConflictError("این ویژگی قبلاً به این دسته‌بندی اضافه شده است", field="attribute_definition_id")
ca = CategoryAttribute(
category_id=category_id,
attribute_definition_id=payload.attribute_definition_id,
is_required=payload.is_required,
is_filterable=payload.is_filterable,
conditional_rules=payload.conditional_rules,
sort_order=payload.sort_order,
)
db.add(ca)
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.patch("/{attribute_definition_id}")
async def update_category_attribute(
category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
if payload.is_required is not None:
ca.is_required = payload.is_required
if payload.is_filterable is not None:
ca.is_filterable = payload.is_filterable
if payload.conditional_rules is not None:
ca.conditional_rules = payload.conditional_rules
if payload.sort_order is not None:
ca.sort_order = payload.sort_order
await db.flush()
return {"data": await _with_attribute(db, ca)}
@router.delete("/{attribute_definition_id}")
async def delete_category_attribute(
category_id, attribute_definition_id, db: AsyncSession = Depends(get_db)
):
ca = await db.scalar(
select(CategoryAttribute).where(
CategoryAttribute.category_id == category_id,
CategoryAttribute.attribute_definition_id == attribute_definition_id,
)
)
if ca is None:
raise NotFoundError("ویژگی این دسته‌بندی یافت نشد")
await db.delete(ca)
await db.flush()
return {"data": {"deleted": True}}
-69
View File
@@ -1,69 +0,0 @@
"""Category reference units — units.html dashboard (P2-26/27).
One reference unit per category (UNIQUE category_id); PUT is an upsert.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import CategoryReferenceUnit, MaterialCategory
from app.schemas.catalog import CategoryReferenceUnitPut
from app.services.expand_service import category_brief
from app.utils.errors import NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"])
def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(cru.id),
"category_id": str(cru.category_id),
"ref_unit": cru.ref_unit,
"updated_at": to_iso(cru.updated_at),
}
if "category" in expand and hasattr(cru, "_category"):
d["category"] = category_brief(cru._category)
return d
@router.get("")
async def list_category_reference_units(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(CategoryReferenceUnit)
if category_id:
stmt = stmt.where(CategoryReferenceUnit.category_id == category_id)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {cru.category_id for cru in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for cru in items:
cru._category = cats.get(cru.category_id)
return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta}
@router.put("/{category_id}")
async def upsert_category_reference_unit(
category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db)
):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id))
if cru is None:
cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit)
db.add(cru)
else:
cru.ref_unit = payload.ref_unit
await db.flush()
return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}}
+3 -89
View File
@@ -15,7 +15,7 @@ from dataclasses import dataclass
from decimal import Decimal
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import exists, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user, require_role
@@ -27,13 +27,12 @@ from app.models.inventory import (
InventoryTransaction,
Warehouse,
)
from app.models.procurement import Project, Supplier, SupplierMaterial
from app.models.procurement import Project
from app.schemas.inventory import (
InventoryTransactionCreate,
WarehouseCreate,
WarehouseUpdate,
)
from app.services.expand_service import category_by_material
from app.services.inventory_service import (
apply_transaction,
get_warehouse_or_404,
@@ -315,7 +314,6 @@ async def delete_warehouse(
@router.get("")
async def list_inventory(
project_id: uuid.UUID | None = None,
category_id=None,
warehouse_id=None,
material_id=None,
sort: str | None = None,
@@ -333,20 +331,6 @@ async def list_inventory(
stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where(
Warehouse.project_id == project_id
)
if category_id is not None:
# v2.7: materials carry no category — match through their suppliers
# (any link, mirroring GET /materials?category_id=…).
stmt = stmt.where(
exists(
select(1)
.select_from(SupplierMaterial)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(
SupplierMaterial.material_id == InventoryItem.material_id,
Supplier.category_id == category_id,
)
)
)
if warehouse_id is not None:
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
if material_id is not None:
@@ -368,8 +352,6 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
unit_ids = {i.unit_id for i in items}
warehouses = {w.id: w for w in (await db.execute(select(Warehouse).where(Warehouse.id.in_(wh_ids)))).scalars()}
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
# v2.7: the category shown for a material comes from its first linked supplier.
material_category = await category_by_material(db, mat_ids)
brands = {}
if brand_ids:
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
@@ -379,7 +361,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
return [
inventory_to_dict(
i, warehouses[i.warehouse_id], materials[i.material_id],
material_category.get(i.material_id), brands.get(i.brand_id), units.get(i.unit_id),
None, brands.get(i.brand_id), units.get(i.unit_id),
)
for i in items
]
@@ -541,71 +523,3 @@ async def transaction_types():
"PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT",
"DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT",
)]}
# ---------------------------------------------------------------------------
# Material selector — filtered by Category + Warehouse (transaction forms)
# ---------------------------------------------------------------------------
@router.get("/materials/options")
async def material_options(
category_id: uuid.UUID,
warehouse_id: uuid.UUID | None = None,
scope: Scope = Depends(inventory_scope),
db: AsyncSession = Depends(get_db),
):
"""Materials for the transaction form.
Always filtered by category. When `warehouse_id` is given, only materials
that already exist in that warehouse's inventory are returned (so the PM
works with the actual stock of that warehouse); otherwise the full
category list is returned (e.g. first purchase of a new material).
"""
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
# v2.7: a material has no category column — it belongs to a category when a
# linked supplier does (same rule as GET /materials?category_id=…).
stmt = select(Material).where(
Material.status == "active",
exists(
select(1)
.select_from(SupplierMaterial)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(
SupplierMaterial.material_id == Material.id,
Supplier.category_id == category_id,
)
),
)
if warehouse_id is not None:
wh = await get_warehouse_or_404(db, warehouse_id)
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
raise ForbiddenError("این انبار متعلق به پروژه شما نیست")
stmt = stmt.where(
Material.id.in_(
select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id)
)
)
stmt = stmt.order_by(Material.name)
materials = (await db.execute(stmt)).scalars().all()
# Brands per material (from brand_materials catalog + brands actually used in stock)
brand_rows = (await db.execute(
select(BrandMaterial.material_id, Brand.id, Brand.name)
.join(Brand, Brand.id == BrandMaterial.brand_id)
.where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True))
)).all()
brands_by_material: dict[str, list[dict]] = {}
for mat_id, b_id, b_name in brand_rows:
brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name})
return {"data": [
{
"id": str(m.id),
"name": m.name,
"brands": brands_by_material.get(str(m.id), []),
}
for m in materials
]}
+1 -13
View File
@@ -18,7 +18,7 @@ from app.models.catalog import (
MaterialUnit,
Unit,
)
from app.models.procurement import LineItem, Supplier, SupplierMaterial
from app.models.procurement import LineItem
from app.schemas.catalog import (
MaterialAttributeValuesPut,
MaterialCreate,
@@ -97,7 +97,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request):
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,
@@ -107,17 +106,6 @@ async def list_materials(
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(
+2 -9
View File
@@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user
from app.config import settings
from app.db.engine import get_db
from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit
from app.models.catalog import BrandMaterial, Material, Unit
from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse
from app.models.procurement import (
ItemDecision,
@@ -201,12 +201,6 @@ async def pm_on_the_way(
# unit / category lookups for display
unit_ids = {d.LineItem.unit_id for d in decisions}
units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()}
sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id}
cats = {}
if sup_cat_ids:
rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all()
cats = {c.id: c for c in rows}
out = []
for d, li, q, m, sup, proj in decisions:
received = received_map.get(d.id)
@@ -219,8 +213,7 @@ async def pm_on_the_way(
"supplier": {"id": str(sup.id), "name": sup.name},
"project": {"id": str(proj.id), "name": proj.name} if proj else None,
"decided_at": to_iso(d.decided_at),
"material": {"id": str(m.id), "name": m.name,
"category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None},
"material": {"id": str(m.id), "name": m.name},
"unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code,
"name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None,
"ordered_qty": str(li.quantity),
-13
View File
@@ -45,14 +45,6 @@ router = APIRouter(prefix="/quotes", tags=["quotes"])
# --- list filter helpers ---------------------------------------------------
def _quote_category_exists(category_id) -> exists:
return exists(
select(1).where(
Supplier.id == Quote.supplier_id,
Supplier.category_id == category_id,
)
)
def _quote_search(q: str) -> exists:
like = f"%{q}%"
@@ -80,7 +72,6 @@ async def get_quote_stats(
project_id=None,
quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None,
category_id=None,
db: AsyncSession = Depends(get_db),
):
return {
@@ -90,7 +81,6 @@ async def get_quote_stats(
project_id=project_id,
quote_date_from=quote_date_from,
quote_date_to=quote_date_to,
category_id=category_id,
)
}
@@ -106,7 +96,6 @@ async def list_quotes(
status: str | None = None,
supplier_id=None,
project_id=None,
category_id=None,
quote_date_from: datetime | None = None,
quote_date_to: datetime | None = None,
q: str | None = None,
@@ -137,8 +126,6 @@ async def list_quotes(
)
)
)
if category_id:
stmt = stmt.where(_quote_category_exists(category_id))
if quote_date_from is not None:
stmt = stmt.where(Quote.quote_date >= quote_date_from)
if quote_date_to is not None:
-146
View File
@@ -1,146 +0,0 @@
"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31).
Model: {category_id, brand?, from_unit, to_unit, factor}
1 from_unit = factor × to_unit
brand empty = all brands of the category; brand set = that brand only.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import MaterialCategory, UnitConversion
from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate
from app.services.expand_service import category_brief
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate
from app.utils.serialization import to_iso
router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"])
def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict:
d = {
"id": str(uc.id),
"category_id": str(uc.category_id),
"brand": uc.brand,
"from_unit": uc.from_unit,
"to_unit": uc.to_unit,
"factor": float(uc.factor),
"updated_at": to_iso(uc.updated_at),
}
if "category" in expand and hasattr(uc, "_category"):
d["category"] = category_brief(uc._category)
return d
def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit):
"""Uniqueness treats NULL brand as a concrete value (matches the partial unique index)."""
where = (
UnitConversion.category_id == category_id,
UnitConversion.from_unit == from_unit,
UnitConversion.to_unit == to_unit,
)
# PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده می‌شود
where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),)
if uc_or_none is not None:
where = where + (UnitConversion.id != uc_or_none.id,)
return where
async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None:
duplicate = await db.scalar(
select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit))
)
if duplicate is not None:
raise ConflictError("این تبدیل قبلاً ثبت شده است")
@router.get("")
async def list_unit_conversions(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id=None,
brand: str | None = None,
from_unit: str | None = None,
to_unit: str | None = None,
expand: str | None = None,
db: AsyncSession = Depends(get_db),
):
stmt = select(UnitConversion)
if category_id:
stmt = stmt.where(UnitConversion.category_id == category_id)
if brand:
stmt = stmt.where(UnitConversion.brand == brand)
if from_unit:
stmt = stmt.where(UnitConversion.from_unit == from_unit)
if to_unit:
stmt = stmt.where(UnitConversion.to_unit == to_unit)
items, meta = await paginate(stmt, page, page_size, db)
tokens = {t.strip() for t in expand.split(",")} if expand else set()
if "category" in tokens:
cat_ids = {uc.category_id for uc in items}
cats = {}
if cat_ids:
cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
for uc in items:
uc._category = cats.get(uc.category_id)
return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta}
@router.post("", status_code=201)
async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, payload.category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit)
uc = UnitConversion(
category_id=payload.category_id,
brand=payload.brand,
from_unit=payload.from_unit,
to_unit=payload.to_unit,
factor=payload.factor,
)
db.add(uc)
await db.flush()
return {"data": uc_to_dict(uc)}
@router.get("/{uc_id}")
async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
return {"data": uc_to_dict(uc)}
@router.patch("/{uc_id}")
async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
new_brand = payload.brand if payload.brand is not None else uc.brand
new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit
new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit
if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None:
await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to)
if payload.brand is not None:
uc.brand = payload.brand
if payload.from_unit is not None:
uc.from_unit = payload.from_unit
if payload.to_unit is not None:
uc.to_unit = payload.to_unit
if payload.factor is not None:
uc.factor = payload.factor
await db.flush()
return {"data": uc_to_dict(uc)}
@router.delete("/{uc_id}")
async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)):
uc = await db.get(UnitConversion, uc_id)
if uc is None:
raise NotFoundError("تبدیل واحد یافت نشد")
await db.delete(uc)
await db.flush()
return {"data": {"deleted": True}}