const crypto = require('node:crypto'); const { pool } = require('../db'); const RFM_QUERY_TIMEOUT_MS = 15000; const RECENT_MAX_DAYS = 7; const COOLING_MAX_DAYS = 15; const LOST_MIN_DAYS = 30; const FREQUENCY_MEDIUM_MAX_ORDERS = 4; const CLIENT_TOKEN_VERSION = 'v1'; const SIZE_SUFFIX_SQL_PATTERN = '\\s+-\\s+(?:(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2})(?:/(?:PP|P|M|G|GG|XG|XGG|EG|EGG|EXG|U|UNICO|ÚNICO|\\d{2}))*)$'; const PRODUCT_NAME_SQL = ` CASE WHEN COALESCE(produto_descricao, 'Unknown') ILIKE 'ETIQUETA%' THEN COALESCE(produto_descricao, 'Unknown') ELSE NULLIF(TRIM(regexp_replace(split_part(COALESCE(produto_descricao, 'Unknown'), ' TAMANHO', 1), '${SIZE_SUFFIX_SQL_PATTERN}', '', 'i')), '') END `; const NORMALIZED_CUSTOMER_NAME_SQL = "NULLIF(LOWER(TRIM(regexp_replace(COALESCE(cliente_nome, ''), '\\s+', ' ', 'g'))), '')"; // Phone-less historical rows must follow the later known phone for the same client name. const CUSTOMER_IDENTITY_CTE = ` WITH customer_phone_by_name AS ( SELECT ${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name, (ARRAY_AGG(NULLIF(cliente_fone, '') ORDER BY data_pedido_date DESC NULLS LAST, id DESC) )[1] as canonical_phone FROM orders WHERE NULLIF(cliente_fone, '') IS NOT NULL AND ${NORMALIZED_CUSTOMER_NAME_SQL} IS NOT NULL GROUP BY normalized_customer_name ), identity_orders AS ( SELECT orders.*, ${NORMALIZED_CUSTOMER_NAME_SQL} as normalized_customer_name, COALESCE( NULLIF(orders.cliente_fone, ''), customer_phone_by_name.canonical_phone, 'name:' || COALESCE(NULLIF(orders.cliente_nome, ''), 'Cliente Desconhecido') ) as customer_key FROM orders LEFT JOIN customer_phone_by_name ON customer_phone_by_name.normalized_customer_name = ${NORMALIZED_CUSTOMER_NAME_SQL} ) `; const CUSTOMER_KEY_SQL = 'customer_key'; const TRAILING_SELLER_ID_SQL_PATTERN = '[[:space:]]*#([0-9]+)[[:space:]]*$'; const SELLER_ID_SQL = ` COALESCE( NULLIF(TRIM(id_vendedor), ''), substring(NULLIF(TRIM(nome_vendedor), '') from '${TRAILING_SELLER_ID_SQL_PATTERN}') ) `; const SELLER_NAME_SQL = `NULLIF(TRIM(regexp_replace(COALESCE(nome_vendedor, ''), '${TRAILING_SELLER_ID_SQL_PATTERN}', '')), '')`; const getClientTokenSecret = () => ( process.env.CLIENT_TOKEN_SECRET || process.env.JWT_SECRET || process.env.API_KEY || 'nexstar-client-token-development-secret' ); let clientTokenSecretCache = null; const getClientTokenHashSecret = () => { const secret = getClientTokenSecret(); if (clientTokenSecretCache?.secret === secret) return clientTokenSecretCache.hashSecret; clientTokenSecretCache = { secret, hashSecret: crypto.createHash('sha256').update(`${secret}:client-token`).digest('base64url') }; return clientTokenSecretCache.hashSecret; }; const createClientToken = (customerKey) => { if (!customerKey) return ''; const normalizedCustomerKey = String(customerKey); const digest = crypto .createHash('sha256') .update(getClientTokenHashSecret()) .update(':') .update(normalizedCustomerKey) .digest('base64url'); return `${CLIENT_TOKEN_VERSION}.${digest}`; }; const isClientToken = (clientToken) => { const parts = String(clientToken || '').split('.'); return parts.length === 2 && parts[0] === CLIENT_TOKEN_VERSION && /^[A-Za-z0-9_-]+$/.test(parts[1] || ''); }; const persistClientTokenMappings = async (clients, queryable = pool) => { const mappingsByCustomerKey = new Map(); clients.forEach(client => { const customerKey = client.customerKey || client.customer_key; const clientToken = client.clientToken || createClientToken(customerKey); if (customerKey && clientToken) { mappingsByCustomerKey.set(customerKey, clientToken); } }); const mappings = [...mappingsByCustomerKey.entries()]; const chunkSize = 5000; for (let index = 0; index < mappings.length; index += chunkSize) { const chunk = mappings.slice(index, index + chunkSize); const params = []; const values = chunk.map(([customerKey, clientToken], chunkIndex) => { params.push(customerKey, clientToken); const offset = chunkIndex * 2; return `($${offset + 1}, $${offset + 2})`; }); await queryable.query(` INSERT INTO client_identity_tokens (customer_key, token) VALUES ${values.join(', ')} ON CONFLICT (customer_key) DO UPDATE SET token = EXCLUDED.token, updated_at = NOW() WHERE client_identity_tokens.token IS DISTINCT FROM EXCLUDED.token; `, params); } }; const resolveClientToken = async (clientToken) => { if (!isClientToken(clientToken)) return null; const result = await pool.query(` SELECT customer_key FROM client_identity_tokens WHERE token = $1 LIMIT 1; `, [clientToken]); return result.rows[0]?.customer_key || null; }; const resolveCanonicalCustomerKey = async (customerKey) => { const normalizedCustomerKey = String(customerKey || ''); if (!normalizedCustomerKey.startsWith('name:')) return customerKey; const legacyName = normalizedCustomerKey.slice(5).trim(); if (!legacyName) return customerKey; const result = await pool.query(` ${CUSTOMER_IDENTITY_CTE} SELECT customer_key FROM identity_orders WHERE normalized_customer_name = NULLIF(LOWER(TRIM(regexp_replace($1, '\\s+', ' ', 'g'))), '') ORDER BY NULLIF(cliente_fone, '') IS NOT NULL DESC, data_pedido_date DESC NULLS LAST, id DESC LIMIT 1; `, [legacyName]); return result.rows[0]?.customer_key || customerKey; }; const normalizeDateParam = (value) => { if (!value) return null; const match = String(value).trim().match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) return null; const [, yearValue, monthValue, dayValue] = match; const year = Number(yearValue); const month = Number(monthValue); const day = Number(dayValue); const date = new Date(Date.UTC(year, month - 1, day)); if ( date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day ) { return null; } return `${yearValue}-${monthValue}-${dayValue}`; }; const normalizeTextFilter = (value) => { if (value === undefined || value === null) return ''; return String(value).trim(); }; const normalizeSellerFilter = (value) => { const normalizedValue = normalizeTextFilter(value); if (!normalizedValue) return null; if (normalizedValue.startsWith('id:')) { const id = normalizeTextFilter(normalizedValue.slice(3)); return id ? { type: 'id', value: id } : null; } if (normalizedValue.startsWith('name:')) { const name = normalizeTextFilter(normalizedValue.slice(5)); return name ? { type: 'name', value: name } : null; } return { type: 'any', value: normalizedValue }; }; const normalizeSellerOption = (row) => { const rawId = String(row.id || '').trim(); const rawName = String(row.name || '').trim(); const idFromName = rawName.match(/#(\d+)\s*$/)?.[1] || ''; const id = rawId || idFromName; const name = rawName.replace(/#\d+\s*$/, '').trim(); return { id, name: name || id }; }; const appendOrderMetadataFilters = (params, filters, range = {}) => { const marketplace = normalizeTextFilter(range.marketplace); const salesChannel = normalizeTextFilter(range.canal_venda || range.canalVenda); const seller = normalizeSellerFilter(range.seller || range.vendedor); if (marketplace) { params.push(marketplace); filters.push(`NULLIF(TRIM(marketplace), '') = $${params.length}`); } if (salesChannel) { params.push(salesChannel); filters.push(`NULLIF(TRIM(canal_venda), '') = $${params.length}`); } if (seller?.type === 'id') { params.push(seller.value); filters.push(`NULLIF(TRIM(id_vendedor), '') = $${params.length}`); } else if (seller?.type === 'name') { params.push(`%${seller.value}%`); filters.push(`NULLIF(TRIM(nome_vendedor), '') ILIKE $${params.length}`); } else if (seller?.type === 'any') { params.push(seller.value); const idParamIndex = params.length; params.push(`%${seller.value}%`); filters.push(`(NULLIF(TRIM(id_vendedor), '') = $${idParamIndex} OR NULLIF(TRIM(nome_vendedor), '') ILIKE $${params.length})`); } }; const buildDateFilter = (range = {}) => { const { start, end } = range; const params = []; const filters = ['data_pedido_date IS NOT NULL']; const normalizedStart = normalizeDateParam(start); const normalizedEnd = normalizeDateParam(end); let startParamIndex = null; let endParamIndex = null; if (normalizedStart) { params.push(normalizedStart); startParamIndex = params.length; filters.push(`data_pedido_date >= $${params.length}::date`); } if (normalizedEnd) { params.push(normalizedEnd); endParamIndex = params.length; filters.push(`data_pedido_date <= $${params.length}::date`); } appendOrderMetadataFilters(params, filters, range); return { params, whereClause: `WHERE ${filters.join(' AND ')}`, startParamIndex, endParamIndex }; }; const getPreviousDate = (value) => { const normalizedDate = normalizeDateParam(value); if (!normalizedDate) return null; const date = new Date(`${normalizedDate}T00:00:00.000Z`); date.setUTCDate(date.getUTCDate() - 1); return date.toISOString().slice(0, 10); }; const toNumber = (value) => Number(value || 0); const RFM_SEGMENTS = { '3-3': { key: 'champions', label: 'Champions' }, '3-2': { key: 'potential_loyalists', label: 'Potenciais Leais' }, '3-1': { key: 'new_customers', label: 'Novos Clientes' }, '2-3': { key: 'loyal_customers', label: 'Clientes Leais' }, '2-2': { key: 'need_attention', label: 'Precisam de Atenção' }, '2-1': { key: 'about_to_sleep', label: 'Quase Dormindo' }, '1-3': { key: 'at_risk', label: 'Em Risco' }, '1-2': { key: 'hibernating', label: 'Hibernando' }, '1-1': { key: 'lost', label: 'Perdidos' } }; const RFM_SEGMENTS_BY_KEY = new Map( Object.values(RFM_SEGMENTS).map(segment => [segment.key, segment]) ); const scoreTertile = (value, values, higherIsBetter = true) => { const numericValues = values.map(toNumber).filter(Number.isFinite); if (!numericValues.length) return 1; if (numericValues.length === 1) return 3; const min = Math.min(...numericValues); const max = Math.max(...numericValues); if (min === max) return higherIsBetter ? 2 : 3; const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a); const index = sorted.findIndex(candidate => candidate === toNumber(value)); const percentile = index / (sorted.length - 1); return Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1)); }; const buildTertileScorer = (values, higherIsBetter = true) => { const numericValues = values.map(toNumber).filter(Number.isFinite); if (!numericValues.length) return () => 1; if (numericValues.length === 1) return () => 3; const min = Math.min(...numericValues); const max = Math.max(...numericValues); if (min === max) return () => higherIsBetter ? 2 : 3; const sorted = [...numericValues].sort((a, b) => higherIsBetter ? a - b : b - a); const scoresByValue = new Map(); sorted.forEach((candidate, index) => { if (scoresByValue.has(candidate)) return; const percentile = index / (sorted.length - 1); scoresByValue.set(candidate, Math.min(3, Math.max(1, Math.floor(percentile * 3) + 1))); }); return (value) => scoresByValue.get(toNumber(value)) || 1; }; const getRfmSegment = (recencyScore, valueScore) => { return RFM_SEGMENTS[`${recencyScore}-${valueScore}`] || RFM_SEGMENTS['1-1']; }; const getRecencyScore = (recencyDays) => { const days = Math.max(0, toNumber(recencyDays)); if (days <= RECENT_MAX_DAYS) return 3; if (days <= COOLING_MAX_DAYS) return 2; return 1; }; const getFrequencyScore = (frequency) => { const orders = Math.max(0, toNumber(frequency)); if (orders <= 1) return 1; if (orders <= FREQUENCY_MEDIUM_MAX_ORDERS) return 2; return 3; }; const getLifecycleSegment = ({ recencyDays, historicalFrequency, frequencyScore, monetaryScore, valueScore }) => { const days = Math.max(0, toNumber(recencyDays)); if (days >= LOST_MIN_DAYS) { return { segment: RFM_SEGMENTS_BY_KEY.get('lost'), valueScore: 1 }; } if (days > COOLING_MAX_DAYS) { const hasStrongHistory = frequencyScore === 3 || monetaryScore === 3; return hasStrongHistory ? { segment: RFM_SEGMENTS_BY_KEY.get('at_risk'), valueScore: 3 } : { segment: RFM_SEGMENTS_BY_KEY.get('hibernating'), valueScore: 2 }; } if (days <= RECENT_MAX_DAYS && historicalFrequency === 1) { return { segment: RFM_SEGMENTS_BY_KEY.get('new_customers'), valueScore: 1 }; } if (days <= RECENT_MAX_DAYS) { const isChampion = frequencyScore === 3 && monetaryScore === 3; return isChampion ? { segment: RFM_SEGMENTS_BY_KEY.get('champions'), valueScore: 3 } : { segment: RFM_SEGMENTS_BY_KEY.get('potential_loyalists'), valueScore: 2 }; } return { segment: getRfmSegment(getRecencyScore(days), valueScore), valueScore }; }; const getDateDiffDays = (endDate, startDate) => { const normalizedEnd = normalizeDateParam(endDate); const normalizedStart = normalizeDateParam(startDate); if (!normalizedEnd || !normalizedStart) return 0; const end = new Date(`${normalizedEnd}T00:00:00.000Z`); const start = new Date(`${normalizedStart}T00:00:00.000Z`); return Math.max(0, Math.floor((end.getTime() - start.getTime()) / 86400000)); }; const getDateOnly = (value) => { if (!value) return null; if (value instanceof Date) return value.toISOString().slice(0, 10); const match = String(value).match(/^(\d{4}-\d{2}-\d{2})/); return match ? match[1] : null; }; const isSingleDayRange = (start, end) => Boolean(start && end && start === end); const WEEKDAY_LABELS = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sab']; const getWeekdayIndex = (value) => { const dateKey = getDateOnly(value); if (!dateKey) return null; const date = new Date(`${dateKey}T00:00:00`); if (Number.isNaN(date.getTime())) return null; return date.getDay(); }; const getHourFromTimestamp = (value) => { if (!value) return null; const rawValue = String(value); const hasTime = /\b\d{1,2}:\d{2}/.test(rawValue); if (!hasTime) return null; const date = value instanceof Date ? value : new Date(rawValue); if (!Number.isNaN(date.getTime())) return date.getHours(); const timeMatch = rawValue.match(/\b(\d{1,2}):\d{2}/); if (!timeMatch) return null; const hour = Number(timeMatch[1]); return hour >= 0 && hour <= 23 ? hour : null; }; const buildRfmSegments = (clients) => { return Object.values(RFM_SEGMENTS).map(segment => { const segmentClients = clients.filter(client => client.segmentKey === segment.key); const totalRevenue = segmentClients.reduce((sum, client) => sum + client.monetary, 0); return { ...segment, count: segmentClients.length, totalRevenue, averageRevenue: segmentClients.length ? totalRevenue / segmentClients.length : 0 }; }); }; const buildRfmClients = (baseClients) => { const monetaryScoreFor = buildTertileScorer(baseClients.map(client => client.rfmMonetary ?? client.monetary), true); return baseClients.map(client => { const recencyDays = Math.max(0, toNumber(client.recencyDays)); const historicalFrequency = toNumber(client.rfmFrequency ?? client.frequency); const historicalMonetary = toNumber(client.rfmMonetary ?? client.monetary); const recencyScore = getRecencyScore(recencyDays); const frequencyScore = client.rfmFrequencyScore ?? getFrequencyScore(historicalFrequency); const monetaryScore = client.rfmMonetaryScore ?? monetaryScoreFor(historicalMonetary); const baseValueScore = Math.min(3, Math.max(1, Math.round((frequencyScore + monetaryScore) / 2))); const classification = getLifecycleSegment({ recencyDays, historicalFrequency, frequencyScore, monetaryScore, valueScore: baseValueScore }); const valueScore = classification.valueScore; const segment = classification.segment; return { ...client, recencyDays, recencyScore, frequencyScore, monetaryScore, valueScore, rfmScore: `${recencyScore}${frequencyScore}${monetaryScore}`, segmentKey: segment.key, segmentLabel: segment.label }; }).sort((a, b) => { if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore; if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore; return b.monetary - a.monetary; }); }; const getDashboardAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const [totalsResult, salesResult, revenueResult, sellerRevenueResult, sellerOrdersResult, sellerRevenueByDateResult, sellerRevenueByHourResult] = await Promise.all([ pool.query(` SELECT COALESCE(SUM(quantidade * valor_unitario), 0) as total_revenue, COALESCE(SUM(quantidade), 0) as total_items, COUNT(*)::int as order_line_count FROM orders ${whereClause}; `, params), pool.query(` SELECT COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name, MAX(produto_id) as id, COALESCE(SUM(quantidade), 0) as value FROM orders ${whereClause} GROUP BY name ORDER BY value DESC LIMIT 10; `, params), pool.query(` SELECT COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as name, MAX(produto_id) as id, COALESCE(SUM(quantidade * valor_unitario), 0) as value FROM orders ${whereClause} GROUP BY name ORDER BY value DESC LIMIT 10; `, params), pool.query(` WITH seller_orders AS ( SELECT COALESCE(${SELLER_ID_SQL}, 'name:' || ${SELLER_NAME_SQL}) as seller_key, COALESCE(${SELLER_NAME_SQL}, ${SELLER_ID_SQL}, 'Sem vendedor') as seller_name, quantidade, valor_unitario, pedido_id, data_pedido, valor_pedido FROM orders ${whereClause} AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL) ) SELECT seller_key as id, MAX(seller_name) as name, COALESCE(SUM(quantidade * valor_unitario), 0) as value FROM seller_orders GROUP BY seller_key ORDER BY value DESC LIMIT 10; `, params), pool.query(` WITH seller_orders AS ( SELECT COALESCE(${SELLER_ID_SQL}, 'name:' || ${SELLER_NAME_SQL}) as seller_key, COALESCE(${SELLER_NAME_SQL}, ${SELLER_ID_SQL}, 'Sem vendedor') as seller_name, pedido_id, data_pedido, valor_pedido FROM orders ${whereClause} AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL) ) SELECT seller_key as id, MAX(seller_name) as name, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as value FROM seller_orders GROUP BY seller_key ORDER BY value DESC LIMIT 10; `, params), pool.query(` WITH seller_orders AS ( SELECT COALESCE(${SELLER_ID_SQL}, 'name:' || ${SELLER_NAME_SQL}) as seller_key, COALESCE(${SELLER_NAME_SQL}, ${SELLER_ID_SQL}, 'Sem vendedor') as seller_name, data_pedido_date, quantidade, valor_unitario, pedido_id, data_pedido, valor_pedido FROM orders ${whereClause} AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL) ), top_sellers AS ( SELECT seller_key FROM seller_orders GROUP BY seller_key ORDER BY COALESCE(SUM(quantidade * valor_unitario), 0) DESC LIMIT 8 ) SELECT seller_orders.seller_key as id, MAX(seller_orders.seller_name) as name, seller_orders.data_pedido_date::text as date, COALESCE(SUM(seller_orders.quantidade * seller_orders.valor_unitario), 0) as value, COUNT(DISTINCT COALESCE(NULLIF(seller_orders.pedido_id, ''), seller_orders.data_pedido || '_' || seller_orders.valor_pedido::text))::int as orders FROM seller_orders INNER JOIN top_sellers ON top_sellers.seller_key = seller_orders.seller_key GROUP BY seller_orders.seller_key, seller_orders.data_pedido_date ORDER BY seller_orders.data_pedido_date ASC, value DESC; `, params), pool.query(` WITH seller_orders AS ( SELECT COALESCE(${SELLER_ID_SQL}, 'name:' || ${SELLER_NAME_SQL}) as seller_key, COALESCE(${SELLER_NAME_SQL}, ${SELLER_ID_SQL}, 'Sem vendedor') as seller_name, EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as order_hour, quantidade, valor_unitario, pedido_id, data_pedido, valor_pedido FROM orders ${whereClause} AND (${SELLER_ID_SQL} IS NOT NULL OR ${SELLER_NAME_SQL} IS NOT NULL) ), top_sellers AS ( SELECT seller_key FROM seller_orders GROUP BY seller_key ORDER BY COALESCE(SUM(quantidade * valor_unitario), 0) DESC LIMIT 8 ) SELECT seller_orders.seller_key as id, MAX(seller_orders.seller_name) as name, seller_orders.order_hour as hour, COALESCE(SUM(seller_orders.quantidade * seller_orders.valor_unitario), 0) as value, COUNT(DISTINCT COALESCE(NULLIF(seller_orders.pedido_id, ''), seller_orders.data_pedido || '_' || seller_orders.valor_pedido::text))::int as orders FROM seller_orders INNER JOIN top_sellers ON top_sellers.seller_key = seller_orders.seller_key GROUP BY seller_orders.seller_key, seller_orders.order_hour ORDER BY seller_orders.order_hour ASC, value DESC; `, params) ]); const totals = totalsResult.rows[0] || {}; const orderLineCount = toNumber(totals.order_line_count); const totalRevenue = toNumber(totals.total_revenue); return { range: { start: normalizeDateParam(range.start), end: normalizeDateParam(range.end) }, totalRevenue, totalOrders: toNumber(totals.total_items), orderLineCount, averageOrderValue: orderLineCount ? totalRevenue / orderLineCount : 0, salesByProduct: salesResult.rows.map(row => ({ name: row.name, id: row.id, value: toNumber(row.value) })), revenueByProduct: revenueResult.rows.map(row => ({ name: row.name, id: row.id, value: toNumber(row.value) })), revenueBySeller: sellerRevenueResult.rows.map(row => ({ name: row.name, id: row.id, value: toNumber(row.value) })), ordersBySeller: sellerOrdersResult.rows.map(row => ({ name: row.name, id: row.id, value: toNumber(row.value) })), sellerRevenueByDate: sellerRevenueByDateResult.rows.map(row => ({ name: row.name, id: row.id, date: row.date, value: toNumber(row.value), orders: toNumber(row.orders) })), sellerRevenueByHour: sellerRevenueByHourResult.rows.map(row => ({ name: row.name, id: row.id, hour: toNumber(row.hour), value: toNumber(row.value), orders: toNumber(row.orders) })) }; }; const getProductAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const result = await pool.query(` WITH period_sales AS ( SELECT produto_id as id, MAX(COALESCE(NULLIF(produto_descricao, ''), 'Unknown')) as order_name, COALESCE(SUM(quantidade), 0) as quantity_sold, COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, COUNT(*)::int as order_line_count, MIN(data_pedido_date) as first_sale_date, MAX(data_pedido_date) as last_sale_date, (ARRAY_AGG(valor_unitario ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as last_price FROM orders ${whereClause} GROUP BY produto_id ), stock_rows AS ( SELECT produto_id as id, MAX(NULLIF(nome, '')) as stock_name, COALESCE(MAX(saldo), 0) as stock FROM stock GROUP BY produto_id ) SELECT COALESCE(period_sales.id, stock_rows.id) as id, COALESCE(stock_rows.stock_name, period_sales.order_name, 'Unknown') as name, COALESCE(period_sales.quantity_sold, 0) as quantity_sold, COALESCE(period_sales.revenue, 0) as revenue, COALESCE(period_sales.order_line_count, 0)::int as order_line_count, period_sales.first_sale_date, period_sales.last_sale_date, COALESCE(period_sales.last_price, 0) as last_price, COALESCE(stock_rows.stock, 0) as stock FROM period_sales FULL OUTER JOIN stock_rows ON stock_rows.id = period_sales.id WHERE COALESCE(period_sales.id, stock_rows.id) IS NOT NULL ORDER BY quantity_sold DESC, revenue DESC, name ASC; `, params); return result.rows.map(row => ({ name: row.name, id: row.id, quantitySold: toNumber(row.quantity_sold), revenue: toNumber(row.revenue), orderLineCount: toNumber(row.order_line_count), lastPrice: toNumber(row.last_price), stock: toNumber(row.stock), firstSaleDate: row.first_sale_date, lastSaleDate: row.last_sale_date })); }; const getProductDetailsAnalytics = async (productId, range = {}) => { const normalizedProductId = String(productId || '').trim(); if (!normalizedProductId) return null; const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); const useHourlyChart = isSingleDayRange(normalizedStart, normalizedEnd); const periodParams = [normalizedProductId]; const periodFilters = [ 'produto_id = $1', 'data_pedido_date IS NOT NULL' ]; const variantParams = [normalizedProductId]; const variantFilters = ['data_pedido_date IS NOT NULL']; if (normalizedStart) { periodParams.push(normalizedStart); periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`); variantParams.push(normalizedStart); variantFilters.push(`data_pedido_date >= $${variantParams.length}::date`); } if (normalizedEnd) { periodParams.push(normalizedEnd); periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`); variantParams.push(normalizedEnd); variantFilters.push(`data_pedido_date <= $${variantParams.length}::date`); } const [summaryResult, periodResult, variantResult] = await Promise.all([ pool.query(` WITH selected_product AS ( SELECT $1::text as id ), stock_info AS ( SELECT produto_id as id, MAX(NULLIF(nome, '')) as name, COALESCE(MAX(saldo), 0) as stock FROM stock WHERE produto_id = $1 GROUP BY produto_id ), order_info AS ( SELECT produto_id as id, (ARRAY_AGG(COALESCE(NULLIF(produto_descricao, ''), 'Unknown') ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as name, (ARRAY_AGG(valor_unitario ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST))[1] as price FROM orders WHERE produto_id = $1 GROUP BY produto_id ) SELECT selected_product.id, COALESCE(stock_info.name, order_info.name, 'Unknown') as name, COALESCE(order_info.price, 0) as price, COALESCE(stock_info.stock, 0) as stock FROM selected_product LEFT JOIN stock_info ON stock_info.id = selected_product.id LEFT JOIN order_info ON order_info.id = selected_product.id WHERE stock_info.id IS NOT NULL OR order_info.id IS NOT NULL; `, [normalizedProductId]), pool.query(useHourlyChart ? ` SELECT EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as hour, COALESCE(SUM(quantidade), 0) as quantity_sold, COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count FROM orders WHERE ${periodFilters.join(' AND ')} GROUP BY hour ORDER BY hour ASC; ` : ` SELECT data_pedido_date, MAX(data_pedido) as date_label, COALESCE(SUM(quantidade), 0) as quantity_sold, COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count FROM orders WHERE ${periodFilters.join(' AND ')} GROUP BY data_pedido_date ORDER BY data_pedido_date ASC; `, periodParams), pool.query(` WITH selected_base AS ( SELECT COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') as base_name FROM orders WHERE produto_id = $1 AND data_pedido_date IS NOT NULL ORDER BY data_pedido_date DESC NULLS LAST, data_pedido DESC NULLS LAST LIMIT 1 ) SELECT produto_id as id, MAX(COALESCE(NULLIF(produto_descricao, ''), 'Unknown')) as name, COALESCE(SUM(quantidade), 0) as quantity_sold, COALESCE(SUM(quantidade * valor_unitario), 0) as revenue, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count FROM orders WHERE ${variantFilters.join(' AND ')} AND COALESCE(${PRODUCT_NAME_SQL}, 'Unknown') = (SELECT base_name FROM selected_base) GROUP BY produto_id ORDER BY quantity_sold DESC, revenue DESC, name ASC LIMIT 8; `, variantParams) ]); const summary = summaryResult.rows[0]; if (!summary) return null; const chartData = useHourlyChart ? Array.from({ length: 24 }, (_, hour) => { const row = periodResult.rows.find(item => toNumber(item.hour) === hour); const quantitySold = row ? toNumber(row.quantity_sold) : 0; const revenue = row ? toNumber(row.revenue) : 0; const orderCount = row ? toNumber(row.order_count) : 0; return { date: `${String(hour).padStart(2, '0')}h`, value: quantitySold, quantitySold, revenue, orderCount, averageTicket: orderCount ? revenue / orderCount : 0 }; }) : periodResult.rows.map(row => { const quantitySold = toNumber(row.quantity_sold); const revenue = toNumber(row.revenue); const orderCount = toNumber(row.order_count); return { date: row.date_label || getDateOnly(row.data_pedido_date) || '', value: quantitySold, quantitySold, revenue, orderCount, averageTicket: orderCount ? revenue / orderCount : 0 }; }); const totalSold = periodResult.rows.reduce((sum, row) => sum + toNumber(row.quantity_sold), 0); const totalRevenue = periodResult.rows.reduce((sum, row) => sum + toNumber(row.revenue), 0); const totalOrders = periodResult.rows.reduce((sum, row) => sum + toNumber(row.order_count), 0); const variantBreakdown = variantResult.rows.map(row => ({ id: row.id, name: row.name, quantitySold: toNumber(row.quantity_sold), revenue: toNumber(row.revenue), orderCount: toNumber(row.order_count) })); return { range: { start: normalizedStart, end: normalizedEnd }, productInfo: { id: summary.id, name: summary.name, price: toNumber(summary.price), stock: toNumber(summary.stock) }, chartData, totalSold, totalRevenue, totalOrders, averageTicket: totalOrders ? totalRevenue / totalOrders : 0, variantBreakdown }; }; const getClientAnalytics = async (range = {}) => { const { params, whereClause } = buildDateFilter(range); const result = await pool.query(` ${CUSTOMER_IDENTITY_CTE} SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, MAX(NULLIF(cliente_fone, '')) as phone, COALESCE(SUM(quantidade), 0) as quantity_purchased, COALESCE(SUM(quantidade * valor_unitario), 0) as total_spent, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as order_count, MAX(data_pedido_date) as last_purchase_date FROM identity_orders ${whereClause} GROUP BY customer_key ORDER BY total_spent DESC; `, params); const clients = result.rows.map(row => ({ customerKey: row.customer_key, clientToken: createClientToken(row.customer_key), name: row.name, phone: row.phone || '', quantityPurchased: toNumber(row.quantity_purchased), totalSpent: toNumber(row.total_spent), orderCount: toNumber(row.order_count), lastPurchaseDate: row.last_purchase_date })); await persistClientTokenMappings(clients); return clients; }; const getClientFilterOptions = async () => { const [marketplaceResult, salesChannelResult, sellerResult] = await Promise.all([ pool.query(` SELECT DISTINCT NULLIF(TRIM(marketplace), '') as value FROM orders WHERE NULLIF(TRIM(marketplace), '') IS NOT NULL ORDER BY value ASC; `), pool.query(` SELECT DISTINCT NULLIF(TRIM(canal_venda), '') as value FROM orders WHERE NULLIF(TRIM(canal_venda), '') IS NOT NULL ORDER BY value ASC; `), pool.query(` WITH seller_options AS ( SELECT ${SELLER_ID_SQL} as id, ${SELLER_NAME_SQL} as name FROM orders WHERE ( NULLIF(TRIM(id_vendedor), '') IS NOT NULL OR NULLIF(TRIM(nome_vendedor), '') IS NOT NULL ) ) SELECT id, name FROM seller_options GROUP BY id, name ORDER BY COALESCE(name, id) ASC, id ASC; `) ]); const sellerOptionsByValue = new Map(); sellerResult.rows.forEach(row => { const { id, name } = normalizeSellerOption(row); const value = id ? `id:${id}` : `name:${name}`; if (!value || sellerOptionsByValue.has(value)) return; sellerOptionsByValue.set(value, { value, id, name: name || id }); }); return { marketplaces: marketplaceResult.rows.map(row => row.value).filter(Boolean), salesChannels: salesChannelResult.rows.map(row => row.value).filter(Boolean), sellers: [...sellerOptionsByValue.values()] }; }; const getClientPurchasePatternAnalytics = async () => { const result = await pool.query(` ${CUSTOMER_IDENTITY_CTE}, order_events AS ( SELECT DISTINCT ON ( ${CUSTOMER_KEY_SQL}, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) ) data_pedido_date, created_at FROM identity_orders WHERE data_pedido_date IS NOT NULL ORDER BY ${CUSTOMER_KEY_SQL}, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text), created_at ASC NULLS LAST ) SELECT EXTRACT(DOW FROM data_pedido_date)::int as weekday, EXTRACT(HOUR FROM created_at AT TIME ZONE 'America/Sao_Paulo')::int as hour, COUNT(*)::int as order_count FROM order_events GROUP BY weekday, hour ORDER BY weekday ASC, hour ASC; `); const purchaseWeekdays = WEEKDAY_LABELS.map(label => ({ label, value: 0 })); const purchaseHours = Array.from({ length: 24 }, (_, hour) => ({ label: `${String(hour).padStart(2, '0')}h`, value: 0 })); result.rows.forEach(row => { const weekday = row.weekday === null || row.weekday === undefined ? null : Number(row.weekday); const hour = row.hour === null || row.hour === undefined ? null : Number(row.hour); const orderCount = toNumber(row.order_count); if (weekday !== null && Number.isInteger(weekday) && purchaseWeekdays[weekday]) { purchaseWeekdays[weekday].value += orderCount; } if (hour !== null && Number.isInteger(hour) && purchaseHours[hour]) { purchaseHours[hour].value += orderCount; } }); return { purchaseWeekdays, purchaseHours }; }; const getOrderGroupKey = (row) => ( row.pedido_id || `${row.data_pedido || getDateOnly(row.data_pedido_date) || ''}_${row.valor_pedido || 0}` ); const getClientDetailsAnalytics = async (clientToken, range = {}) => { const customerKey = await resolveClientToken(clientToken); if (!customerKey) return null; const resolvedCustomerKey = await resolveCanonicalCustomerKey(customerKey); const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); const useHourlyChart = isSingleDayRange(normalizedStart, normalizedEnd); const periodParams = [resolvedCustomerKey]; const periodFilters = [ `${CUSTOMER_KEY_SQL} = $1`, 'data_pedido_date IS NOT NULL' ]; if (normalizedStart) { periodParams.push(normalizedStart); periodFilters.push(`data_pedido_date >= $${periodParams.length}::date`); } if (normalizedEnd) { periodParams.push(normalizedEnd); periodFilters.push(`data_pedido_date <= $${periodParams.length}::date`); } const [summaryResult, periodResult, patternResult] = await Promise.all([ pool.query(` ${CUSTOMER_IDENTITY_CTE} SELECT MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, MAX(NULLIF(cliente_fone, '')) as phone, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as all_time_order_count FROM identity_orders WHERE ${CUSTOMER_KEY_SQL} = $1 AND data_pedido_date IS NOT NULL; `, [resolvedCustomerKey]), pool.query(` ${CUSTOMER_IDENTITY_CTE} SELECT cliente_nome, cliente_fone, data_pedido, data_pedido_date, valor_pedido, produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_nome_fantasia, id_vendedor, nome_vendedor, marketplace, canal_venda, numero_ecommerce, created_at FROM identity_orders WHERE ${periodFilters.join(' AND ')} ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC; `, periodParams), pool.query(` ${CUSTOMER_IDENTITY_CTE} SELECT data_pedido, data_pedido_date, created_at, pedido_id, valor_pedido FROM identity_orders WHERE ${CUSTOMER_KEY_SQL} = $1 AND data_pedido_date IS NOT NULL ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC; `, [resolvedCustomerKey]) ]); const summary = summaryResult.rows[0] || {}; const allTimeOrderCount = toNumber(summary.all_time_order_count); if (!allTimeOrderCount) return null; const groupedOrdersByKey = new Map(); const spentByDate = new Map(); const spentByHour = Array.from({ length: 24 }, (_, hour) => ({ date: `${String(hour).padStart(2, '0')}h`, value: 0 })); const weekdayCounts = WEEKDAY_LABELS.map(label => ({ label, value: 0 })); const hourCounts = Array.from({ length: 24 }, (_, hour) => ({ label: `${String(hour).padStart(2, '0')}h`, value: 0 })); const patternOrderKeys = new Set(); let periodSpent = 0; let periodItems = 0; patternResult.rows.forEach(row => { const groupKey = getOrderGroupKey(row); if (patternOrderKeys.has(groupKey)) return; patternOrderKeys.add(groupKey); const weekdayIndex = getWeekdayIndex(row.data_pedido_date) ?? getWeekdayIndex(row.data_pedido); if (weekdayIndex !== null) { weekdayCounts[weekdayIndex].value += 1; } const orderHour = getHourFromTimestamp(row.created_at) ?? getHourFromTimestamp(row.data_pedido); if (orderHour !== null) { hourCounts[orderHour].value += 1; } }); periodResult.rows.forEach(row => { const itemRevenue = toNumber(row.quantidade) * toNumber(row.valor_unitario); const dateKey = getDateOnly(row.data_pedido_date) || getDateOnly(row.data_pedido) || ''; const dateLabel = row.data_pedido || dateKey; const groupKey = getOrderGroupKey(row); periodSpent += itemRevenue; periodItems += toNumber(row.quantidade); if (dateLabel) { const currentDateSpend = spentByDate.get(dateLabel) || { date: dateLabel, sortDate: dateKey, value: 0 }; currentDateSpend.value += itemRevenue; spentByDate.set(dateLabel, currentDateSpend); } if (useHourlyChart) { const orderHour = getHourFromTimestamp(row.created_at) ?? getHourFromTimestamp(row.data_pedido); if (orderHour !== null) { spentByHour[orderHour].value += itemRevenue; } } if (!groupedOrdersByKey.has(groupKey)) { groupedOrdersByKey.set(groupKey, { date: dateLabel, sortDate: dateKey, orderId: row.pedido_id || groupKey, orderTotal: 0, items: [] }); } const group = groupedOrdersByKey.get(groupKey); group.orderTotal += itemRevenue; group.items.push({ Nome_Cliente: row.cliente_nome || summary.name || 'Cliente Desconhecido', Data_Pedido: dateLabel, Valor_Pedido: toNumber(row.valor_pedido), ID_Produto: row.produto_id || '', Descricao_Produto: row.produto_descricao || 'Unknown', Quantidade: toNumber(row.quantidade), Valor_Unitario: toNumber(row.valor_unitario), ID_Pedido: row.pedido_id || '', Fone_Cliente: row.cliente_fone || '', cliente_nome_fantasia: row.cliente_nome_fantasia || '', id_vendedor: row.id_vendedor || '', nome_vendedor: row.nome_vendedor || '', marketplace: row.marketplace || '', canal_venda: row.canal_venda || '', numero_ecommerce: row.numero_ecommerce || '', Recebido_Em: row.created_at || '' }); }); const groupedOrders = [...groupedOrdersByKey.values()] .sort((a, b) => String(b.sortDate).localeCompare(String(a.sortDate))) .map(({ sortDate, ...group }) => group); const chartData = useHourlyChart ? spentByHour : [...spentByDate.values()] .sort((a, b) => String(a.sortDate).localeCompare(String(b.sortDate))) .map(({ sortDate, ...entry }) => entry); const periodOrderCount = groupedOrders.length; return { range: { start: normalizedStart, end: normalizedEnd }, clientToken, clientName: summary.name || 'Cliente Desconhecido', clientPhone: summary.phone || '', hasClient: true, allTimeOrderCount, periodSpent, periodAverageTicket: periodOrderCount ? periodSpent / periodOrderCount : 0, periodOrderCount, periodItems, chartData, purchaseWeekdays: weekdayCounts, purchaseHours: hourCounts, groupedOrders }; }; const getRfmAnalytics = async (range = {}) => { const { params, whereClause, endParamIndex } = buildDateFilter(range); const normalizedStart = normalizeDateParam(range.start); const normalizedEnd = normalizeDateParam(range.end); const periodRecencyReferenceDate = normalizedEnd && endParamIndex ? `$${endParamIndex}::date` : 'CURRENT_DATE'; const usePeriodAsHistory = !normalizedStart || normalizedStart <= '2000-01-01'; if (usePeriodAsHistory) { const clientRows = await getClientAnalytics({ ...range, start: undefined, end: normalizedEnd }); const recencyEnd = normalizedEnd || new Date().toISOString().slice(0, 10); const clients = buildRfmClients(clientRows.map(row => ({ customerKey: row.customerKey, clientToken: row.clientToken || createClientToken(row.customerKey), name: row.name, phone: row.phone || '', monetary: row.totalSpent, frequency: row.orderCount, quantityPurchased: row.quantityPurchased, lastPurchaseDate: row.lastPurchaseDate, recencyDays: getDateDiffDays(recencyEnd, getDateOnly(row.lastPurchaseDate)) }))); return { range: { start: normalizeDateParam(range.start), end: normalizeDateParam(range.end) }, clients, segments: buildRfmSegments(clients), matrix: { recencyScores: [3, 2, 1], valueScores: [1, 2, 3] } }; } const client = await pool.connect(); try { await client.query('BEGIN'); await client.query(`SET LOCAL statement_timeout = '${RFM_QUERY_TIMEOUT_MS}ms'`); const periodResult = await client.query(` ${CUSTOMER_IDENTITY_CTE} SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, MAX(NULLIF(cliente_fone, '')) as phone, COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, COALESCE(SUM(quantidade), 0) as quantity_purchased, MAX(data_pedido_date) as last_purchase_date, GREATEST((${periodRecencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days FROM identity_orders ${whereClause} GROUP BY customer_key ORDER BY monetary DESC; `, params); if (!periodResult.rows.length) { await client.query('COMMIT'); return { range: { start: normalizeDateParam(range.start), end: normalizeDateParam(range.end) }, clients: [], segments: buildRfmSegments([]), matrix: { recencyScores: [3, 2, 1], valueScores: [1, 2, 3] } }; } let historyRows = periodResult.rows; if (!usePeriodAsHistory) { const periodCustomerKeys = [...new Set(periodResult.rows .map(row => row.customer_key) .filter(Boolean))]; const historyParams = []; const recencyReferenceDate = normalizedEnd ? `$${historyParams.push(normalizedEnd)}::date` : 'CURRENT_DATE'; const historyFilters = [ 'data_pedido_date IS NOT NULL', `data_pedido_date <= ${recencyReferenceDate}` ]; appendOrderMetadataFilters(historyParams, historyFilters, range); const customerKeysParam = `$${historyParams.push(periodCustomerKeys)}::text[]`; const historyResult = await client.query(` ${CUSTOMER_IDENTITY_CTE}, customer_history AS ( SELECT ${CUSTOMER_KEY_SQL} as customer_key, MAX(COALESCE(NULLIF(cliente_nome, ''), 'Cliente Desconhecido')) as name, MAX(NULLIF(cliente_fone, '')) as phone, COALESCE(SUM(quantidade * valor_unitario), 0) as monetary, COUNT(DISTINCT COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text))::int as frequency, COALESCE(SUM(quantidade), 0) as quantity_purchased, MAX(data_pedido_date) as last_purchase_date, GREATEST((${recencyReferenceDate} - MAX(data_pedido_date))::int, 0) as recency_days FROM identity_orders WHERE ${historyFilters.join(' AND ')} GROUP BY customer_key ), scored_history AS MATERIALIZED ( SELECT customer_history.*, CASE WHEN frequency <= 1 THEN 1 WHEN frequency <= ${FREQUENCY_MEDIUM_MAX_ORDERS} THEN 2 ELSE 3 END as frequency_score, CASE WHEN COUNT(*) OVER () = 1 THEN 3 WHEN MIN(monetary) OVER () = MAX(monetary) OVER () THEN 2 ELSE LEAST(3, GREATEST(1, FLOOR(PERCENT_RANK() OVER (ORDER BY monetary) * 3)::int + 1)) END as monetary_score FROM customer_history ) SELECT * FROM scored_history WHERE customer_key = ANY(${customerKeysParam}); `, historyParams); historyRows = historyResult.rows; } const historyClients = buildRfmClients(historyRows.map(row => ({ customerKey: row.customer_key, clientToken: createClientToken(row.customer_key), name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), lastPurchaseDate: row.last_purchase_date, recencyDays: toNumber(row.recency_days), rfmFrequencyScore: row.frequency_score === undefined ? undefined : toNumber(row.frequency_score), rfmMonetaryScore: row.monetary_score === undefined ? undefined : toNumber(row.monetary_score) }))); const tagsByCustomerKey = new Map(historyClients.map(historyClient => [historyClient.customerKey, historyClient])); const clients = periodResult.rows.map(row => { const taggedClient = tagsByCustomerKey.get(row.customer_key); if (!taggedClient) { const [fallbackClient] = buildRfmClients([{ customerKey: row.customer_key, clientToken: createClientToken(row.customer_key), name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), lastPurchaseDate: row.last_purchase_date, recencyDays: toNumber(row.recency_days) }]); return fallbackClient; } return { ...taggedClient, customerKey: row.customer_key, clientToken: taggedClient.clientToken || createClientToken(row.customer_key), name: row.name, phone: row.phone || '', monetary: toNumber(row.monetary), frequency: toNumber(row.frequency), quantityPurchased: toNumber(row.quantity_purchased), lastPurchaseDate: row.last_purchase_date }; }).sort((a, b) => { if (b.recencyScore !== a.recencyScore) return b.recencyScore - a.recencyScore; if (b.valueScore !== a.valueScore) return b.valueScore - a.valueScore; return b.monetary - a.monetary; }); await persistClientTokenMappings(clients, client); await client.query('COMMIT'); return { range: { start: normalizeDateParam(range.start), end: normalizeDateParam(range.end) }, clients, segments: buildRfmSegments(clients), matrix: { recencyScores: [3, 2, 1], valueScores: [1, 2, 3] } }; } catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); } }; module.exports = { buildDateFilter, buildRfmClients, buildRfmSegments, createClientToken, isClientToken, getFrequencyScore, getClientDetailsAnalytics, getClientFilterOptions, getClientPurchasePatternAnalytics, getPreviousDate, getRecencyScore, getRfmAnalytics, getRfmSegment, getClientAnalytics, getDashboardAnalytics, getProductDetailsAnalytics, getProductAnalytics, normalizeDateParam, scoreTertile };