fix(catalog): resolve material category through the linked supplier (v2.7 follow-up)

v2.7 dropped materials.category_id and the API started returning category: null for
every material, but the frontend still read material.category as the material's label.
Effect: the edit page's material field rendered blank for every row, the dashboard
category filter listed nothing (and matched nothing), the wizard's category suggestions
were empty, and the conversion lookups keyed by category (refConverted →
categoryUnits/unitConversions) silently returned null, dropping the per-reference-unit
price. Saving an edited quote matched materials on an always-true category term, so two
materials sharing brand+specs were interchangeable.

Auditing the dropped column also turned up four places still dereferencing it, one
request away from a 500 and hidden only because the inventory is empty:
GET /inventory (serializer), GET /inventory?category_id=…, GET /inventory/materials/options
and DELETE /material-categories/{id}.

Backend:
- expand_service: category_by_material() resolves a material's display category from its
  FIRST linked supplier (oldest supplier_materials row — the v2.5 trigger's rule), used by
  the material and line-item payloads; category_id is populated again when not expanded.
- inventory: list filter, row serializer and material-options filter match through
  supplier_materials ⋈ suppliers, mirroring GET /materials?category_id=…
- material_categories: the delete guard blocks on suppliers (the RESTRICT FK) and says so.

Frontend:
- Material.category → Material.supplier_category (supplier-derived, documented); the
  rename pushed every read site through tsc.
- Labels use the material's own name; the edit modal field is «متریال» and prefills by
  name; formatMaterialTitle no longer falls back to a category.
- Identity matching (edit modal, wizard, material memory) is name + brand + specs via a
  shared matchMaterial(), so same-brand same-spec materials are not conflated; legacy
  composed names are still found through materialBaseName().
- Filters read the supplier's category (dashboard options + quote filter, consistent with
  the backend's quote filtering); supplier categories now come from the supplier FK
  instead of the removed JSONB column. Conversion keys use supplier_category.
- Removed dead code carrying the old logic: two uncalled topicCategories copies and
  materialOptions' unused "cat" branch.

Verified: both images build clean (tsc -b && vite build); the API was exercised in-process
against a restore of the current production dump with an inventory row added — material
and line-item payloads carry the supplier category, the inventory endpoints that used to
raise return 200, and the category delete guard returns 409 instead of 500.
This commit is contained in:
ZCode
2026-09-20 18:51:29 +00:00
parent 7057ac4f96
commit ae62a0391b
13 changed files with 222 additions and 129 deletions
+32 -10
View File
@@ -15,7 +15,7 @@ from dataclasses import dataclass
from decimal import Decimal
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import select
from sqlalchemy import exists, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import UserContext, get_optional_user, require_role
@@ -27,12 +27,13 @@ from app.models.inventory import (
InventoryTransaction,
Warehouse,
)
from app.models.procurement import Project
from app.models.procurement import Project, Supplier, SupplierMaterial
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,
@@ -333,8 +334,18 @@ async def list_inventory(
Warehouse.project_id == project_id
)
if category_id is not None:
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
Material.category_id == category_id
# 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)
@@ -357,10 +368,8 @@ 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()}
categories = {}
if materials:
cat_ids = {m.category_id for m in materials.values()}
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_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()}
@@ -370,7 +379,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
return [
inventory_to_dict(
i, warehouses[i.warehouse_id], materials[i.material_id],
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
material_category.get(i.material_id), brands.get(i.brand_id), units.get(i.unit_id),
)
for i in items
]
@@ -556,7 +565,20 @@ async def material_options(
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد", field="category_id")
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
# 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:
+9 -4
View File
@@ -4,7 +4,8 @@ from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.engine import get_db
from app.models.catalog import Material, MaterialCategory
from app.models.catalog import MaterialCategory
from app.models.procurement import Supplier
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
from app.utils.errors import ConflictError, NotFoundError
from app.utils.pagination import paginate, sort_query
@@ -95,10 +96,14 @@ async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
category = await db.get(MaterialCategory, category_id)
if category is None:
raise NotFoundError("دسته‌بندی یافت نشد")
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
# v2.7: materials no longer carry a category, so what blocks deletion is the
# supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
# material of that category is reached through those suppliers.
linked = await db.scalar(
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
)
if linked:
raise ConflictError(f"این دسته‌بندی شامل {linked} متریال است و قابل حذف نیست")
raise ConflictError(f"این دسته‌بندی برای {linked} تأمین‌کننده ثبت شده و قابل حذف نیست")
await db.delete(category) # category_attributes cascade via DDL
await db.flush()
return {"data": {"id": str(category_id), "deleted": True}}
+48 -3
View File
@@ -35,6 +35,7 @@ from app.models.procurement import (
QuoteEvent,
QuoteProjects,
Supplier,
SupplierMaterial,
Termsheet,
TermsheetScheduleStep,
)
@@ -202,9 +203,47 @@ def quote_flat(q: Quote) -> dict:
# Materials
# ---------------------------------------------------------------------------
async def category_by_material(db: AsyncSession, material_ids) -> dict:
"""Resolve each material's display category through its FIRST linked supplier.
v2.7 made materials category-free: ``materials.category_id`` no longer
exists and the category lives on the supplier (``suppliers.category_id``).
A material linked to several suppliers therefore has no single category of
its own — we keep the v2.5 first-link-wins rule (oldest supplier_materials
row) so the display value stays stable and matches the old derived column.
Materials with no link, or whose first supplier has no category, resolve to
nothing (the serializers emit null).
"""
ids = {mid for mid in material_ids if mid is not None}
if not ids:
return {}
links = (
await db.execute(
select(SupplierMaterial.material_id, Supplier.category_id)
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
.where(SupplierMaterial.material_id.in_(ids))
.order_by(SupplierMaterial.created_at)
)
).all()
first_cat: dict = {}
for material_id, category_id in links:
if category_id is not None and material_id not in first_cat:
first_cat[material_id] = category_id
if not first_cat:
return {}
cats = (
await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(set(first_cat.values()))))
).scalars().all()
by_id = {c.id: c for c in cats}
return {mid: by_id[cid] for mid, cid in first_cat.items() if cid in by_id}
async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict:
material_ids = [m.id for m in materials]
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
# Loaded regardless of expand: the non-expanded branch still returns
# category_id, and a silent null there is the bug this replaced.
maps["material_categories"] = await category_by_material(db, material_ids)
unit_ids: set[int] = set()
if "canonical_unit" in expand:
@@ -252,10 +291,13 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
"created_at": to_iso(m.created_at),
"updated_at": to_iso(m.updated_at),
}
# v2.7: never the material's own column (it no longer has one) — the
# category comes from the first linked supplier via _category_by_material.
material_category = maps["material_categories"].get(m.id)
if "category" in expand:
d["category"] = None
d["category"] = category_brief(material_category)
else:
d["category_id"] = None
d["category_id"] = str(material_category.id) if material_category else None
if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else:
@@ -298,6 +340,8 @@ class _LineItemData:
def __init__(self):
self.materials: dict[uuid.UUID, Material] = {}
self.categories: dict[uuid.UUID, MaterialCategory] = {}
# material id -> its first linked supplier's category (v2.7)
self.material_category: dict[uuid.UUID, MaterialCategory] = {}
self.units: dict[int, Unit] = {}
self.brands: dict[uuid.UUID, Brand] = {}
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
@@ -329,6 +373,7 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
if brand_ids:
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
data.brands = {b.id: b for b in brands}
data.material_category = await category_by_material(db, material_ids)
# Pricing always needs item_termsheets in expanded mode.
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
@@ -442,7 +487,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = {
"id": str(m.id),
"name": m.name,
"category": None,
"category": category_brief(data.material_category.get(m.id)),
"brands": [
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
for bm in data.material_brands.get(m.id, [])