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
@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import type { Material, Supplier } from "../../lib/types";
import type { Supplier } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { useSuppliers } from "../../hooks";
import { useAppStore } from "../../store";
@@ -179,14 +179,3 @@ export function Step1TopicSupplier({
);
}
// دسته‌های موجود داخل یک تاپیک (از materials + تأمین‌کننده‌ها)
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
const out = new Set<string>();
materials
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
.forEach((m) => out.add(m.category));
suppliers
.filter((s) => topic && normalize(s.topic) === normalize(topic))
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
}
+14 -44
View File
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import type { Material } from "../../lib/types";
import { normalize } from "../../lib/normalize";
import { useMaterials } from "../../hooks";
import { specsKey } from "../../lib/quoteUtils";
import { matchMaterial, materialBaseName } from "../../lib/quoteUtils";
import { Combobox } from "../../components/Combobox";
import { NumberInput } from "../../components/NumberInput";
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
@@ -41,38 +41,12 @@ export function itemRowLabel(it: ItemDraft): string {
return qtyPart ? `${name} -- ${qtyPart}` : name;
}
// نام پایه‌ی متریال از نامِ ذخیره‌شده.
// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی
// name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا
// نامِ پایه باقی بماند.
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
const fallback = (name || "").trim();
if (!fallback) return "";
let base = fallback;
// بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند
const parts = [brand, category, ...specs]
.map((p) => (p || "").trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const p of parts) {
if (!base.includes(p)) continue;
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
if (!next) continue;
base = next;
}
// شکلِ قدیمیِ خراب می‌تواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
if (parts.some((p) => p === base)) return fallback;
return base || fallback;
}
// مقادیر مجزای category/brand/spec در کاتالوگ سراسری
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
export function materialOptions(
materials: Material[],
_topic: string,
item: ItemDraft,
field: "cat" | "brand" | "spec",
field: "brand" | "spec",
allItems: ItemDraft[] = [],
apiBrands: string[] = [],
apiSpecs: string[] = [],
@@ -92,19 +66,19 @@ export function materialOptions(
return [...out];
};
if (field === "cat") {
const base = new Set<string>(materials.map((m) => m.category).filter(Boolean));
fromRows((it) => it.cat).forEach((c) => base.add(c));
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
}
if (field === "brand") {
// برند سراسری است — مستقل از تاپیک و دسته
// برند سراسری است — مستقل از تاپیک و متریال
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
apiBrands.forEach((b) => b && allBrands.add(b));
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
}
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصه‌های متریالِ
// هم‌نام پیشنهاد می‌شوند.
if (cat)
list = list.filter(
(m) => normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === normalize(cat),
);
// مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی
// متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ
// فعلی گره نمی‌خورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
@@ -135,7 +109,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
const allMaterialNames = useMemo(() => {
const set = new Set<string>();
materials.forEach((m) => {
const base = materialBaseName(m.name, m.brand, m.specs, m.category);
const base = materialBaseName(m.name, m.brand, m.specs, m.supplier_category);
if (base) set.add(base);
});
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
@@ -193,13 +167,9 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
if (!it.specs.length) return;
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
}
const dup = materials.some(
(m) =>
normalize(m.topic) === normalize(rec.topic) &&
normalize(m.category) === normalize(rec.category) &&
normalize(m.brand) === normalize(rec.brand) &&
specsKey(m.specs) === specsKey(rec.specs),
);
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
// همان هویتِ متریال انجام می‌شود، نه دسته.
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
if (dup) return;
onRemember(rec);
};
+12 -15
View File
@@ -20,7 +20,7 @@ import { topicByName } from "../../lib/topics";
import { useAppStore } from "../../store";
import { normalize } from "../../lib/normalize";
import { faNum } from "../../lib/formatters";
import { specsKey } from "../../lib/quoteUtils";
import { matchMaterial } from "../../lib/quoteUtils";
import { Header, PageLayout } from "../../components/layout/Header";
import { Stepper } from "./Stepper";
import { Step1TopicSupplier } from "./Step1TopicSupplier";
@@ -291,22 +291,19 @@ export function WizardPage() {
if (!cat && !brand && qty == null && price == null) continue;
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با
// برند و مشخصه‌ی یکسان را می‌گیرد (matchMaterial نامِ پایه را هم می‌بیند
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
if (!material) {
material = materials.find(
(m) =>
looksLikeUuid(m.id) &&
normalize(m.category) === normalize(cat) &&
normalize(m.brand) === normalize(brand) &&
specsKey(m.specs) === specsKey(specs),
);
}
// رکوردهای قدیمی name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛
// تطبیق دوم بر اساس category/brand/specs انجام می‌شود تا آن‌ها هم پیدا شوند.
if (!material && cat) {
material = materials.find(
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
const matched = matchMaterial(
materials.filter((m) => looksLikeUuid(m.id)),
cat,
brand,
specs,
);
if (matched) material = matched;
}
let materialId: string;
if (material && looksLikeUuid(material.id)) {
@@ -317,10 +314,10 @@ export function WizardPage() {
id: nextId("m", materials),
name: wantName,
topic: selectedTopic,
category: cat,
brand,
specs,
base_unit: unit || "عدد",
supplier_category: "",
});
materialId = created.id;
} catch (err) {