Add Tiny ERP order metadata ingestion
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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']))
|
||||
];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 || ''
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
83
backend/test/ordersService.test.js
Normal file
83
backend/test/ordersService.test.js
Normal file
@@ -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'
|
||||
]);
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user