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) {