Files
sgse-app/packages/backend/convex/actions/buscarInfoProduto.ts

623 lines
16 KiB
TypeScript

'use node';
import { action } from '../_generated/server';
import { v } from 'convex/values';
import { api, internal } from '../_generated/api';
import { decryptSMTPPassword } from '../auth/utils';
interface OpenFoodFactsProduct {
product?: {
product_name?: string;
product_name_pt?: string;
generic_name?: string;
generic_name_pt?: string;
categories?: string;
categories_tags?: string[];
image_url?: string;
image_front_url?: string;
image_front_small_url?: string;
brands?: string;
quantity?: string;
packaging?: string;
};
status?: number;
status_verbose?: string;
}
interface GS1BrasilProduct {
gtin?: string;
description?: string;
brand?: string;
brandName?: string;
category?: string;
categoryName?: string;
imageUrl?: string;
image?: string;
ncm?: string;
unidadeMedida?: string;
pesoBruto?: string;
}
interface BluesoftProduct {
gtin?: number | string;
description?: string;
brand?: {
name?: string;
picture?: string;
};
gpc?: {
code?: string;
description?: string;
};
ncm?: {
code?: string;
description?: string;
full_description?: string;
};
thumbnail?: string;
price?: string;
avg_price?: number;
max_price?: number;
gross_weight?: number;
net_weight?: number;
height?: number;
width?: number;
length?: number;
}
interface ProductSearchProduct {
gtin?: string;
name?: string;
description?: string;
brand?: string;
category?: string;
imageUrl?: string;
image?: string;
}
interface BuscaUnificadaProduct {
produto?: {
nome?: string;
descricao?: string;
categoria?: string;
marca?: string;
imagem?: string;
imagemUrl?: string;
ean?: string;
};
sucesso?: boolean;
mensagem?: string;
}
interface ProductInfo {
nome?: string;
descricao?: string;
categoria?: string;
imagemUrl?: string;
marca?: string;
quantidade?: string;
embalagem?: string;
fonte?: string;
}
interface GS1TokenResponse {
access_token?: string;
token_type?: string;
expires_in?: number;
}
/**
* Busca informações de produto via múltiplas APIs externas
* Fontes: Open Food Facts, Busca Unificada, GS1 Brasil, Bluesoft Cosmo, Product-Search.net
*
* A busca é feita em paralelo em todas as fontes disponíveis e retorna
* o primeiro resultado válido encontrado.
*/
interface ConfigBuscaCodigoBarras {
gs1BrasilClientId?: string;
gs1BrasilClientSecret?: string;
gs1BrasilTokenUrl?: string;
gs1BrasilApiUrl?: string;
gs1BrasilAtivo: boolean;
bluesoftApiKey?: string;
bluesoftApiUrl?: string;
bluesoftAtivo: boolean;
productSearchApiKey?: string;
productSearchApiUrl?: string;
productSearchAtivo: boolean;
}
export const buscarInfoProdutoPorCodigoBarras = action({
args: {
codigoBarras: v.string()
},
handler: async (ctx, args): Promise<ProductInfo | null> => {
const { codigoBarras } = args;
// Validar formato básico de código de barras (EAN-13, UPC, etc.)
if (!codigoBarras || codigoBarras.length < 8 || codigoBarras.length > 14) {
return null;
}
// Obter configurações do banco de dados
const configDb = await ctx.runQuery(
internal.configuracaoBuscaCodigoBarras.obterConfigBuscaCodigoBarrasInternal
);
// Preparar configurações (com descriptografia de credenciais)
let config: ConfigBuscaCodigoBarras = {
gs1BrasilAtivo: false,
bluesoftAtivo: false,
productSearchAtivo: false
};
if (configDb) {
// Descriptografar credenciais se existirem
let gs1BrasilClientSecret: string | undefined;
if (configDb.gs1BrasilClientSecret) {
try {
gs1BrasilClientSecret = await decryptSMTPPassword(configDb.gs1BrasilClientSecret);
} catch (error) {
console.error('Erro ao descriptografar GS1 Client Secret:', error);
}
}
let bluesoftApiKey: string | undefined;
if (configDb.bluesoftApiKey) {
try {
bluesoftApiKey = await decryptSMTPPassword(configDb.bluesoftApiKey);
} catch (error) {
console.error('Erro ao descriptografar Bluesoft API Key:', error);
}
}
let productSearchApiKey: string | undefined;
if (configDb.productSearchApiKey) {
try {
productSearchApiKey = await decryptSMTPPassword(configDb.productSearchApiKey);
} catch (error) {
console.error('Erro ao descriptografar Product-Search API Key:', error);
}
}
config = {
gs1BrasilClientId: configDb.gs1BrasilClientId,
gs1BrasilClientSecret,
gs1BrasilTokenUrl: configDb.gs1BrasilTokenUrl,
gs1BrasilApiUrl: configDb.gs1BrasilApiUrl,
gs1BrasilAtivo: configDb.gs1BrasilAtivo,
bluesoftApiKey,
bluesoftApiUrl: configDb.bluesoftApiUrl,
bluesoftAtivo: configDb.bluesoftAtivo,
productSearchApiKey,
productSearchApiUrl: configDb.productSearchApiUrl,
productSearchAtivo: configDb.productSearchAtivo
};
}
// Tentar buscar em todas as fontes em paralelo
// Usamos Promise.allSettled para não falhar se uma fonte falhar
const resultados = await Promise.allSettled([
buscarOpenFoodFacts(codigoBarras), // 0
buscarBuscaUnificada(codigoBarras), // 1 - Gratuita, sem credenciais
buscarGS1Brasil(codigoBarras, config), // 2
buscarBluesoftCosmo(codigoBarras, config), // 3
buscarProductSearch(codigoBarras, config) // 4
]);
// Processar resultados e retornar o primeiro que tiver dados válidos
// Prioridade: GS1 Brasil > Bluesoft > Product-Search > Busca Unificada > Open Food Facts
const ordemPrioridade = [2, 3, 4, 1, 0]; // Índices das fontes por prioridade
for (const indice of ordemPrioridade) {
const resultado = resultados[indice];
if (resultado.status === 'fulfilled' && resultado.value) {
return resultado.value;
}
}
// Se nenhuma fonte retornou dados, retornar null
return null;
}
});
/**
* Busca na API de Produtos por Código de Barras (Busca Unificada)
* Gratuita, sem necessidade de credenciais
* Site: https://api-produtos.seunegocionanuvem.com.br
*/
async function buscarBuscaUnificada(codigoBarras: string): Promise<ProductInfo | null> {
try {
const apiUrl = 'https://api-produtos.seunegocionanuvem.com.br/api/produtos';
const response = await fetch(`${apiUrl}?ean=${codigoBarras}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)'
},
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
return null;
}
const data = (await response.json()) as BuscaUnificadaProduct;
if (!data.produto || (!data.produto.nome && !data.produto.descricao)) {
return null;
}
const produto = data.produto;
const info: ProductInfo = {
nome: produto.nome || undefined,
descricao: produto.descricao || undefined,
categoria: produto.categoria || undefined,
imagemUrl: produto.imagemUrl || produto.imagem || undefined,
marca: produto.marca || undefined,
fonte: 'Busca Unificada'
};
if (info.nome || info.descricao) {
return info;
}
return null;
} catch (error) {
console.error('Erro ao buscar Busca Unificada:', error);
return null;
}
}
/**
* Busca na Open Food Facts (gratuita, sem autenticação)
*/
async function buscarOpenFoodFacts(codigoBarras: string): Promise<ProductInfo | null> {
try {
const response = await fetch(
`https://world.openfoodfacts.org/api/v0/product/${codigoBarras}.json`,
{
method: 'GET',
headers: {
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)'
},
signal: AbortSignal.timeout(5000)
}
);
if (!response.ok) {
return null;
}
const data = (await response.json()) as OpenFoodFactsProduct;
if (data.status !== 1 || !data.product) {
return null;
}
const product = data.product;
// Extrair categoria
let categoria: string | undefined;
if (product.categories_tags && product.categories_tags.length > 0) {
const primeiraCategoria = product.categories_tags[0];
categoria = primeiraCategoria
.replace(/^pt:/, '')
.replace(/^en:/, '')
.replace(/-/g, ' ')
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
} else if (product.categories) {
categoria = product.categories.split(',')[0].trim();
}
const info: ProductInfo = {
nome: product.product_name_pt || product.product_name || undefined,
descricao: product.generic_name_pt || product.generic_name || undefined,
categoria,
imagemUrl:
product.image_front_url || product.image_url || product.image_front_small_url || undefined,
marca: product.brands || undefined,
quantidade: product.quantity || undefined,
embalagem: product.packaging || undefined,
fonte: 'Open Food Facts'
};
if (info.nome || info.descricao) {
return info;
}
return null;
} catch (error) {
console.error('Erro ao buscar Open Food Facts:', error);
return null;
}
}
/**
* Obtém token de autenticação da GS1 Brasil
*/
async function obterTokenGS1Brasil(config: ConfigBuscaCodigoBarras): Promise<string | null> {
try {
// Priorizar configurações do banco, fallback para variáveis de ambiente
const clientId = config.gs1BrasilClientId || process.env.GS1_BRASIL_CLIENT_ID;
const clientSecret = config.gs1BrasilClientSecret || process.env.GS1_BRASIL_CLIENT_SECRET;
const tokenUrl =
config.gs1BrasilTokenUrl ||
process.env.GS1_BRASIL_TOKEN_URL ||
'https://apicnp.gs1br.org/oauth/token';
if (!clientId || !clientSecret) {
console.warn('GS1 Brasil: Credenciais não configuradas');
return null;
}
const response = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret
}),
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
console.error('GS1 Brasil: Erro ao obter token', response.status);
return null;
}
const data = (await response.json()) as GS1TokenResponse;
return data.access_token || null;
} catch (error) {
console.error('GS1 Brasil: Erro ao obter token:', error);
return null;
}
}
/**
* Busca na GS1 Brasil (API Verified by GS1)
*/
async function buscarGS1Brasil(
codigoBarras: string,
config: ConfigBuscaCodigoBarras
): Promise<ProductInfo | null> {
try {
if (!config.gs1BrasilAtivo) {
return null;
}
const token = await obterTokenGS1Brasil(config);
if (!token) {
return null;
}
const apiUrl =
config.gs1BrasilApiUrl ||
process.env.GS1_BRASIL_API_URL ||
'https://apicnp.gs1br.org/api/v1/products';
const response = await fetch(`${apiUrl}/${codigoBarras}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)'
},
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
if (response.status === 404) {
return null; // Produto não encontrado
}
console.error('GS1 Brasil: Erro na consulta', response.status);
return null;
}
const data = (await response.json()) as GS1BrasilProduct;
if (!data.gtin && !data.description) {
return null;
}
const info: ProductInfo = {
nome: data.description || undefined,
descricao: data.description || undefined,
categoria: data.categoryName || data.category || undefined,
imagemUrl: data.imageUrl || data.image || undefined,
marca: data.brandName || data.brand || undefined,
quantidade: data.unidadeMedida || undefined,
fonte: 'GS1 Brasil'
};
if (info.nome || info.descricao) {
return info;
}
return null;
} catch (error) {
console.error('Erro ao buscar GS1 Brasil:', error);
return null;
}
}
/**
* Busca na Bluesoft Cosmos
* Documentação oficial: https://api.cosmos.bluesoft.com.br/api
* Endpoint: GET /gtins/{código}.json
*/
async function buscarBluesoftCosmo(
codigoBarras: string,
config: ConfigBuscaCodigoBarras
): Promise<ProductInfo | null> {
try {
if (!config.bluesoftAtivo) {
return null;
}
// Priorizar configurações do banco, fallback para variáveis de ambiente
const apiKey = config.bluesoftApiKey || process.env.BLUESOFT_API_KEY;
// Endpoint base da API - pode ser configurado, mas o padrão é o oficial
const apiBaseUrl =
config.bluesoftApiUrl ||
process.env.BLUESOFT_API_URL ||
'https://api.cosmos.bluesoft.com.br';
if (!apiKey) {
console.warn('Bluesoft Cosmos: API Key não configurada');
return null;
}
// Endpoint correto conforme documentação: /gtins/{codigo}.json
const url = `${apiBaseUrl.replace(/\/$/, '')}/gtins/${codigoBarras}.json`;
const response = await fetch(url, {
method: 'GET',
headers: {
'X-Cosmos-Token': apiKey,
'Content-Type': 'application/json',
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)' // User-Agent é obrigatório
},
signal: AbortSignal.timeout(10000) // 10 segundos de timeout
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Não foi possível ler resposta');
if (response.status === 404) {
// Produto não encontrado é normal, não é erro
return null;
}
if (response.status === 401 || response.status === 403) {
console.error('Bluesoft Cosmos: Erro de autenticação. Verifique a API Key e User-Agent.');
console.error('Bluesoft Cosmos: Detalhes do erro', {
status: response.status,
statusText: response.statusText,
body: errorText.substring(0, 500)
});
return null;
}
if (response.status === 429) {
console.error('Bluesoft Cosmos: Limite de requisições excedido (HTTP 429)');
return null;
}
console.error('Bluesoft Cosmos: Erro na consulta', {
status: response.status,
statusText: response.statusText,
url,
body: errorText.substring(0, 500)
});
return null;
}
const data = (await response.json()) as BluesoftProduct;
// Validar se temos dados mínimos
if (!data.gtin && !data.description) {
return null;
}
// Mapear campos conforme estrutura da API
const info: ProductInfo = {
nome: data.description || undefined,
descricao: data.description || undefined,
categoria: data.gpc?.description || data.ncm?.description || undefined,
imagemUrl: data.thumbnail || undefined,
marca: data.brand?.name || undefined,
quantidade: data.net_weight ? `${data.net_weight}g` : data.gross_weight ? `${data.gross_weight}g` : undefined,
fonte: 'Bluesoft Cosmos'
};
if (info.nome || info.descricao) {
return info;
}
return null;
} catch (error) {
console.error('Bluesoft Cosmos: Erro ao buscar:', error);
if (error instanceof Error) {
console.error('Bluesoft Cosmos: Detalhes do erro:', error.message);
}
return null;
}
}
/**
* Busca no Product-Search.net
* Pode funcionar com ou sem API key dependendo do plano
*/
async function buscarProductSearch(
codigoBarras: string,
config: ConfigBuscaCodigoBarras
): Promise<ProductInfo | null> {
try {
if (!config.productSearchAtivo) {
return null;
}
// Priorizar configurações do banco, fallback para variáveis de ambiente
const apiKey = config.productSearchApiKey || process.env.PRODUCT_SEARCH_API_KEY;
const apiUrl =
config.productSearchApiUrl ||
process.env.PRODUCT_SEARCH_API_URL ||
'https://api.product-search.net/v1/products';
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'SGSE-App/1.0 (Almoxarifado)'
};
// Adicionar API key se disponível
if (apiKey) {
headers['Authorization'] = `Bearer ${apiKey}`;
}
const response = await fetch(`${apiUrl}/${codigoBarras}`, {
method: 'GET',
headers,
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
if (response.status === 404) {
return null; // Produto não encontrado
}
console.error('Product-Search.net: Erro na consulta', response.status);
return null;
}
const data = (await response.json()) as ProductSearchProduct;
if (!data.gtin && !data.name && !data.description) {
return null;
}
const info: ProductInfo = {
nome: data.name || data.description || undefined,
descricao: data.description || undefined,
categoria: data.category || undefined,
imagemUrl: data.imageUrl || data.image || undefined,
marca: data.brand || undefined,
fonte: 'Product-Search.net'
};
if (info.nome || info.descricao) {
return info;
}
return null;
} catch (error) {
console.error('Erro ao buscar Product-Search.net:', error);
return null;
}
}