Compare commits

...

2 Commits

Author SHA1 Message Date
Cauê Faleiros
bd98348989 Preserve existing order metadata on empty upserts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m21s
2026-06-22 15:15:44 -03:00
Cauê Faleiros
3568e48ce9 Add Tiny ERP order metadata ingestion 2026-06-22 15:02:38 -03:00
8 changed files with 341 additions and 39 deletions

View File

@@ -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

View File

@@ -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']))
];
};

View File

@@ -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 || ''
});
});

View File

@@ -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,
@@ -25,7 +26,13 @@ const upsertOrders = async (payload) => {
produto_descricao = EXCLUDED.produto_descricao,
quantidade = EXCLUDED.quantidade,
valor_unitario = EXCLUDED.valor_unitario,
cliente_fone = EXCLUDED.cliente_fone,
cliente_fone = COALESCE(NULLIF(EXCLUDED.cliente_fone, ''), orders.cliente_fone),
cliente_nome_fantasia = COALESCE(NULLIF(EXCLUDED.cliente_nome_fantasia, ''), orders.cliente_nome_fantasia),
id_vendedor = COALESCE(NULLIF(EXCLUDED.id_vendedor, ''), orders.id_vendedor),
nome_vendedor = COALESCE(NULLIF(EXCLUDED.nome_vendedor, ''), orders.nome_vendedor),
marketplace = COALESCE(NULLIF(EXCLUDED.marketplace, ''), orders.marketplace),
canal_venda = COALESCE(NULLIF(EXCLUDED.canal_venda, ''), orders.canal_venda),
numero_ecommerce = COALESCE(NULLIF(EXCLUDED.numero_ecommerce, ''), orders.numero_ecommerce),
created_at = CURRENT_TIMESTAMP
`;

View File

@@ -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');
});

View File

@@ -0,0 +1,140 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const runUpsertWithPayload = async (payload) => {
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(payload);
} finally {
delete require.cache[servicePath];
if (originalServiceCache) {
require.cache[servicePath] = originalServiceCache;
}
if (originalDbCache) {
require.cache[dbPath] = originalDbCache;
} else {
delete require.cache[dbPath];
}
}
return queries.find(query => query.sql.includes('INSERT INTO orders'));
};
const assertPreservesEmptyConflictValue = (sql, fieldName) => {
assert.match(
sql,
new RegExp(`${fieldName} = COALESCE\\(NULLIF\\(EXCLUDED\\.${fieldName}, ''\\), orders\\.${fieldName}\\)`)
);
};
test('upsertOrders persists non-empty Tiny ERP metadata columns', async () => {
const insert = await runUpsertWithPayload([{
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'
}]);
assert.ok(insert);
assert.equal(insert.params.length, 16);
assert.deepEqual(insert.params.slice(9), [
'(16) 99999-9999',
'Cliente Fantasia',
'VEN-1',
'Maria',
'Mercado Livre',
'Online',
'EC-987'
]);
});
test('upsertOrders preserves existing phone and metadata when incoming values are empty', async () => {
const insert = await runUpsertWithPayload([{
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: '',
cliente_nome_fantasia: '',
id_vendedor: '',
nome_vendedor: '',
marketplace: '',
canal_venda: '',
numero_ecommerce: ''
}]);
assert.ok(insert);
[
'cliente_fone',
'cliente_nome_fantasia',
'id_vendedor',
'nome_vendedor',
'marketplace',
'canal_venda',
'numero_ecommerce'
].forEach(fieldName => assertPreservesEmptyConflictValue(insert.sql, fieldName));
assert.deepEqual(insert.params.slice(9), ['', '', '', '', '', '', '']);
});
test('upsertOrders keeps existing payloads without Tiny ERP metadata working', async () => {
const insert = await runUpsertWithPayload([{
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'
}]);
assert.ok(insert);
assert.equal(insert.params.length, 16);
assert.deepEqual(insert.params.slice(9), ['', '', '', '', '', '', '']);
assert.match(insert.sql, /quantidade = EXCLUDED\.quantidade/);
assert.match(insert.sql, /valor_unitario = EXCLUDED\.valor_unitario/);
assertPreservesEmptyConflictValue(insert.sql, 'cliente_fone');
assertPreservesEmptyConflictValue(insert.sql, 'cliente_nome_fantasia');
});

View File

@@ -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 = () => {
</div>
<div className="divide-y divide-zinc-100 dark:divide-dark-border">
{group.items.map((order, index) => (
<div key={`${order.ID_Produto}-${index}`} className="px-4 py-2 flex flex-col md:flex-row md:items-center justify-between gap-3 hover:bg-zinc-50/50 dark:hover:bg-dark-input/30 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<div className="flex items-center gap-1">
<Tag className="w-3 h-3 text-zinc-400 dark:text-dark-muted" />
<span className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted">ID: {order.ID_Produto}</span>
{group.items.map((order, index) => {
const metadata = getOrderMetadata(order);
return (
<div key={`${order.ID_Produto}-${index}`} className="px-4 py-2 flex flex-col md:flex-row md:items-center justify-between gap-3 hover:bg-zinc-50/50 dark:hover:bg-dark-input/30 transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<div className="flex items-center gap-1">
<Tag className="w-3 h-3 text-zinc-400 dark:text-dark-muted" />
<span className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted">ID: {order.ID_Produto}</span>
</div>
{order.Data_Pedido && (
<div className="flex items-center gap-1 ml-2">
<Clock className="w-3 h-3 text-zinc-400 dark:text-dark-muted" />
<span className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted">
Comprado: {order.Data_Pedido}
</span>
</div>
)}
</div>
{order.Data_Pedido && (
<div className="flex items-center gap-1 ml-2">
<Clock className="w-3 h-3 text-zinc-400 dark:text-dark-muted" />
<span className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted">
Comprado: {order.Data_Pedido}
</span>
<h3 className="text-sm font-bold text-zinc-900 dark:text-dark-text truncate mb-1">{order.Descricao_Produto}</h3>
<div className="flex gap-4">
<div className="flex items-center gap-1.5 text-[11px]">
<Package className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" />
<span className="text-zinc-500 dark:text-dark-muted font-medium">Qtd: <span className="text-zinc-900 dark:text-dark-text font-bold">{order.Quantidade}</span></span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<DollarSign className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" />
<span className="text-zinc-500 dark:text-dark-muted font-medium">Preço: <span className="text-zinc-900 dark:text-dark-text font-bold">{formatCurrency(order.Valor_Unitario)}</span></span>
</div>
</div>
{metadata.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
{metadata.map(value => (
<span key={value} className="max-w-full break-all rounded-md border border-zinc-200 dark:border-dark-border px-2 py-0.5 text-[10px] font-bold text-zinc-500 dark:text-dark-muted">
{value}
</span>
))}
</div>
)}
</div>
<h3 className="text-sm font-bold text-zinc-900 dark:text-dark-text truncate mb-1">{order.Descricao_Produto}</h3>
<div className="flex gap-4">
<div className="flex items-center gap-1.5 text-[11px]">
<Package className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" />
<span className="text-zinc-500 dark:text-dark-muted font-medium">Qtd: <span className="text-zinc-900 dark:text-dark-text font-bold">{order.Quantidade}</span></span>
</div>
<div className="flex items-center gap-1.5 text-[11px]">
<DollarSign className="w-3.5 h-3.5 text-zinc-400 dark:text-dark-muted" />
<span className="text-zinc-500 dark:text-dark-muted font-medium">Preço: <span className="text-zinc-900 dark:text-dark-text font-bold">{formatCurrency(order.Valor_Unitario)}</span></span>
</div>
<div className="text-right shrink-0">
<p className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest mb-0.5">Subtotal</p>
<p className="text-base font-bold text-zinc-900 dark:text-dark-text">{formatCurrency(order.Quantidade * order.Valor_Unitario)}</p>
</div>
</div>
<div className="text-right shrink-0">
<p className="text-[10px] font-bold text-zinc-400 dark:text-dark-muted uppercase tracking-widest mb-0.5">Subtotal</p>
<p className="text-base font-bold text-zinc-900 dark:text-dark-text">{formatCurrency(order.Quantidade * order.Valor_Unitario)}</p>
</div>
</div>
))}
);
})}
</div>
</div>
))}

View File

@@ -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 {