Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae62a0391b |
@@ -15,7 +15,7 @@ from dataclasses import dataclass
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import exists, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.auth import UserContext, get_optional_user, require_role
|
from app.auth import UserContext, get_optional_user, require_role
|
||||||
@@ -27,12 +27,13 @@ from app.models.inventory import (
|
|||||||
InventoryTransaction,
|
InventoryTransaction,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
)
|
)
|
||||||
from app.models.procurement import Project
|
from app.models.procurement import Project, Supplier, SupplierMaterial
|
||||||
from app.schemas.inventory import (
|
from app.schemas.inventory import (
|
||||||
InventoryTransactionCreate,
|
InventoryTransactionCreate,
|
||||||
WarehouseCreate,
|
WarehouseCreate,
|
||||||
WarehouseUpdate,
|
WarehouseUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services.expand_service import category_by_material
|
||||||
from app.services.inventory_service import (
|
from app.services.inventory_service import (
|
||||||
apply_transaction,
|
apply_transaction,
|
||||||
get_warehouse_or_404,
|
get_warehouse_or_404,
|
||||||
@@ -333,8 +334,18 @@ async def list_inventory(
|
|||||||
Warehouse.project_id == project_id
|
Warehouse.project_id == project_id
|
||||||
)
|
)
|
||||||
if category_id is not None:
|
if category_id is not None:
|
||||||
stmt = stmt.join(Material, Material.id == InventoryItem.material_id).where(
|
# v2.7: materials carry no category — match through their suppliers
|
||||||
Material.category_id == category_id
|
# (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:
|
if warehouse_id is not None:
|
||||||
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
stmt = stmt.where(InventoryItem.warehouse_id == warehouse_id)
|
||||||
@@ -357,10 +368,8 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
|||||||
unit_ids = {i.unit_id for i in items}
|
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()}
|
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()}
|
materials = {m.id: m for m in (await db.execute(select(Material).where(Material.id.in_(mat_ids)))).scalars()}
|
||||||
categories = {}
|
# v2.7: the category shown for a material comes from its first linked supplier.
|
||||||
if materials:
|
material_category = await category_by_material(db, mat_ids)
|
||||||
cat_ids = {m.category_id for m in materials.values()}
|
|
||||||
categories = {c.id: c for c in (await db.execute(select(MaterialCategory).where(MaterialCategory.id.in_(cat_ids)))).scalars()}
|
|
||||||
brands = {}
|
brands = {}
|
||||||
if brand_ids:
|
if brand_ids:
|
||||||
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
brands = {b.id: b for b in (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars()}
|
||||||
@@ -370,7 +379,7 @@ async def _serialize_inventory_rows(db: AsyncSession, items: list[InventoryItem]
|
|||||||
return [
|
return [
|
||||||
inventory_to_dict(
|
inventory_to_dict(
|
||||||
i, warehouses[i.warehouse_id], materials[i.material_id],
|
i, warehouses[i.warehouse_id], materials[i.material_id],
|
||||||
categories.get(materials[i.material_id].category_id), brands.get(i.brand_id), units.get(i.unit_id),
|
material_category.get(i.material_id), brands.get(i.brand_id), units.get(i.unit_id),
|
||||||
)
|
)
|
||||||
for i in items
|
for i in items
|
||||||
]
|
]
|
||||||
@@ -556,7 +565,20 @@ async def material_options(
|
|||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
raise NotFoundError("دستهبندی یافت نشد", field="category_id")
|
||||||
|
|
||||||
stmt = select(Material).where(Material.category_id == category_id, Material.status == "active")
|
# v2.7: a material has no category column — it belongs to a category when a
|
||||||
|
# linked supplier does (same rule as GET /materials?category_id=…).
|
||||||
|
stmt = select(Material).where(
|
||||||
|
Material.status == "active",
|
||||||
|
exists(
|
||||||
|
select(1)
|
||||||
|
.select_from(SupplierMaterial)
|
||||||
|
.join(Supplier, Supplier.id == SupplierMaterial.supplier_id)
|
||||||
|
.where(
|
||||||
|
SupplierMaterial.material_id == Material.id,
|
||||||
|
Supplier.category_id == category_id,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
if warehouse_id is not None:
|
if warehouse_id is not None:
|
||||||
wh = await get_warehouse_or_404(db, warehouse_id)
|
wh = await get_warehouse_or_404(db, warehouse_id)
|
||||||
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
if scope.role == ROLE_PM and wh.project_id != scope.project_id:
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from sqlalchemy import func, or_, select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.engine import get_db
|
from app.db.engine import get_db
|
||||||
from app.models.catalog import Material, MaterialCategory
|
from app.models.catalog import MaterialCategory
|
||||||
|
from app.models.procurement import Supplier
|
||||||
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
from app.schemas.catalog import MaterialCategoryCreate, MaterialCategoryUpdate
|
||||||
from app.utils.errors import ConflictError, NotFoundError
|
from app.utils.errors import ConflictError, NotFoundError
|
||||||
from app.utils.pagination import paginate, sort_query
|
from app.utils.pagination import paginate, sort_query
|
||||||
@@ -95,10 +96,14 @@ async def delete_category(category_id, db: AsyncSession = Depends(get_db)):
|
|||||||
category = await db.get(MaterialCategory, category_id)
|
category = await db.get(MaterialCategory, category_id)
|
||||||
if category is None:
|
if category is None:
|
||||||
raise NotFoundError("دستهبندی یافت نشد")
|
raise NotFoundError("دستهبندی یافت نشد")
|
||||||
# materials.category_id is ON DELETE RESTRICT — block if any material exists.
|
# v2.7: materials no longer carry a category, so what blocks deletion is the
|
||||||
linked = await db.scalar(select(func.count()).select_from(Material).where(Material.category_id == category_id))
|
# supplier side — suppliers.category_id is ON DELETE RESTRICT. Every
|
||||||
|
# material of that category is reached through those suppliers.
|
||||||
|
linked = await db.scalar(
|
||||||
|
select(func.count()).select_from(Supplier).where(Supplier.category_id == category_id)
|
||||||
|
)
|
||||||
if linked:
|
if linked:
|
||||||
raise ConflictError(f"این دستهبندی شامل {linked} متریال است و قابل حذف نیست")
|
raise ConflictError(f"این دستهبندی برای {linked} تأمینکننده ثبت شده و قابل حذف نیست")
|
||||||
await db.delete(category) # category_attributes cascade via DDL
|
await db.delete(category) # category_attributes cascade via DDL
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return {"data": {"id": str(category_id), "deleted": True}}
|
return {"data": {"id": str(category_id), "deleted": True}}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from app.models.procurement import (
|
|||||||
QuoteEvent,
|
QuoteEvent,
|
||||||
QuoteProjects,
|
QuoteProjects,
|
||||||
Supplier,
|
Supplier,
|
||||||
|
SupplierMaterial,
|
||||||
Termsheet,
|
Termsheet,
|
||||||
TermsheetScheduleStep,
|
TermsheetScheduleStep,
|
||||||
)
|
)
|
||||||
@@ -202,9 +203,47 @@ def quote_flat(q: Quote) -> dict:
|
|||||||
# Materials
|
# 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:
|
async def _load_material_maps(db: AsyncSession, materials: list[Material], expand: set[str]) -> dict:
|
||||||
material_ids = [m.id for m in materials]
|
material_ids = [m.id for m in materials]
|
||||||
maps: dict = {"categories": {}, "units": {}, "brands": {}, "material_brands": {}, "mavs": {}, "attr_defs": {}}
|
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()
|
unit_ids: set[int] = set()
|
||||||
if "canonical_unit" in expand:
|
if "canonical_unit" in expand:
|
||||||
@@ -252,10 +291,13 @@ def _material_to_dict(m: Material, expand: set[str], maps: dict) -> dict:
|
|||||||
"created_at": to_iso(m.created_at),
|
"created_at": to_iso(m.created_at),
|
||||||
"updated_at": to_iso(m.updated_at),
|
"updated_at": to_iso(m.updated_at),
|
||||||
}
|
}
|
||||||
|
# v2.7: never the material's own column (it no longer has one) — the
|
||||||
|
# category comes from the first linked supplier via _category_by_material.
|
||||||
|
material_category = maps["material_categories"].get(m.id)
|
||||||
if "category" in expand:
|
if "category" in expand:
|
||||||
d["category"] = None
|
d["category"] = category_brief(material_category)
|
||||||
else:
|
else:
|
||||||
d["category_id"] = None
|
d["category_id"] = str(material_category.id) if material_category else None
|
||||||
if "canonical_unit" in expand:
|
if "canonical_unit" in expand:
|
||||||
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
d["canonical_unit"] = unit_to_dict(maps["units"].get(m.canonical_unit_id))
|
||||||
else:
|
else:
|
||||||
@@ -298,6 +340,8 @@ class _LineItemData:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.materials: dict[uuid.UUID, Material] = {}
|
self.materials: dict[uuid.UUID, Material] = {}
|
||||||
self.categories: dict[uuid.UUID, MaterialCategory] = {}
|
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.units: dict[int, Unit] = {}
|
||||||
self.brands: dict[uuid.UUID, Brand] = {}
|
self.brands: dict[uuid.UUID, Brand] = {}
|
||||||
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
|
self.material_brands: dict[uuid.UUID, list[BrandMaterial]] = {}
|
||||||
@@ -329,6 +373,7 @@ async def _load_line_item_data(db: AsyncSession, line_items: list[LineItem], exp
|
|||||||
if brand_ids:
|
if brand_ids:
|
||||||
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
|
brands = (await db.execute(select(Brand).where(Brand.id.in_(brand_ids)))).scalars().all()
|
||||||
data.brands = {b.id: b for b in brands}
|
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.
|
# Pricing always needs item_termsheets in expanded mode.
|
||||||
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
its = (await db.execute(select(ItemTermsheet).where(ItemTermsheet.line_item_id.in_(li_ids)))).scalars().all()
|
||||||
@@ -442,7 +487,7 @@ def _line_item_expanded(li: LineItem, expand: set[str], data: _LineItemData, inc
|
|||||||
d["material"] = {
|
d["material"] = {
|
||||||
"id": str(m.id),
|
"id": str(m.id),
|
||||||
"name": m.name,
|
"name": m.name,
|
||||||
"category": None,
|
"category": category_brief(data.material_category.get(m.id)),
|
||||||
"brands": [
|
"brands": [
|
||||||
{"id": str(bm.brand_id), "name": data.brands[bm.brand_id].name, "catalog_code": bm.catalog_code}
|
{"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, [])
|
for bm in data.material_brands.get(m.id, [])
|
||||||
|
|||||||
+11
-5
@@ -562,7 +562,9 @@ function adaptMaterial(raw: RawMaterial, lookup: Lookup): Material {
|
|||||||
topic:
|
topic:
|
||||||
raw.topic ||
|
raw.topic ||
|
||||||
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
|
(raw.category?.slug ? lookup.categorySlugToName.get(raw.category.slug) || "" : ""),
|
||||||
category: raw.category?.name || "",
|
// v2.7: دستهی متریال در بکاند از نخستین تأمینکنندهی متصل میآید
|
||||||
|
// (متریالِ بدون تأمینکننده → خالی)
|
||||||
|
supplier_category: raw.category?.name || "",
|
||||||
brand: raw.brands?.[0]?.name || "",
|
brand: raw.brands?.[0]?.name || "",
|
||||||
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
// مشخصهها از attribute های متریال خوانده میشوند (EAV)
|
||||||
specs: (raw.attributes || [])
|
specs: (raw.attributes || [])
|
||||||
@@ -643,11 +645,15 @@ function adaptQuote(raw: RawQuote): Quote {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
|
function adaptSupplier(raw: RawSupplier, topic: string, categories: string[]): Supplier {
|
||||||
|
// v2.6/v2.7: هر تأمینکننده دقیقاً یک دسته دارد (FK) و بکاند آن را به شکل
|
||||||
|
// category برمیگرداند — همین اولویت اول است. categories قدیمی (ستون حذفشده)
|
||||||
|
// و دستههای استنتاجشده از پیشنهادها فقط پشتیباناند.
|
||||||
|
const own = raw.category?.name ? [raw.category.name] : [];
|
||||||
|
const legacy = Array.isArray(raw.categories) ? raw.categories.filter(Boolean) : [];
|
||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
topic,
|
topic,
|
||||||
// دستههای ذخیرهشده روی تأمینکننده (بکاند) اولویت دارند؛ وگرنه از quotes استنتاج میشود
|
categories: own.length ? own : legacy.length ? legacy : categories,
|
||||||
categories: Array.isArray(raw.categories) && raw.categories.length ? raw.categories : categories,
|
|
||||||
name: raw.name,
|
name: raw.name,
|
||||||
location: raw.location || "",
|
location: raw.location || "",
|
||||||
phone: raw.phone || "",
|
phone: raw.phone || "",
|
||||||
@@ -673,7 +679,7 @@ function deriveSupplierTopics(
|
|||||||
if (!topics.has(q.supplier_id)) topics.set(q.supplier_id, new Set());
|
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 (!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.topic) topics.get(q.supplier_id)!.add(m.topic);
|
||||||
if (m.category) cats.get(q.supplier_id)!.add(m.category);
|
if (m.supplier_category) cats.get(q.supplier_id)!.add(m.supplier_category);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rawSuppliers.map((s) => {
|
return rawSuppliers.map((s) => {
|
||||||
@@ -1149,7 +1155,7 @@ export const api = {
|
|||||||
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصهها در
|
// name فقط نامِ خودِ متریال است؛ برند در brand_ids و مشخصهها در
|
||||||
// attributes (EAV) ذخیره میشوند. اگر برند/مشخصه داخل name ریخته شود،
|
// attributes (EAV) ذخیره میشوند. اگر برند/مشخصه داخل name ریخته شود،
|
||||||
// در ویزارد بهعنوان «نامِ متریال» کش میشود و مشخصهها در نام ظاهر میشوند.
|
// در ویزارد بهعنوان «نامِ متریال» کش میشود و مشخصهها در نام ظاهر میشوند.
|
||||||
const name = (m.category || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
const name = (m.name || m.brand || (m.specs || []).join(" ") || "").trim() || "کالای جدید";
|
||||||
// مشخصهها بهصورت attribute (EAV) ذخیره میشوند تا بعد از رفرش دیده شوند
|
// مشخصهها بهصورت attribute (EAV) ذخیره میشوند تا بعد از رفرش دیده شوند
|
||||||
const attrs: { attribute_definition_id: string; value: string }[] = [];
|
const attrs: { attribute_definition_id: string; value: string }[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function materialById(materials: Material[], id: string): Material {
|
|||||||
id: "",
|
id: "",
|
||||||
name: "",
|
name: "",
|
||||||
topic: "",
|
topic: "",
|
||||||
category: "؟",
|
supplier_category: "",
|
||||||
brand: "",
|
brand: "",
|
||||||
specs: [],
|
specs: [],
|
||||||
base_unit: "",
|
base_unit: "",
|
||||||
@@ -46,7 +46,7 @@ export function specsLabel(specs: string[]): string {
|
|||||||
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
||||||
*/
|
*/
|
||||||
export function formatMaterialTitle(
|
export function formatMaterialTitle(
|
||||||
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
m: Pick<Material, "name" | "brand" | "specs"> | null | undefined,
|
||||||
extraSpecs?: string[],
|
extraSpecs?: string[],
|
||||||
): string {
|
): string {
|
||||||
if (!m) return "کالا";
|
if (!m) return "کالا";
|
||||||
@@ -75,7 +75,7 @@ export function formatMaterialTitle(
|
|||||||
if (rawName) {
|
if (rawName) {
|
||||||
parts.push(rawName);
|
parts.push(rawName);
|
||||||
} else if (!rawBrand) {
|
} else if (!rawBrand) {
|
||||||
parts.push(m.category && m.category !== "؟" ? m.category : "کالا");
|
parts.push("کالا");
|
||||||
}
|
}
|
||||||
|
|
||||||
// اضافه کردن مشخصهها بدون تکرار
|
// اضافه کردن مشخصهها بدون تکرار
|
||||||
@@ -95,10 +95,68 @@ export function formatMaterialTitle(
|
|||||||
return parts.join(" . ");
|
return parts.join(" . ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs"> | null | undefined): string {
|
||||||
return formatMaterialTitle(m);
|
return formatMaterialTitle(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// نام پایهی متریال از نامِ ذخیرهشده.
|
||||||
|
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و
|
||||||
|
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
||||||
|
// name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا
|
||||||
|
// نامِ پایه باقی بماند. دستهی همان رکوردهای قدیمی همان دستهی نخستین
|
||||||
|
// تأمینکننده است (supplier_category).
|
||||||
|
export function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
|
||||||
|
const fallback = (name || "").trim();
|
||||||
|
if (!fallback) return "";
|
||||||
|
let base = fallback;
|
||||||
|
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
||||||
|
const parts = [brand, category, ...specs]
|
||||||
|
.map((p) => (p || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.length - a.length);
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!base.includes(p)) continue;
|
||||||
|
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
||||||
|
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
||||||
|
if (!next) continue;
|
||||||
|
base = next;
|
||||||
|
}
|
||||||
|
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
||||||
|
if (parts.some((p) => p === base)) return fallback;
|
||||||
|
return base || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* یافتن متریالِ متناظر با یک ردیفِ اقلام.
|
||||||
|
*
|
||||||
|
* هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در این تطبیق بود، ولی
|
||||||
|
* دسته دیگر روی متریال نیست؛ تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
||||||
|
* برند و مشخصهی یکسان (مثل دو چمن مصنوعیِ همبرند) را میگیرد. نامِ خالی یعنی
|
||||||
|
* هویت نامعلوم — در آن حالت تطبیق نمیدهیم و متریالِ تازه ساخته میشود.
|
||||||
|
*/
|
||||||
|
export function matchMaterial(
|
||||||
|
materials: Material[],
|
||||||
|
name: string,
|
||||||
|
brand: string,
|
||||||
|
specs: string[],
|
||||||
|
): Material | undefined {
|
||||||
|
const wantName = normalize(name);
|
||||||
|
if (!wantName) return undefined;
|
||||||
|
const wantBrand = normalize(brand);
|
||||||
|
const wantSpecs = specsKey(specs);
|
||||||
|
const sameIdentity = (m: Material) => normalize(m.brand) === wantBrand && specsKey(m.specs) === wantSpecs;
|
||||||
|
const base = normalize(materialBaseName(name, brand, specs));
|
||||||
|
return (
|
||||||
|
materials.find((m) => normalize(m.name) === wantName && sameIdentity(m)) ||
|
||||||
|
materials.find(
|
||||||
|
(m) =>
|
||||||
|
base &&
|
||||||
|
normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === base &&
|
||||||
|
sameIdentity(m),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
// آرایهی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
|
||||||
export function itemTermsheetIds(li: LineItem): string[] {
|
export function itemTermsheetIds(li: LineItem): string[] {
|
||||||
if (li.termsheet_ids) return li.termsheet_ids;
|
if (li.termsheet_ids) return li.termsheet_ids;
|
||||||
@@ -196,9 +254,12 @@ export function refConverted(
|
|||||||
li: LineItem,
|
li: LineItem,
|
||||||
): { qty: number; unit: string; perRef: number | null } | null {
|
): { qty: number; unit: string; perRef: number | null } | null {
|
||||||
const m = materialById(store.materials, li.material_id);
|
const m = materialById(store.materials, li.material_id);
|
||||||
const ref = refUnitForCategory(store, m.category);
|
// categoryUnits/unitConversions با «نام دسته» کلید خوردهاند؛ دستهی متریال
|
||||||
|
// همان دستهی نخستین تأمینکننده است (v2.7). متریال بدون تأمینکننده دسته
|
||||||
|
// ندارد و تبدیلش انجام نمیشود.
|
||||||
|
const ref = refUnitForCategory(store, m.supplier_category);
|
||||||
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
|
||||||
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
|
const conv = findConversion(store, m.supplier_category, m.brand, li.unit, ref);
|
||||||
if (!conv) return null;
|
if (!conv) return null;
|
||||||
const qty = (li.quantity || 0) * conv.factor;
|
const qty = (li.quantity || 0) * conv.factor;
|
||||||
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
|
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
|
||||||
@@ -300,12 +361,15 @@ export function filterQuotes(
|
|||||||
if (stKey && quote.status !== stKey) return false;
|
if (stKey && quote.status !== stKey) return false;
|
||||||
|
|
||||||
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
const matInfo = activeItems(quote).map((li) => materialById(store.materials, li.material_id));
|
||||||
if (catVal && !matInfo.some((m) => m.category === catVal)) return false;
|
// v2.7: دسته روی تأمینکننده است و بکاند هم پیشنهادها را با دستهی
|
||||||
|
// تأمینکننده فیلتر میکند — همین قاعده اینجا هم اعمال میشود تا لیست و
|
||||||
|
// فیلتر یک رفتار داشته باشند.
|
||||||
|
if (catVal && !(supObj.categories || []).includes(catVal)) return false;
|
||||||
|
|
||||||
if (q) {
|
if (q) {
|
||||||
const hay =
|
const hay =
|
||||||
`${supObj.name} ${quote.project || ""} ` +
|
`${supObj.name} ${quote.project || ""} ` +
|
||||||
matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
matInfo.map((m) => `${m.supplier_category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
|
||||||
if (!hay.toLowerCase().includes(q)) return false;
|
if (!hay.toLowerCase().includes(q)) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ export interface Supplier {
|
|||||||
// ماده/کالا
|
// ماده/کالا
|
||||||
export interface Material {
|
export interface Material {
|
||||||
id: string; // "m1", "m2", ...
|
id: string; // "m1", "m2", ...
|
||||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + مشخصه)
|
name: string; // نامِ خودِ متریال — برند در brand و مشخصهها در specs
|
||||||
topic: string;
|
topic: string;
|
||||||
category: string; // اختیاری در بکاند؛ همیشه string در فرانت (default: "")
|
// دستهی نمایشی — از نخستین تأمینکنندهی متصل به متریال میآید (v2.7:
|
||||||
|
// خودِ متریال دیگر دسته ندارد و دسته روی تأمینکننده است). متریال بدون
|
||||||
|
// تأمینکننده = رشتهی خالی. این مقدار کلیدِ تبدیلهای واحد هم هست.
|
||||||
|
supplier_category: string;
|
||||||
brand: string; // مثلاً "ذوب آهن"
|
brand: string; // مثلاً "ذوب آهن"
|
||||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||||
base_unit: string; // واحد پایه
|
base_unit: string; // واحد پایه
|
||||||
|
|||||||
@@ -80,7 +80,12 @@ export function DashboardPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const categoryOptions = useMemo(() => [...new Set(materials.map((m) => m.category))], [materials]);
|
// دستهها روی تأمینکننده هستند (v2.7) و بکاند هم فیلتر پیشنهادها را با
|
||||||
|
// دستهی تأمینکننده انجام میدهد — پس گزینههای فیلتر از همانجا میآید.
|
||||||
|
const categoryOptions = useMemo(
|
||||||
|
() => [...new Set(suppliers.flatMap((s) => s.categories || []).filter(Boolean))].sort((a, b) => a.localeCompare(b, "fa")),
|
||||||
|
[suppliers],
|
||||||
|
);
|
||||||
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
const supplierOptions = useMemo(() => suppliers.map((s) => ({ id: s.id, name: s.name })), [suppliers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ import {
|
|||||||
useAddSupplier,
|
useAddSupplier,
|
||||||
useAddMaterial,
|
useAddMaterial,
|
||||||
} from "../../hooks";
|
} from "../../hooks";
|
||||||
import { itemTermsheetIds, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
import { itemTermsheetIds, matchMaterial, materialById, materialSpecs, supplierById } from "../../lib/quoteUtils";
|
||||||
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
|
import { STATUS_LABEL, statusOptionsFor } from "../../lib/constants";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { specsKey } from "../../lib/quoteUtils";
|
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
import { JalaliDatePicker } from "../../components/JalaliDatePicker";
|
||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
@@ -154,7 +153,9 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
return {
|
return {
|
||||||
key: li.id,
|
key: li.id,
|
||||||
origLi: li,
|
origLi: li,
|
||||||
cat: m.category || "",
|
// «متریال» این ردیف = نامِ خودِ متریال (v2.7: متریال دسته ندارد؛
|
||||||
|
// دسته روی تأمینکننده است و دیگر برچسبِ این فیلد نیست)
|
||||||
|
cat: m.name || "",
|
||||||
brand: m.brand || "",
|
brand: m.brand || "",
|
||||||
specs: materialSpecs(m),
|
specs: materialSpecs(m),
|
||||||
qty: li.quantity,
|
qty: li.quantity,
|
||||||
@@ -294,12 +295,8 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
const total = row.total;
|
const total = row.total;
|
||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
let material = materialsCopy.find(
|
// هویت متریال = نام + برند + مشخصه (دیگر دستهای برای تطبیق وجود ندارد)
|
||||||
(m) =>
|
let material = matchMaterial(materialsCopy, cat, brand, specs);
|
||||||
normalize(m.category) === normalize(cat) &&
|
|
||||||
normalize(m.brand) === normalize(brand) &&
|
|
||||||
specsKey(m.specs) === specsKey(specs),
|
|
||||||
);
|
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material) {
|
if (material) {
|
||||||
materialId = material.id;
|
materialId = material.id;
|
||||||
@@ -308,10 +305,10 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
id: nextId("m", materialsCopy),
|
id: nextId("m", materialsCopy),
|
||||||
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
|
name: (cat || brand || specs.join(" ") || "").trim() || "کالای جدید",
|
||||||
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
topic: materialById(materials, row.origLi?.material_id || "").topic || "",
|
||||||
category: cat,
|
|
||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit,
|
base_unit: unit,
|
||||||
|
supplier_category: "",
|
||||||
};
|
};
|
||||||
materialsCopy.push(nm);
|
materialsCopy.push(nm);
|
||||||
const created = await addMaterial.mutateAsync(nm);
|
const created = await addMaterial.mutateAsync(nm);
|
||||||
@@ -579,7 +576,7 @@ export function EditModal({ quoteId, onClose }: EditModalProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-[11px] font-semibold text-gray-600">متریال / دسته</label>
|
<label className="text-[11px] font-semibold text-gray-600">متریال</label>
|
||||||
<input
|
<input
|
||||||
disabled={hasReceipts}
|
disabled={hasReceipts}
|
||||||
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-2.5 text-xs text-gray-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:opacity-60"
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ export function SupplierDetailPage() {
|
|||||||
(q.line_items || []).forEach((li) => {
|
(q.line_items || []).forEach((li) => {
|
||||||
if (li.rejected) return;
|
if (li.rejected) return;
|
||||||
const m = materials.find((x) => x.id === li.material_id);
|
const m = materials.find((x) => x.id === li.material_id);
|
||||||
if (!m || !m.category) return;
|
// دستهی نمایشی متریال = دستهی نخستین تأمینکنندهی متصل (v2.7)
|
||||||
out.add(m.category);
|
if (!m || !m.supplier_category) return;
|
||||||
|
out.add(m.supplier_category);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
|
import { MapPin, Phone, User, Plus, Timer } from "lucide-react";
|
||||||
import type { Material, Supplier } from "../../lib/types";
|
import type { Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { fmtNum } from "../../lib/formatters";
|
import { fmtNum } from "../../lib/formatters";
|
||||||
|
|
||||||
export function topicCategories(topic: string, materials: Material[], suppliers: Supplier[]): string[] {
|
|
||||||
const out = new Set<string>();
|
|
||||||
materials
|
|
||||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
|
||||||
.forEach((m) => out.add(m.category));
|
|
||||||
suppliers
|
|
||||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
|
||||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
export function suppliersOfCategory(topic: string, cat: string, suppliers: Supplier[]): Supplier[] {
|
||||||
return suppliers.filter(
|
return suppliers.filter(
|
||||||
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
(s) => normalize(s.topic) === normalize(topic) && (s.categories || []).some((c) => normalize(c) === normalize(cat)),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import type { Material, Supplier } from "../../lib/types";
|
import type { Supplier } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useSuppliers } from "../../hooks";
|
import { useSuppliers } from "../../hooks";
|
||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
@@ -179,14 +179,3 @@ export function Step1TopicSupplier({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// دستههای موجود داخل یک تاپیک (از materials + تأمینکنندهها)
|
|
||||||
export function topicCategories(topic: string, suppliers: Supplier[], materials: Material[]): string[] {
|
|
||||||
const out = new Set<string>();
|
|
||||||
materials
|
|
||||||
.filter((m) => topic && normalize(m.topic) === normalize(topic) && m.category)
|
|
||||||
.forEach((m) => out.add(m.category));
|
|
||||||
suppliers
|
|
||||||
.filter((s) => topic && normalize(s.topic) === normalize(topic))
|
|
||||||
.forEach((s) => (s.categories || []).forEach((c) => c && out.add(c)));
|
|
||||||
return [...out].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import type { Material } from "../../lib/types";
|
import type { Material } from "../../lib/types";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { useMaterials } from "../../hooks";
|
import { useMaterials } from "../../hooks";
|
||||||
import { specsKey } from "../../lib/quoteUtils";
|
import { matchMaterial, materialBaseName } from "../../lib/quoteUtils";
|
||||||
import { Combobox } from "../../components/Combobox";
|
import { Combobox } from "../../components/Combobox";
|
||||||
import { NumberInput } from "../../components/NumberInput";
|
import { NumberInput } from "../../components/NumberInput";
|
||||||
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
import { MultiSelectCombobox } from "../../components/MultiSelectCombobox";
|
||||||
@@ -41,38 +41,12 @@ export function itemRowLabel(it: ItemDraft): string {
|
|||||||
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
return qtyPart ? `${name} -- ${qtyPart}` : name;
|
||||||
}
|
}
|
||||||
|
|
||||||
// نام پایهی متریال از نامِ ذخیرهشده.
|
// مقادیر مجزای name/brand/spec در کاتالوگ سراسری
|
||||||
// متریالهای جدید فقط نامِ خودِ متریال را در name دارند (برند در brand_ids و
|
|
||||||
// مشخصهها در attributes ذخیره میشوند) و دستنخورده برمیگردند. رکوردهای قدیمی
|
|
||||||
// name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛ آن قطعات جدا میشوند تا
|
|
||||||
// نامِ پایه باقی بماند.
|
|
||||||
function materialBaseName(name: string, brand: string, specs: string[], category = ""): string {
|
|
||||||
const fallback = (name || "").trim();
|
|
||||||
if (!fallback) return "";
|
|
||||||
let base = fallback;
|
|
||||||
// بلندترین قطعه اول حذف میشود تا حذفهای جزئی، رشته را ناقص نکنند
|
|
||||||
const parts = [brand, category, ...specs]
|
|
||||||
.map((p) => (p || "").trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort((a, b) => b.length - a.length);
|
|
||||||
for (const p of parts) {
|
|
||||||
if (!base.includes(p)) continue;
|
|
||||||
const next = base.split(p).join(" ").replace(/\s+/g, " ").trim();
|
|
||||||
// اگر حذفِ این قطعه کلِ نام را خالی کند، یعنی همان نامِ متریال بوده — نگهش دار
|
|
||||||
if (!next) continue;
|
|
||||||
base = next;
|
|
||||||
}
|
|
||||||
// شکلِ قدیمیِ خراب میتواند فقط یک مشخصه باقی بگذارد؛ آن دیگر نامِ متریال نیست
|
|
||||||
if (parts.some((p) => p === base)) return fallback;
|
|
||||||
return base || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// مقادیر مجزای category/brand/spec در کاتالوگ سراسری
|
|
||||||
export function materialOptions(
|
export function materialOptions(
|
||||||
materials: Material[],
|
materials: Material[],
|
||||||
_topic: string,
|
_topic: string,
|
||||||
item: ItemDraft,
|
item: ItemDraft,
|
||||||
field: "cat" | "brand" | "spec",
|
field: "brand" | "spec",
|
||||||
allItems: ItemDraft[] = [],
|
allItems: ItemDraft[] = [],
|
||||||
apiBrands: string[] = [],
|
apiBrands: string[] = [],
|
||||||
apiSpecs: string[] = [],
|
apiSpecs: string[] = [],
|
||||||
@@ -92,19 +66,19 @@ export function materialOptions(
|
|||||||
return [...out];
|
return [...out];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (field === "cat") {
|
|
||||||
const base = new Set<string>(materials.map((m) => m.category).filter(Boolean));
|
|
||||||
fromRows((it) => it.cat).forEach((c) => base.add(c));
|
|
||||||
return [...base].sort((a, b) => a.localeCompare(b, "fa"));
|
|
||||||
}
|
|
||||||
if (field === "brand") {
|
if (field === "brand") {
|
||||||
// برند سراسری است — مستقل از تاپیک و دسته
|
// برند سراسری است — مستقل از تاپیک و متریال
|
||||||
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
const allBrands = new Set(materials.map((m) => m.brand).filter(Boolean));
|
||||||
apiBrands.forEach((b) => b && allBrands.add(b));
|
apiBrands.forEach((b) => b && allBrands.add(b));
|
||||||
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
fromRows((it) => it.brand).forEach((b) => allBrands.add(b));
|
||||||
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...allBrands].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
}
|
}
|
||||||
if (cat) list = list.filter((m) => normalize(m.category) === normalize(cat));
|
// فیلدِ اولِ هر ردیف «نام متریال» است (نه دسته) — مشخصههای متریالِ
|
||||||
|
// همنام پیشنهاد میشوند.
|
||||||
|
if (cat)
|
||||||
|
list = list.filter(
|
||||||
|
(m) => normalize(materialBaseName(m.name, m.brand, m.specs, m.supplier_category)) === normalize(cat),
|
||||||
|
);
|
||||||
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
// مشخصهها سراسری هستند: رجیستری attribute-definitions + مشخصههای همهی
|
||||||
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
// متریالها + مشخصههای تایپشده در سایر ردیفها. به تاپیک/دسته/برندِ قلمِ
|
||||||
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
// فعلی گره نمیخورند تا هنگام افزودن پیشنهاد جدید همیشه در دسترس باشند.
|
||||||
@@ -135,7 +109,7 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
const allMaterialNames = useMemo(() => {
|
const allMaterialNames = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
materials.forEach((m) => {
|
materials.forEach((m) => {
|
||||||
const base = materialBaseName(m.name, m.brand, m.specs, m.category);
|
const base = materialBaseName(m.name, m.brand, m.specs, m.supplier_category);
|
||||||
if (base) set.add(base);
|
if (base) set.add(base);
|
||||||
});
|
});
|
||||||
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
return [...set].sort((a, b) => a.localeCompare(b, "fa"));
|
||||||
@@ -193,13 +167,9 @@ export function Step2LineItems({ items, onItemsChange, selectedTopic, onRemember
|
|||||||
if (!it.specs.length) return;
|
if (!it.specs.length) return;
|
||||||
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
rec = { topic: selectedTopic, category: it.cat.trim(), brand: it.brand.trim(), specs: it.specs };
|
||||||
}
|
}
|
||||||
const dup = materials.some(
|
// rec.category در واقع نامِ متریال است (فیلدِ اولِ ردیف) — تطبیق بر اساس
|
||||||
(m) =>
|
// همان هویتِ متریال انجام میشود، نه دسته.
|
||||||
normalize(m.topic) === normalize(rec.topic) &&
|
const dup = !!matchMaterial(materials, rec.category, rec.brand, rec.specs);
|
||||||
normalize(m.category) === normalize(rec.category) &&
|
|
||||||
normalize(m.brand) === normalize(rec.brand) &&
|
|
||||||
specsKey(m.specs) === specsKey(rec.specs),
|
|
||||||
);
|
|
||||||
if (dup) return;
|
if (dup) return;
|
||||||
onRemember(rec);
|
onRemember(rec);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { topicByName } from "../../lib/topics";
|
|||||||
import { useAppStore } from "../../store";
|
import { useAppStore } from "../../store";
|
||||||
import { normalize } from "../../lib/normalize";
|
import { normalize } from "../../lib/normalize";
|
||||||
import { faNum } from "../../lib/formatters";
|
import { faNum } from "../../lib/formatters";
|
||||||
import { specsKey } from "../../lib/quoteUtils";
|
import { matchMaterial } from "../../lib/quoteUtils";
|
||||||
import { Header, PageLayout } from "../../components/layout/Header";
|
import { Header, PageLayout } from "../../components/layout/Header";
|
||||||
import { Stepper } from "./Stepper";
|
import { Stepper } from "./Stepper";
|
||||||
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
import { Step1TopicSupplier } from "./Step1TopicSupplier";
|
||||||
@@ -291,22 +291,19 @@ export function WizardPage() {
|
|||||||
if (!cat && !brand && qty == null && price == null) continue;
|
if (!cat && !brand && qty == null && price == null) continue;
|
||||||
|
|
||||||
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
const wantName = (cat || brand || specs.join(" ") || "").trim() || "کالای جدید";
|
||||||
|
// هویت متریال = نام + برند + مشخصه. پیش از v2.7 دسته هم در تطبیق بود؛
|
||||||
|
// دسته دیگر روی متریال نیست و تکیه بر نام جلوی اشتباهگرفتن دو متریال با
|
||||||
|
// برند و مشخصهی یکسان را میگیرد (matchMaterial نامِ پایه را هم میبیند
|
||||||
|
// تا رکوردهای قدیمی با نامِ «برند + دسته + مشخصه» پیدا شوند).
|
||||||
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
let material = materials.find((m) => looksLikeUuid(m.id) && normalize(m.name) === normalize(wantName));
|
||||||
if (!material) {
|
if (!material) {
|
||||||
material = materials.find(
|
const matched = matchMaterial(
|
||||||
(m) =>
|
materials.filter((m) => looksLikeUuid(m.id)),
|
||||||
looksLikeUuid(m.id) &&
|
cat,
|
||||||
normalize(m.category) === normalize(cat) &&
|
brand,
|
||||||
normalize(m.brand) === normalize(brand) &&
|
specs,
|
||||||
specsKey(m.specs) === specsKey(specs),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// رکوردهای قدیمی name را به شکل «برند + دسته + مشخصه» ذخیره کردهاند؛
|
|
||||||
// تطبیق دوم بر اساس category/brand/specs انجام میشود تا آنها هم پیدا شوند.
|
|
||||||
if (!material && cat) {
|
|
||||||
material = materials.find(
|
|
||||||
(m) => looksLikeUuid(m.id) && normalize(m.category) === normalize(cat) && normalize(m.brand) === normalize(brand),
|
|
||||||
);
|
);
|
||||||
|
if (matched) material = matched;
|
||||||
}
|
}
|
||||||
let materialId: string;
|
let materialId: string;
|
||||||
if (material && looksLikeUuid(material.id)) {
|
if (material && looksLikeUuid(material.id)) {
|
||||||
@@ -317,10 +314,10 @@ export function WizardPage() {
|
|||||||
id: nextId("m", materials),
|
id: nextId("m", materials),
|
||||||
name: wantName,
|
name: wantName,
|
||||||
topic: selectedTopic,
|
topic: selectedTopic,
|
||||||
category: cat,
|
|
||||||
brand,
|
brand,
|
||||||
specs,
|
specs,
|
||||||
base_unit: unit || "عدد",
|
base_unit: unit || "عدد",
|
||||||
|
supplier_category: "",
|
||||||
});
|
});
|
||||||
materialId = created.id;
|
materialId = created.id;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user