UNPKG

n8n-nodes-pncp-aec

Version:

n8n node for fetching public construction tenders from Brazil's PNCP (Portal Nacional de Contratações Públicas)

686 lines 31.8 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AecTenders = void 0; const n8n_workflow_1 = require("n8n-workflow"); const PNCP_ERROR_PATTERNS = { 400: { status: 400, code: 'INVALID_REQUEST', message: 'Parâmetros de consulta inválidos ou malformados', details: 'Verifique os parâmetros de data, CNPJ e outros campos obrigatórios', retryable: false }, 401: { status: 401, code: 'UNAUTHORIZED', message: 'Acesso não autorizado à API PNCP', details: 'Credenciais de acesso não fornecidas ou inválidas', retryable: false }, 403: { status: 403, code: 'FORBIDDEN', message: 'Acesso negado aos dados solicitados', details: 'O usuário não possui permissão para acessar estes dados', retryable: false }, 404: { status: 404, code: 'NOT_FOUND', message: 'Licitação ou endpoint não encontrado', details: 'Verifique se o CNPJ, ano e número sequencial estão corretos', retryable: false }, 422: { status: 422, code: 'UNPROCESSABLE_ENTITY', message: 'Dados de entrada não podem ser processados', details: 'Formato de data inválido ou parâmetros inconsistentes', retryable: false }, 429: { status: 429, code: 'RATE_LIMIT_EXCEEDED', message: 'Limite de requisições excedido', details: 'Aguarde antes de fazer novas requisições à API PNCP', retryable: true }, 500: { status: 500, code: 'INTERNAL_SERVER_ERROR', message: 'Erro interno do servidor PNCP', details: 'Problema temporário nos servidores do governo', retryable: true }, 502: { status: 502, code: 'BAD_GATEWAY', message: 'Gateway inválido no acesso à API PNCP', details: 'Problema de conectividade com os servidores governamentais', retryable: true }, 503: { status: 503, code: 'SERVICE_UNAVAILABLE', message: 'Serviço PNCP temporariamente indisponível', details: 'API em manutenção ou sobrecarga temporária', retryable: true }, 504: { status: 504, code: 'GATEWAY_TIMEOUT', message: 'Timeout na conexão com a API PNCP', details: 'Requisição demorou muito para ser processada', retryable: true } }; class AecTenders { constructor() { this.description = { displayName: 'AEC Tenders', name: 'aecTenders', icon: 'file:icons/AecTenders.svg', group: ['transform'], version: 1, subtitle: '={{$parameter["operation"]}}', description: 'Fetches public construction tenders from Brazil\'s PNCP', defaults: { name: 'AEC Tenders', }, inputs: ['main'], outputs: ['main'], properties: [ { displayName: 'Resource', name: 'resource', type: 'options', noDataExpression: true, options: [ { name: 'Tender', value: 'tender', }, ], default: 'tender', }, { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['tender'], }, }, options: [ { name: 'List by Publication Date', value: 'listByPublicationDate', description: 'Get tenders published within a date range', action: 'List tenders by publication date', }, { name: 'List with Open Proposals', value: 'listWithOpenProposals', description: 'Get tenders currently accepting proposals', action: 'List tenders with open proposals', }, { name: 'Get By ID', value: 'getById', description: 'Get detailed information about a specific tender', action: 'Get tender details by ID', }, { name: 'Search by Keyword', value: 'searchByKeyword', description: 'Search tenders by keywords in the contract object', action: 'Search tenders by keyword', }, ], default: 'listByPublicationDate', }, { displayName: 'Start Date', name: 'startDate', type: 'dateTime', displayOptions: { show: { resource: ['tender'], operation: ['listByPublicationDate', 'searchByKeyword'], }, }, default: '', placeholder: '2025-01-01', description: 'Start date for the search in YYYY-MM-DD format (e.g., 2025-01-01)', required: true, }, { displayName: 'End Date', name: 'endDate', type: 'dateTime', displayOptions: { show: { resource: ['tender'], operation: ['listByPublicationDate', 'searchByKeyword'], }, }, default: '', placeholder: '2025-12-31', description: 'End date for the search in YYYY-MM-DD format (e.g., 2025-12-31)', required: true, }, { displayName: 'State (UF)', name: 'stateUf', type: 'string', displayOptions: { show: { resource: ['tender'], operation: ['listWithOpenProposals'], }, }, default: '', placeholder: 'SP', description: 'Optional: Brazilian state code (e.g., SP, RJ, MG)', }, { displayName: 'Procuring Entity CNPJ', name: 'procuringEntityCNPJ', type: 'string', displayOptions: { show: { resource: ['tender'], operation: ['getById'], }, }, default: '', placeholder: '12345678000190', description: 'The CNPJ must be exactly 14 digits without formatting (e.g., 12345678000190)', required: true, }, { displayName: 'Year', name: 'year', type: 'number', displayOptions: { show: { resource: ['tender'], operation: ['getById'], }, }, default: new Date().getFullYear(), description: 'Year of the tender', required: true, }, { displayName: 'Sequence Number', name: 'sequenceNumber', type: 'number', displayOptions: { show: { resource: ['tender'], operation: ['getById'], }, }, default: 1, description: 'Sequential number of the tender', required: true, }, { displayName: 'Keywords', name: 'keywords', type: 'string', displayOptions: { show: { resource: ['tender'], operation: ['searchByKeyword'], }, }, default: '', placeholder: 'engenharia, construção, reforma', description: 'Keywords to search in tender descriptions, separated by commas (e.g., engenharia, construção, reforma)', required: true, }, { displayName: 'Return All', name: 'returnAll', type: 'boolean', displayOptions: { show: { resource: ['tender'], operation: ['listByPublicationDate', 'listWithOpenProposals', 'searchByKeyword'], }, }, default: false, description: 'Whether to return all results or only up to a given limit', }, { displayName: 'Limit', name: 'limit', type: 'number', displayOptions: { show: { resource: ['tender'], operation: ['listByPublicationDate', 'listWithOpenProposals', 'searchByKeyword'], returnAll: [false], }, }, typeOptions: { minValue: 1, }, default: 50, description: 'Max number of results to return', }, ], }; } async execute() { const items = this.getInputData(); const returnData = []; const nodeInstance = new AecTenders(); for (let i = 0; i < items.length; i++) { try { const resource = this.getNodeParameter('resource', i); const operation = this.getNodeParameter('operation', i); if (resource === 'tender') { let responseData = []; switch (operation) { case 'listByPublicationDate': responseData = await nodeInstance.listTendersByDate(this, i); break; case 'listWithOpenProposals': responseData = await nodeInstance.getTendersWithOpenProposals(this, i); break; case 'getById': responseData = await nodeInstance.getTenderById(this, i); break; case 'searchByKeyword': responseData = await nodeInstance.searchTendersByKeyword(this, i); break; default: throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Operation "${operation}" is not supported`); } returnData.push(...responseData); } } catch (error) { if (this.continueOnFail()) { returnData.push({ error: error.message }); } else { throw error; } } } return [returnData.map((data) => ({ json: data }))]; } validateCNPJ(cnpj) { return /^\d{14}$/.test(cnpj); } formatDate(date) { const d = new Date(date); return d.toISOString().split('T')[0].replace(/-/g, ''); } buildPortalUrl(tender) { const numeroControlePNCP = tender.numeroControlePNCP; if (numeroControlePNCP && numeroControlePNCP.trim() !== '') { return `https://pncp.gov.br/app/editais/${numeroControlePNCP}`; } const orgaoEntidade = tender.orgaoEntidade || {}; const cnpj = orgaoEntidade.cnpj; const ano = tender.anoCompra; const sequencial = tender.sequencialCompra; if (cnpj && ano && sequencial) { return `https://pncp.gov.br/app/editais/${cnpj}/${ano}/${sequencial}`; } return ''; } transformTenderData(tender) { const tenderObject = String(tender.objetoContratacao || tender.objetoCompra || '').trim(); const estimatedValue = Number(tender.valorEstimadoTotal || tender.valorTotalEstimado || 0); const orgaoEntidade = tender.orgaoEntidade || {}; const unidadeOrgao = tender.unidadeOrgao || {}; const amparoLegal = tender.amparoLegal || {}; return { pncpId: String(tender.numeroControlePNCP || '').trim(), procuringEntityName: String(tender.razaoSocial || orgaoEntidade.razaoSocial || '').trim(), procuringEntityCNPJ: String(orgaoEntidade.cnpj || '').trim(), tenderObject: tenderObject, tenderModality: String(tender.modalidadeContratacao || tender.modalidadeNome || '').trim(), tenderSituation: String(tender.situacaoContratacao || tender.situacaoCompraNome || '').trim(), publicationDate: String(tender.dataPublicacaoTURE || tender.dataPublicacaoPncp || '').trim(), proposalOpeningDate: String(tender.dataAberturaProposta || '').trim(), estimatedTotalValue: estimatedValue, municipalityCode: String(unidadeOrgao.codigoIbge || '').trim(), municipalityName: String(unidadeOrgao.municipioNome || '').trim(), stateCode: String(unidadeOrgao.ufSigla || '').trim(), stateName: String(unidadeOrgao.ufNome || '').trim(), unitName: String(unidadeOrgao.nomeUnidade || '').trim(), unitCode: String(unidadeOrgao.codigoUnidade || '').trim(), legalProcedure: String(amparoLegal.nome || '').trim(), legalDescription: String(amparoLegal.descricao || '').trim(), legalCode: String(amparoLegal.codigo || '').trim(), process: String(tender.processo || '').trim(), numeroCompra: String(tender.numeroCompra || '').trim(), anoCompra: Number(tender.anoCompra || 0), sequencialCompra: Number(tender.sequencialCompra || 0), linkSistemaOrigem: String(tender.linkSistemaOrigem || '').trim(), informacaoComplementar: String(tender.informacaoComplementar || '').trim(), dataEncerramentoProposta: String(tender.dataEncerramentoProposta || '').trim(), modoDisputaNome: String(tender.modoDisputaNome || '').trim(), tipoInstrumentoConvocatorioNome: String(tender.tipoInstrumentoConvocatorioNome || '').trim(), justificativaPresencial: String(tender.justificativaPresencial || '').trim(), linkProcessoEletronico: String(tender.linkProcessoEletronico || '').trim(), usuarioNome: String(tender.usuarioNome || '').trim(), currency: 'BRL', valorTotalHomologado: Number(tender.valorTotalHomologado || 0), srp: Boolean(tender.srp), dataInclusao: String(tender.dataInclusao || '').trim(), dataAtualizacao: String(tender.dataAtualizacao || '').trim(), dataAtualizacaoGlobal: String(tender.dataAtualizacaoGlobal || '').trim(), evaluationCriteria: '', participationRegime: '', technicalSpecification: '', deliveryAddress: '', contractDuration: '', budgetDetails: '', paymentConditions: '', portalUrl: this.buildPortalUrl(tender), _extractedAt: new Date().toISOString(), _category: this.categorizeTender(tenderObject), _riskScore: this.calculateRiskScore(tender), _dataQuality: this.assessDataQuality(tender), _urgencyLevel: this.calculateUrgencyLevel(tender), }; } categorizeTender(tenderObject) { const obj = tenderObject.toLowerCase(); if (obj.includes('ponte') || obj.includes('viaduto') || obj.includes('túnel') || obj.includes('rodovia') || obj.includes('estrada') || obj.includes('infraestrutura')) { return 'infrastructure'; } if (obj.includes('projeto') || obj.includes('arquitetônico') || obj.includes('arquitetura') || obj.includes('urbanismo') || obj.includes('paisagismo')) { return 'architecture'; } if (obj.includes('engenharia') || obj.includes('estrutural') || obj.includes('geotécnica') || obj.includes('hidráulica') || obj.includes('elétrica') || obj.includes('mecânica')) { return 'engineering'; } if (obj.includes('construção') || obj.includes('obra') || obj.includes('edificação') || obj.includes('fundação') || obj.includes('estrutura')) { return 'construction'; } if (obj.includes('reforma') || obj.includes('modernização') || obj.includes('restauração') || obj.includes('manutenção') || obj.includes('recuperação')) { return 'renovation'; } if (obj.includes('ambiental') || obj.includes('sustentável') || obj.includes('verde') || obj.includes('energia renovável') || obj.includes('eficiência energética')) { return 'environmental'; } return 'general'; } calculateRiskScore(tender) { let score = 0; const value = tender.valorTotalEstimado || 0; if (value > 50000000) score += 40; else if (value > 10000000) score += 30; else if (value > 1000000) score += 20; else if (value > 100000) score += 10; const obj = (tender.objetoCompra || '').toLowerCase(); if (obj.includes('complexa') || obj.includes('integrada') || obj.includes('sistema')) score += 15; if (obj.includes('inovação') || obj.includes('tecnologia') || obj.includes('smart')) score += 10; if (obj.includes('múltipla') || obj.includes('diversas') || obj.includes('várias')) score += 5; const publicationDate = tender.dataPublicacaoPncp; const openingDate = tender.dataAberturaProposta; if (publicationDate && openingDate) { const pubDate = new Date(publicationDate); const openDate = new Date(openingDate); const daysDiff = (openDate.getTime() - pubDate.getTime()) / (1000 * 3600 * 24); if (daysDiff < 15) score += 20; else if (daysDiff < 30) score += 10; } const modality = tender.modalidadeNome || ''; if (modality.includes('Concorrência')) score += 10; else if (modality.includes('Tomada de Preços')) score += 5; return Math.min(100, Math.max(0, score)); } assessDataQuality(tender) { const orgaoEntidade = tender.orgaoEntidade || {}; const requiredFields = [ { key: 'numeroControlePNCP', value: tender.numeroControlePNCP }, { key: 'orgaoEntidade.razaoSocial', value: orgaoEntidade.razaoSocial }, { key: 'objetoCompra', value: tender.objetoCompra }, { key: 'dataPublicacaoPncp', value: tender.dataPublicacaoPncp }, { key: 'dataAberturaProposta', value: tender.dataAberturaProposta }, { key: 'valorTotalEstimado', value: tender.valorTotalEstimado }, { key: 'modalidadeNome', value: tender.modalidadeNome } ]; const presentFields = requiredFields.filter(field => field.value && field.value !== '' && field.value !== 0); return Math.round((presentFields.length / requiredFields.length) * 100); } calculateUrgencyLevel(tender) { const publicationDate = tender.dataPublicacaoPncp; const openingDate = tender.dataAberturaProposta; if (!publicationDate || !openingDate) return 'medium'; const now = new Date(); const openDate = new Date(openingDate); const daysUntilOpening = (openDate.getTime() - now.getTime()) / (1000 * 3600 * 24); if (daysUntilOpening <= 7) return 'critical'; if (daysUntilOpening <= 15) return 'high'; if (daysUntilOpening <= 30) return 'medium'; return 'low'; } async makeAPIRequest(executeFunctions, endpoint, params = {}, retryCount = 0) { var _a, _b, _c, _d; const baseUrl = 'https://pncp.gov.br/api/consulta'; const url = `${baseUrl}${endpoint}`; const maxRetries = 3; const baseDelay = 1000; const requestId = `pncp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const options = { method: 'GET', url, qs: params, headers: { Accept: 'application/json', 'User-Agent': 'n8n-aec-tenders/0.1.0', 'X-Request-ID': requestId, }, returnFullResponse: true, json: true, timeout: 30000, }; try { const response = await executeFunctions.helpers.httpRequest.call(executeFunctions, options); if (!response.body) { throw new n8n_workflow_1.NodeApiError(executeFunctions.getNode(), { message: 'Empty response from PNCP API' }); } return response.body; } catch (error) { const httpStatus = ((_a = error.response) === null || _a === void 0 ? void 0 : _a.status) || error.statusCode || 0; const errorPattern = PNCP_ERROR_PATTERNS[httpStatus]; if (errorPattern) { if (errorPattern.retryable && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); await this.sleep(delay); return this.makeAPIRequest(executeFunctions, endpoint, params, retryCount + 1); } if (errorPattern.code === 'RATE_LIMIT_EXCEEDED') { const retryAfter = ((_c = (_b = error.response) === null || _b === void 0 ? void 0 : _b.headers) === null || _c === void 0 ? void 0 : _c['retry-after']) || 60; throw new n8n_workflow_1.NodeApiError(executeFunctions.getNode(), error, { message: `Rate limit exceeded. Try again in ${retryAfter} seconds`, description: `Too many requests to PNCP API. Please wait ${retryAfter} seconds before making new requests.`, httpCode: `${httpStatus}`, }); } throw new n8n_workflow_1.NodeApiError(executeFunctions.getNode(), error, { message: `PNCP API Error (${errorPattern.code}): ${errorPattern.message}`, description: `${errorPattern.details}\n\nDetalhes técnicos: ${error.message}`, httpCode: `${httpStatus}`, }); } if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') { throw new n8n_workflow_1.NodeApiError(executeFunctions.getNode(), error, { message: 'Não foi possível conectar à API PNCP', description: 'Verifique sua conexão com a internet e tente novamente. O serviço pode estar temporariamente indisponível.', }); } if (error.code === 'ETIMEDOUT' || ((_d = error.message) === null || _d === void 0 ? void 0 : _d.includes('timeout'))) { if (retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); await this.sleep(delay); return this.makeAPIRequest(executeFunctions, endpoint, params, retryCount + 1); } throw new n8n_workflow_1.NodeApiError(executeFunctions.getNode(), error, { message: 'Timeout na consulta à API PNCP', description: 'A requisição demorou muito para ser processada. Tente reduzir o período de consulta ou tente novamente mais tarde.', }); } throw new n8n_workflow_1.NodeApiError(executeFunctions.getNode(), error, { message: `Erro inesperado na API PNCP: ${error.message}`, description: `Request ID: ${requestId}\nEndpoint: ${endpoint}\nParâmetros: ${JSON.stringify(params, null, 2)}`, }); } } async sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async listTendersByDate(executeFunctions, itemIndex) { const startDate = executeFunctions.getNodeParameter('startDate', itemIndex); const endDate = executeFunctions.getNodeParameter('endDate', itemIndex); const returnAll = executeFunctions.getNodeParameter('returnAll', itemIndex); const limit = executeFunctions.getNodeParameter('limit', itemIndex, 50); if (!startDate || !endDate) { throw new n8n_workflow_1.NodeOperationError(executeFunctions.getNode(), 'Start date and end date are required'); } const formattedStartDate = this.formatDate(startDate); const formattedEndDate = this.formatDate(endDate); let allTenders = []; let page = 1; let hasMorePages = true; while (hasMorePages && (returnAll || allTenders.length < limit)) { const params = { dataInicial: formattedStartDate, dataFinal: formattedEndDate, codigoModalidadeContratacao: 1, pagina: page, tamanhoPagina: 50, }; const response = await this.makeAPIRequest(executeFunctions, '/v1/contratacoes/publicacao', params); const tenders = response.data || []; if (tenders.length === 0) { hasMorePages = false; } else { const transformedTenders = tenders.map((tender) => this.transformTenderData(tender)); allTenders.push(...transformedTenders); page++; if (!returnAll && allTenders.length >= limit) { hasMorePages = false; } } } if (!returnAll && allTenders.length > limit) { return allTenders.slice(0, limit); } return allTenders; } async getTendersWithOpenProposals(executeFunctions, itemIndex) { const stateUf = executeFunctions.getNodeParameter('stateUf', itemIndex, ''); const returnAll = executeFunctions.getNodeParameter('returnAll', itemIndex); const limit = executeFunctions.getNodeParameter('limit', itemIndex, 50); let allTenders = []; let page = 1; let hasMorePages = true; while (hasMorePages && (returnAll || allTenders.length < limit)) { const params = { dataFinal: new Date().toISOString().split('T')[0].replace(/-/g, ''), pagina: page, tamanhoPagina: 50, }; if (stateUf) { params.uf = stateUf.toUpperCase(); } const response = await this.makeAPIRequest(executeFunctions, '/v1/contratacoes/proposta', params); const tenders = response.data || []; if (tenders.length === 0) { hasMorePages = false; } else { const transformedTenders = tenders.map((tender) => this.transformTenderData(tender)); allTenders.push(...transformedTenders); page++; if (!returnAll && allTenders.length >= limit) { hasMorePages = false; } } } if (!returnAll && allTenders.length > limit) { return allTenders.slice(0, limit); } return allTenders; } async getTenderById(executeFunctions, itemIndex) { const cnpj = executeFunctions.getNodeParameter('procuringEntityCNPJ', itemIndex); const year = executeFunctions.getNodeParameter('year', itemIndex); const sequenceNumber = executeFunctions.getNodeParameter('sequenceNumber', itemIndex); if (!this.validateCNPJ(cnpj)) { throw new n8n_workflow_1.NodeOperationError(executeFunctions.getNode(), 'Invalid CNPJ format. It must contain exactly 14 digits'); } const endpoint = `/v1/orgaos/${cnpj}/compras/${year}/${sequenceNumber}`; const response = await this.makeAPIRequest(executeFunctions, endpoint); if (!response) { throw new n8n_workflow_1.NodeOperationError(executeFunctions.getNode(), 'Tender not found with the provided parameters'); } return [this.transformTenderData(response)]; } async searchTendersByKeyword(executeFunctions, itemIndex) { const keywords = executeFunctions.getNodeParameter('keywords', itemIndex); const startDate = executeFunctions.getNodeParameter('startDate', itemIndex); const endDate = executeFunctions.getNodeParameter('endDate', itemIndex); const returnAll = executeFunctions.getNodeParameter('returnAll', itemIndex); const limit = executeFunctions.getNodeParameter('limit', itemIndex, 50); if (!keywords || typeof keywords !== 'string' || !keywords.trim()) { throw new n8n_workflow_1.NodeOperationError(executeFunctions.getNode(), 'Keywords are required for search'); } if (!startDate || !endDate) { throw new n8n_workflow_1.NodeOperationError(executeFunctions.getNode(), 'Start date and end date are required for keyword search'); } const allTenders = await this.listTendersByDate(executeFunctions, itemIndex); const keywordArray = keywords.split(',').map((k) => k.trim().toLowerCase()); const filteredTenders = allTenders.filter((tender) => { const tenderObject = (tender.tenderObject || '').toLowerCase(); const procuringEntityName = (tender.procuringEntityName || '').toLowerCase(); const tenderModality = (tender.tenderModality || '').toLowerCase(); return keywordArray.some((keyword) => tenderObject.includes(keyword) || procuringEntityName.includes(keyword) || tenderModality.includes(keyword)); }); if (!returnAll && filteredTenders.length > limit) { return filteredTenders.slice(0, limit); } return filteredTenders; } } exports.AecTenders = AecTenders; //# sourceMappingURL=AecTenders.node.js.map