@fullqueso/mcp-bc-gastos 1.1.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,179 @@
1
+ import { getDateRange } from '../utils/date-helper.js';
2
+ import { getExpenseCategory, getAccountName, EXPENSE_CATEGORIES } from '../config/expense-accounts.js';
3
+ import { resolveStores } from '../config/company-config.js';
4
+ import { round2 } from '../utils/currency-converter.js';
5
+
6
+ export const expenseDetailsTool = {
7
+ name: 'get_expense_details',
8
+ description:
9
+ 'Drill-down de transacciones individuales de gastos operacionales. Muestra cada movimiento contable con fecha, documento, descripción, proveedor (vendor), cuenta, monto y categoría. Ideal para investigar el detalle detrás de un resumen de gastos. Montos en USD.',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ store: {
14
+ type: 'string',
15
+ enum: ['FQ01', 'FQ28', 'FQ88'],
16
+ description: 'Tienda a consultar.',
17
+ },
18
+ period: {
19
+ type: 'string',
20
+ enum: ['last_7_days', 'last_30_days', 'this_month', 'last_month', 'last_3_months', 'specific_month', 'custom'],
21
+ description: 'Periodo de consulta. Usar "specific_month" con month para un mes específico.',
22
+ default: 'last_month',
23
+ },
24
+ month: {
25
+ type: 'string',
26
+ description: 'Mes específico en formato YYYY-MM (ej: "2025-12"). Usar con period="specific_month".',
27
+ },
28
+ start_date: {
29
+ type: 'string',
30
+ description: 'Fecha inicio (YYYY-MM-DD). Requerido si period=custom.',
31
+ },
32
+ end_date: {
33
+ type: 'string',
34
+ description: 'Fecha fin (YYYY-MM-DD). Requerido si period=custom.',
35
+ },
36
+ category: {
37
+ type: 'string',
38
+ enum: [
39
+ 'planta_fisica', 'alquiler_equipos', 'logistica', 'marketing',
40
+ 'administrativos', 'seguros', 'bancarios', 'servicios_contratados',
41
+ 'nomina', 'otros',
42
+ ],
43
+ description: 'Filtrar por categoría de gasto (opcional).',
44
+ },
45
+ account_number: {
46
+ type: 'string',
47
+ description: 'Filtrar por número de cuenta específico, ej: "68210" (opcional).',
48
+ },
49
+ min_amount: {
50
+ type: 'number',
51
+ description: 'Monto mínimo en USD para filtrar transacciones (opcional).',
52
+ },
53
+ limit: {
54
+ type: 'number',
55
+ description: 'Máximo de transacciones a retornar. Default: 50.',
56
+ default: 50,
57
+ },
58
+ },
59
+ required: ['store'],
60
+ },
61
+ };
62
+
63
+ export async function handleExpenseDetails(bcClient, args) {
64
+ const store = args.store;
65
+ const period = args.period || 'last_month';
66
+ const limit = args.limit || 50;
67
+ const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
68
+
69
+ const stores = resolveStores([store]);
70
+ const storeInfo = stores[0];
71
+
72
+ // Determine account range filters
73
+ let accountMin = '60000';
74
+ let accountMax = '99999';
75
+ let specificAccount = null;
76
+
77
+ if (args.account_number) {
78
+ specificAccount = args.account_number;
79
+ } else if (args.category) {
80
+ const catConfig = EXPENSE_CATEGORIES[args.category];
81
+ if (!catConfig) {
82
+ throw new Error(`Categoría desconocida: ${args.category}. Opciones: ${Object.keys(EXPENSE_CATEGORIES).join(', ')}`);
83
+ }
84
+ accountMin = String(catConfig.accountRange.min);
85
+ accountMax = String(catConfig.accountRange.max);
86
+ }
87
+
88
+ // Fetch GL entries
89
+ const filters = {
90
+ startDate: dateRange.start,
91
+ endDate: dateRange.end,
92
+ };
93
+
94
+ if (specificAccount) {
95
+ filters.accountNumber = specificAccount;
96
+ } else {
97
+ filters.accountMin = accountMin;
98
+ filters.accountMax = accountMax;
99
+ }
100
+
101
+ // Fetch GL entries (critical) and vendor map (best-effort) in parallel
102
+ // buildVendorMap is resilient — returns {} if vendor endpoints fail
103
+ const [entries, vendorMap] = await Promise.all([
104
+ bcClient.getDetailedGLEntries(storeInfo.companyId, filters),
105
+ bcClient.buildVendorMap(storeInfo.companyId, dateRange.start, dateRange.end),
106
+ ]);
107
+
108
+ // Map entries with category info, vendor info, and compute expense amount
109
+ let transactions = entries.map((entry) => {
110
+ const category = getExpenseCategory(entry.accountNumber);
111
+ const expenseAmount = (entry.debitAmount || 0) - (entry.creditAmount || 0);
112
+ const vendor = vendorMap[entry.documentNumber] || null;
113
+
114
+ return {
115
+ posting_date: entry.postingDate,
116
+ document_no: entry.documentNumber,
117
+ description: entry.description || '',
118
+ vendor_name: vendor?.vendor_name || null,
119
+ vendor_number: vendor?.vendor_number || null,
120
+ account_number: entry.accountNumber,
121
+ account_name: getAccountName(entry.accountNumber),
122
+ debit: round2(entry.debitAmount || 0),
123
+ credit: round2(entry.creditAmount || 0),
124
+ amount_usd: round2(expenseAmount),
125
+ category: category?.key || 'otros',
126
+ category_name: category?.name || 'Otros Gastos',
127
+ category_icon: category?.icon || '',
128
+ };
129
+ });
130
+
131
+ // Apply category filter if account_number was not specified but category was
132
+ if (args.category && !args.account_number) {
133
+ transactions = transactions.filter((t) => t.category === args.category);
134
+ }
135
+
136
+ // Apply min_amount filter
137
+ if (args.min_amount) {
138
+ transactions = transactions.filter((t) => Math.abs(t.amount_usd) >= args.min_amount);
139
+ }
140
+
141
+ const totalBeforeLimit = transactions.length;
142
+
143
+ // Apply limit
144
+ transactions = transactions.slice(0, limit);
145
+
146
+ // Summary
147
+ const totalAmount = transactions.reduce((sum, t) => sum + t.amount_usd, 0);
148
+
149
+ // Exchange rate
150
+ let exchangeRate = null;
151
+ try {
152
+ const rates = await bcClient.getExchangeRates(store);
153
+ exchangeRate = bcClient.getExchangeRateForDate(rates, dateRange.end);
154
+ } catch (_) {
155
+ // optional
156
+ }
157
+
158
+ return {
159
+ store: storeInfo.name,
160
+ store_code: store,
161
+ period: { start: dateRange.start, end: dateRange.end, label: period },
162
+ filters_applied: {
163
+ category: args.category || null,
164
+ account_number: args.account_number || null,
165
+ min_amount: args.min_amount || null,
166
+ },
167
+ exchange_rate: exchangeRate ? {
168
+ rate: round2(exchangeRate.rate),
169
+ label: `1 USD = ${round2(exchangeRate.rate)} VES`,
170
+ } : null,
171
+ summary: {
172
+ total_transactions: totalBeforeLimit,
173
+ showing: transactions.length,
174
+ total_amount_usd: round2(totalAmount),
175
+ total_amount_ves: exchangeRate ? round2(totalAmount * exchangeRate.rate) : null,
176
+ },
177
+ transactions,
178
+ };
179
+ }
@@ -0,0 +1,179 @@
1
+ import { getDateRange } from '../utils/date-helper.js';
2
+ import { analyzeExpenses, aggregateByCategory } from '../lib/expense-analyzer.js';
3
+ import { calculateRatios } from '../lib/ratio-calculator.js';
4
+ import { round2 } from '../utils/currency-converter.js';
5
+
6
+ export const storeComparisonTool = {
7
+ name: 'compare_stores',
8
+ description:
9
+ 'Compara la eficiencia de gastos entre las tiendas de Full Queso (FQ01 Chacao, FQ28 Marqués, FQ88 Candelaria). Ranking por eficiencia, varianzas entre tiendas y oportunidades de ahorro identificadas.',
10
+ inputSchema: {
11
+ type: 'object',
12
+ properties: {
13
+ period: {
14
+ type: 'string',
15
+ enum: ['last_7_days', 'last_30_days', 'this_month', 'last_month', 'last_3_months', 'specific_month', 'custom'],
16
+ description: 'Periodo de comparación.',
17
+ default: 'last_month',
18
+ },
19
+ month: {
20
+ type: 'string',
21
+ description: 'Mes específico en formato YYYY-MM. 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
+ },
32
+ },
33
+ };
34
+
35
+ export async function handleStoreComparison(bcClient, args) {
36
+ const period = args.period || 'last_month';
37
+ const dateRange = getDateRange(period, args.start_date, args.end_date, args.month);
38
+
39
+ // Fetch all 3 stores
40
+ const allStoresData = await bcClient.getAllStoresFinancialData(['all'], dateRange.start, dateRange.end);
41
+
42
+ // Exchange rate
43
+ const firstStoreCode = Object.keys(allStoresData)[0];
44
+ let exchangeRate = null;
45
+ try {
46
+ const rates = await bcClient.getExchangeRates(firstStoreCode);
47
+ exchangeRate = bcClient.getExchangeRateForDate(rates, dateRange.end);
48
+ } catch (err) {
49
+ // Optional
50
+ }
51
+
52
+ // Analyze each store
53
+ const storeAnalyses = {};
54
+ const storeRatios = {};
55
+ for (const [code, storeData] of Object.entries(allStoresData)) {
56
+ storeAnalyses[code] = analyzeExpenses(storeData);
57
+ storeRatios[code] = calculateRatios(storeData);
58
+ }
59
+
60
+ // Build comparison table
61
+ const comparison = Object.entries(storeAnalyses).map(([code, analysis]) => ({
62
+ store_code: code,
63
+ store_name: analysis.store,
64
+ total_revenue: analysis.total_revenue,
65
+ total_cogs: analysis.total_cogs,
66
+ gross_margin_pct: analysis.gross_margin_pct,
67
+ total_income: analysis.total_revenue,
68
+ total_expenses: analysis.total_expenses,
69
+ operating_margin: analysis.operating_margin,
70
+ operating_margin_pct: analysis.operating_margin_pct,
71
+ margin_status: analysis.margin_status,
72
+ categories: analysis.categories.reduce((obj, cat) => {
73
+ obj[cat.category] = {
74
+ amount: cat.total,
75
+ pct_of_income: cat.pct_of_income,
76
+ benchmark_status: cat.benchmark_status,
77
+ };
78
+ return obj;
79
+ }, {}),
80
+ }));
81
+
82
+ // Ranking by efficiency (highest margin = most efficient)
83
+ const ranking = [...comparison].sort((a, b) => b.operating_margin_pct - a.operating_margin_pct);
84
+
85
+ // Identify savings opportunities (compare worst vs best in each category)
86
+ const savingsOpportunities = identifySavingsOpportunities(comparison, allStoresData);
87
+
88
+ // Cross-store insights
89
+ const crossInsights = generateCrossStoreInsights(comparison, ranking);
90
+
91
+ return {
92
+ period: { start: dateRange.start, end: dateRange.end, label: period },
93
+ exchange_rate: exchangeRate ? {
94
+ rate: Math.round(exchangeRate.rate * 100) / 100,
95
+ label: `1 USD = ${Math.round(exchangeRate.rate * 100) / 100} VES`,
96
+ } : null,
97
+ ranking,
98
+ comparison,
99
+ savings_opportunities: savingsOpportunities,
100
+ insights: crossInsights,
101
+ };
102
+ }
103
+
104
+ function identifySavingsOpportunities(comparison, allStoresData) {
105
+ const opportunities = [];
106
+
107
+ // For each expense category, find the store with lowest % of income
108
+ const categoryKeys = new Set();
109
+ for (const store of comparison) {
110
+ for (const key of Object.keys(store.categories)) {
111
+ categoryKeys.add(key);
112
+ }
113
+ }
114
+
115
+ for (const catKey of categoryKeys) {
116
+ const storesWithCat = comparison
117
+ .filter((s) => s.categories[catKey])
118
+ .map((s) => ({
119
+ store: s.store_name,
120
+ store_code: s.store_code,
121
+ pct: s.categories[catKey].pct_of_income,
122
+ amount: s.categories[catKey].amount,
123
+ income: s.total_income,
124
+ }));
125
+
126
+ if (storesWithCat.length < 2) continue;
127
+
128
+ const best = storesWithCat.reduce((min, s) => s.pct < min.pct ? s : min);
129
+ const worst = storesWithCat.reduce((max, s) => s.pct > max.pct ? s : max);
130
+
131
+ const gap = worst.pct - best.pct;
132
+ if (gap > 2 && worst.amount > 100) {
133
+ // Calculate potential savings if worst matched best's %
134
+ const potentialSaving = worst.income > 0
135
+ ? round2(worst.income * (gap / 100))
136
+ : 0;
137
+
138
+ opportunities.push({
139
+ category: catKey,
140
+ best_store: best.store,
141
+ best_pct: best.pct,
142
+ worst_store: worst.store,
143
+ worst_pct: worst.pct,
144
+ gap_pct: round2(gap),
145
+ potential_monthly_saving: potentialSaving,
146
+ recommendation: `Si ${worst.store} alcanza el nivel de ${best.store} en ${catKey}, ahorro potencial de $${potentialSaving.toLocaleString()}/mes`,
147
+ });
148
+ }
149
+ }
150
+
151
+ return opportunities.sort((a, b) => b.potential_monthly_saving - a.potential_monthly_saving);
152
+ }
153
+
154
+ function generateCrossStoreInsights(comparison, ranking) {
155
+ const insights = [];
156
+
157
+ if (ranking.length > 1) {
158
+ const best = ranking[0];
159
+ const worst = ranking[ranking.length - 1];
160
+
161
+ insights.push({
162
+ type: 'ranking',
163
+ message: `${best.store_name} es la tienda más eficiente con margen operativo de ${best.operating_margin_pct}%`,
164
+ detail: `Diferencia de ${round2(best.operating_margin_pct - worst.operating_margin_pct)} puntos vs ${worst.store_name}`,
165
+ });
166
+
167
+ // Total potential savings
168
+ if (worst.operating_margin_pct < best.operating_margin_pct - 5) {
169
+ const potentialIncrease = round2(worst.total_income * ((best.operating_margin_pct - worst.operating_margin_pct) / 100));
170
+ insights.push({
171
+ type: 'opportunity',
172
+ message: `Si ${worst.store_name} iguala la eficiencia de ${best.store_name}, margen adicional de $${potentialIncrease.toLocaleString()}/mes`,
173
+ detail: 'Revisar categorías con mayor diferencia entre tiendas.',
174
+ });
175
+ }
176
+ }
177
+
178
+ return insights;
179
+ }
@@ -0,0 +1,84 @@
1
+ import { getMonthlyRanges } from '../utils/date-helper.js';
2
+ import { analyzeTrends } from '../lib/trend-analyzer.js';
3
+
4
+ export const trendsTool = {
5
+ name: 'get_trends',
6
+ description:
7
+ 'Análisis de tendencias históricas de gastos e ingresos de Full Queso. Muestra evolución mensual de los últimos 3-6 meses, tasas de crecimiento, estacionalidad y visualización de tendencias. Identifica patrones preocupantes o positivos.',
8
+ inputSchema: {
9
+ type: 'object',
10
+ properties: {
11
+ months: {
12
+ type: 'number',
13
+ description: 'Cantidad de meses a analizar (3-12).',
14
+ default: 6,
15
+ },
16
+ store: {
17
+ type: 'string',
18
+ enum: ['FQ01', 'FQ28', 'FQ88'],
19
+ description: 'Tienda a analizar. Tendencias se calculan por tienda individual.',
20
+ default: 'FQ01',
21
+ },
22
+ },
23
+ },
24
+ };
25
+
26
+ export async function handleTrends(bcClient, args) {
27
+ const months = Math.min(Math.max(args.months || 6, 3), 12);
28
+ const storeCode = args.store || 'FQ01';
29
+
30
+ // Get monthly date ranges
31
+ const monthlyRanges = getMonthlyRanges(months);
32
+
33
+ // Exchange rate
34
+ let exchangeRate = null;
35
+ try {
36
+ const rates = await bcClient.getExchangeRates(storeCode);
37
+ exchangeRate = bcClient.getExchangeRateForDate(rates, monthlyRanges[monthlyRanges.length - 1].end);
38
+ } catch (err) {
39
+ // Optional
40
+ }
41
+
42
+ // Fetch data for each month
43
+ const monthlyData = [];
44
+ for (const range of monthlyRanges) {
45
+ try {
46
+ const storeData = await bcClient.getStoreFinancialData(storeCode, range.start, range.end);
47
+ monthlyData.push({
48
+ label: range.label,
49
+ labelEs: range.labelEs,
50
+ storeData,
51
+ });
52
+ } catch (err) {
53
+ // Skip months with no data
54
+ monthlyData.push({
55
+ label: range.label,
56
+ labelEs: range.labelEs,
57
+ storeData: {
58
+ storeCode,
59
+ storeName: storeCode,
60
+ period: { start: range.start, end: range.end },
61
+ expenses: [],
62
+ income: [],
63
+ totalIncome: 0,
64
+ totalExpenses: 0,
65
+ operatingMargin: 0,
66
+ operatingMarginPct: 0,
67
+ },
68
+ });
69
+ }
70
+ }
71
+
72
+ // Analyze trends
73
+ const trends = analyzeTrends(monthlyData);
74
+
75
+ return {
76
+ store: storeCode,
77
+ months_analyzed: months,
78
+ exchange_rate: exchangeRate ? {
79
+ rate: Math.round(exchangeRate.rate * 100) / 100,
80
+ label: `1 USD = ${Math.round(exchangeRate.rate * 100) / 100} VES`,
81
+ } : null,
82
+ ...trends,
83
+ };
84
+ }
@@ -0,0 +1,30 @@
1
+ // Currency Converter - VES/USD
2
+ // Business Central LCY is USD; VES is the foreign currency
3
+
4
+ export function convertUSDtoVES(amountUSD, exchangeRate) {
5
+ if (!exchangeRate || exchangeRate === 0) return null;
6
+ return amountUSD * exchangeRate;
7
+ }
8
+
9
+ export function convertVEStoUSD(amountVES, exchangeRate) {
10
+ if (!exchangeRate || exchangeRate === 0) return null;
11
+ return amountVES / exchangeRate;
12
+ }
13
+
14
+ export function formatDualCurrency(amountUSD, exchangeRate) {
15
+ if (amountUSD === null || amountUSD === undefined) return 'N/A';
16
+
17
+ const usdStr = `$${Math.abs(amountUSD).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
18
+
19
+ if (exchangeRate) {
20
+ const amountVES = amountUSD * exchangeRate;
21
+ const vesStr = `Bs. ${Math.abs(amountVES).toLocaleString('es-VE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
22
+ return `${usdStr} (${vesStr})`;
23
+ }
24
+
25
+ return usdStr;
26
+ }
27
+
28
+ export function round2(value) {
29
+ return Math.round(value * 100) / 100;
30
+ }
@@ -0,0 +1,107 @@
1
+ import { DateTime } from 'luxon';
2
+
3
+ export function getDateRange(period, startDate, endDate, month) {
4
+ const now = DateTime.now();
5
+
6
+ switch (period) {
7
+ case 'last_7_days':
8
+ return {
9
+ start: now.minus({ days: 7 }).toISODate(),
10
+ end: now.toISODate(),
11
+ };
12
+ case 'last_30_days':
13
+ return {
14
+ start: now.minus({ days: 30 }).toISODate(),
15
+ end: now.toISODate(),
16
+ };
17
+ case 'this_month':
18
+ return {
19
+ start: now.startOf('month').toISODate(),
20
+ end: now.toISODate(),
21
+ };
22
+ case 'last_month': {
23
+ const lastMonth = now.minus({ months: 1 });
24
+ return {
25
+ start: lastMonth.startOf('month').toISODate(),
26
+ end: lastMonth.endOf('month').toISODate(),
27
+ };
28
+ }
29
+ case 'last_3_months': {
30
+ return {
31
+ start: now.minus({ months: 3 }).startOf('month').toISODate(),
32
+ end: now.toISODate(),
33
+ };
34
+ }
35
+ case 'last_6_months': {
36
+ return {
37
+ start: now.minus({ months: 6 }).startOf('month').toISODate(),
38
+ end: now.toISODate(),
39
+ };
40
+ }
41
+ case 'specific_month': {
42
+ if (!month) throw new Error('month es requerido en formato YYYY-MM cuando period es "specific_month"');
43
+ const [year, mon] = month.split('-').map(Number);
44
+ const target = DateTime.fromObject({ year, month: mon });
45
+ return {
46
+ start: target.startOf('month').toISODate(),
47
+ end: target.endOf('month').toISODate(),
48
+ };
49
+ }
50
+ case 'custom':
51
+ if (!startDate || !endDate) {
52
+ throw new Error('start_date y end_date son requeridos cuando period es "custom"');
53
+ }
54
+ return { start: startDate, end: endDate };
55
+ default:
56
+ return {
57
+ start: now.minus({ days: 30 }).toISODate(),
58
+ end: now.toISODate(),
59
+ };
60
+ }
61
+ }
62
+
63
+ export function getPreviousPeriodRange(start, end) {
64
+ const startDT = DateTime.fromISO(start);
65
+ const endDT = DateTime.fromISO(end);
66
+ const days = endDT.diff(startDT, 'days').days;
67
+
68
+ const prevEnd = startDT.minus({ days: 1 });
69
+ const prevStart = prevEnd.minus({ days: Math.round(days) });
70
+
71
+ return {
72
+ start: prevStart.toISODate(),
73
+ end: prevEnd.toISODate(),
74
+ };
75
+ }
76
+
77
+ export function getMonthlyRanges(months = 6) {
78
+ const now = DateTime.now();
79
+ const ranges = [];
80
+
81
+ for (let i = months - 1; i >= 0; i--) {
82
+ const target = now.minus({ months: i });
83
+ const isCurrentMonth = i === 0;
84
+ ranges.push({
85
+ label: target.toFormat('yyyy-MM'),
86
+ labelEs: target.setLocale('es').toFormat('LLLL yyyy'),
87
+ start: target.startOf('month').toISODate(),
88
+ end: isCurrentMonth ? now.toISODate() : target.endOf('month').toISODate(),
89
+ });
90
+ }
91
+
92
+ return ranges;
93
+ }
94
+
95
+ export function formatCurrency(amount, currency = 'USD') {
96
+ if (amount === null || amount === undefined) return 'N/A';
97
+ if (currency === 'VES') {
98
+ return `Bs. ${amount.toLocaleString('es-VE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
99
+ }
100
+ return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
101
+ }
102
+
103
+ export function formatPercent(value) {
104
+ if (value === null || value === undefined) return 'N/A';
105
+ const sign = value > 0 ? '+' : '';
106
+ return `${sign}${value.toFixed(1)}%`;
107
+ }
@@ -0,0 +1,18 @@
1
+ // Logger utility - writes to stderr (MCP convention: stdout is for protocol)
2
+
3
+ const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
4
+ const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL || 'info'] || 1;
5
+
6
+ function log(level, ...args) {
7
+ if (LOG_LEVELS[level] >= currentLevel) {
8
+ const prefix = `[${new Date().toISOString()}] [${level.toUpperCase()}]`;
9
+ console.error(prefix, ...args);
10
+ }
11
+ }
12
+
13
+ export const logger = {
14
+ debug: (...args) => log('debug', ...args),
15
+ info: (...args) => log('info', ...args),
16
+ warn: (...args) => log('warn', ...args),
17
+ error: (...args) => log('error', ...args),
18
+ };