// ============================================================ // منطق بیزنس مشترک پیشنهادها — پورت از app.js // ============================================================ import type { LineItem, Material, Quote, Store, Supplier } 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: "", 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(" . "); } /** * عنوان تمیز و بدون تکرار کالا: * ترتیب اکید با جداکننده‌ی نقطه: * [نام برند] . [نام متریال] . [مشخصه ۱] . [مشخصه ۲] */ export function formatMaterialTitle( m: Pick | null | undefined, extraSpecs?: string[], ): string { if (!m) return "کالا"; let rawName = (m.name || "").trim(); const rawBrand = (m.brand || "").trim(); const rawSpecs = [...(m.specs || []), ...(extraSpecs || [])] .map((s) => (s || "").trim()) .filter(Boolean); const normBrand = normalize(rawBrand); // اگر نام کالا با نام برند شروع شده باشد، برند را از ابتدای نام برمی‌داریم تا تکرار نشود if (normBrand && rawName) { const normName = normalize(rawName); if (normName.startsWith(normBrand)) { const rest = rawName.slice(rawBrand.length).trim(); rawName = rest.replace(/^[.\s·\-]+/, "").trim(); } } // قطعات نهایی با ترتیب مشخص: [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("کالا"); } // اضافه کردن مشخصه‌ها بدون تکرار const normCurrent = normalize(parts.join(" ")); const seenNormSpecs = new Set(); for (const s of rawSpecs) { const ns = normalize(s); if (!ns || seenNormSpecs.has(ns)) continue; seenNormSpecs.add(ns); // اگر مشخصه قبلاً داخل نام یا برند آمده باشد، تکرار نمی‌کنیم if (normCurrent.includes(ns)) continue; parts.push(s); } return parts.join(" . "); } export function materialLabel(m: Pick | null | undefined): string { return formatMaterialTitle(m); } // نام پایه‌ی متریال از نامِ ذخیره‌شده. // متریال‌های جدید فقط نامِ خودِ متریال را در name دارند (برند در brand و // مشخصه‌ها در attributes ذخیره می‌شوند) و دست‌نخورده برمی‌گردند. رکوردهای قدیمی // name را به شکل «برند + مشخصه» ذخیره کرده‌اند؛ آن قطعات جدا می‌شوند تا نامِ // پایه باقی بماند. export function materialBaseName(name: string, brand: string, specs: string[]): string { const fallback = (name || "").trim(); if (!fallback) return ""; let base = fallback; // بلندترین قطعه اول حذف می‌شود تا حذف‌های جزئی، رشته را ناقص نکنند const parts = [brand, ...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)) === base && sameIdentity(m), ) ); } // آرایه‌ی 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(); 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; } // پروژه‌های یک قلم: // - در وضعیت offered: تمام پروژه‌های کاندیدا (line_item_projects) // - در وضعیت accepted یا arrived: فقط تک پروژه‌ی انتخاب‌شده در خرید (item_decisions.project_id) export function itemProjects(q: Quote, li: LineItem): string[] { if (q.status === "accepted" || q.status === "arrived") { return li.target_project?.name ? [li.target_project.name] : []; } // For 'offered', 'rejected', 'expired': return all candidate projects return (li.projects || []).map((p) => (typeof p === "string" ? p : p.name)).filter(Boolean); } // واحدهای ناشناخته‌ی استفاده‌شده در یک quote → لینک به داشبورد واحدها // واحدها دیگر hardcode نیستند — همه از بک‌اند می‌آیند؛ هشداری نمایش داده نمی‌شود export function unitWarning(_store: Store, _q: Quote): { unit: string; cat: string }[] { return []; } // ستون پروژه: // در وضعیت‌های پذیرفته‌شده یا رسیده، فقط پروژه‌های هدف خرید اقلام نشان داده می‌شوند export function projectCell(q: Quote): string { if (q.status === "accepted" || q.status === "arrived") { const targets = (q.line_items || []) .filter((li) => !li.rejected) .map((li) => li.target_project?.name || (li.projects && li.projects.length === 1 ? typeof li.projects[0] === "string" ? li.projects[0] : li.projects[0].name : null), ) .filter(Boolean) as string[]; const unique = [...new Set(targets)]; if (unique.length) return unique.join("، "); } 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: { supplier: string; status: string; search: string }, ): Quote[] { const supVal = filters.supplier.trim(); const stVal = filters.status.trim(); const q = filters.search.trim().toLowerCase(); const STATUS_BY_LABEL: Record = { "پیشنهاد شده": "offered", "پذیرفته شده": "accepted", "رسیده": "arrived", "باز": "offered", "پذیرفته": "accepted", "offered": "offered", "accepted": "accepted", "arrived": "arrived", }; const stKey = STATUS_BY_LABEL[stVal] || 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 (q) { const hay = `${supObj.name} ${quote.project || ""} ` + matInfo.map((m) => `${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" && !quoteHasArrivedOrAccepted(q) && !!q.expiry_date && new Date(q.expiry_date) < new Date(new Date().toDateString()) ); } function quoteHasArrivedOrAccepted(q: Quote): boolean { return q.status === "accepted" || q.status === "arrived"; } // آماده‌سازی نمایش ترم‌شیت در حالت 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 }; }); }