refactor(catalog)!: decouple materials from categories (v2.7)

Materials are now global and category-free. The category lives only on the supplier (suppliers.category_id); every category-facing query goes through the supplier edge instead of the material row.

Schema (migration.sql + init_db DDL):
- drop the materials.category_id column, its FK, and the v2.5 first-link-wins automation (fn_derive_material_category + trg_supplier_materials_derive_category) — the DDL runs idempotently at API startup

Backend:
- GET /materials?category_id=… now filters via EXISTS(supplier_materials ⋈ suppliers.category_id)
- quote category filters (quotes list) use the quote's supplier category
- /pm/on-the-way derives the display category from the supplier of the quote
- supplier_materials links no longer derive/return a material category
- expand_service: material expand=category resolves to null (kept for contract compatibility)
- material create/update no longer validates or stores category_id; seed.py aligned

Frontend:
- material creation no longer sends category_id
- formatMaterialTitle reworked to a strict order with « . » separator: [Brand] . [Material] . [Spec 1] . [Spec 2], brand deduped when the stored name already starts with it; specs separator switched from « · » to « . »

Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
This commit is contained in:
2026-09-19 13:19:26 +03:30
parent 5a9ef408ff
commit 8adfd2267f
19 changed files with 92 additions and 201 deletions
-8
View File
@@ -1108,12 +1108,6 @@ export const api = {
},
create: async (m: Material): Promise<Material> => {
const lookup = await fetchLookup();
// v2.5: category_id اختیاری است — اگر نامِ تایپ‌شده یکی از ۸ دسته‌ی ثابت
// نباشد، متریال به‌صورت سراسری (بدون دسته) ساخته می‌شود و دسته‌اش بعداً
// از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (first-link-wins).
// دسته‌ی rogue دیگر هرگز ساخته نمی‌شود — فقط تطبیق با ۸ دسته‌ی موجود؛
// در صورت نبود تطبیق، خطا نمی‌دهیم و category_id را null می‌فرستیم.
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
// واحد مرجع — بدون مقدار پیش‌فرض؛ متریال بدون واحدِ صریح ساخته نمی‌شود
// (بک‌اند canonical_unit_id را الزامی می‌داند)
const unitName = (m.base_unit || "").trim();
@@ -1138,7 +1132,6 @@ export const api = {
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
}
const raw = await postData<RawMaterial>("/materials", {
category_id: categoryId ?? null,
name,
topic: m.topic || null,
canonical_unit_id: unitId,
@@ -1760,7 +1753,6 @@ export const apiV26 = {
}): Promise<{ id: string; name: string }> =>
postData("/materials", {
name: opts.name,
category_id: null, // دسته از اولین تأمین‌کننده‌ی مرتبط استنتاج می‌شود (v2.5)
topic: null,
canonical_unit_id: opts.canonical_unit_id,
status: "active",