@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,185 @@
1
+ import { resolveStores } from '../../config/company-config.js';
2
+ import { logger } from '../../utils/logger.js';
3
+
4
+ export const itemCostTrendTool = {
5
+ name: 'get_item_cost_trend',
6
+ description:
7
+ 'Tendencia de costo de ítems: compara weighted avg inbound cost de últimas 2 semanas vs últimas 4 semanas. Detecta si el costo está subiendo, bajando, o estable. Usa entradas de Assembly Output, Purchase, y Positive Adjustment — excluye fechas futuras.',
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {
11
+ store: {
12
+ type: 'string',
13
+ enum: ['FQ01', 'FQ28', 'FQ88'],
14
+ description: 'Tienda a consultar.',
15
+ },
16
+ item_number: {
17
+ type: 'string',
18
+ description: 'Número de ítem exacto (e.g. FQ0850). Si se omite, analiza todos los ítems con movimiento reciente.',
19
+ },
20
+ item_category: {
21
+ type: 'string',
22
+ description: 'Filtrar por itemCategoryCode (e.g. TERMINADO, INSUMO).',
23
+ },
24
+ period_days: {
25
+ type: 'number',
26
+ description: 'Período base en días (default 28). El período reciente es la mitad.',
27
+ },
28
+ },
29
+ required: ['store'],
30
+ },
31
+ };
32
+
33
+ export async function handleItemCostTrend(bcClient, args) {
34
+ const storeParam = args.store;
35
+ if (!storeParam) throw new Error('Parámetro requerido: store');
36
+
37
+ const stores = resolveStores([storeParam]);
38
+ const store = stores[0];
39
+ const companyId = store.companyId;
40
+
41
+ const periodDays = args.period_days || 28;
42
+ const recentDays = Math.floor(periodDays / 2);
43
+
44
+ const today = new Date();
45
+ const todayStr = today.toISOString().split('T')[0];
46
+ const baseStart = new Date(today);
47
+ baseStart.setDate(baseStart.getDate() - periodDays);
48
+ const baseStartStr = baseStart.toISOString().split('T')[0];
49
+ const recentStart = new Date(today);
50
+ recentStart.setDate(recentStart.getDate() - recentDays);
51
+ const recentStartStr = recentStart.toISOString().split('T')[0];
52
+
53
+ const typeFilter = `(entryType eq 'Purchase' or entryType eq 'Assembly_x0020_Output' or entryType eq 'Positive_x0020_Adjmt_x002E_')`;
54
+
55
+ // Build filter for ledger entries
56
+ const filters = [
57
+ typeFilter,
58
+ `postingDate ge ${baseStartStr}`,
59
+ `postingDate le ${todayStr}`,
60
+ ];
61
+ if (args.item_number) {
62
+ filters.push(`itemNumber eq '${args.item_number}'`);
63
+ }
64
+
65
+ const url = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
66
+ $filter: filters.join(' and '),
67
+ $select: 'itemNumber,quantity,costAmountActual,postingDate,entryType,documentNumber',
68
+ $orderby: 'postingDate desc',
69
+ });
70
+
71
+ let entries = await bcClient.apiCallAllPages(url);
72
+ logger.info(`${store.code}: ${entries.length} inbound entries in ${periodDays}-day window`);
73
+
74
+ // Filter by category if requested (need to fetch items first)
75
+ if (args.item_category) {
76
+ const itemUrl = bcClient.buildApiUrl(companyId, 'items', {
77
+ $filter: `itemCategoryCode eq '${args.item_category}'`,
78
+ $select: 'number',
79
+ });
80
+ const categoryItems = await bcClient.apiCallAllPages(itemUrl);
81
+ const categorySet = new Set(categoryItems.map((i) => i.number));
82
+ entries = entries.filter((e) => categorySet.has(e.itemNumber));
83
+ logger.info(`Filtered to ${entries.length} entries for category ${args.item_category}`);
84
+ }
85
+
86
+ // Group entries by item
87
+ const byItem = {};
88
+ for (const entry of entries) {
89
+ if (!byItem[entry.itemNumber]) byItem[entry.itemNumber] = [];
90
+ byItem[entry.itemNumber].push(entry);
91
+ }
92
+
93
+ // Calculate trend per item
94
+ const trends = [];
95
+ for (const [itemNo, itemEntries] of Object.entries(byItem)) {
96
+ const base = itemEntries.filter((e) => e.quantity > 0);
97
+ const recent = base.filter((e) => e.postingDate >= recentStartStr);
98
+ const older = base.filter((e) => e.postingDate < recentStartStr);
99
+
100
+ const avg4w = weightedAvg(base);
101
+ const avg2w = weightedAvg(recent);
102
+ const avgOlder = weightedAvg(older);
103
+
104
+ if (avg4w === null) continue;
105
+
106
+ let trendPct = 0;
107
+ let direction = 'stable';
108
+ if (avg2w !== null && avg4w > 0) {
109
+ trendPct = round2(((avg2w - avg4w) / avg4w) * 100);
110
+ if (trendPct > 5) direction = 'up';
111
+ else if (trendPct < -5) direction = 'down';
112
+ else direction = 'stable';
113
+ }
114
+
115
+ // Stale cost detection
116
+ const latestDate = base.length > 0 ? base[0].postingDate : null;
117
+ const daysSinceLastInbound = latestDate
118
+ ? Math.floor((today - new Date(latestDate)) / (1000 * 60 * 60 * 24))
119
+ : null;
120
+ const staleCost = daysSinceLastInbound !== null && daysSinceLastInbound > 30;
121
+
122
+ trends.push({
123
+ item_number: itemNo,
124
+ avg_cost_4w: avg4w !== null ? round5(avg4w) : null,
125
+ avg_cost_2w: avg2w !== null ? round5(avg2w) : null,
126
+ trend_pct: trendPct,
127
+ direction,
128
+ inbound_count_4w: base.length,
129
+ inbound_count_2w: recent.length,
130
+ latest_inbound_date: latestDate,
131
+ days_since_last_inbound: daysSinceLastInbound,
132
+ stale_cost: staleCost,
133
+ entries_detail: base.slice(0, 5).map((e) => ({
134
+ date: e.postingDate,
135
+ type: e.entryType,
136
+ qty: e.quantity,
137
+ cost: round5(e.costAmountActual),
138
+ unit_cost: e.quantity > 0 ? round5(e.costAmountActual / e.quantity) : 0,
139
+ document: e.documentNumber,
140
+ })),
141
+ });
142
+ }
143
+
144
+ // Sort: red flags first (up/down), then stable
145
+ trends.sort((a, b) => {
146
+ const severity = (t) => (t.direction === 'up' ? 0 : t.direction === 'down' ? 1 : 2);
147
+ return severity(a) - severity(b) || Math.abs(b.trend_pct) - Math.abs(a.trend_pct);
148
+ });
149
+
150
+ const summary = {
151
+ up: trends.filter((t) => t.direction === 'up').length,
152
+ down: trends.filter((t) => t.direction === 'down').length,
153
+ stable: trends.filter((t) => t.direction === 'stable').length,
154
+ stale: trends.filter((t) => t.stale_cost).length,
155
+ };
156
+
157
+ return {
158
+ store: store.code,
159
+ store_name: store.name,
160
+ period: {
161
+ base: `${baseStartStr} to ${todayStr}`,
162
+ recent: `${recentStartStr} to ${todayStr}`,
163
+ base_days: periodDays,
164
+ recent_days: recentDays,
165
+ },
166
+ summary,
167
+ note: 'trend_pct = (avg_2w - avg_4w) / avg_4w * 100. direction: up (>+5%), down (<-5%), stable (±5%). stale_cost = no inbound entries in 30+ days.',
168
+ items: trends,
169
+ };
170
+ }
171
+
172
+ function weightedAvg(entries) {
173
+ const totalQty = entries.reduce((s, e) => s + (e.quantity || 0), 0);
174
+ const totalCost = entries.reduce((s, e) => s + (e.costAmountActual || 0), 0);
175
+ if (totalQty <= 0) return null;
176
+ return totalCost / totalQty;
177
+ }
178
+
179
+ function round2(n) {
180
+ return Math.round(n * 100) / 100;
181
+ }
182
+
183
+ function round5(n) {
184
+ return Math.round(n * 100000) / 100000;
185
+ }
@@ -0,0 +1,64 @@
1
+ import { logger } from '../../../utils/logger.js';
2
+
3
+ /**
4
+ * Returns true if the category represents real inventory (not sales/combo items).
5
+ * Excludes FQ-*, FQ_* and FQVENTA categories.
6
+ */
7
+ export function isInventoryCategory(cat) {
8
+ const upper = (cat || '').toUpperCase();
9
+ return !upper.startsWith('FQ-') && !upper.startsWith('FQ_') && upper !== 'FQVENTA';
10
+ }
11
+
12
+ /**
13
+ * Fetch the most recent inbound entries per item and compute current unit cost.
14
+ * Uses the last 3 Assembly Output / Purchase / Positive Adjustment entries.
15
+ * Excludes future-dated entries and Sales Returns.
16
+ *
17
+ * @returns {Object} Map of itemNumber → { unit_cost, source, sample_size, latest_date }
18
+ */
19
+ export async function getCalculatedCosts(bcClient, companyId, itemNumbers) {
20
+ if (itemNumbers.length === 0) return {};
21
+
22
+ const results = {};
23
+ const todayStr = new Date().toISOString().split('T')[0];
24
+ const typeFilter = `(entryType eq 'Purchase' or entryType eq 'Assembly_x0020_Output' or entryType eq 'Positive_x0020_Adjmt_x002E_')`;
25
+
26
+ const fetches = itemNumbers.map(async (itemNo) => {
27
+ const filter = `itemNumber eq '${itemNo}' and ${typeFilter} and postingDate le ${todayStr}`;
28
+ const url = bcClient.buildApiUrl(companyId, 'itemLedgerEntries', {
29
+ $filter: filter,
30
+ $select: 'itemNumber,quantity,costAmountActual,postingDate',
31
+ $orderby: 'postingDate desc',
32
+ $top: '10',
33
+ });
34
+ try {
35
+ const entries = await bcClient.apiCall(url);
36
+ if (entries.length === 0) return;
37
+
38
+ const recent = entries.slice(0, 3);
39
+ const totalQty = recent.reduce((s, e) => s + (e.quantity || 0), 0);
40
+ const totalCost = recent.reduce((s, e) => s + (e.costAmountActual || 0), 0);
41
+ if (totalQty > 0) {
42
+ results[itemNo] = {
43
+ unit_cost: round5(totalCost / totalQty),
44
+ source: 'last_3_inbound',
45
+ sample_size: recent.length,
46
+ latest_date: recent[0].postingDate,
47
+ };
48
+ }
49
+ } catch (err) {
50
+ logger.info(`Cost calc failed for ${itemNo}: ${err.message}`);
51
+ }
52
+ });
53
+
54
+ // Run in batches of 5 to avoid rate limiting
55
+ for (let i = 0; i < fetches.length; i += 5) {
56
+ await Promise.all(fetches.slice(i, i + 5));
57
+ }
58
+
59
+ return results;
60
+ }
61
+
62
+ function round5(n) {
63
+ return Math.round(n * 100000) / 100000;
64
+ }
@@ -0,0 +1,3 @@
1
+ export { salesAnalysisTool, handleSalesAnalysis } from './sales-analysis.js';
2
+ export { productPerformanceTool, handleProductPerformance } from './product-performance.js';
3
+ export { salesStoreComparisonTool, handleSalesStoreComparison } from './sales-store-comparison.js';
@@ -0,0 +1,182 @@
1
+ import { getDateRange, getPreviousPeriodRange, formatPercent } from '../../utils/date-helper.js';
2
+ import { aggregateByField, calculateGrowth } from '../../utils/sales-aggregation.js';
3
+ import { generateProductInsights } from '../../utils/sales-insights.js';
4
+ import { logger } from '../../utils/logger.js';
5
+
6
+ export const productPerformanceTool = {
7
+ name: 'get_product_performance',
8
+ description:
9
+ 'Análisis detallado de rendimiento de productos de Full Queso. Identifica top performers, underperformers y oportunidades. Incluye desglose por tienda, crecimiento vs periodo anterior y margen de utilidad. Montos en USD.',
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 análisis. 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
+ stores: {
32
+ type: 'array',
33
+ items: { type: 'string', enum: ['FQ01', 'FQ28', 'FQ88', 'all'] },
34
+ description: 'Tiendas a analizar',
35
+ default: ['all'],
36
+ },
37
+ sort_by: {
38
+ type: 'string',
39
+ enum: ['revenue', 'margin', 'quantity', 'growth'],
40
+ description: 'Criterio de ordenamiento',
41
+ default: 'revenue',
42
+ },
43
+ top_n: {
44
+ type: 'number',
45
+ description: 'Cantidad de productos a retornar',
46
+ default: 20,
47
+ },
48
+ },
49
+ },
50
+ };
51
+
52
+ export async function handleProductPerformance(bcClient, args) {
53
+ const period = args.period || 'last_30_days';
54
+ const stores = args.stores || ['all'];
55
+ const sortBy = args.sort_by || 'revenue';
56
+ const topNCount = args.top_n || 20;
57
+
58
+ const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
59
+ const prevRange = getPreviousPeriodRange(dateRange.start, dateRange.end);
60
+
61
+ logger.info(`Product performance: ${dateRange.start} to ${dateRange.end}`);
62
+
63
+ const allStoresData = await bcClient.getAllSalesStoreData(stores, dateRange.start, dateRange.end);
64
+
65
+ let prevStoresData = null;
66
+ try {
67
+ prevStoresData = await bcClient.getAllSalesStoreData(stores, prevRange.start, prevRange.end);
68
+ } catch (err) {
69
+ logger.warn('Could not fetch previous period for growth:', err.message);
70
+ }
71
+
72
+ // Aggregate current period by product across all stores
73
+ let allLines = [];
74
+ const linesByStore = {};
75
+
76
+ for (const [code, data] of Object.entries(allStoresData)) {
77
+ linesByStore[code] = data.lines;
78
+ allLines = allLines.concat(
79
+ data.lines.map((l) => ({ ...l, storeCode: code }))
80
+ );
81
+ }
82
+
83
+ const byProduct = aggregateByField(allLines, 'displayName');
84
+
85
+ // Aggregate previous period by product for growth
86
+ const prevByProduct = {};
87
+ if (prevStoresData) {
88
+ let prevLines = [];
89
+ for (const data of Object.values(prevStoresData)) {
90
+ prevLines = prevLines.concat(data.lines);
91
+ }
92
+ const prevAgg = aggregateByField(prevLines, 'displayName');
93
+ for (const p of prevAgg) {
94
+ prevByProduct[p.key] = p;
95
+ }
96
+ }
97
+
98
+ // Enrich products with growth and per-store breakdown
99
+ const enrichedProducts = byProduct.map((product) => {
100
+ const prev = prevByProduct[product.key];
101
+ const growth = prev ? calculateGrowth(product.revenue, prev.revenue) : null;
102
+
103
+ const storeBreakdown = {};
104
+ for (const [code, lines] of Object.entries(linesByStore)) {
105
+ const storeLines = lines.filter((l) => l.displayName === product.key);
106
+ const storeRevenue = storeLines.reduce((s, l) => s + (l.amount || 0), 0);
107
+ const storeQty = storeLines.reduce((s, l) => s + (l.quantity || 0), 0);
108
+ if (storeRevenue > 0 || storeQty > 0) {
109
+ storeBreakdown[code] = {
110
+ revenue: Math.round(storeRevenue * 100) / 100,
111
+ quantity: storeQty,
112
+ };
113
+ }
114
+ }
115
+
116
+ return { ...product, growth, stores: storeBreakdown };
117
+ });
118
+
119
+ // Sort
120
+ const sortField = sortBy === 'growth' ? 'growth' : sortBy === 'margin' ? 'margin_pct' : sortBy;
121
+ const sorted = [...enrichedProducts].sort((a, b) => {
122
+ const aVal = a[sortField] ?? -Infinity;
123
+ const bVal = b[sortField] ?? -Infinity;
124
+ return bVal - aVal;
125
+ });
126
+
127
+ // Top performers
128
+ const totalRevenue = enrichedProducts.reduce((s, p) => s + p.revenue, 0) || 1;
129
+ const topPerformers = sorted.slice(0, topNCount).map((p) => ({
130
+ product: p.key,
131
+ item_number: p.lines[0]?.itemNumber || '',
132
+ revenue: Math.round(p.revenue * 100) / 100,
133
+ revenue_pct: Math.round((p.revenue / totalRevenue) * 1000) / 10,
134
+ quantity: p.quantity,
135
+ cost_usd: Math.round(p.cost_usd * 100) / 100,
136
+ margin_pct: Math.round(p.margin_pct * 10) / 10,
137
+ growth_vs_previous: p.growth !== null ? Math.round(p.growth * 10) / 10 : null,
138
+ growth_label: p.growth !== null ? formatPercent(p.growth) : 'Sin datos previos',
139
+ stores: p.stores,
140
+ insights: generateProductInsights(p, enrichedProducts),
141
+ }));
142
+
143
+ // Underperformers
144
+ const underperformers = enrichedProducts
145
+ .filter((p) => p.revenue > 0 && ((p.growth !== null && p.growth < -10) || p.margin_pct < 15))
146
+ .sort((a, b) => (a.growth ?? 0) - (b.growth ?? 0))
147
+ .slice(0, 10)
148
+ .map((p) => ({
149
+ product: p.key,
150
+ item_number: p.lines[0]?.itemNumber || '',
151
+ revenue: Math.round(p.revenue * 100) / 100,
152
+ quantity: p.quantity,
153
+ margin_pct: Math.round(p.margin_pct * 10) / 10,
154
+ growth_vs_previous: p.growth !== null ? Math.round(p.growth * 10) / 10 : null,
155
+ issue: p.growth !== null && p.growth < -10
156
+ ? `Caída de ${formatPercent(p.growth)} en ventas`
157
+ : `Margen bajo: ${p.margin_pct.toFixed(1)}%`,
158
+ }));
159
+
160
+ // Opportunities
161
+ const opportunities = enrichedProducts
162
+ .filter((p) => p.margin_pct > 40 && p.revenue > 0)
163
+ .sort((a, b) => b.margin_pct - a.margin_pct)
164
+ .slice(0, 10)
165
+ .map((p) => ({
166
+ product: p.key,
167
+ item_number: p.lines[0]?.itemNumber || '',
168
+ current_revenue: Math.round(p.revenue * 100) / 100,
169
+ margin_pct: Math.round(p.margin_pct * 10) / 10,
170
+ recommendation: `Promover más agresivamente. Margen de ${p.margin_pct.toFixed(1)}% permite promociones.`,
171
+ potential_additional_revenue: Math.round(p.revenue * 0.2 * 100) / 100,
172
+ }));
173
+
174
+ return {
175
+ period: { start: dateRange.start, end: dateRange.end, label: period },
176
+ sorted_by: sortBy,
177
+ total_products: enrichedProducts.length,
178
+ top_performers: topPerformers,
179
+ underperformers,
180
+ opportunities,
181
+ };
182
+ }
@@ -0,0 +1,211 @@
1
+ import { getDateRange, getPreviousPeriodRange, formatPercent } from '../../utils/date-helper.js';
2
+ import { aggregateByField, aggregateByDate, calculateSummary, calculateGrowth, topN } from '../../utils/sales-aggregation.js';
3
+ import { generateSalesInsights } from '../../utils/sales-insights.js';
4
+ import { logger } from '../../utils/logger.js';
5
+
6
+ export const salesAnalysisTool = {
7
+ name: 'get_sales_analysis',
8
+ description:
9
+ 'Análisis multidimensional de ventas de Full Queso. Permite analizar ventas por producto, categoría, tienda y periodo de tiempo. Retorna métricas clave (ingresos, transacciones, ticket promedio, margen) e insights accionables con recomendaciones específicas. Montos en USD.',
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 análisis. 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
+ stores: {
32
+ type: 'array',
33
+ items: { type: 'string', enum: ['FQ01', 'FQ28', 'FQ88', 'all'] },
34
+ description: 'Tiendas a analizar. Use ["all"] para todas.',
35
+ default: ['all'],
36
+ },
37
+ dimensions: {
38
+ type: 'array',
39
+ items: { type: 'string', enum: ['product', 'category', 'customer', 'time'] },
40
+ description: 'Dimensiones de agrupación',
41
+ default: ['product'],
42
+ },
43
+ metrics: {
44
+ type: 'array',
45
+ items: { type: 'string', enum: ['revenue', 'quantity', 'margin', 'avg_ticket'] },
46
+ description: 'Métricas a calcular',
47
+ default: ['revenue', 'quantity'],
48
+ },
49
+ },
50
+ },
51
+ };
52
+
53
+ export async function handleSalesAnalysis(bcClient, args) {
54
+ const period = args.period || 'last_30_days';
55
+ const stores = args.stores || ['all'];
56
+ const dimensions = args.dimensions || ['product'];
57
+
58
+ const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
59
+ const prevRange = getPreviousPeriodRange(dateRange.start, dateRange.end);
60
+
61
+ logger.info(`Sales analysis: ${dateRange.start} to ${dateRange.end}, stores: ${stores.join(',')}`);
62
+
63
+ const allStoresData = await bcClient.getAllSalesStoreData(stores, dateRange.start, dateRange.end);
64
+
65
+ // Extract exchange rate and merge GL revenue across stores
66
+ const firstStoreData = Object.values(allStoresData)[0];
67
+ const exchangeRate = firstStoreData?.exchangeRate || null;
68
+
69
+ const mergedGLRevenue = { total_usd: 0, breakdown: {} };
70
+ for (const data of Object.values(allStoresData)) {
71
+ if (data.glRevenue) {
72
+ mergedGLRevenue.total_usd += data.glRevenue.total_usd;
73
+ for (const [acct, amt] of Object.entries(data.glRevenue.breakdown)) {
74
+ mergedGLRevenue.breakdown[acct] = (mergedGLRevenue.breakdown[acct] || 0) + amt;
75
+ }
76
+ }
77
+ }
78
+ for (const acct of Object.keys(mergedGLRevenue.breakdown)) {
79
+ mergedGLRevenue.breakdown[acct] = Math.round(mergedGLRevenue.breakdown[acct] * 100) / 100;
80
+ }
81
+ mergedGLRevenue.total_usd = Math.round(mergedGLRevenue.total_usd * 100) / 100;
82
+
83
+ // Fetch previous period data for trend comparison
84
+ let prevStoresData = null;
85
+ try {
86
+ prevStoresData = await bcClient.getAllSalesStoreData(stores, prevRange.start, prevRange.end);
87
+ } catch (err) {
88
+ logger.warn('Could not fetch previous period data for trends:', err.message);
89
+ }
90
+
91
+ // Combine all lines and invoices across stores
92
+ let allInvoices = [];
93
+ let allLines = [];
94
+ const byStoreResults = [];
95
+
96
+ for (const [code, data] of Object.entries(allStoresData)) {
97
+ allInvoices = allInvoices.concat(data.invoices);
98
+ allLines = allLines.concat(
99
+ data.lines.map((l) => ({ ...l, storeCode: code, storeName: data.storeName }))
100
+ );
101
+
102
+ const storeSummary = calculateSummary(data.invoices, data.lines, exchangeRate, data.glRevenue);
103
+ byStoreResults.push({
104
+ store_code: code,
105
+ store_name: data.storeName,
106
+ ...storeSummary,
107
+ });
108
+ }
109
+
110
+ // Global summary
111
+ const summary = calculateSummary(allInvoices, allLines, exchangeRate, mergedGLRevenue);
112
+
113
+ // Previous period summary for trends
114
+ let prevSummary = null;
115
+ if (prevStoresData) {
116
+ let prevInvoices = [];
117
+ let prevLines = [];
118
+ const prevMergedGL = { total_usd: 0, breakdown: {} };
119
+ for (const data of Object.values(prevStoresData)) {
120
+ prevInvoices = prevInvoices.concat(data.invoices);
121
+ prevLines = prevLines.concat(data.lines);
122
+ if (data.glRevenue) {
123
+ prevMergedGL.total_usd += data.glRevenue.total_usd;
124
+ for (const [acct, amt] of Object.entries(data.glRevenue.breakdown)) {
125
+ prevMergedGL.breakdown[acct] = (prevMergedGL.breakdown[acct] || 0) + amt;
126
+ }
127
+ }
128
+ }
129
+ prevMergedGL.total_usd = Math.round(prevMergedGL.total_usd * 100) / 100;
130
+ prevSummary = calculateSummary(prevInvoices, prevLines, exchangeRate, prevMergedGL);
131
+ }
132
+
133
+ // Trends
134
+ const trends = {
135
+ vs_previous_period: {
136
+ period: `${prevRange.start} a ${prevRange.end}`,
137
+ revenue_change: prevSummary ? formatPercent(calculateGrowth(summary.total_revenue_usd, prevSummary.total_revenue_usd) || 0) : 'N/A',
138
+ transactions_change: prevSummary ? formatPercent(calculateGrowth(summary.total_transactions, prevSummary.total_transactions) || 0) : 'N/A',
139
+ avg_ticket_change: prevSummary ? formatPercent(calculateGrowth(summary.avg_ticket_usd, prevSummary.avg_ticket_usd) || 0) : 'N/A',
140
+ },
141
+ revenuGrowth: prevSummary ? calculateGrowth(summary.total_revenue_usd, prevSummary.total_revenue_usd) : null,
142
+ ticketGrowth: prevSummary ? calculateGrowth(summary.avg_ticket_usd, prevSummary.avg_ticket_usd) : null,
143
+ transactionGrowth: prevSummary ? calculateGrowth(summary.total_transactions, prevSummary.total_transactions) : null,
144
+ };
145
+
146
+ // Build dimension breakdowns
147
+ const result = {
148
+ period: { start: dateRange.start, end: dateRange.end, label: period },
149
+ summary,
150
+ by_store: byStoreResults,
151
+ };
152
+
153
+ if (dimensions.includes('product')) {
154
+ const byProduct = aggregateByField(allLines, 'displayName');
155
+ const totalLineRevenue = byProduct.reduce((s, p) => s + p.revenue, 0) || 1;
156
+ result.by_product = topN(byProduct, 'revenue', 20).map((p) => ({
157
+ product: p.key,
158
+ item_number: p.lines[0]?.itemNumber || '',
159
+ revenue: Math.round(p.revenue * 100) / 100,
160
+ revenue_pct: Math.round((p.revenue / totalLineRevenue) * 1000) / 10,
161
+ quantity: p.quantity,
162
+ cost_usd: Math.round(p.cost_usd * 100) / 100,
163
+ margin_pct: Math.round(p.margin_pct * 10) / 10,
164
+ }));
165
+ }
166
+
167
+ if (dimensions.includes('category')) {
168
+ const byCategory = aggregateByField(allLines, 'itemCategoryCode');
169
+ result.by_category = byCategory
170
+ .sort((a, b) => b.revenue - a.revenue)
171
+ .map((c) => ({
172
+ category: c.key,
173
+ revenue: Math.round(c.revenue * 100) / 100,
174
+ quantity: c.quantity,
175
+ margin_pct: Math.round(c.margin_pct * 10) / 10,
176
+ }));
177
+ }
178
+
179
+ if (dimensions.includes('customer')) {
180
+ const byCustomer = {};
181
+ for (const inv of allInvoices) {
182
+ const name = inv.customerName || 'Desconocido';
183
+ if (!byCustomer[name]) {
184
+ byCustomer[name] = { customer: name, revenue: 0, transactions: 0 };
185
+ }
186
+ byCustomer[name].revenue += inv.totalAmountExcludingTax || 0;
187
+ byCustomer[name].transactions += 1;
188
+ }
189
+ result.by_customer = Object.values(byCustomer)
190
+ .sort((a, b) => b.revenue - a.revenue)
191
+ .slice(0, 20)
192
+ .map((c) => ({
193
+ ...c,
194
+ revenue: Math.round(c.revenue * 100) / 100,
195
+ avg_ticket: Math.round((c.revenue / c.transactions) * 100) / 100,
196
+ }));
197
+ }
198
+
199
+ if (dimensions.includes('time')) {
200
+ result.by_date = aggregateByDate(allLines);
201
+ }
202
+
203
+ result.trends = { vs_previous_period: trends.vs_previous_period };
204
+
205
+ const byProductForInsights = dimensions.includes('product')
206
+ ? aggregateByField(allLines, 'displayName')
207
+ : null;
208
+ result.insights = generateSalesInsights(summary, byProductForInsights, byStoreResults, trends);
209
+
210
+ return result;
211
+ }