From ae62a0391bbfc7efb7b2a1b43ffb25ee492bf40a Mon Sep 17 00:00:00 2001 From: ZCode Date: Sun, 20 Sep 2026 18:51:29 +0000 Subject: [PATCH] fix(catalog): resolve material category through the linked supplier (v2.7 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- backend/app/routers/inventory.py | 42 +++++++--- backend/app/routers/material_categories.py | 13 ++- backend/app/services/expand_service.py | 51 +++++++++++- frontend/src/lib/api.ts | 16 ++-- frontend/src/lib/quoteUtils.ts | 80 +++++++++++++++++-- frontend/src/lib/types.ts | 7 +- .../src/pages/Dashboard/DashboardPage.tsx | 7 +- frontend/src/pages/Dashboard/EditModal.tsx | 19 ++--- .../pages/Suppliers/SupplierDetailPage.tsx | 5 +- .../src/pages/Suppliers/supplierUtils.tsx | 13 +-- .../src/pages/Wizard/Step1TopicSupplier.tsx | 13 +-- frontend/src/pages/Wizard/Step2LineItems.tsx | 58 ++++---------- frontend/src/pages/Wizard/WizardPage.tsx | 27 +++---- 13 files changed, 222 insertions(+), 129 deletions(-) diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index c7456d0..55365e8 100644 --- a/backend/app/routers/inventory.py +++ b/backend/app/routers/inventory.py @@ -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: diff --git a/backend/app/routers/material_categories.py b/backend/app/routers/material_categories.py index 99409aa..8611a74 100644 --- a/backend/app/routers/material_categories.py +++ b/backend/app/routers/material_categories.py @@ -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}} diff --git a/backend/app/services/expand_service.py b/backend/app/services/expand_service.py index 70adc1d..c0d5b9c 100644 --- a/backend/app/services/expand_service.py +++ b/backend/app/services/expand_service.py @@ -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, []) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 869747c..4582550 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -562,7 +562,9 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material { topic: raw.topic || (raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""), - category: raw.category?.name || "", + // v2.7: دسته‌ی متریال در بک‌اند از نخستین تأمین‌کننده‌ی متصل می‌آید + // (متریالِ بدون تأمین‌کننده → خالی) + supplier_category: raw.category?.name || "", brand: raw.brands?.[0]?.name || "", // مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV) specs: (raw.attributes || []) @@ -643,11 +645,15 @@ function adaptQuote(raw: RawQuote): Quote { } function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier { + // v2.6/v2.7: هر تأمین‌کننده دقیقاً یک دسته دارد (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, - // دسته‌های ذخیره‌شده روی تأمین‌کننده (بک‌اند) اولویت دارند؛ وگرنه از quotes استنتاج می‌شود - categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories, + categories: own.length ? own : legacy.length ? legacy : categories, name: raw.name, location: raw.location || "", phone: raw.phone || "", @@ -673,7 +679,7 @@ function deriveSupplierTopics( 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.category) cats.get(q.supplier_id)!.add(m.category); + if (m.supplier_category) cats.get(q.supplier_id)!.add(m.supplier_category); } } return rawSuppliers.map((s) => { @@ -1149,7 +1155,7 @@ export const api = { // name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصه‌ها در // attributes (EAV) ذخیره می‌شوند. اگر برند/مشخصه داخل name ریخته شود، // در ویزارد به‌عنوان «نامِ متریال» کش می‌شود و مشخصه‌ها در نام ظاهر می‌شوند. - const name = (m.category || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید"; + const name = (m.name || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید"; // مشخصه‌ها به‌صورت attribute (EAV) ذخیره می‌شوند تا بعد از رفرش دیده شوند const attrs: { attribute_definition_id: string; value: string }[] = []; const seen = new Set(); diff --git a/frontend/src/lib/quoteUtils.ts b/frontend/src/lib/quoteUtils.ts index 49cac01..0b79bae 100644 --- a/frontend/src/lib/quoteUtils.ts +++ b/frontend/src/lib/quoteUtils.ts @@ -17,7 +17,7 @@ export function materialById(materials: Material[], id: string): Material { id: "", name: "", topic: "", - category: "؟", + supplier_category: "", brand: "", specs: [], base_unit: "", @@ -46,7 +46,7 @@ export function specsLabel(specs: string[]): string { * [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲] */ export function formatMaterialTitle( - m: Pick | null | undefined, + m: Pick | null | undefined, extraSpecs?: string[], ): string { if (!m) return "کالا"; @@ -75,7 +75,7 @@ export function formatMaterialTitle( if (rawName) { parts.push(rawName); } else if (!rawBrand) { - parts.push(m.category && m.category !== "؟" ? m.category : "کالا"); + parts.push("کالا"); } // اضافه کردن مشخصه‌ها بدون تکرار @@ -95,10 +95,68 @@ export function formatMaterialTitle( return parts.join(" . "); } -export function materialLabel(m: Pick | null | undefined): string { +export function materialLabel(m: Pick | null | undefined): string { return formatMaterialTitle(m); } +// نام پایه‌ی متریال از نامِ ذخیره‌شده. +// متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و +// مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی +// name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا +// نامِ پایه باقی بماند. دسته‌ی همان رکوردهای قدیمی همان دسته‌ی نخستین +// تأمین‌کننده است (supplier_category). +export 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; +} + +/** + * یافتن متریالِ متناظر با یک ردیفِ اقلام. + * + * هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در این تطبیق بود، ولی + * دسته دیگر روی متریال نیست؛ تکیه بر نام جلوی اشتباه‌گرفتن دو متریال با + * برند و مشخصه‌ی یکسان (مثل دو چمن مصنوعیِ هم‌برند) را می‌گیرد. نامِ خالی یعنی + * هویت نامعلوم — در آن حالت تطبیق نمی‌دهیم و متریالِ تازه ساخته می‌شود. + */ +export function matchMaterial( + materials: Material[], + name: string, + brand: string, + specs: string[], +): Material | undefined { + const wantName = normalize(name); + if (!wantName) return undefined; + const wantBrand = normalize(brand); + const wantSpecs = specsKey(specs); + const sameIdentity = (m: Material) => normalize(m.brand) === wantBrand && specsKey(m.specs) === wantSpecs; + const base = normalize(materialBaseName(name, brand, specs)); + return ( + materials.find((m) => normalize(m.name) === wantName && sameIdentity(m)) || + materials.find( + (m) => + base && + normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === base && + sameIdentity(m), + ) + ); +} + // آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید) export function itemTermsheetIds(li: LineItem): string[] { if (li.termsheet_ids) return li.termsheet_ids; @@ -196,9 +254,12 @@ export function refConverted( li: LineItem, ): { qty: number; unit: string; perRef: number | null } | null { const m = materialById(store.materials, li.material_id); - const ref = refUnitForCategory(store, m.category); + // categoryUnits/unitConversions با «نام دسته» کلید خورده‌اند؛ دسته‌ی متریال + // همان دسته‌ی نخستین تأمین‌کننده است (v2.7). متریال بدون تأمین‌کننده دسته + // ندارد و تبدیلش انجام نمی‌شود. + const ref = refUnitForCategory(store, m.supplier_category); if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null; - const conv = findConversion(store, m.category, m.brand, li.unit, ref); + const conv = findConversion(store, m.supplier_category, m.brand, li.unit, ref); if (!conv) return null; const qty = (li.quantity || 0) * conv.factor; const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0); @@ -300,12 +361,15 @@ export function filterQuotes( if (stKey && quote.status !== stKey) return false; const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id)); - if (catVal && !matInfo.some((m) => m.category === catVal)) return false; + // v2.7: دسته روی تأمین‌کننده است و بک‌اند هم پیشنهادها را با دسته‌ی + // تأمین‌کننده فیلتر می‌کند — همین قاعده اینجا هم اعمال می‌شود تا لیست و + // فیلتر یک رفتار داشته باشند. + if (catVal && !(supObj.categories || []).includes(catVal)) return false; if (q) { const hay = `${supObj.name} ${quote.project || ""} ` + - matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); + matInfo.map((m) => `${m.supplier_category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); if (!hay.toLowerCase().includes(q)) return false; } return true; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index d1da883..d748f9f 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -20,9 +20,12 @@ export interface Supplier { // ماده/کالا export interface Material { id: string; // "m1", "m2", ... - name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + مشخصه) + name: string; // نامِ خودِ متریال — برند در brand و مشخصه‌ها در specs topic: string; - category: string; // اختیاری در بک‌اند؛ همیشه string در فرانت (default: "") + // دسته‌ی نمایشی — از نخستین تأمین‌کننده‌ی متصل به متریال می‌آید (v2.7: + // خودِ متریال دیگر دسته ندارد و دسته روی تأمین‌کننده است). متریال بدون + // تأمین‌کننده = رشته‌ی خالی. این مقدار کلیدِ تبدیل‌های واحد هم هست. + supplier_category: string; brand: string; // مثلاً "ذوب آهن" specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"]) base_unit: string; // واحد پایه diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index c612fc5..36e372d 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -80,7 +80,12 @@ export function DashboardPage() { })); }; - const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]); + // دسته‌ها روی تأمین‌کننده هستند (v2.7) و بک‌اند هم فیلتر پیشنهادها را با + // دسته‌ی تأمین‌کننده انجام می‌دهد — پس گزینه‌های فیلتر از همان‌جا می‌آید. + const categoryOptions = useMemo( + () => [...new Set(suppliers.flatMap((s) => s.categories || []).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fa")), + [suppliers], + ); const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]); return ( diff --git a/frontend/src/pages/Dashboard/EditModal.tsx b/frontend/src/pages/Dashboard/EditModal.tsx index 5c52ad1..d93b859 100644 --- a/frontend/src/pages/Dashboard/EditModal.tsx +++ b/frontend/src/pages/Dashboard/EditModal.tsx @@ -14,11 +14,10 @@ import { useAddSupplier, useAddMaterial, } from "../../hooks"; -import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils"; +import { itemTermsheetIds, matchMaterial, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils"; import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants"; import { faNum } from "../../lib/formatters"; import { normalize } from "../../lib/normalize"; -import { specsKey } from "../../lib/quoteUtils"; import { Combobox } from "../../components/Combobox"; import { JalaliDatePicker } from "../../components/JalaliDatePicker"; import { NumberInput } from "../../components/NumberInput"; @@ -154,7 +153,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { return { key: li.id, origLi: li, - cat: m.category || "", + // «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛ + // دسته روی تأمین‌کننده است و دیگر برچسبِ این فیلد نیست) + cat: m.name || "", brand: m.brand || "", specs: materialSpecs(m), qty: li.quantity, @@ -294,12 +295,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { const total = row.total; if (!cat && !brand && qty == null && price == null) continue; - let material = materialsCopy.find( - (m) => - normalize(m.category) === normalize(cat) && - normalize(m.brand) === normalize(brand) && - specsKey(m.specs) === specsKey(specs), - ); + // هویت متریال = نام + برند + مشخصه (دیگر دسته‌ای برای تطبیق وجود ندارد) + let material = matchMaterial(materialsCopy, cat, brand, specs); let materialId: string; if (material) { materialId = material.id; @@ -308,10 +305,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { id: nextId("m", materialsCopy), name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید", topic: materialById(materials, row.origLi?.material_id || "").topic || "", - category: cat, brand, specs, base_unit: unit, + supplier_category: "", }; materialsCopy.push(nm); const created = await addMaterial.mutateAsync(nm); @@ -579,7 +576,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
- + { if (li.rejected) return; const m = materials.find((x) => x.id === li.material_id); - if (!m || !m.category) return; - out.add(m.category); + // دسته‌ی نمایشی متریال = دسته‌ی نخستین تأمین‌کننده‌ی متصل (v2.7) + if (!m || !m.supplier_category) return; + out.add(m.supplier_category); }); }); return [...out].sort((a, b) => a.localeCompare(b, "fa")); diff --git a/frontend/src/pages/Suppliers/supplierUtils.tsx b/frontend/src/pages/Suppliers/supplierUtils.tsx index 69fae33..d4fd689 100644 --- a/frontend/src/pages/Suppliers/supplierUtils.tsx +++ b/frontend/src/pages/Suppliers/supplierUtils.tsx @@ -1,20 +1,9 @@ import { Link } from "react-router-dom"; import { MapPin, Phone, User, Plus, Timer } from "lucide-react"; -import type { Material, Supplier } from "../../lib/types"; +import type { Supplier } from "../../lib/types"; import { normalize } from "../../lib/normalize"; import { fmtNum } from "../../lib/formatters"; -export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] { - const out = new Set(); - 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")); -} - export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] { return suppliers.filter( (s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)), diff --git a/frontend/src/pages/Wizard/Step1TopicSupplier.tsx b/frontend/src/pages/Wizard/Step1TopicSupplier.tsx index e336890..e162917 100644 --- a/frontend/src/pages/Wizard/Step1TopicSupplier.tsx +++ b/frontend/src/pages/Wizard/Step1TopicSupplier.tsx @@ -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(); - 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")); -} diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx index f9c749d..0ddd486 100644 --- a/frontend/src/pages/Wizard/Step2LineItems.tsx +++ b/frontend/src/pages/Wizard/Step2LineItems.tsx @@ -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(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(); 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); }; diff --git a/frontend/src/pages/Wizard/WizardPage.tsx b/frontend/src/pages/Wizard/WizardPage.tsx index bc1e2b4..920d35b 100644 --- a/frontend/src/pages/Wizard/WizardPage.tsx +++ b/frontend/src/pages/Wizard/WizardPage.tsx @@ -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) {