Files
ERP/frontend/src/lib/quoteUtils.ts
T
whqreza fd1831b3fc fix(catalog): show real material names, not categories, across quote views
New shared materialLabel() renders the global materials.name with brand/specs
appended only when not already part of the name (deduped; «؟» fallback kept).
Replaces the category-first composer in five places: supplier card badges
(CategoryPage), dashboard item lines (QuoteCard), ledger items summary,
project rows and purchase history. Also fixes the stale Units subtitle
«ضریب تبدیل همان دسته» → «ضریب تبدیل متریال» (category model kept).
2026-09-06 19:03:35 +03:30

284 lines
11 KiB
TypeScript

// ============================================================
// منطق بیزنس مشترک پیشنهادها — پورت از app.js
// ============================================================
import type { LineItem, Material, Quote, StandardConversion, Store, Supplier, UnitConversion } from "./types";
import { normalize } from "./normalize";
import { groupCommas } from "./numutil";
import { fmtDate } from "./formatters";
export function supplierById(suppliers: Supplier[], id: string): Supplier {
return suppliers.find((x) => x.id === id) || { id: "", name: "؟", location: "", topic: "", categories: [], phone: "", contact: "" };
}
export function materialById(materials: Material[], id: string): Material {
return (
materials.find((x) => x.id === id) || {
id: "",
name: "",
topic: "",
category: "؟",
brand: "",
specs: [],
base_unit: "",
}
);
}
// spec chips یک material (داده‌های قدیمی ممکن است spec رشته‌ای داشته باشند)
export function materialSpecs(m: Material | null | undefined): string[] {
return (m && m.specs) || [];
}
// کلید متعارف یک مجموعه spec — ترتیب مهم نیست، متن نرمال‌شده
export function specsKey(specs: string[]): string {
return (specs || []).map((s) => normalize(s)).filter(Boolean).sort().join("|");
}
// specها با جداکننده‌ی « · »
export function specsLabel(specs: string[]): string {
return specs.join(" · ");
}
// نام نمایشی متریال — نام سراسری واقعی (materials.name)، نه دسته.
// برند و مشخصه فقط وقتی به نام اضافه می‌شوند که داخلش نیامده باشند (بدون تکرار).
export function materialLabel(m: Pick<Material, "name" | "brand" | "specs" | "category">): string {
const name = (m.name || "").trim();
const specs = (m.specs || []).filter(Boolean);
if (!name && !m.brand && !specs.length) return m.category === "؟" ? "؟" : "کالا";
if (!name) {
return [m.brand, ...specs].filter(Boolean).join(" ") || "کالا";
}
const parts = [name];
if (m.brand && !name.includes(m.brand)) parts.push(m.brand);
const missing = specs.filter((s) => !name.includes(s));
if (missing.length) parts.push(`(${missing.join("، ")})`);
return parts.join(" ");
}
// آرایه‌ی termsheet_ids یک قلم (هر دو فرمت قدیم/جدید)
export function itemTermsheetIds(li: LineItem): string[] {
if (li.termsheet_ids) return li.termsheet_ids;
return [];
}
// اقلام داخل معامله (ردشده‌ها نگه داشته می‌شوند ولی هرگز شمرده نمی‌شوند)
export function activeItems(q: Quote): LineItem[] {
return (q.line_items || []).filter((li) => !li.rejected);
}
// جمع کل اقلام فعال (total_price اگر هست، وگرنه unit*qty)
export function quoteTotal(q: Quote): number {
return activeItems(q).reduce((sum, li) => {
const t =
li.total_price != null
? li.total_price
: li.unit_price != null && li.quantity != null
? li.unit_price * li.quantity
: 0;
return sum + (t || 0);
}, 0);
}
// ترم‌شیت مؤثر: پذیرفته‌شده، وگرنه اولین offered
export function effectiveTermsheet(q: Quote): Quote["termsheets"][number] | null {
if (!q.termsheets || !q.termsheets.length) return null;
return q.termsheets.find((t) => t.status === "accepted") || q.termsheets[0];
}
// id ترم‌شیت‌هایی که واقعاً توسط اقلام فعال ارجاع شده‌اند
export function usedTermsheetIds(q: Quote): string[] {
const ids = new Set<string>();
activeItems(q).forEach((li) => {
itemTermsheetIds(li).forEach((tid) => {
if (tid) ids.add(tid);
});
});
return [...ids];
}
// مجموع هر قلم
export function lineItemTotal(li: LineItem): number | null {
if (li.total_price != null) return li.total_price;
if (li.unit_price != null && li.quantity != null) return li.unit_price * li.quantity;
return null;
}
// پروژه‌های یک قلم — فقط پروژه‌های تخصیص‌یافته به همین قلم (بدون fallback به سطح پیشنهاد)
export function itemProjects(_q: Quote, li: LineItem): string[] {
return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name));
}
// ---------- واحد مرجع و ضریب تبدیل ----------
// واحد مرجع یک دسته
export function refUnitForCategory(store: Pick<Store, "categoryUnits">, category: string): string {
const cu = (store.categoryUnits || []).find((c) => normalize(c.category) === normalize(category));
return cu ? cu.ref_unit : "";
}
// ضریب تبدیل با اولویت: دسته‌ای (برند مشخص > همه برندها) > تبدیل ثابت جهانی
export function findConversion(
store: Pick<Store, "unitConversions" | "standardConversions">,
category: string,
brand: string,
fromUnit: string,
toUnit: string,
): UnitConversion | StandardConversion | null {
const list = (store.unitConversions || []).filter(
(c) =>
normalize(c.category) === normalize(category) &&
normalize(c.from_unit) === normalize(fromUnit) &&
normalize(c.to_unit) === normalize(toUnit) &&
(!c.brand || normalize(c.brand) === normalize(brand)),
);
list.sort((a, b) => (b.brand ? 1 : 0) - (a.brand ? 1 : 0));
if (list[0]) return list[0];
return (
(store.standardConversions || []).find(
(c) => normalize(c.from_unit) === normalize(fromUnit) && normalize(c.to_unit) === normalize(toUnit),
) || null
);
}
// تبدیل یک قلم به واحد مرجع دسته؛ null وقتی ضریبی نیست یا واحد از قبل مرجع است
export function refConverted(
store: Pick<Store, "categoryUnits" | "unitConversions" | "standardConversions" | "materials">,
li: LineItem,
): { qty: number; unit: string; perRef: number | null } | null {
const m = materialById(store.materials, li.material_id);
const ref = refUnitForCategory(store, m.category);
if (!ref || !li.unit || normalize(ref) === normalize(li.unit)) return null;
const conv = findConversion(store, m.category, m.brand, li.unit, ref);
if (!conv) return null;
const qty = (li.quantity || 0) * conv.factor;
const total = li.total_price != null ? li.total_price : (li.unit_price || 0) * (li.quantity || 0);
const perRef = qty > 0 ? Math.round(total / qty) : null;
return { qty, unit: ref, perRef };
}
// واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها
// واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود
export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] {
return [];
}
// ستون پروژه
export function projectCell(q: Quote): string {
if (q.projects && q.projects.length) return q.projects.join("، ");
return q.project || "—";
}
// خلاصه‌ی اقلام (ردیف جمع‌شده)
export function itemsSummary(store: Store, q: Quote): { text: string; count: number } {
const items = activeItems(q).map((li) => {
const m = materialById(store.materials, li.material_id);
return materialLabel(m);
});
return { text: items.join("، "), count: items.length };
}
// ستون معامله — نمایش فشرده برای ردیف جمع‌شده
export function termsheetSummary(store: Store, q: Quote): { title: string; sub: string; color: string } {
if (!q.termsheets || !q.termsheets.length) return { title: "—", sub: "", color: "" };
const used = usedTermsheetIds(q);
const accepted = q.termsheets.find((t) => t.status === "accepted");
if (accepted) {
const items = activeItems(q).filter((li) => itemTermsheetIds(li).includes(accepted.id));
const itemsDesc =
items.length === q.line_items.length
? "همه اقلام"
: items
.map((li) => {
const m = materialById(store.materials, li.material_id);
const specText = specsLabel(materialSpecs(m));
return specText || m.brand;
})
.join("، ");
return {
title: accepted.label,
sub: `${itemsDesc} · ${accepted.milestones.length} مرحله`,
color: "var(--green)",
};
}
if (used.length === 1) {
return { title: q.termsheets[0].label, sub: `${q.termsheets.length} گزینه`, color: "" };
}
return { title: `${used.length} termsheet جدا · ${q.termsheets.length} گزینه`, sub: "", color: "" };
}
// فیلترهای دفتر
export function filterQuotes(
store: Store,
filters: { category: string; supplier: string; status: string; search: string },
): Quote[] {
const catVal = filters.category.trim();
const supVal = filters.supplier.trim();
const stVal = filters.status.trim();
const q = filters.search.trim().toLowerCase();
const STATUS_BY_LABEL: Record<string, string> = {
"پیشنهاد شده": "offered",
"پذیرفته شده": "accepted",
"رسیده": "arrived",
};
const stKey = STATUS_BY_LABEL[stVal] || "";
return store.quotes.filter((quote) => {
const supObj = supplierById(store.suppliers, quote.supplier_id);
if (supVal && supObj.name !== supVal) return false;
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;
if (q) {
const hay =
`${supObj.name} ${quote.project || ""} ` +
matInfo.map((m) => `${m.category} ${m.brand} ${materialSpecs(m).join(" ")}`).join(" ");
if (!hay.toLowerCase().includes(q)) return false;
}
return true;
});
}
// وضعیت منقضی
export function isExpired(q: Quote): boolean {
return (
q.status === "offered" &&
!!q.expiry_date &&
new Date(q.expiry_date) < new Date(new Date().toDateString())
);
}
// آماده‌سازی نمایش ترم‌شیت در حالت expanded
export function termSheetBlocks(
store: Store,
q: Quote,
): { ts: Quote["termsheets"][number]; isAccepted: boolean; isUsed: boolean; itemsDesc: string; milestones: string[] }[] {
const used = usedTermsheetIds(q);
const active = activeItems(q);
return q.termsheets.map((ts) => {
const isAccepted = ts.status === "accepted";
const isUsed = used.includes(ts.id);
const items = active.filter((li) => itemTermsheetIds(li).includes(ts.id));
const itemsDesc =
items.length === active.length
? "همه اقلام"
: items.length
? items
.map((li) => {
const m = materialById(store.materials, li.material_id);
return specsLabel(materialSpecs(m)) || m.brand;
})
.join("، ")
: "بدون قلم";
const milestones = ts.milestones.map((m) => {
const dateLabel = m.when_iso ? fmtDate(m.when_iso) : "?";
const amt = m.amount != null ? groupCommas(m.amount, true) : "?";
return `${dateLabel} · ${amt}`;
});
return { ts, isAccepted, isUsed, itemsDesc, milestones };
});
}