vantuz 3.3.7 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ // modules/crm/sentiment-crm.js
2
+ // Sentiment CRM for Vantuz OS V2
3
+ // Analyzes reviews/questions, drafts brand-persona replies, escalates angry customers.
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { log } from '../../core/ai-provider.js';
8
+
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+ // SENTIMENT ANALYSIS (Local / AI-Enhanced)
11
+ // ═══════════════════════════════════════════════════════════════════════════
12
+
13
+ const SENTIMENT_KEYWORDS = {
14
+ angry: [
15
+ 'rezalet', 'kötü', 'berbat', 'iade', 'şikayet', 'korkunç', 'sahtekarlık',
16
+ 'dolandırıcı', 'cevap yok', 'pişman', 'lütfen çözün', 'iğrenç', 'saygısız',
17
+ 'hala gelmedi', 'kırık', 'bozuk', 'yanlış ürün', 'parasını istiyorum'
18
+ ],
19
+ happy: [
20
+ 'mükemmel', 'harika', 'güzel', 'teşekkür', 'süper', 'hızlı', 'kaliteli',
21
+ 'memnun', 'tavsiye', 'beğendim', 'perfect', 'çok iyi', 'bravo', 'başarılı'
22
+ ],
23
+ confused: [
24
+ 'ne zaman', 'nerede', 'nasıl', 'anlamadım', 'bilgi', 'soruyorum', 'cevap bekliyorum',
25
+ 'açıklama', 'yardım', 'destek', 'merak ediyorum', 'ne oldu'
26
+ ]
27
+ };
28
+
29
+ /**
30
+ * Local sentiment detection (fast, no API call).
31
+ * @param {string} text
32
+ * @returns {{ sentiment: string, confidence: number, keywords: string[] }}
33
+ */
34
+ function detectSentimentLocal(text) {
35
+ const lower = text.toLowerCase();
36
+ const found = { angry: [], happy: [], confused: [] };
37
+
38
+ for (const [sentiment, keywords] of Object.entries(SENTIMENT_KEYWORDS)) {
39
+ for (const kw of keywords) {
40
+ if (lower.includes(kw)) found[sentiment].push(kw);
41
+ }
42
+ }
43
+
44
+ // Score: angry keywords weigh more
45
+ const scores = {
46
+ angry: found.angry.length * 2,
47
+ happy: found.happy.length * 1.5,
48
+ confused: found.confused.length * 1
49
+ };
50
+
51
+ const winner = Object.entries(scores).sort((a, b) => b[1] - a[1])[0];
52
+
53
+ if (winner[1] === 0) {
54
+ return { sentiment: 'neutral', confidence: 0.5, keywords: [] };
55
+ }
56
+
57
+ return {
58
+ sentiment: winner[0],
59
+ confidence: Math.min(winner[1] / 5, 1),
60
+ keywords: found[winner[0]]
61
+ };
62
+ }
63
+
64
+ // ═══════════════════════════════════════════════════════════════════════════
65
+ // BRAND PERSONA LOADER
66
+ // ═══════════════════════════════════════════════════════════════════════════
67
+
68
+ function loadBrandTone() {
69
+ const brandPath = path.join(process.cwd(), 'workspace', 'BRAND.md');
70
+ try {
71
+ if (fs.existsSync(brandPath)) {
72
+ const content = fs.readFileSync(brandPath, 'utf-8').toLowerCase();
73
+ if (content.includes('samimi') || content.includes('friendly') || content.includes('emoji')) {
74
+ return 'friendly';
75
+ }
76
+ if (content.includes('premium') || content.includes('lüks') || content.includes('formal')) {
77
+ return 'formal';
78
+ }
79
+ }
80
+ } catch (e) { /* ignore */ }
81
+ return 'professional'; // default
82
+ }
83
+
84
+ // ═══════════════════════════════════════════════════════════════════════════
85
+ // CRM ENGINE
86
+ // ═══════════════════════════════════════════════════════════════════════════
87
+
88
+ class SentimentCRM {
89
+ constructor() {
90
+ this.tone = loadBrandTone();
91
+ this.escalationCallbacks = []; // for angry customer alerts
92
+ this.processed = []; // recent analysis log
93
+ log('INFO', 'SentimentCRM initialized', { tone: this.tone });
94
+ }
95
+
96
+ /**
97
+ * Register an escalation handler (webhook, Slack, WhatsApp).
98
+ */
99
+ onEscalation(callback) {
100
+ this.escalationCallbacks.push(callback);
101
+ }
102
+
103
+ /**
104
+ * Analyze a customer message/review.
105
+ * @param {object} params
106
+ * @param {string} params.text - The message/review text.
107
+ * @param {string} params.customerName - Customer name.
108
+ * @param {string} params.platform - Source platform.
109
+ * @param {string} params.productBarcode - Related product barcode.
110
+ * @returns {{ sentiment, confidence, suggestedReply, escalated }}
111
+ */
112
+ async analyze({ text, customerName = '', platform = '', productBarcode = '' }) {
113
+ const analysis = detectSentimentLocal(text);
114
+
115
+ // Generate reply suggestion
116
+ const suggestedReply = this._generateReply(analysis.sentiment, customerName, text);
117
+
118
+ // Escalate angry customers
119
+ let escalated = false;
120
+ if (analysis.sentiment === 'angry' && analysis.confidence >= 0.5) {
121
+ escalated = true;
122
+ this._escalate({
123
+ customerName,
124
+ platform,
125
+ text,
126
+ sentiment: analysis.sentiment,
127
+ confidence: analysis.confidence,
128
+ productBarcode
129
+ });
130
+ }
131
+
132
+ const result = {
133
+ ...analysis,
134
+ customerName,
135
+ platform,
136
+ productBarcode,
137
+ suggestedReply,
138
+ escalated,
139
+ analyzedAt: new Date().toISOString()
140
+ };
141
+
142
+ this.processed.push(result);
143
+ if (this.processed.length > 100) this.processed = this.processed.slice(-100);
144
+
145
+ return result;
146
+ }
147
+
148
+ /**
149
+ * Batch analyze reviews.
150
+ */
151
+ async analyzeBatch(reviews) {
152
+ const results = [];
153
+ for (const review of reviews) {
154
+ results.push(await this.analyze(review));
155
+ }
156
+
157
+ const summary = {
158
+ total: results.length,
159
+ angry: results.filter(r => r.sentiment === 'angry').length,
160
+ happy: results.filter(r => r.sentiment === 'happy').length,
161
+ confused: results.filter(r => r.sentiment === 'confused').length,
162
+ neutral: results.filter(r => r.sentiment === 'neutral').length,
163
+ escalated: results.filter(r => r.escalated).length
164
+ };
165
+
166
+ log('INFO', 'CRM batch analysis complete', summary);
167
+ return { results, summary };
168
+ }
169
+
170
+ getRecent(limit = 20) {
171
+ return this.processed.slice(-limit);
172
+ }
173
+
174
+ getStatus() {
175
+ const all = this.processed;
176
+ return {
177
+ tone: this.tone,
178
+ totalProcessed: all.length,
179
+ angryCount: all.filter(r => r.sentiment === 'angry').length,
180
+ escalatedCount: all.filter(r => r.escalated).length
181
+ };
182
+ }
183
+
184
+ // ─────────────────────────────────────────────────────────────────────
185
+
186
+ _generateReply(sentiment, customerName, originalText) {
187
+ const name = customerName ? ` ${customerName} Bey/Hanım` : '';
188
+
189
+ const replies = {
190
+ friendly: {
191
+ angry: `Merhaba${name} 🙏 Yaşadığınız bu sorun için çok üzgünüz! Hemen çözmek istiyoruz. Sipariş numaranızı paylaşır mısınız?`,
192
+ happy: `Teşekkürler${name} 🎉 Böyle güzel yorumlar bizi çok mutlu ediyor! Tekrar bekleriz 💜`,
193
+ confused: `Merhaba${name} 👋 Yardımcı olmak isteriz! Detayları paylaşır mısınız?`,
194
+ neutral: `Merhaba${name}, yorumunuz için teşekkürler! Başka sorunuz olursa yazabilirsiniz 😊`
195
+ },
196
+ formal: {
197
+ angry: `Sayın${name}, yaşadığınız olumsuzluktan dolayı özür dileriz. Konuyu derhal incelemeye alıyoruz. En kısa sürede size dönüş yapacağız.`,
198
+ happy: `Sayın${name}, değerli görüşleriniz için teşekkür ederiz. Memnuniyetiniz bizim için büyük önem taşımaktadır.`,
199
+ confused: `Sayın${name}, sorularınız için teşekkür ederiz. Konuyla ilgili size detaylı bilgi sunmak isteriz.`,
200
+ neutral: `Sayın${name}, yorumunuz için teşekkür ederiz.`
201
+ },
202
+ professional: {
203
+ angry: `Merhaba${name}, yaşadığınız sorun için özür dileriz. Konuyu inceliyoruz ve en kısa sürede dönüş yapacağız.`,
204
+ happy: `Merhaba${name}, güzel yorumunuz için teşekkürler! Tekrar bekleriz.`,
205
+ confused: `Merhaba${name}, yardımcı olmak isteriz. Lütfen detayları paylaşın.`,
206
+ neutral: `Merhaba${name}, yorumunuz için teşekkürler.`
207
+ }
208
+ };
209
+
210
+ return (replies[this.tone] || replies.professional)[sentiment] || replies.professional.neutral;
211
+ }
212
+
213
+ _escalate(data) {
214
+ log('WARN', `🚨 ESCALATION: Kızgın müşteri — ${data.customerName || 'Anonim'} (${data.platform})`, data);
215
+
216
+ for (const cb of this.escalationCallbacks) {
217
+ try { cb(data); } catch (e) { /* swallow */ }
218
+ }
219
+ }
220
+ }
221
+
222
+ let crmInstance = null;
223
+
224
+ export function getSentimentCRM() {
225
+ if (!crmInstance) {
226
+ crmInstance = new SentimentCRM();
227
+ }
228
+ return crmInstance;
229
+ }
230
+
231
+ export default SentimentCRM;
@@ -0,0 +1,201 @@
1
+ // modules/healer/listing-healer.js
2
+ // Self-Healing Listings for Vantuz OS V2
3
+ // Audits product listings and optimizes underperforming ones.
4
+
5
+ import { log } from '../../core/ai-provider.js';
6
+
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ // LISTING HEALTH CHECKS
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ const HEALTH_RULES = [
12
+ {
13
+ name: 'short_title',
14
+ check: (p) => (p.title || '').length < 40,
15
+ severity: 'warning',
16
+ message: 'Başlık çok kısa — SEO performansı düşük',
17
+ fix: 'title_optimization'
18
+ },
19
+ {
20
+ name: 'no_images',
21
+ check: (p) => !p.images || p.images.length === 0,
22
+ severity: 'critical',
23
+ message: 'Ürün görseli yok — satış ihtimali çok düşük',
24
+ fix: 'add_images'
25
+ },
26
+ {
27
+ name: 'few_images',
28
+ check: (p) => p.images && p.images.length > 0 && p.images.length < 3,
29
+ severity: 'warning',
30
+ message: 'Görsel sayısı az — en az 3 görsel önerilir',
31
+ fix: 'add_images'
32
+ },
33
+ {
34
+ name: 'no_brand',
35
+ check: (p) => !p.brand || p.brand.trim() === '',
36
+ severity: 'info',
37
+ message: 'Marka belirtilmemiş',
38
+ fix: 'add_brand'
39
+ },
40
+ {
41
+ name: 'zero_stock',
42
+ check: (p) => p.stock === 0 && p.onSale,
43
+ severity: 'critical',
44
+ message: 'Stok sıfır ama satışta — phantom listing',
45
+ fix: 'deactivate_or_restock'
46
+ },
47
+ {
48
+ name: 'turkish_chars_missing',
49
+ check: (p) => {
50
+ const title = p.title || '';
51
+ // Titles with Turkish words but no Turkish chars might be poorly optimized
52
+ const hasTurkish = /[çğıöşüÇĞİÖŞÜ]/.test(title);
53
+ const looksLatin = /^[a-zA-Z0-9\s\-\+\.\,\/\(\)]+$/.test(title);
54
+ return looksLatin && title.length > 20; // Might be missing Turkish chars
55
+ },
56
+ severity: 'info',
57
+ message: 'Başlıkta Türkçe karakter yok — arama sıralaması düşebilir',
58
+ fix: 'title_optimization'
59
+ },
60
+ {
61
+ name: 'high_price_no_sales',
62
+ check: (p) => p.salesVelocity === 0 && p.margin > 40,
63
+ severity: 'warning',
64
+ message: 'Yüksek marj ama sıfır satış — fiyat çok yüksek olabilir',
65
+ fix: 'price_review'
66
+ }
67
+ ];
68
+
69
+ // ═══════════════════════════════════════════════════════════════════════════
70
+ // LISTING HEALER
71
+ // ═══════════════════════════════════════════════════════════════════════════
72
+
73
+ class ListingHealer {
74
+ constructor() {
75
+ this.auditResults = [];
76
+ log('INFO', 'ListingHealer initialized');
77
+ }
78
+
79
+ /**
80
+ * Audit a single product listing.
81
+ * @param {object} product - Unified product from catalog.
82
+ * @returns {{ barcode, issues: array, score: number }}
83
+ */
84
+ audit(product) {
85
+ const issues = [];
86
+
87
+ for (const rule of HEALTH_RULES) {
88
+ try {
89
+ if (rule.check(product)) {
90
+ issues.push({
91
+ rule: rule.name,
92
+ severity: rule.severity,
93
+ message: rule.message,
94
+ suggestedFix: rule.fix
95
+ });
96
+ }
97
+ } catch (e) {
98
+ // Rule check error — skip silently
99
+ }
100
+ }
101
+
102
+ // Health score: 100 = perfect
103
+ const penalties = {
104
+ critical: 30,
105
+ warning: 15,
106
+ info: 5
107
+ };
108
+ const totalPenalty = issues.reduce((sum, i) => sum + (penalties[i.severity] || 0), 0);
109
+ const score = Math.max(0, 100 - totalPenalty);
110
+
111
+ return {
112
+ barcode: product.barcode || product.sku,
113
+ title: product.title,
114
+ score,
115
+ issues,
116
+ auditedAt: new Date().toISOString()
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Audit all products in the catalog.
122
+ * @param {object[]} products - Array of unified products.
123
+ * @returns {{ healthy, warning, critical, results }}
124
+ */
125
+ auditAll(products) {
126
+ const results = products.map(p => this.audit(p));
127
+
128
+ const healthy = results.filter(r => r.score >= 80).length;
129
+ const warning = results.filter(r => r.score >= 50 && r.score < 80).length;
130
+ const critical = results.filter(r => r.score < 50).length;
131
+
132
+ // Sort by worst first
133
+ results.sort((a, b) => a.score - b.score);
134
+
135
+ this.auditResults = results;
136
+
137
+ const report = {
138
+ total: results.length,
139
+ healthy,
140
+ warning,
141
+ critical,
142
+ avgScore: results.length > 0
143
+ ? Math.round(results.reduce((s, r) => s + r.score, 0) / results.length)
144
+ : 0,
145
+ worstListings: results.slice(0, 10), // Top 10 worst
146
+ auditedAt: new Date().toISOString()
147
+ };
148
+
149
+ log('INFO', 'Listing audit complete', {
150
+ total: report.total,
151
+ healthy,
152
+ warning,
153
+ critical,
154
+ avgScore: report.avgScore
155
+ });
156
+
157
+ return report;
158
+ }
159
+
160
+ /**
161
+ * Generate AI-powered title suggestions for a product.
162
+ * @param {object} product - Unified product.
163
+ * @param {function} aiChat - AI chat function for generating suggestions.
164
+ * @returns {string} Suggested title.
165
+ */
166
+ async suggestTitle(product, aiChat) {
167
+ if (!aiChat) return product.title;
168
+
169
+ const prompt = `Şu ürün başlığını SEO için optimize et. Türkçe arama trendlerine uygun olsun. Mevcut: "${product.title}". Kategori: ${product.category || 'bilinmiyor'}. Sadece optimize edilmiş başlığı yaz, başka bir şey yazma.`;
170
+
171
+ try {
172
+ const response = await aiChat(prompt);
173
+ return response.trim();
174
+ } catch (e) {
175
+ log('WARN', 'Title suggestion failed', { error: e.message });
176
+ return product.title;
177
+ }
178
+ }
179
+
180
+ getStatus() {
181
+ const results = this.auditResults;
182
+ return {
183
+ lastAuditSize: results.length,
184
+ avgScore: results.length > 0
185
+ ? Math.round(results.reduce((s, r) => s + r.score, 0) / results.length)
186
+ : 0,
187
+ criticalCount: results.filter(r => r.score < 50).length
188
+ };
189
+ }
190
+ }
191
+
192
+ let healerInstance = null;
193
+
194
+ export function getListingHealer() {
195
+ if (!healerInstance) {
196
+ healerInstance = new ListingHealer();
197
+ }
198
+ return healerInstance;
199
+ }
200
+
201
+ export default ListingHealer;
@@ -0,0 +1,214 @@
1
+ // modules/oracle/predictor.js
2
+ // Oracle — Predictive Analytics for Vantuz OS V2
3
+ // Predicts stockout dates and generates reorder alerts.
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import { log } from '../../core/ai-provider.js';
9
+
10
+ const SALES_HISTORY_FILE = path.join(os.homedir(), '.vantuz', 'memory', 'sales-velocity.json');
11
+
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+ // SALES VELOCITY TRACKER
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+
16
+ function loadVelocityData() {
17
+ try {
18
+ if (fs.existsSync(SALES_HISTORY_FILE)) {
19
+ return JSON.parse(fs.readFileSync(SALES_HISTORY_FILE, 'utf-8'));
20
+ }
21
+ } catch (e) { /* ignore */ }
22
+ return {};
23
+ }
24
+
25
+ function saveVelocityData(data) {
26
+ const dir = path.dirname(SALES_HISTORY_FILE);
27
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
28
+ const tmp = SALES_HISTORY_FILE + '.tmp';
29
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
30
+ fs.renameSync(tmp, SALES_HISTORY_FILE);
31
+ }
32
+
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+ // ORACLE
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+
37
+ class Oracle {
38
+ constructor() {
39
+ this.velocityData = loadVelocityData(); // barcode -> { snapshots: [{date, stock}], velocity }
40
+ log('INFO', 'Oracle initialized', { trackedProducts: Object.keys(this.velocityData).length });
41
+ }
42
+
43
+ /**
44
+ * Record a stock snapshot for velocity calculation.
45
+ * @param {string} barcode
46
+ * @param {number} stock - Current stock level.
47
+ */
48
+ recordSnapshot(barcode, stock) {
49
+ if (!this.velocityData[barcode]) {
50
+ this.velocityData[barcode] = { snapshots: [], velocity: 0 };
51
+ }
52
+
53
+ const entry = this.velocityData[barcode];
54
+ entry.snapshots.push({
55
+ date: new Date().toISOString(),
56
+ stock: parseInt(stock, 10)
57
+ });
58
+
59
+ // Keep max 30 snapshots per product
60
+ if (entry.snapshots.length > 30) {
61
+ entry.snapshots = entry.snapshots.slice(-30);
62
+ }
63
+
64
+ // Recalculate velocity
65
+ entry.velocity = this._calculateVelocity(entry.snapshots);
66
+
67
+ saveVelocityData(this.velocityData);
68
+ }
69
+
70
+ /**
71
+ * Record snapshots for multiple products.
72
+ */
73
+ recordBatch(products) {
74
+ for (const p of products) {
75
+ const barcode = p.barcode || p.sku;
76
+ const stock = p.stock || p.quantity || 0;
77
+ if (barcode) this.recordSnapshot(barcode, stock);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Predict stockout date for a product.
83
+ * @param {string} barcode
84
+ * @param {number} currentStock - Current stock (if not in history).
85
+ * @returns {{ barcode, velocity, daysLeft, stockoutDate, severity }}
86
+ */
87
+ predictStockout(barcode, currentStock = null) {
88
+ const entry = this.velocityData[barcode];
89
+ if (!entry || entry.snapshots.length < 2) {
90
+ return {
91
+ barcode,
92
+ velocity: 0,
93
+ daysLeft: null,
94
+ stockoutDate: null,
95
+ severity: 'unknown',
96
+ message: 'Yeterli veri yok — en az 2 snapshot gerekli'
97
+ };
98
+ }
99
+
100
+ const stock = currentStock ?? entry.snapshots[entry.snapshots.length - 1].stock;
101
+ const velocity = entry.velocity;
102
+
103
+ if (velocity <= 0) {
104
+ return {
105
+ barcode, velocity: 0, daysLeft: Infinity,
106
+ stockoutDate: null, severity: 'safe',
107
+ message: 'Satış hızı sıfır veya negatif — stok azalmıyor'
108
+ };
109
+ }
110
+
111
+ const daysLeft = Math.floor(stock / velocity);
112
+ const stockoutDate = new Date();
113
+ stockoutDate.setDate(stockoutDate.getDate() + daysLeft);
114
+
115
+ let severity = 'safe';
116
+ if (daysLeft <= 3) severity = 'critical';
117
+ else if (daysLeft <= 7) severity = 'warning';
118
+ else if (daysLeft <= 14) severity = 'attention';
119
+
120
+ return {
121
+ barcode,
122
+ currentStock: stock,
123
+ velocity: Math.round(velocity * 100) / 100,
124
+ daysLeft,
125
+ stockoutDate: stockoutDate.toISOString().split('T')[0],
126
+ severity,
127
+ message: this._formatMessage(barcode, daysLeft, velocity, severity)
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Generate reorder report for all tracked products.
133
+ * @param {number} criticalDays - Days threshold for "critical" (default: 7).
134
+ * @returns {{ critical: array, warning: array, safe: number }}
135
+ */
136
+ generateReorderReport(criticalDays = 7) {
137
+ const critical = [];
138
+ const warning = [];
139
+ let safe = 0;
140
+
141
+ for (const barcode of Object.keys(this.velocityData)) {
142
+ const prediction = this.predictStockout(barcode);
143
+
144
+ if (prediction.daysLeft !== null && prediction.daysLeft <= criticalDays) {
145
+ const reorderQty = Math.ceil(prediction.velocity * 30); // 30 days supply
146
+ critical.push({
147
+ ...prediction,
148
+ reorderQty,
149
+ reorderMessage: `⚠️ ${barcode}: ${prediction.daysLeft} gün kaldı! ${reorderQty} adet sipariş ver.`
150
+ });
151
+ } else if (prediction.severity === 'attention') {
152
+ warning.push(prediction);
153
+ } else {
154
+ safe++;
155
+ }
156
+ }
157
+
158
+ // Sort by urgency
159
+ critical.sort((a, b) => a.daysLeft - b.daysLeft);
160
+
161
+ const report = { critical, warning, safe, generatedAt: new Date().toISOString() };
162
+ log('INFO', 'Reorder report generated', {
163
+ critical: critical.length, warning: warning.length, safe
164
+ });
165
+
166
+ return report;
167
+ }
168
+
169
+ getStatus() {
170
+ return {
171
+ trackedProducts: Object.keys(this.velocityData).length,
172
+ critical: this.generateReorderReport().critical.length
173
+ };
174
+ }
175
+
176
+ // ─────────────────────────────────────────────────────────────────────
177
+
178
+ _calculateVelocity(snapshots) {
179
+ if (snapshots.length < 2) return 0;
180
+
181
+ const first = snapshots[0];
182
+ const last = snapshots[snapshots.length - 1];
183
+ const daysDiff = (new Date(last.date) - new Date(first.date)) / (1000 * 60 * 60 * 24);
184
+
185
+ if (daysDiff <= 0) return 0;
186
+
187
+ const stockConsumed = first.stock - last.stock;
188
+ return stockConsumed > 0 ? stockConsumed / daysDiff : 0;
189
+ }
190
+
191
+ _formatMessage(barcode, daysLeft, velocity, severity) {
192
+ if (severity === 'critical') {
193
+ return `🔴 KRİTİK: ${barcode} — ${daysLeft} gün içinde stok bitecek! (Günlük ${velocity.toFixed(1)} adet satılıyor)`;
194
+ }
195
+ if (severity === 'warning') {
196
+ return `🟡 UYARI: ${barcode} — ${daysLeft} gün stok kaldı. Sipariş planla.`;
197
+ }
198
+ if (severity === 'attention') {
199
+ return `🟠 DİKKAT: ${barcode} — ${daysLeft} gün stok kaldı.`;
200
+ }
201
+ return `🟢 ${barcode} — Stok yeterli (${daysLeft} gün)`;
202
+ }
203
+ }
204
+
205
+ let oracleInstance = null;
206
+
207
+ export function getOracle() {
208
+ if (!oracleInstance) {
209
+ oracleInstance = new Oracle();
210
+ }
211
+ return oracleInstance;
212
+ }
213
+
214
+ export default Oracle;