From b469238153f03305a4e44a103dbe349f2448f69b Mon Sep 17 00:00:00 2001 From: ZCode Date: Sun, 20 Sep 2026 19:44:23 +0000 Subject: [PATCH] refactor(catalog): reduce the category taxonomy to a supplier label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Categories are kept only as the label each supplier carries (suppliers.category_id + the material_categories list it points at). Everything that treated category as a shared key across materials, inventory, quotes and unit conversions is removed — including two features that were never populated (0 rows on every volume). Removed (backend): - routers category_attributes, category_ref_units, unit_conversions + registration - ?category_id= filters on /materials, /inventory and /quotes (the /suppliers one stays — it filters suppliers by their own label) - GET /inventory/materials/options: required a category and had no caller - the material-payload category derivation (expand_service.category_by_material, the inventory serializer lookup) and the material category on /pm/on-the-way; the wire keys stay, now always null, so the payload shape is unchanged - startup DDL: DROP the empty category_attributes / category_reference_units / unit_conversions tables (idempotent) and their CREATE statements; seed.py truncate list updated Removed (frontend): - the category-keyed conversion path: refUnitForCategory / findConversion / refConverted, the categoryUnits + unitConversions store slices, api clients, hooks and the "≈ converted quantity" line on quote detail and project pages (it needed rows in those tables to render, and there were none) - material.supplier_category and its read sites (the edit modal grew one yesterday to restore labels; the label lives on the supplier now) - the inventory page's category chips, the row filter and the «دسته» column, plus the lookup's categories fetch and catName helper Kept: supplier label + its list and picker, /suppliers?category_id=, the global standard_conversions feature and the units page (material_units conversions). Verified against a restore of the production dump: startup DDL drops exactly the three tables, suppliers keep their label (3/3), material_categories still serves its 8 rows, the removed endpoints 404, and materials/inventory/quotes/units/ standard-conversions all return 200 with quote line items intact. --- backend/app/db/init_db.py | 35 ++--- backend/app/main.py | 2 + backend/app/routers/__init__.py | 6 - backend/app/routers/category_attributes.py | 136 ---------------- backend/app/routers/category_ref_units.py | 69 --------- backend/app/routers/inventory.py | 92 +---------- backend/app/routers/materials.py | 14 +- backend/app/routers/pm.py | 11 +- backend/app/routers/quotes.py | 13 -- backend/app/routers/unit_conversions.py | 146 ------------------ backend/app/services/expand_service.py | 53 +------ backend/app/services/quote_service.py | 15 -- backend/tools/seed.py | 2 +- frontend/src/hooks/index.ts | 9 +- frontend/src/hooks/useUnits.ts | 44 +----- frontend/src/lib/api.ts | 127 ++------------- frontend/src/lib/quoteUtils.ts | 67 +------- frontend/src/lib/types.ts | 23 --- .../src/pages/Dashboard/DashboardPage.tsx | 6 +- frontend/src/pages/Dashboard/EditModal.tsx | 1 - frontend/src/pages/Dashboard/QuoteDetail.tsx | 10 +- .../pages/Inventory/ChangeInventoryPage.tsx | 2 +- .../src/pages/Inventory/InventoryPage.tsx | 59 +------ .../src/pages/Inventory/useInventoryData.ts | 15 +- frontend/src/pages/Projects/ProjectsPage.tsx | 12 +- .../pages/Suppliers/SupplierDetailPage.tsx | 17 +- frontend/src/pages/Wizard/Step2LineItems.tsx | 4 +- frontend/src/pages/Wizard/WizardPage.tsx | 1 - frontend/src/store/index.ts | 29 ---- 29 files changed, 73 insertions(+), 947 deletions(-) delete mode 100644 backend/app/routers/category_attributes.py delete mode 100644 backend/app/routers/category_ref_units.py delete mode 100644 backend/app/routers/unit_conversions.py diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index d2c98e7..018ff34 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -18,28 +18,9 @@ logger = logging.getLogger(__name__) _DASHBOARD_DDL = [ # One reference unit per category (units.html «واحد مرجع هر دسته») - """ - CREATE TABLE IF NOT EXISTS category_reference_units ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - category_id UUID NOT NULL UNIQUE REFERENCES material_categories(id) ON DELETE CASCADE, - ref_unit TEXT NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """, # Brand-scoped / category-scoped conversions: 1 from_unit = factor × to_unit. # UNIQUE must treat brand NULL as a concrete value (PG treats NULLs as distinct). """ - CREATE TABLE IF NOT EXISTS unit_conversions ( - id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, - category_id UUID NOT NULL REFERENCES material_categories(id) ON DELETE CASCADE, - brand TEXT, - from_unit TEXT NOT NULL, - to_unit TEXT NOT NULL, - factor NUMERIC(18,6) NOT NULL, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() - ) - """, - """ CREATE UNIQUE INDEX IF NOT EXISTS uq_unit_conversion ON unit_conversions (category_id, COALESCE(brand, ''), from_unit, to_unit) """, @@ -413,6 +394,22 @@ async def ensure_v26(engine: AsyncEngine) -> None: """) +async def ensure_drop_category_tables(engine: AsyncEngine) -> None: + """v2.7 follow-up: the category taxonomy is reduced to a supplier label. + + These three tables backed features that were never populated (0 rows on every + volume): per-category reference units and conversion factors (the + per-reference-unit price line) and the category -> attribute mapping. Dropping + them is idempotent and loses no data. material_categories itself stays — every + supplier carries one as its label. + """ + async with engine.begin() as conn: + await conn.execute(text( + "DROP TABLE IF EXISTS category_attributes, category_reference_units, unit_conversions CASCADE" + )) + logger.info("category taxonomy tables dropped (attributes / ref units / conversions)") + + async def ensure_audit_table(engine: AsyncEngine) -> None: """v2.7: general activity trail (audit_log). Idempotent; safe on any volume.""" ddl = """ diff --git a/backend/app/main.py b/backend/app/main.py index de1e10f..adf54bb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,6 +27,7 @@ from app.db.engine import AsyncSessionLocal, engine from app.db.init_db import ( ensure_attachment_table, ensure_audit_table, + ensure_drop_category_tables, ensure_dashboard_tables, ensure_material_topic_column, ensure_quote_projects, @@ -105,6 +106,7 @@ async def lifespan(_app: FastAPI): await ensure_quote_projects(engine) await ensure_v26(engine) await ensure_audit_table(engine) + await ensure_drop_category_tables(engine) task = asyncio.create_task(_auto_expire_loop()) if settings.AUTO_EXPIRE_ENABLED else None try: yield diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py index e2bd6c1..edbe584 100644 --- a/backend/app/routers/__init__.py +++ b/backend/app/routers/__init__.py @@ -4,8 +4,6 @@ from app.routers import ( auth, brand_materials, brands, - category_attributes, - category_ref_units, files, inventory, item_decisions, @@ -24,7 +22,6 @@ from app.routers import ( supplier_materials, suppliers, termsheets, - unit_conversions, units, users, ) @@ -35,7 +32,6 @@ ROUTERS = [ brands.router, material_categories.router, attribute_definitions.router, - category_attributes.router, materials.router, brand_materials.router, suppliers.router, @@ -51,8 +47,6 @@ ROUTERS = [ item_receipts.router, quote_events.router, files.router, - category_ref_units.router, - unit_conversions.router, standard_conversions.router, inventory.router, pm.router, diff --git a/backend/app/routers/category_attributes.py b/backend/app/routers/category_attributes.py deleted file mode 100644 index bc65c9a..0000000 --- a/backend/app/routers/category_attributes.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Category attributes — scoped attribute definitions per category (P2 set). - -Identified by composite key {category_id}/{attribute_definition_id}. -The nested `attribute` object is ALWAYS included (not expand-based). -""" -from fastapi import APIRouter, Depends, Query -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.engine import get_db -from app.models.catalog import AttributeDefinition, CategoryAttribute, MaterialCategory -from app.schemas.catalog import CategoryAttributeCreate, CategoryAttributeUpdate -from app.utils.errors import ConflictError, NotFoundError -from app.utils.pagination import paginate - -router = APIRouter( - prefix="/material-categories/{category_id}/attributes", - tags=["category-attributes"], -) - - -def ca_to_dict(ca: CategoryAttribute, attribute: AttributeDefinition) -> dict: - return { - "id": str(ca.id), - "category_id": str(ca.category_id), - "attribute_definition_id": str(ca.attribute_definition_id), - "is_required": ca.is_required, - "is_filterable": ca.is_filterable, - "conditional_rules": ca.conditional_rules, - "sort_order": ca.sort_order, - "attribute": { - "attribute_key": attribute.attribute_key, - "label": attribute.label, - "data_type": attribute.data_type, - "unit_id": attribute.unit_id, - "enum_values": attribute.enum_values, - }, - } - - -async def _category_or_404(db: AsyncSession, category_id) -> MaterialCategory: - category = await db.get(MaterialCategory, category_id) - if category is None: - raise NotFoundError("دسته‌بندی یافت نشد") - return category - - -async def _with_attribute(db: AsyncSession, ca: CategoryAttribute) -> dict: - attribute = await db.get(AttributeDefinition, ca.attribute_definition_id) - return ca_to_dict(ca, attribute) - - -@router.get("") -async def list_category_attributes( - category_id, - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - db: AsyncSession = Depends(get_db), -): - await _category_or_404(db, category_id) - stmt = ( - select(CategoryAttribute) - .where(CategoryAttribute.category_id == category_id) - .order_by(CategoryAttribute.sort_order.asc()) - ) - items, meta = await paginate(stmt, page, page_size, db) - return {"data": [await _with_attribute(db, ca) for ca in items], "meta": meta} - - -@router.post("", status_code=201) -async def create_category_attribute( - category_id, payload: CategoryAttributeCreate, db: AsyncSession = Depends(get_db) -): - await _category_or_404(db, category_id) - if await db.get(AttributeDefinition, payload.attribute_definition_id) is None: - raise NotFoundError("تعریف ویژگی یافت نشد", field="attribute_definition_id") - duplicate = await db.scalar( - select(CategoryAttribute.id).where( - CategoryAttribute.category_id == category_id, - CategoryAttribute.attribute_definition_id == payload.attribute_definition_id, - ) - ) - if duplicate is not None: - raise ConflictError("این ویژگی قبلاً به این دسته‌بندی اضافه شده است", field="attribute_definition_id") - ca = CategoryAttribute( - category_id=category_id, - attribute_definition_id=payload.attribute_definition_id, - is_required=payload.is_required, - is_filterable=payload.is_filterable, - conditional_rules=payload.conditional_rules, - sort_order=payload.sort_order, - ) - db.add(ca) - await db.flush() - return {"data": await _with_attribute(db, ca)} - - -@router.patch("/{attribute_definition_id}") -async def update_category_attribute( - category_id, attribute_definition_id, payload: CategoryAttributeUpdate, db: AsyncSession = Depends(get_db) -): - ca = await db.scalar( - select(CategoryAttribute).where( - CategoryAttribute.category_id == category_id, - CategoryAttribute.attribute_definition_id == attribute_definition_id, - ) - ) - if ca is None: - raise NotFoundError("ویژگی این دسته‌بندی یافت نشد") - if payload.is_required is not None: - ca.is_required = payload.is_required - if payload.is_filterable is not None: - ca.is_filterable = payload.is_filterable - if payload.conditional_rules is not None: - ca.conditional_rules = payload.conditional_rules - if payload.sort_order is not None: - ca.sort_order = payload.sort_order - await db.flush() - return {"data": await _with_attribute(db, ca)} - - -@router.delete("/{attribute_definition_id}") -async def delete_category_attribute( - category_id, attribute_definition_id, db: AsyncSession = Depends(get_db) -): - ca = await db.scalar( - select(CategoryAttribute).where( - CategoryAttribute.category_id == category_id, - CategoryAttribute.attribute_definition_id == attribute_definition_id, - ) - ) - if ca is None: - raise NotFoundError("ویژگی این دسته‌بندی یافت نشد") - await db.delete(ca) - await db.flush() - return {"data": {"deleted": True}} diff --git a/backend/app/routers/category_ref_units.py b/backend/app/routers/category_ref_units.py deleted file mode 100644 index a95ac8c..0000000 --- a/backend/app/routers/category_ref_units.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Category reference units — units.html dashboard (P2-26/27). - -One reference unit per category (UNIQUE category_id); PUT is an upsert. -""" -from fastapi import APIRouter, Depends, Query -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.engine import get_db -from app.models.catalog import CategoryReferenceUnit, MaterialCategory -from app.schemas.catalog import CategoryReferenceUnitPut -from app.services.expand_service import category_brief -from app.utils.errors import NotFoundError -from app.utils.pagination import paginate -from app.utils.serialization import to_iso - -router = APIRouter(prefix="/category-reference-units", tags=["category-reference-units"]) - - -def cru_to_dict(cru: CategoryReferenceUnit, expand: set[str] = frozenset()) -> dict: - d = { - "id": str(cru.id), - "category_id": str(cru.category_id), - "ref_unit": cru.ref_unit, - "updated_at": to_iso(cru.updated_at), - } - if "category" in expand and hasattr(cru, "_category"): - d["category"] = category_brief(cru._category) - return d - - -@router.get("") -async def list_category_reference_units( - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - category_id=None, - expand: str | None = None, - db: AsyncSession = Depends(get_db), -): - stmt = select(CategoryReferenceUnit) - if category_id: - stmt = stmt.where(CategoryReferenceUnit.category_id == category_id) - items, meta = await paginate(stmt, page, page_size, db) - tokens = {t.strip() for t in expand.split(",")} if expand else set() - if "category" in tokens: - cat_ids = {cru.category_id for cru in items} - cats = {} - if cat_ids: - cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()} - for cru in items: - cru._category = cats.get(cru.category_id) - return {"data": [cru_to_dict(cru, tokens) for cru in items], "meta": meta} - - -@router.put("/{category_id}") -async def upsert_category_reference_unit( - category_id, payload: CategoryReferenceUnitPut, db: AsyncSession = Depends(get_db) -): - category = await db.get(MaterialCategory, category_id) - if category is None: - raise NotFoundError("دسته‌بندی یافت نشد") - cru = await db.scalar(select(CategoryReferenceUnit).where(CategoryReferenceUnit.category_id == category_id)) - if cru is None: - cru = CategoryReferenceUnit(category_id=category_id, ref_unit=payload.ref_unit) - db.add(cru) - else: - cru.ref_unit = payload.ref_unit - await db.flush() - return {"data": {"category_id": str(category_id), "ref_unit": cru.ref_unit}} diff --git a/backend/app/routers/inventory.py b/backend/app/routers/inventory.py index 55365e8..468998e 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 exists, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_optional_user, require_role @@ -27,13 +27,12 @@ from app.models.inventory import ( InventoryTransaction, Warehouse, ) -from app.models.procurement import Project, Supplier, SupplierMaterial +from app.models.procurement import Project 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, @@ -315,7 +314,6 @@ async def delete_warehouse( @router.get("") async def list_inventory( project_id: uuid.UUID | None = None, - category_id=None, warehouse_id=None, material_id=None, sort: str | None = None, @@ -333,20 +331,6 @@ async def list_inventory( stmt = stmt.join(Warehouse, Warehouse.id == InventoryItem.warehouse_id).where( Warehouse.project_id == project_id ) - if category_id is not None: - # 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) if material_id is not None: @@ -368,8 +352,6 @@ 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()} - # 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()} @@ -379,7 +361,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem] return [ inventory_to_dict( i, warehouses[i.warehouse_id], materials[i.material_id], - material_category.get(i.material_id), brands.get(i.brand_id), units.get(i.unit_id), + None, brands.get(i.brand_id), units.get(i.unit_id), ) for i in items ] @@ -541,71 +523,3 @@ async def transaction_types(): "PURCHASE", "WITHDRAWAL", "RETURN", "TRANSFER_IN", "TRANSFER_OUT", "DAMAGE", "WASTE", "LOSS", "FOUND", "STOCK_COUNT", )]} - - -# --------------------------------------------------------------------------- -# Material selector — filtered by Category + Warehouse (transaction forms) -# --------------------------------------------------------------------------- - -@router.get("/materials/options") -async def material_options( - category_id: uuid.UUID, - warehouse_id: uuid.UUID | None = None, - scope: Scope = Depends(inventory_scope), - db: AsyncSession = Depends(get_db), -): - """Materials for the transaction form. - - Always filtered by category. When `warehouse_id` is given, only materials - that already exist in that warehouse's inventory are returned (so the PM - works with the actual stock of that warehouse); otherwise the full - category list is returned (e.g. first purchase of a new material). - """ - category = await db.get(MaterialCategory, category_id) - if category is None: - raise NotFoundError("دسته‌بندی یافت نشد", field="category_id") - - # 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: - raise ForbiddenError("این انبار متعلق به پروژه شما نیست") - stmt = stmt.where( - Material.id.in_( - select(InventoryItem.material_id).where(InventoryItem.warehouse_id == warehouse_id) - ) - ) - stmt = stmt.order_by(Material.name) - materials = (await db.execute(stmt)).scalars().all() - - # Brands per material (from brand_materials catalog + brands actually used in stock) - brand_rows = (await db.execute( - select(BrandMaterial.material_id, Brand.id, Brand.name) - .join(Brand, Brand.id == BrandMaterial.brand_id) - .where(BrandMaterial.material_id.in_([m.id for m in materials]), BrandMaterial.is_active.is_(True)) - )).all() - brands_by_material: dict[str, list[dict]] = {} - for mat_id, b_id, b_name in brand_rows: - brands_by_material.setdefault(str(mat_id), []).append({"id": str(b_id), "name": b_name}) - - return {"data": [ - { - "id": str(m.id), - "name": m.name, - "brands": brands_by_material.get(str(m.id), []), - } - for m in materials - ]} diff --git a/backend/app/routers/materials.py b/backend/app/routers/materials.py index c568f80..c128f9c 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, Supplier, SupplierMaterial +from app.models.procurement import LineItem from app.schemas.catalog import ( MaterialAttributeValuesPut, MaterialCreate, @@ -97,7 +97,6 @@ async def _apply_attr_filters(db: AsyncSession, stmt, request: Request): async def list_materials( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), - category_id=None, brand_id=None, status: str | None = None, q: str | None = None, @@ -107,17 +106,6 @@ async def list_materials( db: AsyncSession = Depends(get_db), ): stmt = select(Material) - if 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 64c1209..f8c1c3f 100644 --- a/backend/app/routers/pm.py +++ b/backend/app/routers/pm.py @@ -38,7 +38,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.auth import UserContext, get_optional_user from app.config import settings from app.db.engine import get_db -from app.models.catalog import BrandMaterial, Material, MaterialCategory, Unit +from app.models.catalog import BrandMaterial, Material, Unit from app.models.inventory import InventoryItem, InventoryTransaction, Warehouse from app.models.procurement import ( ItemDecision, @@ -201,12 +201,6 @@ 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()} - sup_cat_ids = {d.Supplier.category_id for d in decisions if d.Supplier.category_id} - cats = {} - 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: received = received_map.get(d.id) @@ -219,8 +213,7 @@ async def pm_on_the_way( "supplier": {"id": str(sup.id), "name": sup.name}, "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(sup.category_id)) else None}, + "material": {"id": str(m.id), "name": m.name}, "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 c59ba5d..489921c 100644 --- a/backend/app/routers/quotes.py +++ b/backend/app/routers/quotes.py @@ -45,14 +45,6 @@ router = APIRouter(prefix="/quotes", tags=["quotes"]) # --- list filter helpers --------------------------------------------------- -def _quote_category_exists(category_id) -> exists: - return exists( - select(1).where( - Supplier.id == Quote.supplier_id, - Supplier.category_id == category_id, - ) - ) - def _quote_search(q: str) -> exists: like = f"%{q}%" @@ -80,7 +72,6 @@ async def get_quote_stats( project_id=None, quote_date_from: datetime | None = None, quote_date_to: datetime | None = None, - category_id=None, db: AsyncSession = Depends(get_db), ): return { @@ -90,7 +81,6 @@ async def get_quote_stats( project_id=project_id, quote_date_from=quote_date_from, quote_date_to=quote_date_to, - category_id=category_id, ) } @@ -106,7 +96,6 @@ async def list_quotes( status: str | None = None, supplier_id=None, project_id=None, - category_id=None, quote_date_from: datetime | None = None, quote_date_to: datetime | None = None, q: str | None = None, @@ -137,8 +126,6 @@ async def list_quotes( ) ) ) - if category_id: - stmt = stmt.where(_quote_category_exists(category_id)) if quote_date_from is not None: stmt = stmt.where(Quote.quote_date >= quote_date_from) if quote_date_to is not None: diff --git a/backend/app/routers/unit_conversions.py b/backend/app/routers/unit_conversions.py deleted file mode 100644 index aef42fb..0000000 --- a/backend/app/routers/unit_conversions.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Unit conversions — category/brand-scoped (units.html dashboard, P2-28..31). - -Model: {category_id, brand?, from_unit, to_unit, factor} - 1 from_unit = factor × to_unit - brand empty = all brands of the category; brand set = that brand only. -""" -from fastapi import APIRouter, Depends, Query -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db.engine import get_db -from app.models.catalog import MaterialCategory, UnitConversion -from app.schemas.catalog import UnitConversionCreate, UnitConversionUpdate -from app.services.expand_service import category_brief -from app.utils.errors import ConflictError, NotFoundError -from app.utils.pagination import paginate -from app.utils.serialization import to_iso - -router = APIRouter(prefix="/unit-conversions", tags=["unit-conversions"]) - - -def uc_to_dict(uc: UnitConversion, expand: set[str] = frozenset()) -> dict: - d = { - "id": str(uc.id), - "category_id": str(uc.category_id), - "brand": uc.brand, - "from_unit": uc.from_unit, - "to_unit": uc.to_unit, - "factor": float(uc.factor), - "updated_at": to_iso(uc.updated_at), - } - if "category" in expand and hasattr(uc, "_category"): - d["category"] = category_brief(uc._category) - return d - - -def _duplicate_condition(uc_or_none, category_id, brand, from_unit, to_unit): - """Uniqueness treats NULL brand as a concrete value (matches the partial unique index).""" - where = ( - UnitConversion.category_id == category_id, - UnitConversion.from_unit == from_unit, - UnitConversion.to_unit == to_unit, - ) - # PG «IS» فقط با NULL معتبر است؛ برای برند مقداردار از «==» استفاده می‌شود - where = where + (UnitConversion.brand == brand if brand is not None else UnitConversion.brand.is_(None),) - if uc_or_none is not None: - where = where + (UnitConversion.id != uc_or_none.id,) - return where - - -async def _check_duplicate(db: AsyncSession, exclude_id, category_id, brand, from_unit, to_unit) -> None: - duplicate = await db.scalar( - select(UnitConversion.id).where(*_duplicate_condition(exclude_id, category_id, brand, from_unit, to_unit)) - ) - if duplicate is not None: - raise ConflictError("این تبدیل قبلاً ثبت شده است") - - -@router.get("") -async def list_unit_conversions( - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - category_id=None, - brand: str | None = None, - from_unit: str | None = None, - to_unit: str | None = None, - expand: str | None = None, - db: AsyncSession = Depends(get_db), -): - stmt = select(UnitConversion) - if category_id: - stmt = stmt.where(UnitConversion.category_id == category_id) - if brand: - stmt = stmt.where(UnitConversion.brand == brand) - if from_unit: - stmt = stmt.where(UnitConversion.from_unit == from_unit) - if to_unit: - stmt = stmt.where(UnitConversion.to_unit == to_unit) - items, meta = await paginate(stmt, page, page_size, db) - tokens = {t.strip() for t in expand.split(",")} if expand else set() - if "category" in tokens: - cat_ids = {uc.category_id for uc in items} - cats = {} - if cat_ids: - cats = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()} - for uc in items: - uc._category = cats.get(uc.category_id) - return {"data": [uc_to_dict(uc, tokens) for uc in items], "meta": meta} - - -@router.post("", status_code=201) -async def create_unit_conversion(payload: UnitConversionCreate, db: AsyncSession = Depends(get_db)): - category = await db.get(MaterialCategory, payload.category_id) - if category is None: - raise NotFoundError("دسته‌بندی یافت نشد", field="category_id") - await _check_duplicate(db, None, payload.category_id, payload.brand, payload.from_unit, payload.to_unit) - uc = UnitConversion( - category_id=payload.category_id, - brand=payload.brand, - from_unit=payload.from_unit, - to_unit=payload.to_unit, - factor=payload.factor, - ) - db.add(uc) - await db.flush() - return {"data": uc_to_dict(uc)} - - -@router.get("/{uc_id}") -async def get_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)): - uc = await db.get(UnitConversion, uc_id) - if uc is None: - raise NotFoundError("تبدیل واحد یافت نشد") - return {"data": uc_to_dict(uc)} - - -@router.patch("/{uc_id}") -async def update_unit_conversion(uc_id, payload: UnitConversionUpdate, db: AsyncSession = Depends(get_db)): - uc = await db.get(UnitConversion, uc_id) - if uc is None: - raise NotFoundError("تبدیل واحد یافت نشد") - new_brand = payload.brand if payload.brand is not None else uc.brand - new_from = payload.from_unit if payload.from_unit is not None else uc.from_unit - new_to = payload.to_unit if payload.to_unit is not None else uc.to_unit - if payload.brand is not None or payload.from_unit is not None or payload.to_unit is not None: - await _check_duplicate(db, uc.id, uc.category_id, new_brand, new_from, new_to) - if payload.brand is not None: - uc.brand = payload.brand - if payload.from_unit is not None: - uc.from_unit = payload.from_unit - if payload.to_unit is not None: - uc.to_unit = payload.to_unit - if payload.factor is not None: - uc.factor = payload.factor - await db.flush() - return {"data": uc_to_dict(uc)} - - -@router.delete("/{uc_id}") -async def delete_unit_conversion(uc_id, db: AsyncSession = Depends(get_db)): - uc = await db.get(UnitConversion, uc_id) - if uc is None: - raise NotFoundError("تبدیل واحد یافت نشد") - await db.delete(uc) - await db.flush() - return {"data": {"deleted": True}} diff --git a/backend/app/services/expand_service.py b/backend/app/services/expand_service.py index c0d5b9c..c933440 100644 --- a/backend/app/services/expand_service.py +++ b/backend/app/services/expand_service.py @@ -35,7 +35,6 @@ from app.models.procurement import ( QuoteEvent, QuoteProjects, Supplier, - SupplierMaterial, Termsheet, TermsheetScheduleStep, ) @@ -203,47 +202,9 @@ 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: @@ -291,13 +252,12 @@ 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) + # Category is a supplier attribute now, not a material one — the key stays + # in the payload (null) so the contract shape is unchanged. if "category" in expand: - d["category"] = category_brief(material_category) + d["category"] = None else: - d["category_id"] = str(material_category.id) if material_category else None + d["category_id"] = None if "canonical_unit" in expand: d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id)) else: @@ -340,8 +300,6 @@ 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]] = {} @@ -373,7 +331,6 @@ 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() @@ -487,7 +444,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.material_category.get(m.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/quote_service.py b/backend/app/services/quote_service.py index 55d457a..f776986 100644 --- a/backend/app/services/quote_service.py +++ b/backend/app/services/quote_service.py @@ -873,7 +873,6 @@ async def quote_stats( project_id: UUID | None = None, quote_date_from=None, quote_date_to=None, - category_id: UUID | None = None, ) -> dict: conditions = [] if supplier_id is not None: @@ -884,8 +883,6 @@ async def quote_stats( conditions.append(Quote.quote_date >= quote_date_from) if quote_date_to is not None: conditions.append(Quote.quote_date <= quote_date_to) - if category_id is not None: - conditions.append(_quote_has_category(category_id)) rows = ( await db.execute( @@ -917,18 +914,6 @@ async def quote_stats( } -def _quote_has_category(category_id: UUID): - """EXISTS: quote's supplier belongs to category_id.""" - sub = ( - select(1) - .where( - Supplier.id == Quote.supplier_id, - Supplier.category_id == category_id, - ) - ) - return func.exists(sub) - - async def project_stats(db: AsyncSession, project_id: UUID) -> dict: """Counts + total value per quote status for ONE project, isolated per project. diff --git a/backend/tools/seed.py b/backend/tools/seed.py index 64d1044..0e97c7c 100644 --- a/backend/tools/seed.py +++ b/backend/tools/seed.py @@ -209,7 +209,7 @@ async def run() -> None: "item_decisions, item_termsheets, termsheet_schedule_steps, termsheets, line_item_specs, " "line_items, quotes, supplier_materials, suppliers, brand_materials, " "material_attribute_values, materials, brands, warehouses, projects, users, " - "category_reference_units, unit_conversions, standard_conversions " + "standard_conversions " "RESTART IDENTITY CASCADE" )) # Only the 8 canonical topic categories may exist — any leftover slug diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index b209538..861a22f 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -3,11 +3,4 @@ export { useQuotes, useCreateQuote, useUpdateQuote, useDeleteQuote } from "./use export { useSuppliers, useAddSupplier, useUpdateSupplier, useRemoveSupplier } from "./useSuppliers"; export { useMaterials, useAddMaterial } from "./useMaterials"; export { useProjects, useAddProject } from "./useProjects"; -export { - useUnits, - useSaveRefUnit, - useSaveConversion, - useDeleteConversion, - useSaveStandardConversion, - useDeleteStandardConversion, -} from "./useUnits"; +export { useUnits, useSaveStandardConversion, useDeleteStandardConversion } from "./useUnits"; diff --git a/frontend/src/hooks/useUnits.ts b/frontend/src/hooks/useUnits.ts index 94d7b80..055bdaa 100644 --- a/frontend/src/hooks/useUnits.ts +++ b/frontend/src/hooks/useUnits.ts @@ -1,52 +1,16 @@ // ============================================================ -// hooks واحدها — واحد مرجع، ضرایب دسته‌ای و ثابت +// hooks واحدها — تبدیل‌های ثابت جهانی (دسته‌ای‌ها حذف شدند) // ============================================================ import { useMutation } from "@tanstack/react-query"; -import type { CategoryUnit, StandardConversion, UnitConversion } from "../lib/types"; +import type { StandardConversion } from "../lib/types"; import { api } from "../lib/api"; import { useAppStore } from "../store"; import { useInvalidateStore } from "./useStoreData"; -export function useUnits(): { - categoryUnits: CategoryUnit[]; - unitConversions: UnitConversion[]; - standardConversions: StandardConversion[]; -} { - const categoryUnits = useAppStore((s) => s.categoryUnits); - const unitConversions = useAppStore((s) => s.unitConversions); +export function useUnits(): { standardConversions: StandardConversion[] } { const standardConversions = useAppStore((s) => s.standardConversions); - return { categoryUnits, unitConversions, standardConversions }; -} - -export function useSaveRefUnit() { - const setCategoryUnit = useAppStore((s) => s.setCategoryUnit); - const invalidate = useInvalidateStore(); - return useMutation({ - mutationFn: (cu: CategoryUnit) => api.units.saveCategoryUnit(cu), - onMutate: (cu) => setCategoryUnit(cu), - onSettled: () => invalidate(), - }); -} - -export function useSaveConversion() { - const setConversion = useAppStore((s) => s.setConversion); - const invalidate = useInvalidateStore(); - return useMutation({ - mutationFn: (c: UnitConversion) => api.units.saveConversion(c), - onMutate: (c) => setConversion(c), - onSettled: () => invalidate(), - }); -} - -export function useDeleteConversion() { - const deleteConversion = useAppStore((s) => s.deleteConversion); - const invalidate = useInvalidateStore(); - return useMutation({ - mutationFn: (id: string) => api.units.deleteConversion(id), - onMutate: (id) => deleteConversion(id), - onSettled: () => invalidate(), - }); + return { standardConversions }; } export function useSaveStandardConversion() { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4582550..6e5f6ca 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -18,7 +18,6 @@ import { jalaliToGregorian, toAsciiDigits } from "./jalali"; import { useAuthStore } from "../store/auth"; import type { Attachment, - CategoryUnit, ConfirmedReceipt, InventoryRow, InventoryTransaction, @@ -30,7 +29,6 @@ import type { Store, Supplier, TransactionType, - UnitConversion, Warehouse, } from "./types"; @@ -252,10 +250,6 @@ async function patchData(path: string, body: unknown): Promise { const res = await request<{ data: T }>(path, { method: "PATCH", body: JSON.stringify(body) }); return res.data; } -async function putData(path: string, body: unknown): Promise { - const res = await request<{ data: T }>(path, { method: "PUT", body: JSON.stringify(body) }); - return res.data; -} async function deleteData(path: string): Promise { const res = await request<{ data: T }>(path, { method: "DELETE" }); return res.data; @@ -322,7 +316,6 @@ interface RawSupplier { phone?: string | null; contact_person?: string | null; tax_id?: string | null; - categories?: string[] | null; // متریال‌های تأمین‌شده // v2.6: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) — بک‌اند این را می‌فرستد category_id?: string | null; category?: { id: string; slug: string; name: string } | null; @@ -402,21 +395,6 @@ interface RawQuote { line_items?: RawLineItem[] | null; termsheets?: RawTermsheet[] | null; } -interface RawCategoryUnit { - id: string; - category_id: string; - ref_unit: string; - category?: RawCategory | null; -} -interface RawUnitConversion { - id: string; - category_id: string; - brand?: string | null; - from_unit: string; - to_unit: string; - factor: number; - category?: RawCategory | null; -} interface RawStandardConversion { id: string; from_unit: string; @@ -562,9 +540,6 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material { topic: raw.topic || (raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""), - // v2.7: دسته‌ی متریال در بک‌اند از نخستین تأمین‌کننده‌ی متصل می‌آید - // (متریالِ بدون تأمین‌کننده → خالی) - supplier_category: raw.category?.name || "", brand: raw.brands?.[0]?.name || "", // مشخصه‌ها از attribute های متریال خوانده می‌شوند (EAV) specs: (raw.attributes || []) @@ -645,15 +620,13 @@ function adaptQuote(raw: RawQuote): Quote { } function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier { - // v2.6/v2.7: هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل - // category برمی‌گرداند — همین اولویت اول است. categories قدیمی (ستون حذف‌شده) - // و دسته‌های استنتاج‌شده از پیشنهادها فقط پشتیبان‌اند. + // هر تأمین‌کننده دقیقاً یک دسته دارد (FK) و بک‌اند آن را به شکل category + // برمی‌گرداند — منبع اصلی همین است؛ پارامتر categories فقط پشتیبان است. const own = raw.category?.name ? [raw.category.name] : []; - const legacy = Array.isArray(raw.categories) ? raw.categories.filter(Boolean) : []; return { id: raw.id, topic, - categories: own.length ? own : legacy.length ? legacy : categories, + categories: own.length ? own : categories, name: raw.name, location: raw.location || "", phone: raw.phone || "", @@ -671,54 +644,29 @@ function deriveSupplierTopics( materialsById: Map, ): Supplier[] { const topics = new Map>(); - const cats = new Map>(); for (const q of quotes) { for (const li of q.line_items) { const m = materialsById.get(li.material_id); if (!m) continue; if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set()); - if (!cats.has(q.supplier_id)) cats.set(q.supplier_id, new Set()); if (m.topic) topics.get(q.supplier_id)!.add(m.topic); - if (m.supplier_category) cats.get(q.supplier_id)!.add(m.supplier_category); } } return rawSuppliers.map((s) => { const t = topics.get(s.id); - const c = cats.get(s.id); const derivedTopic = t && t.size ? [...t][0] : ""; - const derivedCats = c ? [...c].sort((a, b) => a.localeCompare(b, "fa")) : []; // اگر از quotes چیزی پیدا نشد، registry را چک کن - if (!derivedTopic && !derivedCats.length) { + if (!derivedTopic) { const reg = supplierTopicRegistry.get(s.id); - if (reg) { - return adaptSupplier(s, reg.topic, reg.categories); - } + if (reg) return adaptSupplier(s, reg.topic, reg.categories); } - // v2.7: متریال‌ها از دسته جدا شدند، پس استنتاج از اقلام پیشنهاد معمولاً - // دسته‌ای نمی‌دهد و صفحه‌ی تأمین‌کننده‌ها بی‌نام می‌شد. دستهٔ ثبت‌شده روی - // خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است؛ نامش با نام تاپیک‌ها یکی است. + // دستهٔ ثبت‌شده روی خودِ تأمین‌کننده (FK بک‌اند) منبع اصلی است و نامش با نام + // تاپیک‌ها یکی است؛ تاپیک از اقلام پیشنهاد استنتاج می‌شود. const backendCat = s.category?.name || ""; - return adaptSupplier( - s, - derivedTopic || backendCat, - derivedCats.length ? derivedCats : backendCat ? [backendCat] : [], - ); + return adaptSupplier(s, derivedTopic || backendCat, backendCat ? [backendCat] : []); }); } -function adaptCategoryUnit(raw: RawCategoryUnit): CategoryUnit { - return { id: raw.id, category: raw.category?.name || "", ref_unit: raw.ref_unit }; -} -function adaptUnitConversion(raw: RawUnitConversion, lookup: Lookup): UnitConversion { - return { - id: raw.id, - category: raw.category?.name || lookup.categoryIdToName.get(raw.category_id) || "", - brand: raw.brand || "", - from_unit: raw.from_unit, - to_unit: raw.to_unit, - factor: raw.factor, - }; -} function adaptStandardConversion(raw: RawStandardConversion): StandardConversion { return { id: raw.id, from_unit: raw.from_unit, to_unit: raw.to_unit, factor: raw.factor }; } @@ -836,16 +784,14 @@ function registerSupplierTopic(id: string, topic: string, categories: string[]): export const api = { // ---------- کل store (یک بار در ریشه) ---------- async listAll(): Promise { - const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, cruRes, ucRes, scuRes] = + const [quotesRes, suppliersRes, materialsRes, projectsRes, unitsRes, categoriesRes, scuRes] = await Promise.all([ getData(`/quotes?expand=supplier,project,events,line_items,termsheets,decision,receipt&page_size=${PAGE_SIZE}`), getData(`/suppliers?page_size=${PAGE_SIZE}`), - getData(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`), + getData(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`), getData(`/projects?page_size=${PAGE_SIZE}`), getData(`/units?page_size=${PAGE_SIZE}`), getData(`/material-categories?page_size=${PAGE_SIZE}`), - getData(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`), - getData(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`), getData(`/standard-conversions?page_size=${PAGE_SIZE}`), ]); @@ -860,8 +806,6 @@ export const api = { materials, projects: projectsRes.map((p) => ({ id: p.id, name: p.name })), quotes, - categoryUnits: cruRes.map(adaptCategoryUnit), - unitConversions: ucRes.map((uc) => adaptUnitConversion(uc, lookup)), standardConversions: scuRes.map(adaptStandardConversion), topics: buildTopics(categoriesRes), }; @@ -1136,7 +1080,7 @@ export const api = { materials: { list: async (): Promise => { - const raw = await getData(`/materials?expand=category,brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`); + const raw = await getData(`/materials?expand=brands,canonical_unit,attributes&page_size=${PAGE_SIZE}`); const units = await getData(`/units?page_size=${PAGE_SIZE}`); const categories = await getData(`/material-categories?page_size=${PAGE_SIZE}`); return raw.map((m) => adaptMaterial(m, buildLookup(units, categories))); @@ -1196,46 +1140,6 @@ export const api = { }, units: { - getCategoryUnits: async (): Promise => { - const raw = await getData(`/category-reference-units?expand=category&page_size=${PAGE_SIZE}`); - return raw.map(adaptCategoryUnit); - }, - saveCategoryUnit: async (cu: CategoryUnit): Promise => { - const lookup = await fetchLookup(); - const categoryId = lookup.categoryNameToId.get(norm(cu.category)); - if (!categoryId) throw new Error(`دسته‌بندی «${cu.category}» یافت نشد`); - await putData(`/category-reference-units/${categoryId}`, { ref_unit: cu.ref_unit }); - }, - getConversions: async (): Promise => { - const lookup = await fetchLookup(); - const raw = await getData(`/unit-conversions?expand=category&page_size=${PAGE_SIZE}`); - return raw.map((uc) => adaptUnitConversion(uc, lookup)); - }, - saveConversion: async (c: UnitConversion): Promise => { - const lookup = await fetchLookup(); - const categoryId = lookup.categoryNameToId.get(norm(c.category)); - if (!categoryId) throw new Error(`دسته‌بندی «${c.category}» یافت نشد`); - const body = { - category_id: categoryId, - brand: c.brand || null, - from_unit: c.from_unit, - to_unit: c.to_unit, - factor: c.factor, - }; - if (looksLikeUuid(c.id)) { - await patchData(`/unit-conversions/${c.id}`, { - brand: c.brand || null, - from_unit: c.from_unit, - to_unit: c.to_unit, - factor: c.factor, - }); - } else { - await postData("/unit-conversions", body); - } - }, - deleteConversion: async (id: string): Promise => { - await deleteData(`/unit-conversions/${id}`); - }, getStandardConversions: async (): Promise => { const raw = await getData(`/standard-conversions?page_size=${PAGE_SIZE}`); return raw.map(adaptStandardConversion); @@ -1781,15 +1685,6 @@ export const apiV26 = { types: async (): Promise<{ type: TransactionType; label: string }[]> => { return getData<{ type: TransactionType; label: string }[]>("/inventory/meta/types"); }, - materialOptions: async (category_id: string, warehouse_id?: string): Promise<{ id: string; name: string; brands: { id: string; name: string }[] }[]> => { - const params: Record = { category_id }; - if (warehouse_id) params.warehouse_id = warehouse_id; - const pid = activeProjectId(); - if (pid) params.project_id = pid; - return getData<{ id: string; name: string; brands: { id: string; name: string }[] }[]>( - `/inventory/materials/options?${new URLSearchParams(params).toString()}`, - ); - }, }, pm: { diff --git a/frontend/src/lib/quoteUtils.ts b/frontend/src/lib/quoteUtils.ts index 643ec77..22d9c8f 100644 --- a/frontend/src/lib/quoteUtils.ts +++ b/frontend/src/lib/quoteUtils.ts @@ -2,7 +2,7 @@ // منطق بیزنس مشترک پیشنهادها — پورت از app.js // ============================================================ -import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types"; +import type { LineItem, Material, Quote, Store, Supplier } from "./types"; import { normalize } from "./normalize"; import { groupCommas } from "./numutil"; import { fmtDate } from "./formatters"; @@ -17,7 +17,6 @@ export function materialById(materials: Material[], id: string): Material { id: "", name: "", topic: "", - supplier_category: "", brand: "", specs: [], base_unit: "", @@ -102,15 +101,14 @@ export function materialLabel(m: Pick | nu // نام پایه‌ی متریال از نامِ ذخیره‌شده. // متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و // مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی -// name را به شکل «برند + دسته + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا -// نامِ پایه باقی بماند. دسته‌ی همان رکوردهای قدیمی همان دسته‌ی نخستین -// تأمین‌کننده است (supplier_category). -export function materialBaseName(name: string, brand: string, specs: string[], category = ""): string { +// name را به شکل «برند + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا نامِ +// پایه باقی بماند. +export function materialBaseName(name: string, brand: string, specs: string[]): string { const fallback = (name || "").trim(); if (!fallback) return ""; let base = fallback; // بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند - const parts = [brand, category, ...specs] + const parts = [brand, ...specs] .map((p) => (p || "").trim()) .filter(Boolean) .sort((a, b) => b.length - a.length); @@ -151,7 +149,7 @@ export function matchMaterial( materials.find( (m) => base && - normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === base && + normalize(materialBaseName(m.name, m.brand, m.specs)) === base && sameIdentity(m), ) ); @@ -216,57 +214,6 @@ export function itemProjects(q: Quote, li: LineItem): string[] { return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean); } -// ---------- واحد مرجع و ضریب تبدیل ---------- - -// واحد مرجع یک دسته -export function refUnitForCategory(store: Pick, category: string): string { - const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category)); - return cu ? cu.ref_unit : ""; -} - -// ضریب تبدیل با اولویت: دسته‌ای (برند مشخص > همه برندها) > تبدیل ثابت جهانی -export function findConversion( - store: Pick, - category: string, - brand: string, - fromUnit: string, - toUnit: string, -): UnitConversion | StandardConversion | null { - const list = (store.unitConversions || []).filter( - (c) => - normalize(c.category) === normalize(category) && - normalize(c.from_unit) === normalize(fromUnit) && - normalize(c.to_unit) === normalize(toUnit) && - (!c.brand || normalize(c.brand) === normalize(brand)), - ); - list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0)); - if (list[0]) return list[0]; - return ( - (store.standardConversions || []).find( - (c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit), - ) || null - ); -} - -// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است -export function refConverted( - store: Pick, - li: LineItem, -): { qty: number; unit: string; perRef: number | null } | null { - const m = materialById(store.materials, li.material_id); - // 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.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); - const perRef = qty > 0 ? Math.round(total / qty) : null; - return { qty, unit: ref, perRef }; -} - // واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها // واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] { @@ -364,7 +311,7 @@ export function filterQuotes( if (q) { const hay = `${supObj.name} ${quote.project || ""} ` + - matInfo.map((m) => `${m.supplier_category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" "); + matInfo.map((m) => `${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 d748f9f..dad95b0 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -22,10 +22,6 @@ export interface Material { id: string; // "m1", "m2", ... name: string; // نامِ خودِ متریال — برند در brand و مشخصه‌ها در specs topic: string; - // دسته‌ی نمایشی — از نخستین تأمین‌کننده‌ی متصل به متریال می‌آید (v2.7: - // خودِ متریال دیگر دسته ندارد و دسته روی تأمین‌کننده است). متریال بدون - // تأمین‌کننده = رشته‌ی خالی. این مقدار کلیدِ تبدیل‌های واحد هم هست. - supplier_category: string; brand: string; // مثلاً "ذوب آهن" specs: string[]; // آرایه‌ای از مشخصه‌ها (مثلاً ["A2"]) base_unit: string; // واحد پایه @@ -100,23 +96,6 @@ export interface Quote { termsheets: Termsheet[]; } -// واحد مرجع هر دسته -export interface CategoryUnit { - id: string; - category: string; - ref_unit: string; -} - -// ضریب تبدیل (دسته‌ای) -export interface UnitConversion { - id: string; - category: string; - brand: string; // خالی = همه برندها - from_unit: string; - to_unit: string; - factor: number; // 1 from_unit = factor * to_unit -} - // ضریب تبدیل استاندارد (جهانی) export interface StandardConversion { id: string; @@ -131,8 +110,6 @@ export interface Store { materials: Material[]; projects: Project[]; quotes: Quote[]; - categoryUnits: CategoryUnit[]; - unitConversions: UnitConversion[]; standardConversions: StandardConversion[]; topics: Topic[]; // دسته‌بندی‌های ۸گانه از /material-categories } diff --git a/frontend/src/pages/Dashboard/DashboardPage.tsx b/frontend/src/pages/Dashboard/DashboardPage.tsx index 08cc5cd..227b1cd 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -28,7 +28,7 @@ export function DashboardPage() { const suppliers = useSuppliers(); const materials = useMaterials(); const projects = useProjects(); - const { categoryUnits, unitConversions, standardConversions } = useUnits(); + const { standardConversions } = useUnits(); const expandedQuoteIds = useAppStore((s) => s.expandedQuoteIds); const toggleExpand = useAppStore((s) => s.toggleExpand); @@ -40,8 +40,8 @@ export function DashboardPage() { const [editId, setEditId] = useState(null); const store = useMemo( - () => ({ quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }), - [quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics], + () => ({ quotes, suppliers, materials, projects, standardConversions, topics }), + [quotes, suppliers, materials, projects, standardConversions, topics], ); // فیلتر + مرتب‌سازی بر اساس زمان ساخت (created_at) diff --git a/frontend/src/pages/Dashboard/EditModal.tsx b/frontend/src/pages/Dashboard/EditModal.tsx index d93b859..04686b1 100644 --- a/frontend/src/pages/Dashboard/EditModal.tsx +++ b/frontend/src/pages/Dashboard/EditModal.tsx @@ -308,7 +308,6 @@ export function EditModal({ quoteId, onClose }: EditModalProps) { brand, specs, base_unit: unit, - supplier_category: "", }; materialsCopy.push(nm); const created = await addMaterial.mutateAsync(nm); diff --git a/frontend/src/pages/Dashboard/QuoteDetail.tsx b/frontend/src/pages/Dashboard/QuoteDetail.tsx index 9be009a..a064444 100644 --- a/frontend/src/pages/Dashboard/QuoteDetail.tsx +++ b/frontend/src/pages/Dashboard/QuoteDetail.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Package, Scale, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react"; +import { Package, CreditCard, CalendarDays, Paperclip, Building2, Ban } from "lucide-react"; import type { Attachment, Quote, Store } from "../../lib/types"; import { api } from "../../lib/api"; import { @@ -7,7 +7,6 @@ import { itemTermsheetIds, lineItemTotal, materialById, - refConverted, } from "../../lib/quoteUtils"; import { fmtDate, fmtDateTime, fmtNum } from "../../lib/formatters"; import { groupCommas } from "../../lib/numutil"; @@ -76,7 +75,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) { const tsIds = itemTermsheetIds(li).filter((tid) => tsList.some((t) => t.id === tid)); const total = lineItemTotal(li); const projs = itemProjects(q, li); - const conv = isRejected ? null : refConverted(store, li); // رسید جزیی → قلم به دو بخش «رسید شده» و «در راه» شکسته می‌شود (نمایشی): // مجموع دو بخش همیشه با تعداد/مبلغ اصلی برابر است (عدم تغییر جمع کل پیشنهاد). @@ -140,12 +138,6 @@ export function QuoteDetail({ store, q }: QuoteDetailProps) { {whNames.length > 0 && به انبار: {whNames.join("، ")}} )} - {conv && ( -
- ≈ {groupCommas(conv.qty, true)} {conv.unit} - {conv.perRef != null ? ` · ${groupCommas(conv.perRef, true)} تومان/${conv.unit}` : ""} -
- )} ); }; diff --git a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx index 6037796..8f40188 100644 --- a/frontend/src/pages/Inventory/ChangeInventoryPage.tsx +++ b/frontend/src/pages/Inventory/ChangeInventoryPage.tsx @@ -220,7 +220,7 @@ export function ChangeInventoryPage() { let m = matByName(matName); if (!m) { const created = await apiV26.catalog.createMaterial({ name: matName, canonical_unit_id: uid, brand_ids: bid ? [bid] : [] }); - m = { id: created.id, name: created.name, category: null, canonical_unit: null }; + m = { id: created.id, name: created.name, canonical_unit: null }; } const op: Record = { transaction_type: "PURCHASE", warehouse_id: whId, material_id: m.id, brand_id: bid, diff --git a/frontend/src/pages/Inventory/InventoryPage.tsx b/frontend/src/pages/Inventory/InventoryPage.tsx index a2eb7f8..57fb6d8 100644 --- a/frontend/src/pages/Inventory/InventoryPage.tsx +++ b/frontend/src/pages/Inventory/InventoryPage.tsx @@ -7,7 +7,7 @@ import { useAuthStore } from "../../store/auth"; import { AppSwitcherMenu } from "../../components/AppSwitcherMenu"; import { inventorySubheader } from "./panelMenu"; import { InventoryTopNav } from "./InventoryTopNav"; -import { useInventoryData, brandName, catName, materialName, unitName, whName } from "./useInventoryData"; +import { useInventoryData, brandName, materialName, unitName, whName } from "./useInventoryData"; import type { InventoryRow, InventoryTransaction, TransactionType } from "../../lib/types"; import { faNum } from "../../lib/formatters"; import { isoToJalaliStr } from "../../lib/jalali"; @@ -55,8 +55,7 @@ export function InventoryPage() { const isPM = user?.role === "pm"; const [params] = useSearchParams(); - const [filters, setFilters] = useState<{ category: string; warehouse: string; material: string; project: string }>({ - category: "", + const [filters, setFilters] = useState<{ warehouse: string; material: string; project: string }>({ warehouse: params.get("warehouse") || "", material: "", project: "", @@ -81,7 +80,6 @@ export function InventoryPage() { inventory.forEach((r) => { const whId = r.warehouse?.id; if (!whId || !visibleIds.has(whId)) return; - if (filters.category && r.material.category?.id !== filters.category) return; if (filters.warehouse && whId !== filters.warehouse) return; if (filters.material && !r.material.name.includes(filters.material.trim())) return; const gk = `${whId}|${r.material.id}|${specsHash(r.specs)}`; @@ -132,7 +130,7 @@ export function InventoryPage() { .slice() .sort((a, b) => new Date(a.transaction_date).getTime() - new Date(b.transaction_date).getTime()); - const hasFilter = !!(filters.category || filters.warehouse || (isAdmin && filters.project)); + const hasFilter = !!(filters.warehouse || (isAdmin && filters.project)); if (loading) return
; if (!user || (!isAdmin && !isPM)) { @@ -248,40 +246,6 @@ export function InventoryPage() { {/* فیلترهای پیشرفته — برچسب‌های لمسی بدون سلکت و بدون بیرون‌زدگی */} {showMobileFilters && (
- {/* دسته‌بندی‌ها */} -
-
دسته‌بندی متریال:
-
- - {lookup.categories.map((c) => ( - - ))} -
-
- {/* پروژه‌ها (مخصوص ادمین) */} {isAdmin && lookup.projects.length > 0 && (
@@ -324,7 +288,7 @@ export function InventoryPage() {
{isExp ? "▾" : "▸"} {matName} - {matCatName}
{wh}
{isAdmin && {projName}} diff --git a/frontend/src/pages/Inventory/useInventoryData.ts b/frontend/src/pages/Inventory/useInventoryData.ts index 2b2b55a..63938c8 100644 --- a/frontend/src/pages/Inventory/useInventoryData.ts +++ b/frontend/src/pages/Inventory/useInventoryData.ts @@ -9,10 +9,9 @@ import { useDataChanged } from "../../lib/crossTab"; import type { InventoryRow, InventoryTransaction, Warehouse } from "../../lib/types"; export interface LookupData { - categories: { id: string; slug: string; name: string }[]; units: { id: number; code: string; name_fa: string | null }[]; brands: { id: string; name: string }[]; - materials: { id: string; name: string; category: { id: string; name: string } | null; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[]; + materials: { id: string; name: string; canonical_unit: { id: number; code: string; name_fa: string | null } | null }[]; projects: { id: string; name: string; pm_user_id: string | null; pm_name?: string | null }[]; } @@ -34,14 +33,13 @@ export function useInventoryData(): InventoryData { const [inventory, setInventory] = useState([]); const [transactions, setTransactions] = useState([]); const [lookup, setLookup] = useState({ - categories: [], units: [], brands: [], materials: [], projects: [], + units: [], brands: [], materials: [], projects: [], }); const [loading, setLoading] = useState(true); const load = useCallback(async () => { try { - const [cats, units, brands, mats, projs, whs, inv, txs] = await Promise.all([ - apiV26.lookup.categories(), + const [units, brands, mats, projs, whs, inv, txs] = await Promise.all([ apiV26.lookup.units(), apiV26.lookup.brands(), apiV26.lookup.materials(), @@ -50,7 +48,7 @@ export function useInventoryData(): InventoryData { apiV26.inventory.list({ project_id: projectId }), apiV26.inventory.transactions({ project_id: projectId }), ]); - setLookup({ categories: cats, units, brands, materials: mats, projects: projs }); + setLookup({ units, brands, materials: mats, projects: projs }); setWarehouses(whs); setInventory(inv); setTransactions(txs); @@ -108,11 +106,6 @@ export function materialName(materials: LookupData["materials"], id: string): st return m ? m.name : "—"; } -export function catName(categories: LookupData["categories"], id: string | null | undefined): string { - const c = categories.find((x) => x.id === id); - return c ? c.name : "—"; -} - export function projName(projects: LookupData["projects"], id: string | null | undefined): string { const p = projects.find((x) => x.id === id); return p ? p.name : "—"; diff --git a/frontend/src/pages/Projects/ProjectsPage.tsx b/frontend/src/pages/Projects/ProjectsPage.tsx index ff0fed4..31d3946 100644 --- a/frontend/src/pages/Projects/ProjectsPage.tsx +++ b/frontend/src/pages/Projects/ProjectsPage.tsx @@ -19,7 +19,6 @@ import { lineItemTotal, materialById, materialLabel, - refConverted, supplierById, } from "../../lib/quoteUtils"; import { faNum, fmtDate } from "../../lib/formatters"; @@ -81,11 +80,11 @@ export function ProjectsPage() { const quotes = useQuotes(); const suppliers = useSuppliers(); const materials = useMaterials(); - const { categoryUnits, unitConversions, standardConversions } = useUnits(); + const { standardConversions } = useUnits(); const addProject = useAddProject(); const topics = useAppStore((s) => s.topics); - const store: Store = { quotes, suppliers, materials, projects, categoryUnits, unitConversions, standardConversions, topics }; + const store: Store = { quotes, suppliers, materials, projects, standardConversions, topics }; const [openProject, setOpenProject] = useState(null); const [newName, setNewName] = useState(""); @@ -189,17 +188,10 @@ function renderProjectDetail(store: Store, name: string): React.ReactElement { const itemLine = (li: Quote["line_items"][number]) => { const total = lineItemTotal(li); - const conv = refConverted(store, li); return (
{matName(li.material_id)} — {faNum(li.quantity)} {li.unit} × {faNum(li.unit_price || 0)} ={" "} {faNum(total || 0)} تومان - {conv && ( - - {" "} - — ≈ {faNum(conv.qty)} {conv.unit}({faNum(conv.perRef ?? 0)} تومان/{conv.unit}) - - )}
); }; diff --git a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx index 28037a7..c371c8a 100644 --- a/frontend/src/pages/Suppliers/SupplierDetailPage.tsx +++ b/frontend/src/pages/Suppliers/SupplierDetailPage.tsx @@ -48,21 +48,8 @@ export function SupplierDetailPage() { return [...set].sort((a, b) => a.localeCompare(b, "fa")); }, [materials]); - // متریال‌های خریداری‌شده از این تأمین‌کننده (از پیشنهادها) - const purchased = useMemo(() => { - const out = new Set(); - quotes.forEach((q) => { - if (q.supplier_id !== id) return; - (q.line_items || []).forEach((li) => { - if (li.rejected) return; - const m = materials.find((x) => x.id === li.material_id); - // دسته‌ی نمایشی متریال = دسته‌ی نخستین تأمین‌کننده‌ی متصل (v2.7) - if (!m || !m.supplier_category) return; - out.add(m.supplier_category); - }); - }); - return [...out].sort((a, b) => a.localeCompare(b, "fa")); - }, [quotes, materials, id]); + // دسته‌ی این تأمین‌کننده — دسته فقط برچسبِ خودِ تأمین‌کننده است (v2.7) + const purchased = useMemo(() => [...(s?.categories || [])].sort((a, b) => a.localeCompare(b, "fa")), [s]); if (!s) { return ( diff --git a/frontend/src/pages/Wizard/Step2LineItems.tsx b/frontend/src/pages/Wizard/Step2LineItems.tsx index 0ddd486..37a6567 100644 --- a/frontend/src/pages/Wizard/Step2LineItems.tsx +++ b/frontend/src/pages/Wizard/Step2LineItems.tsx @@ -77,7 +77,7 @@ export function materialOptions( // هم‌نام پیشنهاد می‌شوند. if (cat) list = list.filter( - (m) => normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === normalize(cat), + (m) => normalize(materialBaseName(m.name, m.brand, m.specs)) === normalize(cat), ); // مشخصه‌ها سراسری هستند: رجیستری attribute-definitions + مشخصه‌های همه‌ی // متریال‌ها + مشخصه‌های تایپ‌شده در سایر ردیف‌ها. به تاپیک/دسته/برندِ قلمِ @@ -109,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.supplier_category); + const base = materialBaseName(m.name, m.brand, m.specs); if (base) set.add(base); }); return [...set].sort((a, b) => a.localeCompare(b, "fa")); diff --git a/frontend/src/pages/Wizard/WizardPage.tsx b/frontend/src/pages/Wizard/WizardPage.tsx index 920d35b..46903bc 100644 --- a/frontend/src/pages/Wizard/WizardPage.tsx +++ b/frontend/src/pages/Wizard/WizardPage.tsx @@ -317,7 +317,6 @@ export function WizardPage() { brand, specs, base_unit: unit || "عدد", - supplier_category: "", }); materialId = created.id; } catch (err) { diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index a216351..f75d280 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -7,7 +7,6 @@ import { create } from "zustand"; import type { - CategoryUnit, Material, Project, Quote, @@ -15,7 +14,6 @@ import type { Store, Supplier, Topic, - UnitConversion, } from "../lib/types"; interface AppStore { @@ -24,8 +22,6 @@ interface AppStore { materials: Material[]; projects: Project[]; quotes: Quote[]; - categoryUnits: CategoryUnit[]; - unitConversions: UnitConversion[]; standardConversions: StandardConversion[]; topics: Topic[]; // دسته‌بندی‌های ۸گانه — از /material-categories @@ -45,9 +41,6 @@ interface AppStore { deleteSupplier: (id: string) => void; addMaterial: (m: Material) => void; addProject: (p: Project) => void; - setCategoryUnit: (cu: CategoryUnit) => void; - setConversion: (c: UnitConversion) => void; - deleteConversion: (id: string) => void; setStandardConversion: (sc: StandardConversion) => void; deleteStandardConversion: (id: string) => void; @@ -62,8 +55,6 @@ export const useAppStore = create()((set) => ({ materials: [], projects: [], quotes: [], - categoryUnits: [], - unitConversions: [], standardConversions: [], topics: [], @@ -77,8 +68,6 @@ export const useAppStore = create()((set) => ({ materials: store.materials, projects: store.projects, quotes: store.quotes, - categoryUnits: store.categoryUnits, - unitConversions: store.unitConversions, standardConversions: store.standardConversions, topics: store.topics, }), @@ -96,24 +85,6 @@ export const useAppStore = create()((set) => ({ deleteSupplier: (id) => set((s) => ({ suppliers: s.suppliers.filter((x) => x.id !== id) })), addMaterial: (m) => set((s) => ({ materials: [...s.materials, m] })), addProject: (p) => set((s) => ({ projects: [...s.projects, p] })), - setCategoryUnit: (cu) => - set((s) => { - const idx = s.categoryUnits.findIndex((x) => x.id === cu.id); - const categoryUnits = [...s.categoryUnits]; - if (idx >= 0) categoryUnits[idx] = cu; - else categoryUnits.push(cu); - return { categoryUnits }; - }), - setConversion: (c) => - set((s) => { - const idx = s.unitConversions.findIndex((x) => x.id === c.id); - const unitConversions = [...s.unitConversions]; - if (idx >= 0) unitConversions[idx] = c; - else unitConversions.push(c); - return { unitConversions }; - }), - deleteConversion: (id) => - set((s) => ({ unitConversions: s.unitConversions.filter((x) => x.id !== id) })), setStandardConversion: (sc) => set((s) => { const idx = s.standardConversions.findIndex((x) => x.id === sc.id);