diff --git a/backend/db.js b/backend/db.js index d97898e..d1f1d09 100644 --- a/backend/db.js +++ b/backend/db.js @@ -27,6 +27,12 @@ const initDB = async () => { await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS pedido_id VARCHAR(100);`).catch(() => {}); await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS cliente_fone VARCHAR(50);`).catch(() => {}); await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS data_pedido_date DATE;`).catch(() => {}); + await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS cliente_nome_fantasia VARCHAR(255);`).catch(() => {}); + await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS id_vendedor VARCHAR(100);`).catch(() => {}); + await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS nome_vendedor VARCHAR(255);`).catch(() => {}); + await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS marketplace VARCHAR(255);`).catch(() => {}); + await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS canal_venda VARCHAR(255);`).catch(() => {}); + await pool.query(`ALTER TABLE orders ADD COLUMN IF NOT EXISTS numero_ecommerce VARCHAR(100);`).catch(() => {}); await pool.query(` ALTER TABLE orders diff --git a/backend/mappers/orderMapper.js b/backend/mappers/orderMapper.js index 517554c..d5f1f3d 100644 --- a/backend/mappers/orderMapper.js +++ b/backend/mappers/orderMapper.js @@ -8,7 +8,13 @@ const formatOrderRow = (row) => ({ Valor_Unitario: parseFloat(row.valor_unitario), Recebido_Em: row.created_at, ID_Pedido: row.pedido_id, - Fone_Cliente: row.cliente_fone + 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 || '' }); const normalizeOrderDate = (dateValue) => { @@ -35,6 +41,17 @@ const normalizeOrderDate = (dateValue) => { return date.toISOString().slice(0, 10); }; +const pickFirstValue = (item, fieldNames) => { + for (const fieldName of fieldNames) { + const value = item[fieldName]; + if (value !== undefined && value !== null && value !== '') { + return value; + } + } + + return ''; +}; + const normalizeOrderPayload = (item) => { const fallbackId = `${item.Nome_Cliente}_${item.Data_Pedido}_${item.Valor_Pedido}`; const orderId = item.id || item.ID_Pedido || (item.json && item.json.body && item.json.body.id) || fallbackId; @@ -51,7 +68,13 @@ const normalizeOrderPayload = (item) => { parseInt(item.Quantidade, 10) || 0, parseFloat(item.Valor_Unitario) || 0, String(orderId), - String(fone) + String(fone), + String(pickFirstValue(item, ['nome_fantasia', 'Nome_Fantasia', 'Cliente_Nome_Fantasia', 'cliente_nome_fantasia'])), + String(pickFirstValue(item, ['id_vendedor', 'ID_Vendedor'])), + String(pickFirstValue(item, ['nome_vendedor', 'Nome_Vendedor'])), + String(pickFirstValue(item, ['marketplace', 'Marketplace', 'nome_ecommerce', 'Nome_Ecommerce'])), + String(pickFirstValue(item, ['canal_venda', 'Canal_Venda'])), + String(pickFirstValue(item, ['numero_ecommerce', 'Numero_Ecommerce'])) ]; }; diff --git a/backend/services/analyticsService.js b/backend/services/analyticsService.js index c729c29..c085649 100644 --- a/backend/services/analyticsService.js +++ b/backend/services/analyticsService.js @@ -507,7 +507,13 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { produto_descricao, quantidade, valor_unitario, - pedido_id + pedido_id, + cliente_nome_fantasia, + id_vendedor, + nome_vendedor, + marketplace, + canal_venda, + numero_ecommerce FROM orders WHERE ${periodFilters.join(' AND ')} ORDER BY data_pedido_date DESC, COALESCE(NULLIF(pedido_id, ''), data_pedido || '_' || valor_pedido::text) DESC; @@ -559,7 +565,13 @@ const getClientDetailsAnalytics = async (clientToken, range = {}) => { Quantidade: toNumber(row.quantidade), Valor_Unitario: toNumber(row.valor_unitario), ID_Pedido: row.pedido_id || '', - Fone_Cliente: row.cliente_fone || '' + 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 || '' }); }); diff --git a/backend/services/ordersService.js b/backend/services/ordersService.js index 3839164..ba9f49f 100644 --- a/backend/services/ordersService.js +++ b/backend/services/ordersService.js @@ -15,8 +15,9 @@ const upsertOrders = async (payload) => { const insertQuery = ` INSERT INTO orders ( cliente_nome, data_pedido, data_pedido_date, valor_pedido, - produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + produto_id, produto_descricao, quantidade, valor_unitario, pedido_id, cliente_fone, + cliente_nome_fantasia, id_vendedor, nome_vendedor, marketplace, canal_venda, numero_ecommerce + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ON CONFLICT (pedido_id, produto_id) DO UPDATE SET cliente_nome = EXCLUDED.cliente_nome, data_pedido = EXCLUDED.data_pedido, @@ -26,6 +27,12 @@ const upsertOrders = async (payload) => { quantidade = EXCLUDED.quantidade, valor_unitario = EXCLUDED.valor_unitario, cliente_fone = EXCLUDED.cliente_fone, + cliente_nome_fantasia = EXCLUDED.cliente_nome_fantasia, + id_vendedor = EXCLUDED.id_vendedor, + nome_vendedor = EXCLUDED.nome_vendedor, + marketplace = EXCLUDED.marketplace, + canal_venda = EXCLUDED.canal_venda, + numero_ecommerce = EXCLUDED.numero_ecommerce, created_at = CURRENT_TIMESTAMP `; diff --git a/backend/test/orderMapper.test.js b/backend/test/orderMapper.test.js index 9dd2283..7fed056 100644 --- a/backend/test/orderMapper.test.js +++ b/backend/test/orderMapper.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const test = require('node:test'); -const { normalizeOrderDate, normalizeOrderPayload } = require('../mappers/orderMapper'); +const { formatOrderRow, normalizeOrderDate, normalizeOrderPayload } = require('../mappers/orderMapper'); test('normalizeOrderDate accepts Brazilian display dates', () => { assert.equal(normalizeOrderDate('28/05/2026'), '2026-05-28'); @@ -35,3 +35,83 @@ test('normalizeOrderPayload includes normalized date without changing display da assert.equal(payload[1], '28/05/2026'); assert.equal(payload[2], '2026-05-28'); }); + +test('normalizeOrderPayload maps Tiny ERP metadata aliases', () => { + const payload = normalizeOrderPayload({ + Nome_Cliente: 'Cliente Teste', + Data_Pedido: '28/05/2026', + Valor_Pedido: '120.50', + ID_Produto: 'SKU-1', + Descricao_Produto: 'Produto', + Quantidade: '2', + Valor_Unitario: '60.25', + ID_Pedido: 'ORDER-1', + Nome_Fantasia: 'Cliente Fantasia', + ID_Vendedor: 123, + Nome_Vendedor: 'Maria', + Nome_Ecommerce: 'Mercado Livre', + Canal_Venda: 'Online', + Numero_Ecommerce: 'EC-987' + }); + + assert.equal(payload[10], 'Cliente Fantasia'); + assert.equal(payload[11], '123'); + assert.equal(payload[12], 'Maria'); + assert.equal(payload[13], 'Mercado Livre'); + assert.equal(payload[14], 'Online'); + assert.equal(payload[15], 'EC-987'); +}); + +test('normalizeOrderPayload maps lower-case metadata names', () => { + const payload = normalizeOrderPayload({ + Nome_Cliente: 'Cliente Teste', + Data_Pedido: '28/05/2026', + Valor_Pedido: '120.50', + ID_Produto: 'SKU-1', + Descricao_Produto: 'Produto', + Quantidade: '2', + Valor_Unitario: '60.25', + ID_Pedido: 'ORDER-1', + cliente_nome_fantasia: 'Fantasia Lower', + id_vendedor: 'VEN-7', + nome_vendedor: 'Joao', + marketplace: 'Shopee', + canal_venda: 'Marketplace', + numero_ecommerce: '100200' + }); + + assert.equal(payload[10], 'Fantasia Lower'); + assert.equal(payload[11], 'VEN-7'); + assert.equal(payload[12], 'Joao'); + assert.equal(payload[13], 'Shopee'); + assert.equal(payload[14], 'Marketplace'); + assert.equal(payload[15], '100200'); +}); + +test('formatOrderRow returns Tiny ERP metadata fields', () => { + const row = formatOrderRow({ + cliente_nome: 'Cliente Teste', + data_pedido: '28/05/2026', + valor_pedido: '120.50', + produto_id: 'SKU-1', + produto_descricao: 'Produto', + quantidade: 2, + valor_unitario: '60.25', + created_at: '2026-05-28T12:00:00.000Z', + pedido_id: 'ORDER-1', + cliente_fone: '(16) 99999-9999', + cliente_nome_fantasia: 'Cliente Fantasia', + id_vendedor: '123', + nome_vendedor: 'Maria', + marketplace: 'Mercado Livre', + canal_venda: 'Online', + numero_ecommerce: 'EC-987' + }); + + assert.equal(row.cliente_nome_fantasia, 'Cliente Fantasia'); + assert.equal(row.id_vendedor, '123'); + assert.equal(row.nome_vendedor, 'Maria'); + assert.equal(row.marketplace, 'Mercado Livre'); + assert.equal(row.canal_venda, 'Online'); + assert.equal(row.numero_ecommerce, 'EC-987'); +}); diff --git a/backend/test/ordersService.test.js b/backend/test/ordersService.test.js new file mode 100644 index 0000000..a164fe0 --- /dev/null +++ b/backend/test/ordersService.test.js @@ -0,0 +1,83 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +test('upsertOrders persists Tiny ERP metadata columns', async () => { + const queries = []; + const client = { + query: async (sql, params) => { + queries.push({ sql, params }); + return { rows: [] }; + }, + release: () => { + queries.push({ sql: 'RELEASE' }); + } + }; + const dbPath = require.resolve('../db'); + const servicePath = require.resolve('../services/ordersService'); + const originalDbCache = require.cache[dbPath]; + const originalServiceCache = require.cache[servicePath]; + + delete require.cache[servicePath]; + require.cache[dbPath] = { + id: dbPath, + filename: dbPath, + loaded: true, + exports: { + pool: { + connect: async () => client + } + } + }; + + try { + const { upsertOrders } = require('../services/ordersService'); + + await upsertOrders([{ + Nome_Cliente: 'Cliente Teste', + Data_Pedido: '28/05/2026', + Valor_Pedido: '120.50', + ID_Produto: 'SKU-1', + Descricao_Produto: 'Produto', + Quantidade: '2', + Valor_Unitario: '60.25', + ID_Pedido: 'ORDER-1', + Fone_Cliente: '(16) 99999-9999', + cliente_nome_fantasia: 'Cliente Fantasia', + id_vendedor: 'VEN-1', + nome_vendedor: 'Maria', + marketplace: 'Mercado Livre', + canal_venda: 'Online', + numero_ecommerce: 'EC-987' + }]); + } finally { + delete require.cache[servicePath]; + if (originalServiceCache) { + require.cache[servicePath] = originalServiceCache; + } + if (originalDbCache) { + require.cache[dbPath] = originalDbCache; + } else { + delete require.cache[dbPath]; + } + } + + const insert = queries.find(query => query.sql.includes('INSERT INTO orders')); + + assert.ok(insert); + assert.match(insert.sql, /cliente_nome_fantasia/); + assert.match(insert.sql, /id_vendedor/); + assert.match(insert.sql, /nome_vendedor/); + assert.match(insert.sql, /marketplace/); + assert.match(insert.sql, /canal_venda/); + assert.match(insert.sql, /numero_ecommerce/); + assert.match(insert.sql, /cliente_nome_fantasia = EXCLUDED\.cliente_nome_fantasia/); + assert.equal(insert.params.length, 16); + assert.deepEqual(insert.params.slice(10), [ + 'Cliente Fantasia', + 'VEN-1', + 'Maria', + 'Mercado Livre', + 'Online', + 'EC-987' + ]); +}); diff --git a/src/pages/ClientDetails.tsx b/src/pages/ClientDetails.tsx index d5d2b30..9b65bb5 100644 --- a/src/pages/ClientDetails.tsx +++ b/src/pages/ClientDetails.tsx @@ -3,7 +3,7 @@ import { useParams, Link, useOutletContext } from 'react-router-dom'; import { ArrowLeft, User, Tag, Package, DollarSign, Clock, Phone, ChevronLeft, ChevronRight, ShoppingBag, ReceiptText } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import DateRangePicker from '../components/DateRangePicker'; -import type { ClientDetailsAnalytics, DateRange } from '../types'; +import type { ClientDetailsAnalytics, DateRange, OrderData } from '../types'; import { fetchClientDetailsAnalytics } from '../dataService'; type CustomTooltipProps = { @@ -26,6 +26,21 @@ const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { return null; }; +const getOrderMetadata = (order: OrderData) => { + const seller = [ + order.nome_vendedor, + order.id_vendedor ? `#${order.id_vendedor}` : '' + ].filter(Boolean).join(' '); + + return [ + order.cliente_nome_fantasia ? `Fantasia: ${order.cliente_nome_fantasia}` : '', + seller ? `Vendedor: ${seller}` : '', + order.marketplace ? `Marketplace: ${order.marketplace}` : '', + order.canal_venda ? `Canal: ${order.canal_venda}` : '', + order.numero_ecommerce ? `E-commerce: ${order.numero_ecommerce}` : '' + ].filter(Boolean); +}; + const ClientDetails = () => { const { clientToken } = useParams<{ clientToken: string }>(); const decodedClientToken = clientToken ? decodeURIComponent(clientToken) : ''; @@ -242,43 +257,56 @@ const ClientDetails = () => {
- {group.items.map((order, index) => ( -
-
-
-
- - ID: {order.ID_Produto} + {group.items.map((order, index) => { + const metadata = getOrderMetadata(order); + + return ( +
+
+
+
+ + ID: {order.ID_Produto} +
+ {order.Data_Pedido && ( +
+ + + Comprado: {order.Data_Pedido} + +
+ )}
- {order.Data_Pedido && ( -
- - - Comprado: {order.Data_Pedido} - +

{order.Descricao_Produto}

+ +
+
+ + Qtd: {order.Quantidade} +
+
+ + Preço: {formatCurrency(order.Valor_Unitario)} +
+
+ {metadata.length > 0 && ( +
+ {metadata.map(value => ( + + {value} + + ))}
)}
-

{order.Descricao_Produto}

-
-
- - Qtd: {order.Quantidade} -
-
- - Preço: {formatCurrency(order.Valor_Unitario)} -
+
+

Subtotal

+

{formatCurrency(order.Quantidade * order.Valor_Unitario)}

- -
-

Subtotal

-

{formatCurrency(order.Quantidade * order.Valor_Unitario)}

-
-
- ))} + ); + })}
))} diff --git a/src/types.ts b/src/types.ts index c144461..69ea53c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,12 @@ export interface OrderData { Recebido_Em?: string; ID_Pedido?: string; Fone_Cliente?: string; + cliente_nome_fantasia?: string; + id_vendedor?: string; + nome_vendedor?: string; + marketplace?: string; + canal_venda?: string; + numero_ecommerce?: string; } export interface StockData {