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.
- package/README.md +22 -64
- package/cli.js +0 -0
- package/core/ai-analyst.js +2 -2
- package/core/database.js +63 -72
- package/core/license-manager.js +9 -9
- package/index.js +175 -0
- package/modules/crm/sentiment-crm.js +231 -0
- package/modules/healer/listing-healer.js +201 -0
- package/modules/oracle/predictor.js +214 -0
- package/modules/researcher/agent.js +169 -0
- package/modules/team/agents/base.js +92 -0
- package/modules/team/agents/dev.js +33 -0
- package/modules/team/agents/josh.js +40 -0
- package/modules/team/agents/marketing.js +33 -0
- package/modules/team/agents/milo.js +36 -0
- package/modules/team/index.js +78 -0
- package/modules/team/shared-memory.js +87 -0
- package/modules/war-room/competitor-tracker.js +250 -0
- package/modules/war-room/pricing-engine.js +308 -0
- package/nodes/warehouse.js +238 -0
- package/onboard.js +0 -0
- package/package.json +47 -87
- package/platforms/amazon.js +3 -8
- package/platforms/ciceksepeti.js +2 -5
- package/platforms/hepsiburada.js +4 -6
- package/platforms/n11.js +3 -5
- package/platforms/pazarama.js +2 -5
- package/platforms/trendyol.js +3 -3
- package/plugins/vantuz/index.js +48 -0
- package/vantuz-3.3.4.tgz +0 -0
|
@@ -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;
|