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
JavaScript
;
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