@fullqueso/mcp-bc-gastos 1.14.2 → 1.19.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,192 @@
1
+ import { getDateRange, formatCurrency, formatPercent } from '../../utils/date-helper.js';
2
+ import { aggregateByField, calculateSummary, topN } from '../../utils/sales-aggregation.js';
3
+ import { generateStoreInsights } from '../../utils/sales-insights.js';
4
+ import { logger } from '../../utils/logger.js';
5
+
6
+ export const salesStoreComparisonTool = {
7
+ name: 'compare_sales_by_store',
8
+ description:
9
+ 'Comparación de rendimiento de VENTAS entre las tiendas de Full Queso (FQ01 Chacao, FQ28 Marqués, FQ88 Candelaria). Identifica fortalezas, oportunidades y diferencias en ventas entre tiendas. Montos en USD. (Para comparar GASTOS, usar compare_stores.)',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ period: {
14
+ type: 'string',
15
+ enum: ['last_7_days', 'last_30_days', 'this_month', 'last_month', 'specific_month', 'custom'],
16
+ description: 'Periodo de comparación. Usar "specific_month" con el parámetro month para un mes específico.',
17
+ default: 'last_30_days',
18
+ },
19
+ month: {
20
+ type: 'string',
21
+ description: 'Mes específico en formato YYYY-MM (ej: "2026-01" para enero 2026). Usar con period="specific_month".',
22
+ },
23
+ start_date: {
24
+ type: 'string',
25
+ description: 'Fecha inicio (YYYY-MM-DD). Requerido si period=custom',
26
+ },
27
+ end_date: {
28
+ type: 'string',
29
+ description: 'Fecha fin (YYYY-MM-DD). Requerido si period=custom',
30
+ },
31
+ metrics: {
32
+ type: 'array',
33
+ items: {
34
+ type: 'string',
35
+ enum: ['revenue', 'transactions', 'avg_ticket', 'top_products', 'all'],
36
+ },
37
+ description: 'Métricas a comparar',
38
+ default: ['all'],
39
+ },
40
+ },
41
+ },
42
+ };
43
+
44
+ export async function handleSalesStoreComparison(bcClient, args) {
45
+ const period = args.period || 'last_30_days';
46
+ const requestedMetrics = args.metrics || ['all'];
47
+ const showAll = requestedMetrics.includes('all');
48
+
49
+ const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
50
+
51
+ logger.info(`Sales store comparison: ${dateRange.start} to ${dateRange.end}`);
52
+
53
+ const allStoresData = await bcClient.getAllSalesStoreData(['all'], dateRange.start, dateRange.end);
54
+
55
+ const firstStoreData = Object.values(allStoresData)[0];
56
+ const exchangeRate = firstStoreData?.exchangeRate || null;
57
+
58
+ const comparison = {};
59
+ const storesSummaries = {};
60
+
61
+ for (const [code, data] of Object.entries(allStoresData)) {
62
+ const summary = calculateSummary(data.invoices, data.lines, exchangeRate, data.glRevenue);
63
+ storesSummaries[data.storeName] = { summary };
64
+
65
+ const storeResult = {
66
+ store_code: code,
67
+ store_name: data.storeName,
68
+ };
69
+
70
+ if (showAll || requestedMetrics.includes('revenue')) {
71
+ storeResult.revenue_usd = summary.total_revenue_usd;
72
+ storeResult.revenue_breakdown = summary.revenue_breakdown;
73
+ storeResult.margin_usd = summary.total_margin_usd;
74
+ storeResult.margin_pct = summary.margin_pct;
75
+ storeResult.cost_usd = summary.total_cost_usd;
76
+ }
77
+
78
+ if (showAll || requestedMetrics.includes('transactions')) {
79
+ storeResult.transactions = summary.total_transactions;
80
+ storeResult.total_quantity = summary.total_quantity;
81
+ }
82
+
83
+ if (showAll || requestedMetrics.includes('avg_ticket')) {
84
+ storeResult.avg_ticket_usd = summary.avg_ticket_usd;
85
+ }
86
+
87
+ if (showAll || requestedMetrics.includes('top_products')) {
88
+ const byProduct = aggregateByField(data.lines, 'displayName');
89
+ const storeLineRevTotal = byProduct.reduce((s, p) => s + p.revenue, 0) || 1;
90
+ storeResult.top_products = topN(byProduct, 'revenue', 10).map((p) => ({
91
+ product: p.key,
92
+ item_number: p.lines[0]?.itemNumber || '',
93
+ revenue: Math.round(p.revenue * 100) / 100,
94
+ revenue_pct: Math.round((p.revenue / storeLineRevTotal) * 1000) / 10,
95
+ quantity: p.quantity,
96
+ margin_pct: Math.round(p.margin_pct * 10) / 10,
97
+ }));
98
+
99
+ const byCategory = aggregateByField(data.lines, 'itemCategoryCode');
100
+ storeResult.top_categories = topN(byCategory, 'revenue', 5).map((c) => ({
101
+ category: c.key,
102
+ revenue: Math.round(c.revenue * 100) / 100,
103
+ quantity: c.quantity,
104
+ }));
105
+ }
106
+
107
+ if (showAll) {
108
+ const byDay = {};
109
+ for (const inv of data.invoices) {
110
+ const day = inv.postingDate;
111
+ if (!byDay[day]) byDay[day] = { date: day, transactions: 0 };
112
+ byDay[day].transactions += 1;
113
+ }
114
+ const dailyData = Object.values(byDay).sort((a, b) => a.date.localeCompare(b.date));
115
+
116
+ if (dailyData.length > 0) {
117
+ const bestDay = dailyData.reduce((best, d) => d.transactions > best.transactions ? d : best);
118
+ const worstDay = dailyData.reduce((worst, d) => d.transactions < worst.transactions ? d : worst);
119
+ storeResult.best_day = { date: bestDay.date, transactions: bestDay.transactions };
120
+ storeResult.worst_day = { date: worstDay.date, transactions: worstDay.transactions };
121
+ storeResult.avg_daily_transactions = Math.round(
122
+ (dailyData.reduce((s, d) => s + d.transactions, 0) / dailyData.length) * 10
123
+ ) / 10;
124
+ }
125
+ }
126
+
127
+ comparison[code] = storeResult;
128
+ }
129
+
130
+ const storeInsights = generateStoreInsights(storesSummaries, storesSummaries);
131
+
132
+ // Cross-store analysis
133
+ const crossInsights = [];
134
+ const storeEntries = Object.values(comparison);
135
+
136
+ if (storeEntries.length > 1) {
137
+ const revenueLeader = storeEntries.reduce((a, b) => (a.revenue_usd || 0) > (b.revenue_usd || 0) ? a : b);
138
+ const ticketLeader = storeEntries.reduce((a, b) => (a.avg_ticket_usd || 0) > (b.avg_ticket_usd || 0) ? a : b);
139
+ const transactionLeader = storeEntries.reduce((a, b) => (a.transactions || 0) > (b.transactions || 0) ? a : b);
140
+
141
+ crossInsights.push({
142
+ finding: `${revenueLeader.store_name} lidera en ingresos con ${formatCurrency(revenueLeader.revenue_usd, 'USD')}`,
143
+ reason: revenueLeader.transactions > transactionLeader.transactions * 0.9
144
+ ? 'Mayor volumen de transacciones'
145
+ : 'Ticket promedio más alto',
146
+ recommendation: `Las otras tiendas pueden estudiar el mix de productos de ${revenueLeader.store_name} para mejorar.`,
147
+ });
148
+
149
+ if (ticketLeader.store_code !== revenueLeader.store_code) {
150
+ crossInsights.push({
151
+ finding: `${ticketLeader.store_name} tiene el mejor ticket promedio (${formatCurrency(ticketLeader.avg_ticket_usd, 'USD')})`,
152
+ reason: 'Estrategia de upselling más efectiva o mix de productos de mayor valor',
153
+ recommendation: `Replicar estrategias de venta de ${ticketLeader.store_name} en las demás tiendas.`,
154
+ });
155
+ }
156
+
157
+ if (showAll || requestedMetrics.includes('top_products')) {
158
+ const topProductSets = {};
159
+ for (const [code, store] of Object.entries(comparison)) {
160
+ if (store.top_products) {
161
+ topProductSets[code] = new Set(store.top_products.slice(0, 5).map((p) => p.product));
162
+ }
163
+ }
164
+
165
+ const codes = Object.keys(topProductSets);
166
+ for (let i = 0; i < codes.length; i++) {
167
+ for (let j = i + 1; j < codes.length; j++) {
168
+ const set1 = topProductSets[codes[i]];
169
+ const set2 = topProductSets[codes[j]];
170
+ if (set1 && set2) {
171
+ const unique1 = [...set1].filter((p) => !set2.has(p));
172
+ const unique2 = [...set2].filter((p) => !set1.has(p));
173
+ if (unique1.length > 0 || unique2.length > 0) {
174
+ crossInsights.push({
175
+ finding: `Productos top diferentes entre ${comparison[codes[i]].store_name} y ${comparison[codes[j]].store_name}`,
176
+ reason: `${comparison[codes[i]].store_name} vende más: ${unique1.join(', ') || 'N/A'}. ${comparison[codes[j]].store_name} vende más: ${unique2.join(', ') || 'N/A'}`,
177
+ recommendation: 'Evaluar si productos exitosos en una tienda pueden promoverse en las otras.',
178
+ });
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ return {
187
+ period: { start: dateRange.start, end: dateRange.end, label: period },
188
+ comparison,
189
+ store_insights: storeInsights,
190
+ cross_store_insights: crossInsights,
191
+ };
192
+ }
@@ -0,0 +1,82 @@
1
+ export function aggregateByField(lines, field) {
2
+ const map = {};
3
+ for (const line of lines) {
4
+ const key = line[field] || 'Sin categoría';
5
+ if (!map[key]) {
6
+ map[key] = { key, revenue: 0, quantity: 0, cost_ves: 0, cost_usd: 0, count: 0, lines: [] };
7
+ }
8
+ map[key].revenue += line.amount || 0;
9
+ map[key].quantity += line.quantity || 0;
10
+ map[key].cost_ves += (line.unitCostVES || 0) * (line.quantity || 0);
11
+ map[key].cost_usd += (line.unitCostUSD || 0) * (line.quantity || 0);
12
+ map[key].count += 1;
13
+ map[key].lines.push(line);
14
+ }
15
+
16
+ return Object.values(map).map((item) => ({
17
+ ...item,
18
+ cost: item.cost_ves,
19
+ margin: item.revenue - item.cost_ves,
20
+ margin_pct: item.revenue > 0 ? ((item.revenue - item.cost_ves) / item.revenue) * 100 : 0,
21
+ }));
22
+ }
23
+
24
+ export function aggregateByDate(lines) {
25
+ const map = {};
26
+ for (const line of lines) {
27
+ const date = line.postingDate || line.invoiceDate || 'unknown';
28
+ if (!map[date]) {
29
+ map[date] = { date, revenue: 0, quantity: 0, transactions: new Set() };
30
+ }
31
+ map[date].revenue += line.amount || 0;
32
+ map[date].quantity += line.quantity || 0;
33
+ if (line.documentNo) map[date].transactions.add(line.documentNo);
34
+ }
35
+
36
+ return Object.values(map)
37
+ .map((item) => ({
38
+ date: item.date,
39
+ revenue: item.revenue,
40
+ quantity: item.quantity,
41
+ transactions: item.transactions.size,
42
+ }))
43
+ .sort((a, b) => a.date.localeCompare(b.date));
44
+ }
45
+
46
+ export function calculateSummary(invoices, lines, exchangeRate = null, glRevenue = null) {
47
+ const totalTransactions = invoices.length;
48
+ const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
49
+ const totalCostUSD = lines.reduce((sum, l) => sum + (l.unitCostUSD || 0) * (l.quantity || 0), 0);
50
+
51
+ const totalRevenueUSD = glRevenue ? glRevenue.total_usd : null;
52
+ const avgTicketUSD = totalRevenueUSD !== null && totalTransactions > 0
53
+ ? totalRevenueUSD / totalTransactions : null;
54
+ const totalMarginUSD = totalRevenueUSD !== null ? totalRevenueUSD - totalCostUSD : null;
55
+ const marginPct = totalRevenueUSD && totalRevenueUSD !== 0
56
+ ? (totalMarginUSD / totalRevenueUSD) * 100 : 0;
57
+
58
+ return {
59
+ total_revenue_usd: totalRevenueUSD !== null ? Math.round(totalRevenueUSD * 100) / 100 : null,
60
+ total_transactions: totalTransactions,
61
+ total_quantity: totalQuantity,
62
+ avg_ticket_usd: avgTicketUSD !== null ? Math.round(avgTicketUSD * 100) / 100 : null,
63
+ total_cost_usd: Math.round(totalCostUSD * 100) / 100,
64
+ total_margin_usd: totalMarginUSD !== null ? Math.round(totalMarginUSD * 100) / 100 : null,
65
+ margin_pct: Math.round(marginPct * 10) / 10,
66
+ revenue_breakdown: glRevenue ? glRevenue.breakdown : null,
67
+ currency: 'USD',
68
+ };
69
+ }
70
+
71
+ export function calculateGrowth(current, previous) {
72
+ if (!previous || previous === 0) return null;
73
+ return ((current - previous) / Math.abs(previous)) * 100;
74
+ }
75
+
76
+ export function topN(arr, field, n = 10) {
77
+ return [...arr].sort((a, b) => (b[field] || 0) - (a[field] || 0)).slice(0, n);
78
+ }
79
+
80
+ export function bottomN(arr, field, n = 10) {
81
+ return [...arr].sort((a, b) => (a[field] || 0) - (b[field] || 0)).slice(0, n);
82
+ }
@@ -0,0 +1,167 @@
1
+ import { formatCurrency, formatPercent } from './date-helper.js';
2
+
3
+ function fmtUSD(amount) {
4
+ if (amount === null || amount === undefined) return 'N/A';
5
+ return formatCurrency(amount, 'USD');
6
+ }
7
+
8
+ export function generateSalesInsights(summary, byProduct, byStore, trends) {
9
+ const insights = [];
10
+
11
+ if (trends.revenuGrowth !== null) {
12
+ if (trends.revenuGrowth > 10) {
13
+ insights.push({
14
+ type: 'success',
15
+ description: `Ingresos crecieron ${formatPercent(trends.revenuGrowth)} vs periodo anterior`,
16
+ recommendation: 'Mantener la estrategia actual. Identificar qué productos impulsan el crecimiento para reforzar.',
17
+ potential_impact: `Si se mantiene esta tendencia, ingresos podrían alcanzar ${fmtUSD(summary.total_revenue_usd * (1 + trends.revenuGrowth / 100))} el próximo periodo.`,
18
+ });
19
+ } else if (trends.revenuGrowth < -10) {
20
+ insights.push({
21
+ type: 'warning',
22
+ description: `Ingresos cayeron ${formatPercent(trends.revenuGrowth)} vs periodo anterior`,
23
+ recommendation: 'Revisar cambios en menú, precios o factores externos. Considerar promociones para recuperar volumen.',
24
+ potential_impact: `Pérdida potencial de ${fmtUSD(Math.abs(summary.total_revenue_usd * trends.revenuGrowth / 100))} si la tendencia continúa.`,
25
+ });
26
+ }
27
+ }
28
+
29
+ if (trends.ticketGrowth !== null && Math.abs(trends.ticketGrowth) > 5) {
30
+ insights.push({
31
+ type: trends.ticketGrowth > 0 ? 'success' : 'warning',
32
+ description: `Ticket promedio ${trends.ticketGrowth > 0 ? 'aumentó' : 'disminuyó'} ${formatPercent(trends.ticketGrowth)}`,
33
+ recommendation: trends.ticketGrowth > 0
34
+ ? 'Buen trabajo en upselling. Identificar qué combos o productos contribuyen.'
35
+ : 'Considerar estrategias de upselling: combos, sugerencias de acompañamientos, promociones por monto mínimo.',
36
+ potential_impact: `Ticket actual: ${fmtUSD(summary.avg_ticket_usd)}`,
37
+ });
38
+ }
39
+
40
+ if (byProduct && byProduct.length > 0) {
41
+ const highMargin = byProduct.filter((p) => p.margin_pct > 50).slice(0, 3);
42
+ if (highMargin.length > 0) {
43
+ insights.push({
44
+ type: 'opportunity',
45
+ description: `${highMargin.length} productos con margen >50%: ${highMargin.map((p) => p.key).join(', ')}`,
46
+ recommendation: 'Promover estos productos con mayor visibilidad en menú y sugerencias del personal.',
47
+ potential_impact: `Un aumento del 20% en ventas de estos productos podría generar ${fmtUSD(highMargin.reduce((s, p) => s + p.margin * 0.2, 0))} adicionales en margen.`,
48
+ });
49
+ }
50
+
51
+ const lowMargin = byProduct.filter((p) => p.margin_pct < 20 && p.revenue > 0);
52
+ if (lowMargin.length > 0) {
53
+ insights.push({
54
+ type: 'warning',
55
+ description: `${lowMargin.length} productos con margen <20%`,
56
+ recommendation: 'Revisar costos de estos productos. Considerar ajustar precios o renegociar con proveedores.',
57
+ potential_impact: `Mejorar margen de estos productos al 30% generaría ${fmtUSD(lowMargin.reduce((s, p) => s + p.revenue * 0.1, 0))} adicionales.`,
58
+ });
59
+ }
60
+ }
61
+
62
+ if (byStore && byStore.length > 1) {
63
+ const sorted = [...byStore].sort((a, b) => (b.total_revenue_usd || 0) - (a.total_revenue_usd || 0));
64
+ const best = sorted[0];
65
+ const worst = sorted[sorted.length - 1];
66
+ const bestRev = best.total_revenue_usd || 0;
67
+ const worstRev = worst.total_revenue_usd || 0;
68
+ const gap = bestRev - worstRev;
69
+
70
+ if (gap > 0 && worstRev > 0) {
71
+ const gapPct = (gap / worstRev) * 100;
72
+ if (gapPct > 30) {
73
+ insights.push({
74
+ type: 'opportunity',
75
+ description: `${best.store_name} supera a ${worst.store_name} por ${formatPercent(gapPct)} en ingresos`,
76
+ recommendation: `Analizar qué hace diferente ${best.store_name}: productos estrella, horarios pico, flujo de clientes. Replicar estrategias exitosas.`,
77
+ potential_impact: `Si ${worst.store_name} alcanza el 80% del nivel de ${best.store_name}, ingresos adicionales de ${fmtUSD(gap * 0.5)}.`,
78
+ });
79
+ }
80
+ }
81
+ }
82
+
83
+ if (trends.transactionGrowth !== null && trends.transactionGrowth < -15) {
84
+ insights.push({
85
+ type: 'warning',
86
+ description: `Número de transacciones cayó ${formatPercent(trends.transactionGrowth)}`,
87
+ recommendation: 'Posible problema de tráfico. Revisar horarios, promociones de atracción, delivery, redes sociales.',
88
+ potential_impact: `Recuperar el volumen anterior representaría ${fmtUSD(Math.abs(summary.total_transactions * trends.transactionGrowth / 100) * (summary.avg_ticket_usd || 0))} en ingresos.`,
89
+ });
90
+ }
91
+
92
+ if (insights.length === 0) {
93
+ insights.push({
94
+ type: 'success',
95
+ description: 'Rendimiento estable en el periodo analizado',
96
+ recommendation: 'Continuar monitoreando métricas clave. Buscar oportunidades de crecimiento incremental.',
97
+ potential_impact: 'N/A',
98
+ });
99
+ }
100
+
101
+ return insights;
102
+ }
103
+
104
+ export function generateProductInsights(product, allProducts) {
105
+ const insights = [];
106
+
107
+ if (product.margin_pct > 60) {
108
+ insights.push(`Alto margen (${product.margin_pct.toFixed(1)}%) - Priorizar en promociones`);
109
+ }
110
+ if (product.margin_pct < 15 && product.revenue > 0) {
111
+ insights.push(`Margen bajo (${product.margin_pct.toFixed(1)}%) - Revisar precios o costos`);
112
+ }
113
+ if (product.growth !== null && product.growth > 20) {
114
+ insights.push(`Crecimiento fuerte (${formatPercent(product.growth)}) - Producto en tendencia`);
115
+ }
116
+ if (product.growth !== null && product.growth < -20) {
117
+ insights.push(`Caída significativa (${formatPercent(product.growth)}) - Investigar causas`);
118
+ }
119
+
120
+ const revenueRank = allProducts.filter((p) => p.revenue > product.revenue).length + 1;
121
+ if (revenueRank <= 5) {
122
+ insights.push(`Top ${revenueRank} en ingresos`);
123
+ }
124
+
125
+ return insights;
126
+ }
127
+
128
+ export function generateStoreInsights(storeData, allStoresData) {
129
+ const insights = [];
130
+ const storeNames = Object.keys(allStoresData);
131
+
132
+ for (const storeName of storeNames) {
133
+ const store = allStoresData[storeName];
134
+ const others = storeNames.filter((s) => s !== storeName).map((s) => allStoresData[s]);
135
+
136
+ const avgRevenue = others.reduce((s, o) => s + (o.summary.total_revenue_usd || 0), 0) / others.length;
137
+ const avgTicket = others.reduce((s, o) => s + (o.summary.avg_ticket_usd || 0), 0) / others.length;
138
+
139
+ const strengths = [];
140
+ const opportunities = [];
141
+
142
+ const storeRev = store.summary.total_revenue_usd || 0;
143
+ const storeTicket = store.summary.avg_ticket_usd || 0;
144
+
145
+ if (storeRev > avgRevenue * 1.15) {
146
+ strengths.push(`Ingresos ${formatPercent(((storeRev - avgRevenue) / avgRevenue) * 100)} por encima del promedio`);
147
+ } else if (storeRev < avgRevenue * 0.85) {
148
+ opportunities.push(`Ingresos ${formatPercent(((storeRev - avgRevenue) / avgRevenue) * 100)} por debajo del promedio`);
149
+ }
150
+
151
+ if (storeTicket > avgTicket * 1.1) {
152
+ strengths.push(`Ticket promedio superior: ${formatCurrency(storeTicket, 'USD')} vs ${formatCurrency(avgTicket, 'USD')} promedio`);
153
+ } else if (storeTicket < avgTicket * 0.9) {
154
+ opportunities.push(`Ticket promedio bajo: ${formatCurrency(storeTicket, 'USD')} vs ${formatCurrency(avgTicket, 'USD')} promedio. Implementar estrategias de upselling.`);
155
+ }
156
+
157
+ if (store.summary.margin_pct > 40) {
158
+ strengths.push(`Margen saludable: ${store.summary.margin_pct.toFixed(1)}%`);
159
+ } else if (store.summary.margin_pct < 25) {
160
+ opportunities.push(`Margen bajo: ${store.summary.margin_pct.toFixed(1)}%. Revisar mix de productos y costos.`);
161
+ }
162
+
163
+ insights.push({ store: storeName, strengths, opportunities });
164
+ }
165
+
166
+ return insights;
167
+ }