@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.
- package/CHANGELOG.md +137 -0
- package/config/bank-gl-map.json +93 -0
- package/lib/bc-client.js +143 -0
- package/package.json +1 -1
- package/scripts/generate-pending-invoices-xlsx.py +378 -0
- package/scripts/generate-pending-sales-invoices-xlsx.py +372 -0
- package/scripts/report-pending-invoices.js +250 -0
- package/scripts/report-pending-sales-invoices.js +253 -0
- package/server.js +61 -0
- package/tools/auditoria/bank-ledger-entries.js +104 -0
- package/tools/auditoria/gl-account-entries.js +75 -0
- package/tools/auditoria/pm-receipts.js +182 -0
- package/tools/auditoria/reconcile-pos-sales.js +39 -16
- package/tools/cobranzas/customer-list.js +63 -0
- package/tools/inventario/index.js +4 -0
- package/tools/inventario/inventory-by-location.js +212 -0
- package/tools/inventario/inventory-change.js +386 -0
- package/tools/inventario/inventory-levels.js +214 -0
- package/tools/inventario/item-card.js +1 -50
- package/tools/inventario/item-cost-trend.js +185 -0
- package/tools/inventario/shared/cost-calculator.js +64 -0
- package/tools/ventas/index.js +3 -0
- package/tools/ventas/product-performance.js +182 -0
- package/tools/ventas/sales-analysis.js +211 -0
- package/tools/ventas/sales-store-comparison.js +192 -0
- package/utils/sales-aggregation.js +82 -0
- package/utils/sales-insights.js +167 -0
|
@@ -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,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
|
+
}
|