/* Bonyan ERP — المساعد الصوتي الذكي (عربي) يسمع عبر Web Speech API ويفهم الأمر وينفّذه: «كشف أحمد» → كشف حسابه · «حديد ١٢» → رصيده بالمخزن · «النواقص» → قائمة الشراء «أرباح السنة» → التقارير · «فاتورة رقم ١٠٤٢٨» → فتحها · أسماء الشاشات → تنقّل. محلّل النوايا window.BnyVoiceParse(text, user) دالة نقية (قابلة للاختبار)، مع fallback كتابي عند غياب المايك، ورد صوتي مختصر (speechSynthesis). */ (function () { /* ---------- تطبيع عربي + مطابقة ضبابية ---------- */ function bnyNorm(s) { return String(s || "") .replace(/[ً-ْٰـ]/g, "") // تشكيل وتطويل .replace(/[أإآٱ]/g, "ا").replace(/ة/g, "ه").replace(/ى/g, "ي").replace(/ؤ/g, "و").replace(/ئ/g, "ي") .replace(/[٠-٩]/g, function (d) { return "٠١٢٣٤٥٦٧٨٩".indexOf(d); }) .replace(/[^0-9a-z؀-ۿ\s]/gi, " ") .replace(/\s+/g, " ").trim().toLowerCase(); } function bnyToks(s) { return bnyNorm(s).split(" ").map(function (t) { return t.replace(/^(ال|لل|وال|بال)/, ""); }).filter(function (t) { return t.length > 1; }); } // نسبة كلمات الاستعلام المطابقة لاسم الكيان (تطابق أو بداية كلمة) function bnyScore(qToks, name) { var nToks = bnyToks(name); if (!nToks.length || !qToks.length) return 0; var hit = 0; qToks.forEach(function (q) { if (nToks.some(function (n) { return n === q || n.indexOf(q) === 0 || q.indexOf(n) === 0; })) hit++; }); return hit / qToks.length; } // أفضل المطابقات في قائمة كيانات {الحد الأدنى 0.5 = نصف الكلمات} function bnyBest(qToks, list, nameOf) { var scored = list.map(function (e) { return { e: e, s: bnyScore(qToks, nameOf(e)) }; }) .filter(function (x) { return x.s >= 0.5; }) .sort(function (a, b) { return b.s - a.s; }); if (!scored.length) return { match: null, choices: [] }; var top = scored[0].s; var ties = scored.filter(function (x) { return x.s >= top - 0.01; }); return ties.length === 1 ? { match: ties[0].e, choices: [] } : { match: null, choices: ties.slice(0, 4).map(function (x) { return x.e; }) }; } var fmtN = function (n) { return Number(n || 0).toLocaleString("en-US"); }; /* ---------- بناة النتائج ---------- */ function resCustomer(c) { var ans = "المستحق عليه: " + fmtN(c.balance) + " د.ع" + (c.balanceUsd ? " + " + fmtN(c.balanceUsd) + " $" : ""); return { kind: "statement", icon: "users", title: "كشف حساب العميل: " + c.name, answer: ans, speak: "المستحق على " + c.name + " " + fmtN(c.balance) + " دينار", run: { screen: "customers", custId: c.id } }; } function resSupplier(s) { var ans = "المستحق له: " + fmtN(s.payable) + " د.ع" + (s.payableUsd ? " + " + fmtN(s.payableUsd) + " $" : ""); return { kind: "statement", icon: "supplier", title: "كشف حساب المورد: " + s.name, answer: ans, speak: "المستحق للمورد " + s.name + " " + fmtN(s.payable) + " دينار", run: { screen: "suppliers", supId: s.id } }; } function resMaterial(m) { var packs = window.BnyStock && window.BnyStock(m); var ans = m.stock > 0 ? "متوفر: " + fmtN(m.stock) + " " + m.unit + (packs ? " (" + packs + ")" : "") + " · مخزن " + (m.wh || "—") : "نفدت من المخزن!"; return { kind: "stock", icon: "inventory", title: "المادة: " + m.name, answer: ans, speak: m.stock > 0 ? "متوفر " + fmtN(m.stock) + " " + m.unit + " من " + m.name : m.name + " نفدت من المخزن", run: { screen: "inventory", search: m.name } }; } /* ---------- محلّل النوايا (نقي وقابل للاختبار) ---------- */ window.BnyVoiceParse = function (text, user) { var D = window.BnyData || {}; var can = function (perm) { return !user || window.BnyCan(user, perm); }; var denied = function (what) { return { kind: "denied", icon: "alert", title: "غير مسموح", answer: "لا تملك صلاحية " + what + "." }; }; var t = bnyNorm(text); if (!t) return { kind: "none" }; // ١) فاتورة برقم → فتح مباشر var mInv = t.match(/فاتور\S*\s*(?:رقم\s*)?(\d+)/); if (mInv) { if (!can("invoices_view")) return denied("عرض الفواتير"); var inv = (D.invoices || []).find(function (i) { return String(i.num) === mInv[1] || i.no === mInv[1]; }); return inv ? { kind: "invoice", icon: "invoice", title: "فتح الفاتورة #" + inv.no, answer: inv.party + " · " + fmtN(inv.total) + " " + (inv.cur === "usd" ? "$" : "د.ع"), run: { invoiceNo: inv.no }, speak: "فتح الفاتورة " + mInv[1] } : { kind: "none", icon: "invoice", title: "لا توجد فاتورة بالرقم " + mInv[1] }; } // ٢) فاتورة جديدة / بيع if (/فاتوره جديد|بيع جديد|افتح فاتوره|انشئ فاتوره|سو فاتوره/.test(t)) { return can("pos") ? { kind: "nav", icon: "invoice", title: "فاتورة بيع جديدة", run: { screen: "pos" }, speak: "فاتورة جديدة" } : denied("إنشاء الفواتير"); } // ٣) النواقص / المطلوب شراؤها if (/نواقص|ناقص|مطلوب شرا|يجب شرا|اعاده طلب|احتاج شرا|نحتاج شرا|خلصان|توفير مواد/.test(t)) { if (!can("inventory_view")) return denied("عرض المخزن"); var low = window.BnyLowStockMaterials ? window.BnyLowStockMaterials() : []; return { kind: "reorder", icon: "alert", title: "المواد المطلوب شراؤها", answer: low.length ? low.length + " مادة تحتاج شراء — أبرزها: " + low.slice(0, 3).map(function (m) { return m.name; }).join("، ") : "لا نواقص — كل المواد ضمن الحد ✓", speak: low.length ? "لديك " + low.length + " مادة تحتاج شراء" : "لا توجد نواقص", run: { screen: "inventory", filter: "reorder" } }; } // ٤) المواد الراكدة if (/راكد/.test(t)) { if (!can("inventory_view")) return denied("عرض المخزن"); var stg = window.BnyStagnantMaterials ? window.BnyStagnantMaterials() : []; return { kind: "stagnant", icon: "inventory", title: "المواد الراكدة", answer: stg.length ? stg.length + " مادة راكدة (دون بيع)" : "لا مواد راكدة ✓", speak: stg.length ? stg.length + " مادة راكدة" : "لا مواد راكدة", run: { screen: "inventory" } }; } // ٥) الأرباح / التقارير / المبيعات if (/ربح|ارباح|تقرير|تقارير|مبيعات/.test(t)) { if (!can("reports")) return denied("التقارير"); var year = new Date().getFullYear() + ""; var sales = (D.invoices || []).filter(function (i) { return i.type === "sale" && i.cur === "iqd" && (i.date || "").indexOf(year) === 0; }); var totalSales = sales.reduce(function (s, i) { return s + (Number(i.total) || 0); }, 0); var costOf = {}; (D.materials || []).forEach(function (m) { costOf[m.sku] = Number(m.cost) || 0; }); var profit = sales.reduce(function (s, i) { return s + (i.items || []).reduce(function (x, it) { var c = it.sku != null && costOf[it.sku] != null ? costOf[it.sku] : 0; return x + ((Number(it.price) || 0) - c) * (Number(it.qty) || 0); }, 0); }, 0); return { kind: "reports", icon: "chart", title: "أرباح ومبيعات السنة", answer: "مبيعات " + year + ": " + fmtN(totalSales) + " د.ع · أرباح تقديرية: " + fmtN(Math.round(profit)) + " د.ع", speak: "مبيعات السنة " + fmtN(totalSales) + " دينار", run: { screen: "reports" } }; } // ٦) ديون العملاء if (/ديون|مديونيه|اعلي الديون/.test(t)) { if (!can("customers")) return denied("العملاء"); var totalDebt = (D.customers || []).reduce(function (s, c) { return s + (window.BnyToIqd ? window.BnyToIqd(c.balance, c.balanceUsd) : (c.balance || 0)); }, 0); return { kind: "debts", icon: "wallet", title: "ديون العملاء", answer: "إجمالي الديون: " + fmtN(Math.round(totalDebt)) + " د.ع (بالمكافئ)", speak: "إجمالي ديون العملاء " + fmtN(Math.round(totalDebt)) + " دينار", run: { screen: "customers" } }; } // ٧) تنقّل مباشر بأسماء الشاشات var NAVS = [ [/^(الرئيسيه|رئيسيه|لوحه التحكم|الصفحه الرئيسيه|هوم)$/, "dashboard", "dashboard", "الرئيسية"], [/^(الفواتير|فواتير)$/, "invoices_view", "invoices", "الفواتير"], [/^(العملاء|عملاء)$/, "customers", "customers", "العملاء"], [/^(الموردون|الموردين|موردين)$/, "suppliers", "suppliers", "الموردون"], [/^(المواد|مواد|المخزن|مخزن)$/, "inventory_view", "inventory", "المواد"], [/^(المخازن|اداره المخازن)$/, "inventory_edit", "warehouses", "إدارة المخازن"], [/^(التسعير|تسعير|الاسعار)$/, "pricing", "pricing", "التسعير"], [/^(الحركات|السجل|سجل الحركات)$/, "activity", "activity", "الحركات"], [/^(الاعدادات|اعدادات الفاتوره)$/, "invoice_settings", "settings", "إعدادات الفاتورة"], [/^(المستخدمون|المستخدمين|الصلاحيات)$/, "users_manage", "users", "صلاحيات المستخدمين"], ]; for (var i = 0; i < NAVS.length; i++) { if (NAVS[i][0].test(t)) { return can(NAVS[i][1]) ? { kind: "nav", icon: "dashboard", title: "الانتقال إلى: " + NAVS[i][3], run: { screen: NAVS[i][2] }, speak: NAVS[i][3] } : denied(NAVS[i][3]); } } // ٨) كشف/حساب + اسم → عميل أو مورد (وإلا مادة) var isStatement = /كشف|حساب|رصيد|دين/.test(t); // فلترة كلمات الأوامر على مستوى الكلمة (\b لا يعمل مع العربية) var STOP = { "كشف": 1, "حساب": 1, "رصيد": 1, "دين": 1, "ديون": 1, "مخزون": 1, "متوفر": 1, "كم": 1, "عندنا": 1, "عدنا": 1, "اريد": 1, "افتح": 1, "اعرض": 1, "ورجيني": 1, "شوف": 1, "سعر": 1, "شنو": 1, "وين": 1, "هل": 1, "ماذا": 1, "عند": 1, "في": 1, "من": 1, "عن": 1 }; var qToks = bnyToks(t).filter(function (tok) { return !STOP[tok]; }); if (qToks.length) { if (isStatement) { if (can("customers")) { var bc = bnyBest(qToks, (D.customers || []).filter(function (c) { return !c.walkin; }), function (c) { return c.name; }); if (bc.match) return resCustomer(bc.match); if (bc.choices.length) return { kind: "choices", icon: "users", title: "أي عميل تقصد؟", choices: bc.choices.map(function (c) { return { label: c.name, result: resCustomer(c) }; }) }; } if (can("suppliers")) { var bs = bnyBest(qToks, D.suppliers || [], function (s) { return s.name; }); if (bs.match) return resSupplier(bs.match); if (bs.choices.length) return { kind: "choices", icon: "supplier", title: "أي مورد تقصد؟", choices: bs.choices.map(function (s) { return { label: s.name, result: resSupplier(s) }; }) }; } } // مادة (بكلمة مفتاحية مخزنية أو باسم مجرد) if (can("inventory_view")) { var bm = bnyBest(qToks, D.materials || [], function (m) { return m.name; }); if (bm.match) return resMaterial(bm.match); if (bm.choices.length) return { kind: "choices", icon: "inventory", title: "أي مادة تقصد؟", choices: bm.choices.map(function (m) { return { label: m.name, result: resMaterial(m) }; }) }; } // كشف بلا كلمة مفتاحية؟ جرّب الأشخاص أخيرًا if (!isStatement) { if (can("customers")) { var bc2 = bnyBest(qToks, (D.customers || []).filter(function (c) { return !c.walkin; }), function (c) { return c.name; }); if (bc2.match) return resCustomer(bc2.match); } if (can("suppliers")) { var bs2 = bnyBest(qToks, D.suppliers || [], function (s) { return s.name; }); if (bs2.match) return resSupplier(bs2.match); } } } return { kind: "none" }; }; /* ---------- تنفيذ نتيجة ---------- */ function bnyRunResult(res, onAction) { var run = res.run || {}; if (run.invoiceNo) { window.BnyOpenInvoice(run.invoiceNo); return; } if (run.custId != null) window.BnyPendingCustomerId = run.custId; if (run.supId != null) window.BnyPendingSupplierId = run.supId; if (run.search) window.BnyPendingInvSearch = run.search; if (run.filter) window.BnyPendingInvFilter = run.filter; if (run.screen) onAction(run.screen); } function bnySpeak(text) { try { if (!text || !window.speechSynthesis) return; window.speechSynthesis.cancel(); var u = new SpeechSynthesisUtterance(text); u.lang = "ar"; u.rate = 1.05; window.speechSynthesis.speak(u); } catch (e) {} } var HINTS = [ "«كشف أحمد المقاول» — كشف حساب عميل أو مورد", "«حديد ١٢» أو «كم عندنا إسمنت» — رصيد مادة بالمخزن", "«شنو النواقص» — المواد المطلوب شراؤها", "«أرباح السنة» — التقارير ومخطط الأرباح", "«فاتورة رقم ١٠٤٢٨» — فتح فاتورة مباشرة", "«فاتورة جديدة» · «العملاء» · «الحركات» — تنقّل سريع", ]; /* ---------- الواجهة ---------- */ function VoiceOverlay({ open, onClose, onAction, user }) { const I = window.BnyIcon; const { Button, Input } = window.BonyanDesignSystem_ad3ca0; const [phase, setPhase] = React.useState("listen"); // listen | typing | result const [transcript, setTranscript] = React.useState(""); const [res, setRes] = React.useState(null); const [typed, setTyped] = React.useState(""); const recRef = React.useRef(null); const timerRef = React.useRef(null); const SR = window.SpeechRecognition || window.webkitSpeechRecognition; const finish = (text) => { const r = window.BnyVoiceParse(text, user); r._heard = text; setRes(r); setPhase("result"); if (r.speak) bnySpeak(r.speak); // تنفيذ تلقائي بعد مهلة قصيرة (يمكن إلغاؤه) — إلا للاختيارات/الرفض/عدم الفهم if (r.run && r.kind !== "choices") { timerRef.current = setTimeout(() => { bnyRunResult(r, onAction); onClose(); }, 1400); } }; const cancelTimer = () => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } }; const startMic = React.useCallback(() => { setTranscript(""); setRes(null); setPhase("listen"); if (!SR) { setPhase("typing"); return; } try { const rec = new SR(); recRef.current = rec; rec.lang = "ar-IQ"; rec.interimResults = true; rec.continuous = false; rec.maxAlternatives = 1; let finalText = ""; rec.onresult = (ev) => { let interim = ""; for (let i = ev.resultIndex; i < ev.results.length; i++) { if (ev.results[i].isFinal) finalText += ev.results[i][0].transcript; else interim += ev.results[i][0].transcript; } setTranscript(finalText || interim); if (finalText) { try { rec.stop(); } catch (e) {} finish(finalText); } }; rec.onerror = () => setPhase("typing"); rec.onend = () => { if (!finalText) setPhase((p) => (p === "listen" ? "typing" : p)); }; rec.start(); } catch (e) { setPhase("typing"); } }, [SR, user]); React.useEffect(() => { if (!open) { cancelTimer(); try { if (recRef.current) recRef.current.abort(); } catch (e) {} setPhase("listen"); setTranscript(""); setRes(null); setTyped(""); return; } startMic(); return () => { cancelTimer(); try { if (recRef.current) recRef.current.abort(); } catch (e) {} }; }, [open]); // eslint-disable-line if (!open) return null; const Icon = res && res.icon && I[res.icon] ? I[res.icon] : I.mic; return (
{ cancelTimer(); onClose(); }} style={{ position: "fixed", inset: 0, background: "var(--surface-overlay)", backdropFilter: "blur(3px)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1200, padding: 16 }}>
e.stopPropagation()} style={{ width: "100%", maxWidth: 480, background: "var(--surface-card)", borderRadius: "var(--radius-xl)", boxShadow: "var(--shadow-xl)", padding: 26, textAlign: "center" }}> {/* أيقونة الحالة */}
{phase === "listen" && } {phase === "result" ? : }
{/* استماع */} {phase === "listen" && ( <>
جارٍ الاستماع…
تكلّم الآن — مثل: «كشف أحمد» أو «شنو النواقص»
{transcript || "…"}
)} {/* كتابة */} {phase === "typing" && ( <>
اكتب أمرك
{!SR &&
المتصفح لا يدعم الإدخال الصوتي — الكتابة تعمل بنفس الذكاء.
}
{ e.preventDefault(); if (typed.trim()) finish(typed); }} style={{ marginTop: 14 }}> setTyped(e.target.value)} placeholder="مثال: كشف أحمد المقاول" autoFocus />
{SR && }
)} {/* النتيجة */} {phase === "result" && res && ( <> {res._heard &&
سمعت: «{res._heard}»
} {res.kind === "none" ? ( <>
لم أفهم الأمر
أمثلة مما يمكنك قوله:
{HINTS.map((h, i) =>
{h}
)}
) : res.kind === "choices" ? ( <>
{res.title}
{res.choices.map((c, i) => ( ))}
) : ( <>
{res.title}
{res.answer && (
{res.answer}
)} {res.run &&
سيُنفَّذ تلقائيًا…
} )}
{res.run && res.kind !== "choices" ? : }
)}
); } window.VoiceOverlay = VoiceOverlay; })();