From 8adfd2267fb88493db1b14aa184148adac367c16 Mon Sep 17 00:00:00 2001 From: "reza.g" Date: Sat, 19 Sep 2026 13:19:26 +0330 Subject: [PATCH] refactor(catalog)!: decouple materials from categories (v2.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/app/db/init_db.py | 12 +-- backend/app/models/catalog.py | 6 +- backend/app/routers/materials.py | 13 ++- backend/app/routers/pm.py | 16 ++-- backend/app/routers/quotes.py | 5 +- backend/app/routers/supplier_materials.py | 9 +-- backend/app/schemas/catalog.py | 1 - backend/app/schemas/procurement.py | 2 +- backend/app/services/expand_service.py | 14 +--- backend/app/services/material_service.py | 9 +-- backend/app/services/quote_service.py | 9 +-- .../app/services/supplier_material_service.py | 16 +--- backend/tools/seed.py | 6 +- frontend/src/lib/api.ts | 8 -- frontend/src/lib/quoteUtils.ts | 49 ++++++----- frontend/src/lib/types.ts | 4 +- frontend/src/pages/Dashboard/QuoteCard.tsx | 81 ++++--------------- frontend/src/pages/Wizard/Step2LineItems.tsx | 9 ++- sql/migration.sql | 24 +----- 19 files changed, 92 insertions(+), 201 deletions(-) diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 5201498..d8e175c 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -379,16 +379,16 @@ async def ensure_v26(engine: AsyncEngine) -> None: "VALUES (:code, :name_fa, :dimension, :si_factor, :is_base) ON CONFLICT (code) DO NOTHING", u, ) - # v2.5 automation triggers (idempotent — DROP + CREATE keeps them in sync). + # v2.5/v2.6 category decoupling from materials: drop trigger, function, and column. await run_statement("DROP TRIGGER IF EXISTS trg_supplier_materials_derive_category ON supplier_materials") - await run_statement(""" - CREATE TRIGGER trg_supplier_materials_derive_category - AFTER INSERT ON supplier_materials - FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category() - """) + await run_statement("DROP FUNCTION IF EXISTS fn_derive_material_category()") + await run_statement("ALTER TABLE materials DROP CONSTRAINT IF EXISTS materials_category_id_fkey") + await run_statement("ALTER TABLE materials DROP COLUMN IF EXISTS category_id") + await run_statement("DROP TRIGGER IF EXISTS trg_item_decisions_sync_price ON item_decisions") await run_statement(""" CREATE TRIGGER trg_item_decisions_sync_price AFTER INSERT OR UPDATE OF status ON item_decisions FOR EACH ROW EXECUTE FUNCTION fn_sync_last_quoted_price() """) + diff --git a/backend/app/models/catalog.py b/backend/app/models/catalog.py index e9e776c..d0cd78e 100644 --- a/backend/app/models/catalog.py +++ b/backend/app/models/catalog.py @@ -130,14 +130,11 @@ class CategoryAttribute(Base): class Material(Base): __tablename__ = "materials" - # v2.5/2.6: name is GLOBALLY unique; category_id is nullable + derived. + # v2.6/v2.7: name is GLOBALLY unique; decoupled from categories. __table_args__ = (UniqueConstraint("name", name="uq_material_name"),) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = _uuid_pk() - category_id: Mapped[uuid.UUID | None] = mapped_column( - ForeignKey("material_categories.id", ondelete="RESTRICT") - ) name: Mapped[str] = mapped_column(Text, nullable=False) topic: Mapped[str | None] = mapped_column(Text) # تاپیک جلوی برنامه (Persian) — legacy canonical_unit_id: Mapped[int] = mapped_column( @@ -149,7 +146,6 @@ class Material(Base): DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) - category: Mapped["MaterialCategory | None"] = relationship(lazy="raise") canonical_unit: Mapped["Unit"] = relationship(lazy="raise") attribute_values: Mapped[list["MaterialAttributeValue"]] = relationship( back_populates="material", lazy="raise", passive_deletes=True diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py index 42d39f0..c568f80 100644 --- a/backend/app/routers/materials.py +++ b/backend/app/routers/materials.py @@ -18,7 +18,7 @@ from app.models.catalog import ( MaterialUnit, Unit, ) -from app.models.procurement import LineItem +from app.models.procurement import LineItem, Supplier, SupplierMaterial from app.schemas.catalog import ( MaterialAttributeValuesPut, MaterialCreate, @@ -108,7 +108,16 @@ async def list_materials( ): stmt = select(Material) if category_id: - stmt = stmt.where(Material.category_id == category_id) + sub = ( + select(1) + .select_from(SupplierMaterial) + .join(Supplier, Supplier.id == SupplierMaterial.supplier_id) + .where( + SupplierMaterial.material_id == Material.id, + Supplier.category_id == category_id, + ) + ) + stmt = stmt.where(exists(sub)) if brand_id: # WHERE EXISTS brand_materials with that brand_id sub = select(1).where( diff --git a/backend/app/routers/pm.py b/backend/app/routers/pm.py index d5ea10f..1e2a5bf 100644 --- a/backend/app/routers/pm.py +++ b/backend/app/routers/pm.py @@ -195,17 +195,11 @@ async def pm_on_the_way( # unit / category lookups for display unit_ids = {d.LineItem.unit_id for d in decisions} units = {u.id: u for u in (await db.execute(select(Unit).where(Unit.id.in_(unit_ids)))).scalars()} - mat_ids = {d.Material.id for d in decisions} + sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id} cats = {} - if mat_ids: - rows = ( - await db.execute( - select(Material.id, MaterialCategory) - .join(MaterialCategory, MaterialCategory.id == Material.category_id) - .where(Material.id.in_(mat_ids)) - ) - ).all() - cats = {m_id: c for m_id, c in rows} + if sup_cat_ids: + rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(sup_cat_ids)))).scalars().all() + cats = {c.id: c for c in rows} out = [] for d, li, q, m, sup, proj in decisions: @@ -220,7 +214,7 @@ async def pm_on_the_way( "project": {"id": str(proj.id), "name": proj.name} if proj else None, "decided_at": to_iso(d.decided_at), "material": {"id": str(m.id), "name": m.name, - "category": {"id": str(c.id), "name": c.name} if (c := cats.get(m.id)) else None}, + "category": {"id": str(c.id), "name": c.name} if (c := cats.get(sup.category_id)) else None}, "unit": {"id": units[li.unit_id].id, "code": units[li.unit_id].code, "name_fa": units[li.unit_id].name_fa} if li.unit_id in units else None, "ordered_qty": str(li.quantity), diff --git a/backend/app/routers/quotes.py b/backend/app/routers/quotes.py index ee6249a..8aaef91 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -39,9 +39,8 @@ router = APIRouter(prefix="/quotes", tags=["quotes"]) def _quote_category_exists(category_id) -> exists: return exists( select(1).where( - LineItem.quote_id == Quote.id, - Material.id == LineItem.material_id, - Material.category_id == category_id, + Supplier.id == Quote.supplier_id, + Supplier.category_id == category_id, ) ) diff --git a/backend/app/routers/supplier_materials.py b/backend/app/routers/supplier_materials.py index fb8a975..26e4026 100644 --- a/backend/app/routers/supplier_materials.py +++ b/backend/app/routers/supplier_materials.py @@ -58,11 +58,7 @@ async def list_supplier_materials( items, meta = await paginate(stmt, page, page_size, db) mat_ids = {l.material_id for l in items} materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()} - cat_ids = {m.category_id for m in materials.values() if m.category_id} - cats = {} - if cat_ids: - cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()} - return {"data": [link_to_dict(l, materials.get(l.material_id), cats.get(materials.get(l.material_id).category_id if l.material_id in materials else None)) for l in items], "meta": meta} + return {"data": [link_to_dict(l, materials.get(l.material_id), None) for l in items], "meta": meta} @router.post("/suppliers/{supplier_id}/materials", status_code=201) @@ -72,8 +68,7 @@ async def create_supplier_material( async with db.begin(): link = await link_supplier_material(db, supplier_id, payload.material_id) material = await db.get(Material, payload.material_id) - category = await db.get(MaterialCategory, material.category_id) if material and material.category_id else None - return {"data": link_to_dict(link, material, category)} + return {"data": link_to_dict(link, material, None)} @router.delete("/suppliers/{supplier_id}/materials/{material_id}") diff --git a/backend/app/schemas/catalog.py b/backend/app/schemas/catalog.py index 7ba47ae..9129cf7 100644 --- a/backend/app/schemas/catalog.py +++ b/backend/app/schemas/catalog.py @@ -89,7 +89,6 @@ class MaterialAttributeIn(APIModel): class MaterialCreate(APIModel): - category_id: UUID | None = None # v2.5: optional — derived from first linked supplier name: str = Field(min_length=1) topic: str | None = None # تاپیک جلوی برنامه (Persian) — legacy canonical_unit_id: int diff --git a/backend/app/schemas/procurement.py b/backend/app/schemas/procurement.py index 4d4946b..2d6f0b6 100644 --- a/backend/app/schemas/procurement.py +++ b/backend/app/schemas/procurement.py @@ -37,7 +37,7 @@ class SupplierUpdate(APIModel): class SupplierMaterialCreate(APIModel): - """Link a global material to a supplier (derives materials.category_id).""" + """Link a global material to a supplier.""" material_id: UUID diff --git a/backend/app/services/expand_service.py b/backend/app/services/expand_service.py index 4ae44f0..70adc1d 100644 --- a/backend/app/services/expand_service.py +++ b/backend/app/services/expand_service.py @@ -206,11 +206,6 @@ async def _load_material_maps(db: AsyncSession, materials: list[Material], expan material_ids = [m.id for m in materials] maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}} - if "category" in expand: - cat_ids = {m.category_id for m in materials} - rows = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all() - maps["categories"] = {c.id: c for c in rows} - unit_ids: set[int] = set() if "canonical_unit" in expand: unit_ids |= {m.canonical_unit_id for m in materials} @@ -258,9 +253,9 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict: "updated_at": to_iso(m.updated_at), } if "category" in expand: - d["category"] = category_brief(maps["categories"].get(m.category_id)) + d["category"] = None else: - d["category_id"] = str(m.category_id) + d["category_id"] = None if "canonical_unit" in expand: d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id)) else: @@ -326,9 +321,6 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp material_ids = {li.material_id for li in line_items} materials = (await db.execute(select(Material).where(Material.id.in_(material_ids)))).scalars().all() data.materials = {m.id: m for m in materials} - cat_ids = {m.category_id for m in materials} - cats = (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars().all() - data.categories = {c.id: c for c in cats} bms = (await db.execute(select(BrandMaterial).where(BrandMaterial.material_id.in_(material_ids)))).scalars().all() brand_ids: set[uuid.UUID] = set() for bm in bms: @@ -450,7 +442,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc d["material"] = { "id": str(m.id), "name": m.name, - "category": category_brief(data.categories.get(m.category_id)), + "category": None, "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/backend/app/services/material_service.py b/backend/app/services/material_service.py index 0da33f2..f1e8655 100644 --- a/backend/app/services/material_service.py +++ b/backend/app/services/material_service.py @@ -12,7 +12,6 @@ from app.models.catalog import ( BrandMaterial, Material, MaterialAttributeValue, - MaterialCategory, Unit, ) from app.schemas.catalog import MaterialAttributeIn, MaterialCreate, MaterialUpdate @@ -110,9 +109,7 @@ async def validate_attributes( async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material: """Create a material + brand_materials rows + EAV values in ONE transaction. - v2.5/2.6: category_id is OPTIONAL — a global material may be created - without a category (it gets derived automatically from the first linked - supplier). name is GLOBALLY unique. + v2.6/v2.7: materials are global and category-free. name is GLOBALLY unique. """ async with db.begin(): duplicate = await db.scalar( @@ -121,14 +118,10 @@ async def create_material(db: AsyncSession, payload: MaterialCreate) -> Material if duplicate is not None: raise ConflictError("متریالی با این نام در کل کاتالوگ قبلاً ثبت شده است", field="name") - if payload.category_id is not None: - if await db.get(MaterialCategory, payload.category_id) is None: - raise NotFoundError("دسته‌بندی یافت نشد", field="category_id") if not await db.get(Unit, payload.canonical_unit_id): raise NotFoundError("واحد مرجع یافت نشد", field="canonical_unit_id") material = Material( - category_id=payload.category_id, name=payload.name, topic=payload.topic, canonical_unit_id=payload.canonical_unit_id, diff --git a/backend/app/services/quote_service.py b/backend/app/services/quote_service.py index 0cb0701..9f091c5 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -903,15 +903,12 @@ async def quote_stats( def _quote_has_category(category_id: UUID): - """EXISTS: quote has a line item whose material belongs to category_id.""" - from app.models.catalog import Material - + """EXISTS: quote's supplier belongs to category_id.""" sub = ( select(1) .where( - LineItem.quote_id == Quote.id, - Material.id == LineItem.material_id, - Material.category_id == category_id, + Supplier.id == Quote.supplier_id, + Supplier.category_id == category_id, ) ) return func.exists(sub) diff --git a/backend/app/services/supplier_material_service.py b/backend/app/services/supplier_material_service.py index 28d8982..4f3c002 100644 --- a/backend/app/services/supplier_material_service.py +++ b/backend/app/services/supplier_material_service.py @@ -1,10 +1,6 @@ -"""Supplier ↔ material link service (v2.5) — the direct edge of the new flow. +"""Supplier ↔ material link service (v2.6/v2.7) — the direct edge of the catalog flow. -Linking a material to a supplier derives materials.category_id from the -supplier's category when the material has none (first-link-wins). The same -rule is enforced by the DB trigger trg_supplier_materials_derive_category; -this service applies it in the application layer so the ORM state is -consistent inside the same transaction. +Suppliers can supply any global material across categories. """ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -17,7 +13,7 @@ from app.utils.errors import NotFoundError, ValidationError async def link_supplier_material( db: AsyncSession, supplier_id, material_id, is_active: bool = True ) -> SupplierMaterial: - """Upsert a supplier<->material link (idempotent) + derive category (first-link-wins). + """Upsert a supplier<->material link (idempotent). Caller is responsible for the surrounding transaction. """ @@ -42,15 +38,11 @@ async def link_supplier_material( link = SupplierMaterial(supplier_id=supplier_id, material_id=material_id, is_active=is_active) db.add(link) await db.flush() - - # First-link-wins: only when the material has no category yet. - if material.category_id is None: - material.category_id = supplier.category_id return link async def unlink_supplier_material(db: AsyncSession, supplier_id, material_id) -> None: - """Remove the link. The material's category is NEVER auto-cleared (documented rule).""" + """Remove the link.""" link = await db.scalar( select(SupplierMaterial).where( SupplierMaterial.supplier_id == supplier_id, diff --git a/backend/tools/seed.py b/backend/tools/seed.py index 08ba223..64d1044 100644 --- a/backend/tools/seed.py +++ b/backend/tools/seed.py @@ -261,9 +261,7 @@ async def run() -> None: material_by_name: dict[str, Material] = {} brand_material_by_material: dict[str, object] = {} for name, cat_slug, unit_code, brand_name in MATERIALS: - cat = await get_category(db, cat_slug) material = Material( - category_id=cat.id, name=name, canonical_unit_id=unit_ids[unit_code], status="active", @@ -289,8 +287,10 @@ async def run() -> None: await db.flush() suppliers.append(sup) # last-quoted prices: link each supplier to 2 materials of its category + sup_cat_slug = {name: cat_slug for name, cat_slug, *_ in SUPPLIERS} for sup in suppliers: - cat_materials = [m for m in material_by_name.values() if m.category_id == sup.category_id] + target_slug = sup_cat_slug.get(sup.name) + cat_materials = [material_by_name[mat_name] for mat_name, c_slug, *_ in MATERIALS if c_slug == target_slug] for m in cat_materials[:2]: db.add(SupplierMaterial( supplier_id=sup.id, material_id=m.id, is_active=True, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e832a05..10eecc9 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1108,12 +1108,6 @@ export const api = { }, create: async (m: Material): Promise => { 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("/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", diff --git a/frontend/src/lib/quoteUtils.ts b/frontend/src/lib/quoteUtils.ts index bf3fc9d..9cc0da9 100644 --- a/frontend/src/lib/quoteUtils.ts +++ b/frontend/src/lib/quoteUtils.ts @@ -35,43 +35,51 @@ export function specsKey(specs: string[]): string { return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|"); } -// specها با جداکننده‌ی « · » +// specها با جداکننده‌ی « . » export function specsLabel(specs: string[]): string { - return specs.join(" · "); + return specs.join(" . "); } /** * عنوان تمیز و بدون تکرار کالا: - * ترکیب هوشمندانه‌ی نام اصلی (materials.name)، برند، و مشخصه‌ها بدون تکرار کلمات مشترک. + * ترتیب اکید با جداکننده‌ی نقطه: + * [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲] */ export function formatMaterialTitle( m: Pick | null | undefined, extraSpecs?: string[], ): string { if (!m) return "کالا"; - const rawName = (m.name || "").trim(); + let rawName = (m.name || "").trim(); const rawBrand = (m.brand || "").trim(); const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])] .map((s) => (s || "").trim()) .filter(Boolean); - const normName = normalize(rawName); const normBrand = normalize(rawBrand); - if (!rawName) { - if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`; - if (rawBrand) return rawBrand; - if (rawSpecs.length) return rawSpecs.join(" · "); - return m.category && m.category !== "؟" ? m.category : "کالا"; + // اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمی‌داریم تا تکرار نشود + if (normBrand && rawName) { + const normName = normalize(rawName); + if (normName.startsWith(normBrand)) { + const rest = rawName.slice(rawBrand.length).trim(); + rawName = rest.replace(/^[.\s·\-]+/, "").trim(); + } } - let title = rawName; - if (normBrand && !normName.includes(normBrand)) { - title = `${rawBrand} ${title}`; + // قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2] + const parts: string[] = []; + if (rawBrand) { + parts.push(rawBrand); + } + if (rawName) { + parts.push(rawName); + } else if (!rawBrand) { + parts.push(m.category && m.category !== "؟" ? m.category : "کالا"); } - const normTitle = normalize(title); - const distinctSpecs: string[] = []; + // اضافه کردن مشخصه‌ها بدون تکرار + const normCurrent = normalize(parts.join(" ")); const seenNormSpecs = new Set(); for (const s of rawSpecs) { @@ -79,15 +87,12 @@ export function formatMaterialTitle( if (!ns || seenNormSpecs.has(ns)) continue; seenNormSpecs.add(ns); - // اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمی‌کنیم - if (normTitle.includes(ns)) continue; - distinctSpecs.push(s); + // اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمی‌کنیم + if (normCurrent.includes(ns)) continue; + parts.push(s); } - if (distinctSpecs.length > 0) { - return `${title} · ${distinctSpecs.join(" · ")}`; - } - return title; + return parts.join(" . "); } export function materialLabel(m: Pick | null | undefined): string { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 49a2a38..d1da883 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -20,9 +20,9 @@ export interface Supplier { // ماده/کالا export interface Material { id: string; // "m1", "m2", ... - name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + دسته + مشخصه) + name: string; // نام کامل ذخیره‌شده در بک‌اند (برند + مشخصه) topic: string; - category: string; // مثلاً "میلگرد" + category: string; // اختیاری در بک‌اند؛ همیشه string در فرانت (default: "") brand: string; // مثلاً "ذوب آهن" specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"]) base_unit: string; // واحد پایه diff --git a/frontend/src/pages/Dashboard/QuoteCard.tsx b/frontend/src/pages/Dashboard/QuoteCard.tsx index 3852e17..5f210e3 100644 --- a/frontend/src/pages/Dashboard/QuoteCard.tsx +++ b/frontend/src/pages/Dashboard/QuoteCard.tsx @@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre import type { Quote, Store } from "../../lib/types"; import { isExpired, - itemsSummary, materialById, materialLabel, projectCell, @@ -44,7 +43,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit const pc = priceCell(total); const ts = termsheetSummary(store, q); const unitWarns = unitWarning(store, q); - const summary = itemsSummary(store, q); // ستون / دکمه پذیرش let acceptedCell: React.ReactNode; @@ -188,47 +186,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit - {/* ۲. اقلام — تمام‌عرض زیر پروژه با شکستن متن بدون سه‌نقطه */} + {/* ۲. اقلام — نمایش عمودی چندخطی */} - {summary.count > 1 ? ( -
- - {fmtNum(summary.count)} قلم کالا - -
- {store.quotes - .find((x) => x.id === q.id) - ?.line_items?.filter((li) => !li.rejected) - .slice(0, 3) - .map((li) => { - const m = materialById(store.materials, li.material_id); - return ( -
- - • {materialLabel(m)} - - - {fmtNum(li.quantity)}{" "} - {li.unit} - -
- ); - })} - {(q.line_items?.length || 0) > 3 && ( - - و {(q.line_items?.length || 0) - 3} قلم دیگر... +
+ {activeItems.length > 0 ? ( + activeItems.map((li) => { + const m = materialById(store.materials, li.material_id); + return ( + + {materialLabel(m)} - )} -
-
- ) : ( -
- {summary.text} -
- )} + ); + }) + ) : ( + — + )} +
{unitWarns.map((w) => ( - - - - - {(() => { - const activeItems = (q.line_items || []).filter((li) => !li.rejected); - if (q.status === "accepted" || q.status === "arrived") { - // فقط پروژه‌ی هدف پذیرفته‌شده — همان منطق itemProjects در وضعیت accepted - const targetProj = activeItems.find((li) => li.target_project)?.target_project; - return targetProj?.name ?? projectCell(q); - } - // وضعیت offered: همه‌ی پروژه‌های کاندیدا از line_items (همان منطق itemProjects) - const names = new Set(); - activeItems.forEach((li) => - (li.projects || []).forEach((p) => { - const name = typeof p === "string" ? p : p.name; - if (name) names.add(name); - }) - ); - if (names.size > 0) return [...names].join("، "); - return projectCell(q); - })()} - - - - {pc ? (
diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx index dcd37e1..f9c749d 100644 --- a/frontend/src/pages/Wizard/Step2LineItems.tsx +++ b/frontend/src/pages/Wizard/Step2LineItems.tsx @@ -67,17 +67,18 @@ function materialBaseName(name: string, brand: string, specs: string[], category return base || fallback; } -// مقادیر مجزای category/brand/spec داخل تاپیک +// مقادیر مجزای category/brand/spec در کاتالوگ سراسری export function materialOptions( materials: Material[], - topic: string, + _topic: string, item: ItemDraft, field: "cat" | "brand" | "spec", allItems: ItemDraft[] = [], apiBrands: string[] = [], apiSpecs: string[] = [], ): string[] { - let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic)); + // متریال‌ها سراسری هستند — مستقل از تاپیک + let list = materials; const cat = item.cat.trim(); const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => { @@ -92,7 +93,7 @@ export function materialOptions( }; if (field === "cat") { - const base = new Set(list.map((m) => m.category).filter(Boolean)); + 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")); } diff --git a/sql/migration.sql b/sql/migration.sql index 1944fe8..7bc4b00 100644 --- a/sql/migration.sql +++ b/sql/migration.sql @@ -164,14 +164,12 @@ CREATE TABLE category_attributes ( CONSTRAINT uq_category_attribute UNIQUE (category_id, attribute_definition_id) ); --- ---------- MATERIALS (GLOBAL catalog entities — v2.5) ---------- --- category_id is NULLABLE and DERIVED automatically from the first linked --- supplier (supplier_materials insert, first-link-wins). name is GLOBALLY +-- ---------- MATERIALS (GLOBAL catalog entities — v2.6/v2.7) ---------- +-- Materials are global, category-free entities. name is GLOBALLY -- unique (uq_material_name). topic is a legacy display column no longer -- written by the app. CREATE TABLE materials ( id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - category_id UUID REFERENCES material_categories(id) ON DELETE RESTRICT, name TEXT NOT NULL, topic TEXT, -- LEGACY display column canonical_unit_id INTEGER NOT NULL REFERENCES units(id) ON DELETE RESTRICT, @@ -585,24 +583,6 @@ CREATE TRIGGER trg_quote_attachments_updated_at -- AUTOMATION TRIGGERS (v2.5 + v2.6) -- ============================================================================ --- Derive materials.category_id from the FIRST linked supplier (first-link-wins). --- Idempotent: only touches rows where category_id IS NULL. -CREATE OR REPLACE FUNCTION fn_derive_material_category() -RETURNS TRIGGER AS $$ -BEGIN - UPDATE materials - SET category_id = (SELECT category_id FROM suppliers WHERE id = NEW.supplier_id), - updated_at = now() - WHERE id = NEW.material_id - AND category_id IS NULL; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_supplier_materials_derive_category - AFTER INSERT ON supplier_materials - FOR EACH ROW EXECUTE FUNCTION fn_derive_material_category(); - -- Sync supplier_materials.last_quoted_price / last_quoted_at when an item -- decision becomes 'chosen' (INSERT or UPDATE — the accept-quote flow -- inserts decisions directly as 'chosen').