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
+5 -48
View File
@@ -35,7 +35,6 @@ from app.models.procurement import (
QuoteEvent,
QuoteProjects,
Supplier,
SupplierMaterial,
Termsheet,
TermsheetScheduleStep,
)
@@ -203,47 +202,9 @@ 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:
@@ -291,13 +252,12 @@ 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)
# Category is a supplier attribute now, not a material one — the key stays
# in the payload (null) so the contract shape is unchanged.
if "category" in expand:
d["category"] = category_brief(material_category)
d["category"] = None
else:
d["category_id"] = str(material_category.id) if material_category else None
d["category_id"] = None
if "canonical_unit" in expand:
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
else:
@@ -340,8 +300,6 @@ 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]] = {}
@@ -373,7 +331,6 @@ 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()
@@ -487,7 +444,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
d["material"] = {
"id": str(m.id),
"name": m.name,
"category": category_brief(data.material_category.get(m.id)),
"category": None,
"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, [])
-15
View File
@@ -873,7 +873,6 @@ async def quote_stats(
project_id: UUID | None = None,
quote_date_from=None,
quote_date_to=None,
category_id: UUID | None = None,
) -> dict:
conditions = []
if supplier_id is not None:
@@ -884,8 +883,6 @@ async def quote_stats(
conditions.append(Quote.quote_date >= quote_date_from)
if quote_date_to is not None:
conditions.append(Quote.quote_date <= quote_date_to)
if category_id is not None:
conditions.append(_quote_has_category(category_id))
rows = (
await db.execute(
@@ -917,18 +914,6 @@ async def quote_stats(
}
def _quote_has_category(category_id: UUID):
"""EXISTS: quote's supplier belongs to category_id."""
sub = (
select(1)
.where(
Supplier.id == Quote.supplier_id,
Supplier.category_id == category_id,
)
)
return func.exists(sub)
async def project_stats(db: AsyncSession, project_id: UUID) -> dict:
"""Counts + total value per quote status for ONE project, isolated per project.