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
+11 -116
View File
@@ -18,7 +18,6 @@ import { jalaliToGregorian, toAsciiDigits } from "./jalali";
import { useAuthStore } from "../store/auth";
import type {
Attachment,
CategoryUnit,
ConfirmedReceipt,
InventoryRow,
InventoryTransaction,
@@ -30,7 +29,6 @@ import type {
Store,
Supplier,
TransactionType,
UnitConversion,
Warehouse,
} from "./types";
@@ -252,10 +250,6 @@ async function patchData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) });
return res.data;
}
async function putData<T>(path: string, body: unknown): Promise<T> {
const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) });
return res.data;
}
async function deleteData<T>(path: string): Promise<T> {
const res = await request<{ data: T }>(path, { method: "DELETE" });
return res.data;
@@ -322,7 +316,6 @@ interface RawSupplier {
phone?: string | null;
contact_person?: string | null;
tax_id?: string | null;
categories?: string[] | null; // متریال‌های تأمین‌شده
// v2.6: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) — بک‌اند این را می‌فرستد
category_id?: string | null;
category?: { id: string; slug: string; name: string } | null;
@@ -402,21 +395,6 @@ interface RawQuote {
line_items?: RawLineItem[] | null;
termsheets?: RawTermsheet[] | null;
}
interface RawCategoryUnit {
id: string;
category_id: string;
ref_unit: string;
category?: RawCategory | null;
}
interface RawUnitConversion {
id: string;
category_id: string;
brand?: string | null;
from_unit: string;
to_unit: string;
factor: number;
category?: RawCategory | null;
}
interface RawStandardConversion {
id: string;
from_unit: string;
@@ -562,9 +540,6 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
topic:
raw.topic ||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
// v2.7: دسته‌ی متریال در بک‌اند از نخستین تأمین‌کننده‌ی متصل می‌آید
// (متریالِ بدون تأمین‌کننده → خالی)
supplier_category: raw.category?.name || "",
brand: raw.brands?.[0]?.name || "",
// مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV)
specs: (raw.attributes || [])
@@ -645,15 +620,13 @@ function adaptQuote(raw: RawQuote): Quote {
}
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
// v2.6/v2.7: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل
// category برمی‌گرداند — همین اولویت اول است. categories قدیمی (ستون حذف‌شده)
// و دسته‌های استنتاج‌شده از پیشنهادها فقط پشتیبان‌اند.
// هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل category
// برمی‌گرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است.
const own = raw.category?.name ? [raw.category.name] : [];
const legacy = Array.isArray(raw.categories) ? raw.categories.filter(Boolean) : [];
return {
id: raw.id,
topic,
categories: own.length ? own : legacy.length ? legacy : categories,
categories: own.length ? own : categories,
name: raw.name,
location: raw.location || "",
phone: raw.phone || "",
@@ -671,54 +644,29 @@ function deriveSupplierTopics(
materialsById: Map<string, Material>,
): Supplier[] {
const topics = new Map<string, Set<string>>();
const cats = new Map<string, Set<string>>();
for (const q of quotes) {
for (const li of q.line_items) {
const m = materialsById.get(li.material_id);
if (!m) continue;
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set());
if (m.topic) topics.get(q.supplier_id)!.add(m.topic);
if (m.supplier_category) cats.get(q.supplier_id)!.add(m.supplier_category);
}
}
return rawSuppliers.map((s) => {
const t = topics.get(s.id);
const c = cats.get(s.id);
const derivedTopic = t && t.size ? [...t][0] : "";
const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : [];
// اگر از quotes چیزی پیدا نشد، registry را چک کن
if (!derivedTopic && !derivedCats.length) {
if (!derivedTopic) {
const reg = supplierTopicRegistry.get(s.id);
if (reg) {
return adaptSupplier(s, reg.topic, reg.categories);
}
if (reg) return adaptSupplier(s, reg.topic, reg.categories);
}
// v2.7: متریال‌ها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً
// دسته‌ای نمی‌دهد و صفحه‌ی تأمین‌کننده‌ها بی‌نام می‌شد. دستهٔ ثبت‌شده روی
// خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است؛ نامش با نام تاپیک‌ها یکی است.
// دستهٔ ثبت‌شده روی خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است و نامش با نام
// تاپیک‌ها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج می‌شود.
const backendCat = s.category?.name || "";
return adaptSupplier(
s,
derivedTopic || backendCat,
derivedCats.length ? derivedCats : backendCat ? [backendCat] : [],
);
return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []);
});
}
function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit {
return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit };
}
function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion {
return {
id: raw.id,
category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "",
brand: raw.brand || "",
from_unit: raw.from_unit,
to_unit: raw.to_unit,
factor: raw.factor,
};
}
function adaptStandardConversion(raw: RawStandardConversion): StandardConversion {
return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor };
}
@@ -836,16 +784,14 @@ function registerSupplierTopic(id: string, topic: string, categories: string[]):
export const api = {
// ---------- کل store (یک بار در ریشه) ----------
async listAll(): Promise<Store> {
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] =
const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] =
await Promise.all([
getData<RawQuote[]>(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`),
getData<RawSupplier[]>(`/suppliers?page_size=${PAGE_SIZE}`),
getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`),
getData<RawProject[]>(`/projects?page_size=${PAGE_SIZE}`),
getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`),
getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`),
getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`),
getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`),
getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`),
]);
@@ -860,8 +806,6 @@ export const api = {
materials,
projects: projectsRes.map((p) => ({ id: p.id, name: p.name })),
quotes,
categoryUnits: cruRes.map(adaptCategoryUnit),
unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)),
standardConversions: scuRes.map(adaptStandardConversion),
topics: buildTopics(categoriesRes),
};
@@ -1136,7 +1080,7 @@ export const api = {
materials: {
list: async (): Promise<Material[]> => {
const raw = await getData<RawMaterial[]>(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
const raw = await getData<RawMaterial[]>(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`);
const units = await getData<RawUnit[]>(`/units?page_size=${PAGE_SIZE}`);
const categories = await getData<RawMaterialCategory[]>(`/material-categories?page_size=${PAGE_SIZE}`);
return raw.map((m) => adaptMaterial(m, buildLookup(units, categories)));
@@ -1196,46 +1140,6 @@ export const api = {
},
units: {
getCategoryUnits: async (): Promise<CategoryUnit[]> => {
const raw = await getData<RawCategoryUnit[]>(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`);
return raw.map(adaptCategoryUnit);
},
saveCategoryUnit: async (cu: CategoryUnit): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(cu.category));
if (!categoryId) throw new Error(`دسته‌بندی «${cu.category}» یافت نشد`);
await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit });
},
getConversions: async (): Promise<UnitConversion[]> => {
const lookup = await fetchLookup();
const raw = await getData<RawUnitConversion[]>(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`);
return raw.map((uc) => adaptUnitConversion(uc, lookup));
},
saveConversion: async (c: UnitConversion): Promise<void> => {
const lookup = await fetchLookup();
const categoryId = lookup.categoryNameToId.get(norm(c.category));
if (!categoryId) throw new Error(`دسته‌بندی «${c.category}» یافت نشد`);
const body = {
category_id: categoryId,
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
};
if (looksLikeUuid(c.id)) {
await patchData(`/unit-conversions/${c.id}`, {
brand: c.brand || null,
from_unit: c.from_unit,
to_unit: c.to_unit,
factor: c.factor,
});
} else {
await postData("/unit-conversions", body);
}
},
deleteConversion: async (id: string): Promise<void> => {
await deleteData(`/unit-conversions/${id}`);
},
getStandardConversions: async (): Promise<StandardConversion[]> => {
const raw = await getData<RawStandardConversion[]>(`/standard-conversions?page_size=${PAGE_SIZE}`);
return raw.map(adaptStandardConversion);
@@ -1781,15 +1685,6 @@ export const apiV26 = {
types: async (): Promise<{ type: TransactionType; label: string }[]> => {
return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types");
},
materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => {
const params: Record<string, string> = { category_id };
if (warehouse_id) params.warehouse_id = warehouse_id;
const pid = activeProjectId();
if (pid) params.project_id = pid;
return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>(
`/inventory/materials/options?${new URLSearchParams(params).toString()}`,
);
},
},
pm: {