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