@quantyapp/quanty-mcp-server 1.0.11 → 1.2.6
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/dist/bin/quanty-mcp-server.exe +0 -0
- package/dist/core/analysisService.d.ts +28 -0
- package/dist/core/analysisService.d.ts.map +1 -1
- package/dist/core/analysisService.js +86 -0
- package/dist/core/bankService.d.ts +26 -1
- package/dist/core/bankService.d.ts.map +1 -1
- package/dist/core/bankService.js +111 -2
- package/dist/core/budgetService.d.ts +356 -0
- package/dist/core/budgetService.d.ts.map +1 -1
- package/dist/core/budgetService.js +1020 -2
- package/dist/core/learningService.d.ts +94 -0
- package/dist/core/learningService.d.ts.map +1 -0
- package/dist/core/learningService.js +462 -0
- package/dist/core/matchingService.d.ts +76 -0
- package/dist/core/matchingService.d.ts.map +1 -0
- package/dist/core/matchingService.js +437 -0
- package/dist/mcp/index.js +1608 -190
- package/package.json +10 -1
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { supabase } from './supabase.js';
|
|
2
|
+
// ================ TEXT NORMALIZATION ================
|
|
3
|
+
/**
|
|
4
|
+
* Normaliza texto removendo acentos e convertendo para minúsculas
|
|
5
|
+
*/
|
|
6
|
+
function normalizeText(text) {
|
|
7
|
+
if (!text)
|
|
8
|
+
return '';
|
|
9
|
+
return text
|
|
10
|
+
.toLowerCase()
|
|
11
|
+
.normalize('NFD')
|
|
12
|
+
.replace(/[\u0300-\u036f]/g, '') // Remove acentos
|
|
13
|
+
.replace(/[^\w\s]/g, ' ') // Remove pontuação
|
|
14
|
+
.replace(/\s+/g, ' ') // Colapsa espaços múltiplos
|
|
15
|
+
.trim();
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Dicionário de sinônimos e abreviações comuns em orçamentação
|
|
19
|
+
*/
|
|
20
|
+
const SYNONYMS = {
|
|
21
|
+
'concreto': ['conc', 'concr', 'cto'],
|
|
22
|
+
'argamassa': ['arg', 'argam'],
|
|
23
|
+
'metro': ['m', 'mt', 'mts'],
|
|
24
|
+
'centimetro': ['cm', 'cent'],
|
|
25
|
+
'milimetro': ['mm', 'milim'],
|
|
26
|
+
'quilograma': ['kg', 'quilo', 'kilo'],
|
|
27
|
+
'tonelada': ['t', 'ton'],
|
|
28
|
+
'litro': ['l', 'lt', 'lts'],
|
|
29
|
+
'unidade': ['un', 'und', 'unid', 'peca', 'pc', 'pç'],
|
|
30
|
+
'verba': ['vb', 'vrb'],
|
|
31
|
+
'conjunto': ['cj', 'conj'],
|
|
32
|
+
'hora': ['h', 'hr', 'hs'],
|
|
33
|
+
'dia': ['d'],
|
|
34
|
+
'mes': ['m', 'meses'],
|
|
35
|
+
'equipamento': ['equip', 'eqp', 'eq'],
|
|
36
|
+
'material': ['mat', 'mater'],
|
|
37
|
+
'servico': ['serv', 'svc'],
|
|
38
|
+
'maodeobra': ['mo', 'mao de obra', 'mão de obra'],
|
|
39
|
+
'transporte': ['transp', 'trans'],
|
|
40
|
+
'instalacao': ['inst', 'instal'],
|
|
41
|
+
'eletrico': ['eletr', 'elet', 'elétrico'],
|
|
42
|
+
'hidraulico': ['hidr', 'hidra', 'hidráulico'],
|
|
43
|
+
'tubo': ['tb', 'tub'],
|
|
44
|
+
'conexao': ['cnx', 'conex', 'conexão'],
|
|
45
|
+
'fundacao': ['fund', 'fundação'],
|
|
46
|
+
'estrutura': ['estr', 'estrut'],
|
|
47
|
+
'alvenaria': ['alv', 'alven'],
|
|
48
|
+
'revestimento': ['revest', 'rev'],
|
|
49
|
+
'pintura': ['pint', 'pin'],
|
|
50
|
+
'impermeabilizacao': ['imper', 'impermeabilização'],
|
|
51
|
+
'esquadria': ['esq', 'esquad'],
|
|
52
|
+
'cobertura': ['cob', 'cobert'],
|
|
53
|
+
'administracao': ['adm', 'admin', 'administração'],
|
|
54
|
+
'canteiro': ['cant'],
|
|
55
|
+
'mobilizacao': ['mob', 'mobil', 'mobilização'],
|
|
56
|
+
'desmobilizacao': ['desmob', 'desmobil', 'desmobilização'],
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Expande sinônimos e abreviações no texto
|
|
60
|
+
*/
|
|
61
|
+
function expandSynonyms(text) {
|
|
62
|
+
let normalized = normalizeText(text);
|
|
63
|
+
// Substitui abreviações pelos termos completos
|
|
64
|
+
for (const [fullTerm, abbreviations] of Object.entries(SYNONYMS)) {
|
|
65
|
+
for (const abbrev of abbreviations) {
|
|
66
|
+
// Substitui apenas palavras inteiras
|
|
67
|
+
const regex = new RegExp(`\\b${abbrev}\\b`, 'gi');
|
|
68
|
+
normalized = normalized.replace(regex, fullTerm);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return normalized;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Extrai features de uma descrição de item
|
|
75
|
+
*/
|
|
76
|
+
function extractFeatures(description) {
|
|
77
|
+
const normalized = expandSynonyms(description);
|
|
78
|
+
const words = normalized.split(' ').filter(w => w.length > 2);
|
|
79
|
+
// Extrai dimensões (ex: 20x15, 10cm, 2.5m)
|
|
80
|
+
const dimensionPattern = /(\d+(?:[.,]\d+)?)\s*x\s*(\d+(?:[.,]\d+)?)|(\d+(?:[.,]\d+)?)\s*(cm|mm|m|pol)/gi;
|
|
81
|
+
const dimensions = [];
|
|
82
|
+
let match;
|
|
83
|
+
while ((match = dimensionPattern.exec(description)) !== null) {
|
|
84
|
+
dimensions.push(match[0]);
|
|
85
|
+
}
|
|
86
|
+
// Extrai números isolados
|
|
87
|
+
const numberPattern = /\b(\d+(?:[.,]\d+)?)\b/g;
|
|
88
|
+
const numbers = [];
|
|
89
|
+
while ((match = numberPattern.exec(normalized)) !== null) {
|
|
90
|
+
numbers.push(parseFloat(match[1].replace(',', '.')));
|
|
91
|
+
}
|
|
92
|
+
// Identifica materiais comuns
|
|
93
|
+
const materialKeywords = [
|
|
94
|
+
'concreto', 'aco', 'ferro', 'argamassa', 'cimento', 'areia', 'brita',
|
|
95
|
+
'madeira', 'vidro', 'aluminio', 'pvc', 'cobre', 'ceramica', 'porcelanato',
|
|
96
|
+
'granito', 'marmore', 'gesso', 'drywall', 'tijolo', 'bloco'
|
|
97
|
+
];
|
|
98
|
+
const materials = words.filter(w => materialKeywords.includes(w));
|
|
99
|
+
// Identifica categoria
|
|
100
|
+
const categoryKeywords = {
|
|
101
|
+
'fundacao': ['fundacao', 'estaca', 'radier', 'sapata', 'tubulao'],
|
|
102
|
+
'estrutura': ['estrutura', 'pilar', 'viga', 'laje', 'concreto armado'],
|
|
103
|
+
'alvenaria': ['alvenaria', 'parede', 'tijolo', 'bloco'],
|
|
104
|
+
'revestimento': ['revestimento', 'reboco', 'chapisco', 'ceramica', 'piso'],
|
|
105
|
+
'pintura': ['pintura', 'tinta', 'latex', 'acrilica', 'massa corrida'],
|
|
106
|
+
'instalacao_eletrica': ['eletrico', 'fio', 'cabo', 'tomada', 'interruptor', 'disjuntor'],
|
|
107
|
+
'instalacao_hidraulica': ['hidraulico', 'tubo', 'conexao', 'registro', 'valvula'],
|
|
108
|
+
'impermeabilizacao': ['impermeabilizacao', 'manta', 'asfaltica'],
|
|
109
|
+
'esquadria': ['esquadria', 'porta', 'janela', 'vidro'],
|
|
110
|
+
'cobertura': ['cobertura', 'telha', 'calha', 'rufo'],
|
|
111
|
+
'administracao': ['administracao', 'canteiro', 'mobilizacao', 'desmobilizacao', 'engenheiro']
|
|
112
|
+
};
|
|
113
|
+
let category = 'outros';
|
|
114
|
+
for (const [cat, keywords] of Object.entries(categoryKeywords)) {
|
|
115
|
+
if (keywords.some(kw => normalized.includes(kw))) {
|
|
116
|
+
category = cat;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Keywords são palavras significativas (não stop words)
|
|
121
|
+
const stopWords = ['de', 'da', 'do', 'para', 'com', 'em', 'por', 'uma', 'um', 'que', 'e', 'ou'];
|
|
122
|
+
const keywords = words.filter(w => !stopWords.includes(w) && w.length > 2);
|
|
123
|
+
return { keywords, dimensions, numbers, materials, category };
|
|
124
|
+
}
|
|
125
|
+
// ================ SIMILARITY METRICS ================
|
|
126
|
+
/**
|
|
127
|
+
* Calcula distância de Levenshtein entre duas strings
|
|
128
|
+
*/
|
|
129
|
+
function levenshteinDistance(a, b) {
|
|
130
|
+
const matrix = [];
|
|
131
|
+
for (let i = 0; i <= b.length; i++) {
|
|
132
|
+
matrix[i] = [i];
|
|
133
|
+
}
|
|
134
|
+
for (let j = 0; j <= a.length; j++) {
|
|
135
|
+
matrix[0][j] = j;
|
|
136
|
+
}
|
|
137
|
+
for (let i = 1; i <= b.length; i++) {
|
|
138
|
+
for (let j = 1; j <= a.length; j++) {
|
|
139
|
+
if (b.charAt(i - 1) === a.charAt(j - 1)) {
|
|
140
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return matrix[b.length][a.length];
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Calcula similaridade fuzzy (baseada em Levenshtein)
|
|
151
|
+
*/
|
|
152
|
+
function fuzzySimilarity(a, b) {
|
|
153
|
+
const normA = expandSynonyms(a);
|
|
154
|
+
const normB = expandSynonyms(b);
|
|
155
|
+
if (normA === normB)
|
|
156
|
+
return 100;
|
|
157
|
+
const maxLen = Math.max(normA.length, normB.length);
|
|
158
|
+
if (maxLen === 0)
|
|
159
|
+
return 100;
|
|
160
|
+
const distance = levenshteinDistance(normA, normB);
|
|
161
|
+
return Math.round((1 - distance / maxLen) * 100);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Calcula similaridade baseada em palavras-chave (Jaccard)
|
|
165
|
+
*/
|
|
166
|
+
function keywordSimilarity(a, b) {
|
|
167
|
+
const featuresA = extractFeatures(a);
|
|
168
|
+
const featuresB = extractFeatures(b);
|
|
169
|
+
const setA = new Set(featuresA.keywords);
|
|
170
|
+
const setB = new Set(featuresB.keywords);
|
|
171
|
+
const intersection = new Set([...setA].filter(x => setB.has(x)));
|
|
172
|
+
const union = new Set([...setA, ...setB]);
|
|
173
|
+
if (union.size === 0)
|
|
174
|
+
return 0;
|
|
175
|
+
return Math.round((intersection.size / union.size) * 100);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Calcula similaridade TF-IDF simplificada
|
|
179
|
+
*/
|
|
180
|
+
function tfidfSimilarity(a, b) {
|
|
181
|
+
const wordsA = expandSynonyms(a).split(' ').filter(w => w.length > 2);
|
|
182
|
+
const wordsB = expandSynonyms(b).split(' ').filter(w => w.length > 2);
|
|
183
|
+
// Conta frequência de palavras
|
|
184
|
+
const freqA = {};
|
|
185
|
+
const freqB = {};
|
|
186
|
+
for (const w of wordsA)
|
|
187
|
+
freqA[w] = (freqA[w] || 0) + 1;
|
|
188
|
+
for (const w of wordsB)
|
|
189
|
+
freqB[w] = (freqB[w] || 0) + 1;
|
|
190
|
+
// Calcula produto escalar normalizado (cosine similarity)
|
|
191
|
+
const allWords = new Set([...Object.keys(freqA), ...Object.keys(freqB)]);
|
|
192
|
+
let dotProduct = 0;
|
|
193
|
+
let normA = 0;
|
|
194
|
+
let normB = 0;
|
|
195
|
+
for (const word of allWords) {
|
|
196
|
+
const vA = freqA[word] || 0;
|
|
197
|
+
const vB = freqB[word] || 0;
|
|
198
|
+
dotProduct += vA * vB;
|
|
199
|
+
normA += vA * vA;
|
|
200
|
+
normB += vB * vB;
|
|
201
|
+
}
|
|
202
|
+
if (normA === 0 || normB === 0)
|
|
203
|
+
return 0;
|
|
204
|
+
return Math.round((dotProduct / (Math.sqrt(normA) * Math.sqrt(normB))) * 100);
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Calcula similaridade combinada (ensemble)
|
|
208
|
+
*/
|
|
209
|
+
export function calculateSimilarity(a, b) {
|
|
210
|
+
const fuzzy = fuzzySimilarity(a, b);
|
|
211
|
+
const keyword = keywordSimilarity(a, b);
|
|
212
|
+
const tfidf = tfidfSimilarity(a, b);
|
|
213
|
+
const featuresA = extractFeatures(a);
|
|
214
|
+
const featuresB = extractFeatures(b);
|
|
215
|
+
const categoryMatch = featuresA.category === featuresB.category;
|
|
216
|
+
// Pesos ajustados
|
|
217
|
+
const weights = { fuzzy: 0.3, keyword: 0.4, tfidf: 0.3 };
|
|
218
|
+
// Bonus por categoria igual
|
|
219
|
+
const categoryBonus = categoryMatch ? 10 : 0;
|
|
220
|
+
const overall = Math.min(100, Math.round(fuzzy * weights.fuzzy +
|
|
221
|
+
keyword * weights.keyword +
|
|
222
|
+
tfidf * weights.tfidf +
|
|
223
|
+
categoryBonus));
|
|
224
|
+
return {
|
|
225
|
+
overall,
|
|
226
|
+
fuzzy,
|
|
227
|
+
keyword,
|
|
228
|
+
tfidf,
|
|
229
|
+
category: {
|
|
230
|
+
a: featuresA.category,
|
|
231
|
+
b: featuresB.category,
|
|
232
|
+
match: categoryMatch
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Busca itens similares em outros orçamentos
|
|
238
|
+
*/
|
|
239
|
+
export async function findSimilarInBudgets(auth, description, excludeBudgetId, limit = 10, minSimilarity = 50) {
|
|
240
|
+
// Busca orçamentos do tenant/usuário
|
|
241
|
+
const { data: budgets, error } = await supabase
|
|
242
|
+
.from('budgets')
|
|
243
|
+
.select('id, title, data')
|
|
244
|
+
.or(`tenant_id.eq.${auth.tenantId},user_id.eq.${auth.userId}`)
|
|
245
|
+
.eq('deleted', false)
|
|
246
|
+
.limit(50); // Limita para performance
|
|
247
|
+
if (error || !budgets)
|
|
248
|
+
return [];
|
|
249
|
+
const results = [];
|
|
250
|
+
for (const budget of budgets) {
|
|
251
|
+
if (budget.id === excludeBudgetId)
|
|
252
|
+
continue;
|
|
253
|
+
const rows = budget.data?.rows || [];
|
|
254
|
+
for (const row of rows) {
|
|
255
|
+
if (!row.description)
|
|
256
|
+
continue;
|
|
257
|
+
const sim = calculateSimilarity(description, row.description);
|
|
258
|
+
if (sim.overall >= minSimilarity) {
|
|
259
|
+
results.push({
|
|
260
|
+
id: row.id,
|
|
261
|
+
code: row.code || '',
|
|
262
|
+
description: row.description,
|
|
263
|
+
unit: row.unit || '',
|
|
264
|
+
unitCost: row.unitCost || 0,
|
|
265
|
+
source: 'budget',
|
|
266
|
+
sourceId: budget.id,
|
|
267
|
+
sourceName: budget.title,
|
|
268
|
+
similarity: sim.overall,
|
|
269
|
+
similarityDetails: {
|
|
270
|
+
fuzzy: sim.fuzzy,
|
|
271
|
+
keyword: sim.keyword,
|
|
272
|
+
tfidf: sim.tfidf
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// Ordena por similaridade e limita
|
|
279
|
+
return results
|
|
280
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
281
|
+
.slice(0, limit);
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Busca itens similares em bancos de composições
|
|
285
|
+
*/
|
|
286
|
+
export async function findSimilarInBanks(auth, description, bankIds, limit = 10, minSimilarity = 50) {
|
|
287
|
+
// Busca bancos disponíveis
|
|
288
|
+
let query = supabase
|
|
289
|
+
.from('composition_banks')
|
|
290
|
+
.select('id, title, data, visibility');
|
|
291
|
+
if (bankIds && bankIds.length > 0) {
|
|
292
|
+
query = query.in('id', bankIds);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// Busca bancos públicos ou do tenant/usuário
|
|
296
|
+
query = query.or(`visibility.eq.public,tenant_id.eq.${auth.tenantId},user_id.eq.${auth.userId}`);
|
|
297
|
+
}
|
|
298
|
+
const { data: banks, error } = await query.limit(20);
|
|
299
|
+
if (error || !banks)
|
|
300
|
+
return [];
|
|
301
|
+
const results = [];
|
|
302
|
+
for (const bank of banks) {
|
|
303
|
+
const categories = bank.data?.categories || [];
|
|
304
|
+
// Função recursiva para extrair itens
|
|
305
|
+
const extractItems = (nodes) => {
|
|
306
|
+
const items = [];
|
|
307
|
+
for (const node of nodes) {
|
|
308
|
+
if (node.items) {
|
|
309
|
+
items.push(...node.items);
|
|
310
|
+
}
|
|
311
|
+
if (node.children) {
|
|
312
|
+
items.push(...extractItems(node.children));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return items;
|
|
316
|
+
};
|
|
317
|
+
const items = extractItems(categories);
|
|
318
|
+
for (const item of items) {
|
|
319
|
+
const itemDesc = item.description || item.name || item.title || '';
|
|
320
|
+
if (!itemDesc)
|
|
321
|
+
continue;
|
|
322
|
+
const sim = calculateSimilarity(description, itemDesc);
|
|
323
|
+
if (sim.overall >= minSimilarity) {
|
|
324
|
+
results.push({
|
|
325
|
+
id: item.id,
|
|
326
|
+
code: item.code || '',
|
|
327
|
+
description: itemDesc,
|
|
328
|
+
unit: item.unit || item.unidade || '',
|
|
329
|
+
unitCost: item.unitCost || item.unit_cost || item.custoUnitario || 0,
|
|
330
|
+
source: 'bank',
|
|
331
|
+
sourceId: bank.id,
|
|
332
|
+
sourceName: bank.title,
|
|
333
|
+
similarity: sim.overall,
|
|
334
|
+
similarityDetails: {
|
|
335
|
+
fuzzy: sim.fuzzy,
|
|
336
|
+
keyword: sim.keyword,
|
|
337
|
+
tfidf: sim.tfidf
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Ordena por similaridade e limita
|
|
344
|
+
return results
|
|
345
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
346
|
+
.slice(0, limit);
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Busca unificada em orçamentos e bancos
|
|
350
|
+
*/
|
|
351
|
+
export async function findSimilarItems(auth, description, options = {}) {
|
|
352
|
+
const { searchBudgets = true, searchBanks = true, excludeBudgetId, bankIds, limit = 10, minSimilarity = 50 } = options;
|
|
353
|
+
const [budgetResults, bankResults] = await Promise.all([
|
|
354
|
+
searchBudgets ? findSimilarInBudgets(auth, description, excludeBudgetId, limit, minSimilarity) : [],
|
|
355
|
+
searchBanks ? findSimilarInBanks(auth, description, bankIds, limit, minSimilarity) : []
|
|
356
|
+
]);
|
|
357
|
+
// Combina e ordena os top matches
|
|
358
|
+
const allResults = [...budgetResults, ...bankResults];
|
|
359
|
+
const topMatches = allResults
|
|
360
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
361
|
+
.slice(0, limit);
|
|
362
|
+
return {
|
|
363
|
+
budgetResults,
|
|
364
|
+
bankResults,
|
|
365
|
+
topMatches
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Compara preços de itens similares
|
|
370
|
+
*/
|
|
371
|
+
export async function comparePrices(auth, description, currentPrice, options = {}) {
|
|
372
|
+
const { topMatches } = await findSimilarItems(auth, description, {
|
|
373
|
+
searchBudgets: true,
|
|
374
|
+
searchBanks: true,
|
|
375
|
+
excludeBudgetId: options.excludeBudgetId,
|
|
376
|
+
limit: options.limit || 20,
|
|
377
|
+
minSimilarity: options.minSimilarity || 60
|
|
378
|
+
});
|
|
379
|
+
// Filtra itens com preço válido
|
|
380
|
+
const withPrice = topMatches.filter(m => m.unitCost > 0);
|
|
381
|
+
if (withPrice.length === 0) {
|
|
382
|
+
return {
|
|
383
|
+
currentPrice,
|
|
384
|
+
matches: [],
|
|
385
|
+
statistics: {
|
|
386
|
+
minPrice: 0,
|
|
387
|
+
maxPrice: 0,
|
|
388
|
+
avgPrice: 0,
|
|
389
|
+
medianPrice: 0,
|
|
390
|
+
recommendation: 'Não foram encontrados itens similares com preço para comparação.'
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// Calcula diferença de preço para cada match
|
|
395
|
+
const matchesWithDiff = withPrice.map(m => ({
|
|
396
|
+
...m,
|
|
397
|
+
priceDiff: m.unitCost - currentPrice,
|
|
398
|
+
priceDiffPercent: currentPrice > 0 ? Math.round(((m.unitCost - currentPrice) / currentPrice) * 100) : 0
|
|
399
|
+
}));
|
|
400
|
+
// Estatísticas
|
|
401
|
+
const prices = withPrice.map(m => m.unitCost).sort((a, b) => a - b);
|
|
402
|
+
const minPrice = prices[0];
|
|
403
|
+
const maxPrice = prices[prices.length - 1];
|
|
404
|
+
const avgPrice = prices.reduce((a, b) => a + b, 0) / prices.length;
|
|
405
|
+
const medianPrice = prices.length % 2 === 0
|
|
406
|
+
? (prices[prices.length / 2 - 1] + prices[prices.length / 2]) / 2
|
|
407
|
+
: prices[Math.floor(prices.length / 2)];
|
|
408
|
+
// Recomendação
|
|
409
|
+
let recommendation = '';
|
|
410
|
+
const diffFromAvg = ((currentPrice - avgPrice) / avgPrice) * 100;
|
|
411
|
+
if (currentPrice < minPrice * 0.8) {
|
|
412
|
+
recommendation = `⚠️ Preço muito abaixo do mercado (${Math.abs(Math.round(diffFromAvg))}% abaixo da média). Verifique se está correto.`;
|
|
413
|
+
}
|
|
414
|
+
else if (currentPrice > maxPrice * 1.2) {
|
|
415
|
+
recommendation = `⚠️ Preço muito acima do mercado (${Math.round(diffFromAvg)}% acima da média). Considere revisar.`;
|
|
416
|
+
}
|
|
417
|
+
else if (Math.abs(diffFromAvg) <= 10) {
|
|
418
|
+
recommendation = `✅ Preço dentro da média de mercado.`;
|
|
419
|
+
}
|
|
420
|
+
else if (diffFromAvg > 0) {
|
|
421
|
+
recommendation = `📊 Preço ${Math.round(diffFromAvg)}% acima da média, mas dentro da faixa de mercado.`;
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
recommendation = `📊 Preço ${Math.abs(Math.round(diffFromAvg))}% abaixo da média, mas dentro da faixa de mercado.`;
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
currentPrice,
|
|
428
|
+
matches: matchesWithDiff,
|
|
429
|
+
statistics: {
|
|
430
|
+
minPrice: Math.round(minPrice * 100) / 100,
|
|
431
|
+
maxPrice: Math.round(maxPrice * 100) / 100,
|
|
432
|
+
avgPrice: Math.round(avgPrice * 100) / 100,
|
|
433
|
+
medianPrice: Math.round(medianPrice * 100) / 100,
|
|
434
|
+
recommendation
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|