refactor(catalog)!: decouple materials from categories (v2.7)
Materials are now global and category-free. The category lives only on the supplier (suppliers.category_id); every category-facing query goes through the supplier edge instead of the material row.
Schema (migration.sql + init_db DDL):
- drop the materials.category_id column, its FK, and the v2.5 first-link-wins automation (fn_derive_material_category + trg_supplier_materials_derive_category) — the DDL runs idempotently at API startup
Backend:
- GET /materials?category_id=… now filters via EXISTS(supplier_materials ⋈ suppliers.category_id)
- quote category filters (quotes list) use the quote's supplier category
- /pm/on-the-way derives the display category from the supplier of the quote
- supplier_materials links no longer derive/return a material category
- expand_service: material expand=category resolves to null (kept for contract compatibility)
- material create/update no longer validates or stores category_id; seed.py aligned
Frontend:
- material creation no longer sends category_id
- formatMaterialTitle reworked to a strict order with « . » separator: [Brand] . [Material] . [Spec 1] . [Spec 2], brand deduped when the stored name already starts with it; specs separator switched from « · » to « . »
Validated: python compile, api + frontend image builds (tsc + vite) clean; branch cut from the tip of prod_v4 (= origin/production, 5a9ef40 — the team's merge made them identical).
This commit is contained in:
@@ -1108,12 +1108,6 @@ export const api = {
|
||||
},
|
||||
create: async (m: Material): Promise<Material> => {
|
||||
const lookup = await fetchLookup();
|
||||
// v2.5: category_id اختیاری است — اگر نامِ تایپشده یکی از ۸ دستهی ثابت
|
||||
// نباشد، متریال بهصورت سراسری (بدون دسته) ساخته میشود و دستهاش بعداً
|
||||
// از اولین تأمینکنندهی مرتبط استنتاج میشود (first-link-wins).
|
||||
// دستهی rogue دیگر هرگز ساخته نمیشود — فقط تطبیق با ۸ دستهی موجود؛
|
||||
// در صورت نبود تطبیق، خطا نمیدهیم و category_id را null میفرستیم.
|
||||
const categoryId = m.category ? lookup.categoryNameToId.get(norm(m.category)) : undefined;
|
||||
// واحد مرجع — بدون مقدار پیشفرض؛ متریال بدون واحدِ صریح ساخته نمیشود
|
||||
// (بکاند canonical_unit_id را الزامی میداند)
|
||||
const unitName = (m.base_unit || "").trim();
|
||||
@@ -1138,7 +1132,6 @@ export const api = {
|
||||
if (defId) attrs.push({ attribute_definition_id: defId, value: spec });
|
||||
}
|
||||
const raw = await postData<RawMaterial>("/materials", {
|
||||
category_id: categoryId ?? null,
|
||||
name,
|
||||
topic: m.topic || null,
|
||||
canonical_unit_id: unitId,
|
||||
@@ -1760,7 +1753,6 @@ export const apiV26 = {
|
||||
}): Promise<{ id: string; name: string }> =>
|
||||
postData("/materials", {
|
||||
name: opts.name,
|
||||
category_id: null, // دسته از اولین تأمینکنندهی مرتبط استنتاج میشود (v2.5)
|
||||
topic: null,
|
||||
canonical_unit_id: opts.canonical_unit_id,
|
||||
status: "active",
|
||||
|
||||
@@ -35,43 +35,51 @@ export function specsKey(specs: string[]): string {
|
||||
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
|
||||
}
|
||||
|
||||
// specها با جداکنندهی « · »
|
||||
// specها با جداکنندهی « . »
|
||||
export function specsLabel(specs: string[]): string {
|
||||
return specs.join(" · ");
|
||||
return specs.join(" . ");
|
||||
}
|
||||
|
||||
/**
|
||||
* عنوان تمیز و بدون تکرار کالا:
|
||||
* ترکیب هوشمندانهی نام اصلی (materials.name)، برند، و مشخصهها بدون تکرار کلمات مشترک.
|
||||
* ترتیب اکید با جداکنندهی نقطه:
|
||||
* [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲]
|
||||
*/
|
||||
export function formatMaterialTitle(
|
||||
m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined,
|
||||
extraSpecs?: string[],
|
||||
): string {
|
||||
if (!m) return "کالا";
|
||||
const rawName = (m.name || "").trim();
|
||||
let rawName = (m.name || "").trim();
|
||||
const rawBrand = (m.brand || "").trim();
|
||||
const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])]
|
||||
.map((s) => (s || "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const normName = normalize(rawName);
|
||||
const normBrand = normalize(rawBrand);
|
||||
|
||||
if (!rawName) {
|
||||
if (rawBrand && rawSpecs.length) return `${rawBrand} · ${rawSpecs.join(" · ")}`;
|
||||
if (rawBrand) return rawBrand;
|
||||
if (rawSpecs.length) return rawSpecs.join(" · ");
|
||||
return m.category && m.category !== "؟" ? m.category : "کالا";
|
||||
// اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمیداریم تا تکرار نشود
|
||||
if (normBrand && rawName) {
|
||||
const normName = normalize(rawName);
|
||||
if (normName.startsWith(normBrand)) {
|
||||
const rest = rawName.slice(rawBrand.length).trim();
|
||||
rawName = rest.replace(/^[.\s·\-]+/, "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
let title = rawName;
|
||||
if (normBrand && !normName.includes(normBrand)) {
|
||||
title = `${rawBrand} ${title}`;
|
||||
// قطعات نهایی با ترتیب مشخص: [Brand Name] . [Material Name] . [Specification 1] . [Specification 2]
|
||||
const parts: string[] = [];
|
||||
if (rawBrand) {
|
||||
parts.push(rawBrand);
|
||||
}
|
||||
if (rawName) {
|
||||
parts.push(rawName);
|
||||
} else if (!rawBrand) {
|
||||
parts.push(m.category && m.category !== "؟" ? m.category : "کالا");
|
||||
}
|
||||
|
||||
const normTitle = normalize(title);
|
||||
const distinctSpecs: string[] = [];
|
||||
// اضافه کردن مشخصهها بدون تکرار
|
||||
const normCurrent = normalize(parts.join(" "));
|
||||
const seenNormSpecs = new Set<string>();
|
||||
|
||||
for (const s of rawSpecs) {
|
||||
@@ -79,15 +87,12 @@ export function formatMaterialTitle(
|
||||
if (!ns || seenNormSpecs.has(ns)) continue;
|
||||
seenNormSpecs.add(ns);
|
||||
|
||||
// اگر مشخصه قبلاً داخل عنوان یا نام کالا آمده باشد، تکرار نمیکنیم
|
||||
if (normTitle.includes(ns)) continue;
|
||||
distinctSpecs.push(s);
|
||||
// اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمیکنیم
|
||||
if (normCurrent.includes(ns)) continue;
|
||||
parts.push(s);
|
||||
}
|
||||
|
||||
if (distinctSpecs.length > 0) {
|
||||
return `${title} · ${distinctSpecs.join(" · ")}`;
|
||||
}
|
||||
return title;
|
||||
return parts.join(" . ");
|
||||
}
|
||||
|
||||
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category"> | null | undefined): string {
|
||||
|
||||
@@ -20,9 +20,9 @@ export interface Supplier {
|
||||
// ماده/کالا
|
||||
export interface Material {
|
||||
id: string; // "m1", "m2", ...
|
||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + دسته + مشخصه)
|
||||
name: string; // نام کامل ذخیرهشده در بکاند (برند + مشخصه)
|
||||
topic: string;
|
||||
category: string; // مثلاً "میلگرد"
|
||||
category: string; // اختیاری در بکاند؛ همیشه string در فرانت (default: "")
|
||||
brand: string; // مثلاً "ذوب آهن"
|
||||
specs: string[]; // آرایهای از مشخصهها (مثلاً ["A2"])
|
||||
base_unit: string; // واحد پایه
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AlarmClock, ChevronDown, Pencil, TriangleAlert, Building2, Package, Cre
|
||||
import type { Quote, Store } from "../../lib/types";
|
||||
import {
|
||||
isExpired,
|
||||
itemsSummary,
|
||||
materialById,
|
||||
materialLabel,
|
||||
projectCell,
|
||||
@@ -44,7 +43,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
const pc = priceCell(total);
|
||||
const ts = termsheetSummary(store, q);
|
||||
const unitWarns = unitWarning(store, q);
|
||||
const summary = itemsSummary(store, q);
|
||||
|
||||
// ستون / دکمه پذیرش
|
||||
let acceptedCell: React.ReactNode;
|
||||
@@ -188,47 +186,22 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
</div>
|
||||
</InfoCell>
|
||||
|
||||
{/* ۲. اقلام — تمامعرض زیر پروژه با شکستن متن بدون سهنقطه */}
|
||||
{/* ۲. اقلام — نمایش عمودی چندخطی */}
|
||||
<InfoCell label="اقلام" className="col-span-2 md:col-span-1">
|
||||
{summary.count > 1 ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-bold text-xs text-ink sm:text-sm">
|
||||
{fmtNum(summary.count)} قلم کالا
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5 rounded-lg border border-line/60 bg-bg/50 p-2.5 text-[11px] leading-5 text-muted sm:text-[11.5px]">
|
||||
{store.quotes
|
||||
.find((x) => x.id === q.id)
|
||||
?.line_items?.filter((li) => !li.rejected)
|
||||
.slice(0, 3)
|
||||
.map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return (
|
||||
<div
|
||||
key={li.id}
|
||||
className="flex items-start justify-between gap-2 border-b border-line/30 pb-1.5 last:border-0 last:pb-0"
|
||||
>
|
||||
<span className="min-w-0 flex-1 font-medium text-ink leading-snug break-words">
|
||||
• {materialLabel(m)}
|
||||
</span>
|
||||
<span className="shrink-0 font-semibold tabular-nums text-ink text-[10.5px] pt-0.5">
|
||||
{fmtNum(li.quantity)}{" "}
|
||||
<span className="font-normal text-muted text-[9.5px]">{li.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(q.line_items?.length || 0) > 3 && (
|
||||
<span className="pt-0.5 text-[10px] text-muted">
|
||||
و {(q.line_items?.length || 0) - 3} قلم دیگر...
|
||||
<div className="flex flex-col gap-1 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||
{activeItems.length > 0 ? (
|
||||
activeItems.map((li) => {
|
||||
const m = materialById(store.materials, li.material_id);
|
||||
return (
|
||||
<span key={li.id} className="break-words">
|
||||
{materialLabel(m)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-1.5 text-xs font-medium leading-5 text-ink sm:text-[13px]">
|
||||
<span className="break-words">{summary.text}</span>
|
||||
</div>
|
||||
)}
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span>—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unitWarns.map((w) => (
|
||||
<Link
|
||||
@@ -243,32 +216,6 @@ export function QuoteCard({ store, q, expanded, onToggleExpand, onAccept, onEdit
|
||||
))}
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="پروژه">
|
||||
<span className="inline-flex items-center gap-1 text-xs sm:text-[13px]">
|
||||
<Building2 className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="truncate">
|
||||
{(() => {
|
||||
const activeItems = (q.line_items || []).filter((li) => !li.rejected);
|
||||
if (q.status === "accepted" || q.status === "arrived") {
|
||||
// فقط پروژهی هدف پذیرفتهشده — همان منطق itemProjects در وضعیت accepted
|
||||
const targetProj = activeItems.find((li) => li.target_project)?.target_project;
|
||||
return targetProj?.name ?? projectCell(q);
|
||||
}
|
||||
// وضعیت offered: همهی پروژههای کاندیدا از line_items (همان منطق itemProjects)
|
||||
const names = new Set<string>();
|
||||
activeItems.forEach((li) =>
|
||||
(li.projects || []).forEach((p) => {
|
||||
const name = typeof p === "string" ? p : p.name;
|
||||
if (name) names.add(name);
|
||||
})
|
||||
);
|
||||
if (names.size > 0) return [...names].join("، ");
|
||||
return projectCell(q);
|
||||
})()}
|
||||
</span>
|
||||
</span>
|
||||
</InfoCell>
|
||||
|
||||
<InfoCell label="مجموع مبلغ" className="col-span-1">
|
||||
{pc ? (
|
||||
<div>
|
||||
|
||||
@@ -67,17 +67,18 @@ function materialBaseName(name: string, brand: string, specs: string[], category
|
||||
return base || fallback;
|
||||
}
|
||||
|
||||
// مقادیر مجزای category/brand/spec داخل تاپیک
|
||||
// مقادیر مجزای category/brand/spec در کاتالوگ سراسری
|
||||
export function materialOptions(
|
||||
materials: Material[],
|
||||
topic: string,
|
||||
_topic: string,
|
||||
item: ItemDraft,
|
||||
field: "cat" | "brand" | "spec",
|
||||
allItems: ItemDraft[] = [],
|
||||
apiBrands: string[] = [],
|
||||
apiSpecs: string[] = [],
|
||||
): string[] {
|
||||
let list = materials.filter((m) => topic && normalize(m.topic) === normalize(topic));
|
||||
// متریالها سراسری هستند — مستقل از تاپیک
|
||||
let list = materials;
|
||||
const cat = item.cat.trim();
|
||||
|
||||
const fromRows = (sel: (it: ItemDraft) => string | string[]): string[] => {
|
||||
@@ -92,7 +93,7 @@ export function materialOptions(
|
||||
};
|
||||
|
||||
if (field === "cat") {
|
||||
const base = new Set(list.map((m) => m.category).filter(Boolean));
|
||||
const base = new Set<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"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user