All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m35s
1333 lines
50 KiB
JavaScript
1333 lines
50 KiB
JavaScript
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 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 periodParams = [normalizedProductId];
|
|
const periodFilters = [
|
|
'produto_id = $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] = 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
|
|
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
|
|
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(`
|
|
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
|
|
FROM orders
|
|
WHERE ${periodFilters.join(' AND ')}
|
|
GROUP BY data_pedido_date
|
|
ORDER BY data_pedido_date ASC;
|
|
`, periodParams)
|
|
]);
|
|
|
|
const summary = summaryResult.rows[0];
|
|
if (!summary) return null;
|
|
|
|
const chartData = periodResult.rows.map(row => ({
|
|
date: row.date_label || getDateOnly(row.data_pedido_date) || '',
|
|
value: toNumber(row.quantity_sold)
|
|
}));
|
|
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);
|
|
|
|
return {
|
|
range: {
|
|
start: normalizedStart,
|
|
end: normalizedEnd
|
|
},
|
|
productInfo: {
|
|
id: summary.id,
|
|
name: summary.name,
|
|
price: toNumber(summary.price)
|
|
},
|
|
chartData,
|
|
totalSold,
|
|
totalRevenue
|
|
};
|
|
};
|
|
|
|
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 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 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 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 (!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 = [...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,
|
|
getPreviousDate,
|
|
getRecencyScore,
|
|
getRfmAnalytics,
|
|
getRfmSegment,
|
|
getClientAnalytics,
|
|
getDashboardAnalytics,
|
|
getProductDetailsAnalytics,
|
|
getProductAnalytics,
|
|
normalizeDateParam,
|
|
scoreTertile
|
|
};
|