From 3cd4bfc426cac1d8ba9e67430e47e7edcf872d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 22 Jun 2026 15:30:54 -0300 Subject: [PATCH] Optimize products list analytics loading --- CONTEXT.md | 22 +++++++++ backend/services/analyticsService.js | 49 ++++++++++++++----- backend/test/analyticsService.test.js | 62 ++++++++++++++++++++++++ src/components/Layout.tsx | 2 +- src/dataService.ts | 17 ++++++- src/pages/Products.tsx | 68 +++++++++++++++++++++------ src/types.ts | 12 +++++ 7 files changed, 204 insertions(+), 28 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 3679808..5f6eadf 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -73,6 +73,10 @@ This project (often referred to as "Nexstar Graphs" or simply "Graphs") is a rea * **Campaign Observability:** The frontend has a `Campanhas` page backed by `/api/campaigns`, `/api/campaigns/preview`, `/api/campaigns/process`, and `/api/campaigns/retry`. * **WhatsApp Marketing Integration:** The system extracts phone numbers from incoming n8n payloads (checking `Fone_Cliente`, `fone`, or `celular`). Numbers are exposed in the UI for direct "Click-to-Chat" links and exported to CSV files. * **Filter Persistence:** User preferences for Date Ranges, Sort options, and Auto-Refresh intervals are persisted to `localStorage` to survive page reloads. +* **RFV Segmentation:** The Portuguese UI calls customer segmentation **RFV** (`Recência`, `Frequência`, `Valor`). Internal code/API names may still use `rfm` to avoid route/type churn. The visible app labels should use RFV. +* **RFV Date Semantics:** Date filters use local calendar-day boundaries and send stable `YYYY-MM-DD` query params. Backend analytics treats `start` and `end` as inclusive `data_pedido_date` bounds. +* **RFV Tag Aggregation Rule:** RFV is not recalculated from only the selected period. For a selected period, the backend first calculates each buyer's RFV tag using history **before the period starts**. It then aggregates only the purchases inside the selected period by that prior tag. Example: if a client was `Champions` before today and buys today, `Champions` gets +1 client and that period revenue. If an `Em Risco` client does not buy today, `Em Risco` does not get counted for today's filter. +* **RFV Display Rule:** The RFV table's `Última Compra` date is the purchase date inside the selected period. Date-only strings must be parsed as local dates in the frontend to avoid UTC shifting (`2026-06-15` must render as `15/06/2026`, not `14/06/2026`). The small recency text below it displays `Hoje`, `Ontem`, or `X dias` relative to the selected range end. ## 6. CI/CD & Deployment * **Gitea Actions:** A workflow located in `.gitea/workflows/deploy.yml` triggers on pushes to the `main` branch. @@ -110,6 +114,11 @@ cd backend npm test ``` +**Frontend Date/RFV Helper Test:** +```bash +node --experimental-strip-types --test src/dateRanges.test.ts +``` + **Persistent Local Stack:** ```bash docker compose up -d --build @@ -124,3 +133,16 @@ The local Docker services use `restart: unless-stopped`, so containers should co * **Database Migrations:** There is no ORM (like Prisma or Sequelize). Table schemas, indexes, and lightweight data repairs are managed via raw SQL statements inside `initDB()` in `backend/db.js` using `IF NOT EXISTS` and safe startup execution patterns. * **API Security:** All backend modifications exposing or altering data MUST use the `verifyToken` middleware for frontend requests or `authenticateAPIKey` for external n8n webhooks. * **Build Discipline:** After frontend/backend behavior changes, run `npm run lint`, `npm run build`, and relevant backend tests. The user prefers builds after changes to catch issues before deployment. + +## 9. Recent RFV Work +Recent commits related to RFV/date behavior: +* `73033ca Aggregate RFM period buyers by prior tag` - backend RFV aggregation now groups period buyers by their prior tag. +* `ae73a54 Fix RFM period date display` - frontend date-only display and period recency fixed. +* `9a58b2a Improve RFM recency labels` - period recency labels show `Hoje`/`Ontem`/`X dias`. +* `f811d3c Rename visible RFM labels to RFV` - visible UI wording changed from RFM to RFV. + +Files most relevant to RFV: +* `backend/services/analyticsService.js` - `getRfmAnalytics`, date filters, RFV score/tag logic. +* `backend/test/analyticsService.test.js` - unit coverage for date filters and RFV aggregation. +* `src/pages/Rfm.tsx` - RFV page UI, matrix/table/export labels, period recency display. +* `src/components/DateRangePicker.tsx` and `src/dateRanges.ts` - local calendar date ranges and ISO query param formatting. diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index c085649..c835b84 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -401,19 +401,42 @@ const getDashboardAnalytics = async (range = {}) => { 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(${PRODUCT_NAME_SQL}, 'Unknown') as name, - MAX(produto_id) as id, - 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 - FROM orders - ${whereClause} - GROUP BY name - ORDER BY revenue DESC, quantity_sold DESC - LIMIT 500; + 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 => ({ @@ -422,6 +445,8 @@ const getProductAnalytics = async (range = {}) => { 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 })); diff --git a/backend/test/analyticsService.test.js b/backend/test/analyticsService.test.js index 90dd8df..4cce14b 100644 --- a/backend/test/analyticsService.test.js +++ b/backend/test/analyticsService.test.js @@ -11,6 +11,7 @@ const { getClientAnalytics, getClientDetailsAnalytics, getPreviousDate, + getProductAnalytics, getRecencyScore, getRfmAnalytics, getRfmSegment, @@ -330,6 +331,67 @@ test('buildRfmClients applies lifecycle protections to new, hibernating, at-risk assert.equal(byKey.get('lost').rfmScore, '113'); }); +test('getProductAnalytics returns exact product rows with stock and latest price', async () => { + const originalQuery = pool.query; + const calls = []; + + pool.query = async (sql, params = []) => { + calls.push({ sql, params }); + + return { + rows: [ + { + id: '919483307', + name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G', + quantity_sold: 9513, + revenue: 113216.83, + order_line_count: 100, + last_price: 11.9, + stock: 11731, + first_sale_date: '2026-06-01', + last_sale_date: '2026-06-22' + }, + { + id: 'stock-only', + name: 'Produto sem venda no período', + quantity_sold: 0, + revenue: 0, + order_line_count: 0, + last_price: 0, + stock: 12, + first_sale_date: null, + last_sale_date: null + } + ] + }; + }; + + try { + const products = await getProductAnalytics({ start: '2026-06-01', end: '2026-06-22' }); + + assert.equal(calls.length, 1); + assert.match(calls[0].sql, /WITH period_sales AS/); + assert.match(calls[0].sql, /FULL OUTER JOIN stock_rows/); + assert.doesNotMatch(calls[0].sql, /GROUP BY name/); + assert.deepEqual(calls[0].params, ['2026-06-01', '2026-06-22']); + assert.deepEqual(products[0], { + id: '919483307', + name: 'BASE LISA CAMISETA COR PRETO TAMANHO - G', + quantitySold: 9513, + revenue: 113216.83, + orderLineCount: 100, + lastPrice: 11.9, + stock: 11731, + firstSaleDate: '2026-06-01', + lastSaleDate: '2026-06-22' + }); + assert.equal(products[1].id, 'stock-only'); + assert.equal(products[1].stock, 12); + } finally { + pool.query = originalQuery; + } +}); + test('getClientAnalytics returns opaque client tokens', async () => { const originalQuery = pool.query; const calls = []; diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 12c67ba..39ae4b6 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -7,7 +7,7 @@ import { rangeForLastDays } from '../dateRanges'; const Layout = () => { const location = useLocation(); - const needsRawData = location.pathname.startsWith('/products'); + const needsRawData = location.pathname.startsWith('/products/'); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { return localStorage.getItem('graph_sidebar_collapsed') === 'true'; }); diff --git a/src/dataService.ts b/src/dataService.ts index f3af46d..88e1248 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, RfmAnalytics, StockData } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, RfmAnalytics, StockData } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -124,6 +124,21 @@ export const fetchDashboardAnalytics = async (dateRange: DateRange): Promise => { + try { + const params = new URLSearchParams({ + start: formatDateParam(dateRange.start), + end: formatDateParam(dateRange.end) + }); + const response = await authFetch(`/analytics/products?${params.toString()}`); + if (!response.ok) return []; + return await response.json(); + } catch (error) { + console.error('Fetch product analytics failed', error); + return []; + } +}; + export const fetchRfmAnalytics = async (dateRange: DateRange): Promise => { try { const params = new URLSearchParams({ diff --git a/src/pages/Products.tsx b/src/pages/Products.tsx index 30556be..fae105d 100644 --- a/src/pages/Products.tsx +++ b/src/pages/Products.tsx @@ -1,30 +1,63 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; -import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download } from 'lucide-react'; +import { Search, Package, TrendingUp, ChevronLeft, ChevronRight, Download, Loader2 } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; -import type { OrderData, DateRange, StockData } from '../types'; -import { exportToCSV } from '../dataService'; -import { buildProductsSummary } from '../analytics/products'; +import type { DateRange, ProductAnalyticsItem } from '../types'; +import { exportToCSV, fetchProductAnalytics } from '../dataService'; +import type { ProductSummary } from '../analytics/products'; const Products = () => { - const { dateRange, setDateRange, ordersData, stockData } = useOutletContext<{ + const { dateRange, setDateRange } = useOutletContext<{ dateRange: DateRange, - setDateRange: (range: DateRange) => void, - ordersData: OrderData[], - stockData: StockData[], - refreshInterval: number, - setRefreshInterval: (interval: number) => void, - loadData: (showLoading?: boolean) => void + setDateRange: (range: DateRange) => void }>(); const [searchTerm, setSearchTerm] = useState(''); + const [productAnalytics, setProductAnalytics] = useState([]); + const [isLoading, setIsLoading] = useState(true); // Pagination state const [currentPage, setCurrentPage] = useState(1); const [itemsPerPage, setItemsPerPage] = useState(10); + useEffect(() => { + let isMounted = true; + + const loadProducts = async () => { + setIsLoading(true); + const products = await fetchProductAnalytics(dateRange); + + if (isMounted) { + setProductAnalytics(products); + setIsLoading(false); + } + }; + + void loadProducts(); + + return () => { + isMounted = false; + }; + }, [dateRange]); + const productsData = useMemo(() => { - return buildProductsSummary(ordersData, stockData, dateRange, searchTerm); - }, [dateRange, searchTerm, ordersData, stockData]); + const normalizedSearch = searchTerm.trim().toLowerCase(); + const products: ProductSummary[] = productAnalytics.map(product => ({ + id: product.id, + name: product.name, + totalSold: product.quantitySold, + revenue: product.revenue, + lastPrice: product.lastPrice, + stock: product.stock + })); + const filteredProducts = normalizedSearch + ? products.filter(product => + product.name.toLowerCase().includes(normalizedSearch) || + product.id.toLowerCase().includes(normalizedSearch) + ) + : products; + + return filteredProducts.sort((a, b) => b.totalSold - a.totalSold); + }, [productAnalytics, searchTerm]); // Pagination logic const totalPages = Math.ceil(productsData.length / itemsPerPage); @@ -86,6 +119,13 @@ const Products = () => { + {isLoading && ( +
+ + Atualizando produtos +
+ )} +
diff --git a/src/types.ts b/src/types.ts index 69ea53c..f7da0d5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -69,6 +69,18 @@ export interface DashboardAnalytics { }>; } +export interface ProductAnalyticsItem { + id: string; + name: string; + quantitySold: number; + revenue: number; + orderLineCount: number; + lastPrice: number; + stock: number; + firstSaleDate: string | null; + lastSaleDate: string | null; +} + export interface ClientAnalyticsItem { customerKey: string; clientToken: string;