From 125d3e9677d3095177432b28c07c540b3b28bdf1 Mon Sep 17 00:00:00 2001 From: als-v Date: Sat, 30 May 2026 16:19:42 -0300 Subject: [PATCH 1/4] HCF-API #453: Adicionado rota e controller das rotas via dashboard --- src/controllers/dashboard-controller.js | 305 ++++++++++++++++++++++++ src/models/Tombo.js | 1 + src/routes/dashboard.js | 5 + 3 files changed, 311 insertions(+) create mode 100644 src/controllers/dashboard-controller.js create mode 100644 src/routes/dashboard.js diff --git a/src/controllers/dashboard-controller.js b/src/controllers/dashboard-controller.js new file mode 100644 index 00000000..138ad1a5 --- /dev/null +++ b/src/controllers/dashboard-controller.js @@ -0,0 +1,305 @@ +import { Op, Sequelize } from 'sequelize'; +import models from '../models/index.js'; + +const { Tombo, Especie, Coletor, Cidade, Familia, Genero, Herbario } = models; + +const agruparPorIndice = (dados, tamanho, indice, offset = 0) => { + const array = Array(tamanho).fill(0); + let total = 0; + + dados.forEach(item => { + const idx = parseInt(item.get(indice)) - offset; + const qtd = parseInt(item.get('total')); + + if (idx >= 0 && idx < tamanho) { + array[idx] += qtd; + total += qtd; + } + }); + + return { array, total }; +}; + +const agruparMesPorSemana = (dados) => { + const array = Array(5).fill(0); + let total = 0; + + dados.forEach(item => { + const dia = parseInt(item.get('dia')); + const qtd = parseInt(item.get('total')); + const semanaIdx = Math.floor((dia - 1) / 7); + + if (semanaIdx >= 0 && semanaIdx < 5) { + array[semanaIdx] += qtd; + total += qtd; + } + }); + + return { array, total }; +}; + +const calcularPorcentagem = (atual, passado) => { + if (passado > 0) return parseFloat((((atual - passado) / passado) * 100).toFixed(1)); + if (atual > 0) return 100.0; + return 0.0; +}; + +const formatarRanking = (dadosQuery, aliasTabela) => { + return dadosQuery.map(item => { + const info = item[aliasTabela] || item[aliasTabela.charAt(0).toUpperCase() + aliasTabela.slice(1)]; + + return { + nome: info?.nome || info?.sigla || 'N/A', + total: parseInt(item.get('quantidade'), 10) || 0 + }; + }); +}; + +const formatarSemana = (array) => { + const dias = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']; + return array.map((total, index) => ({ dia: dias[index], total })); +}; + +const formatarAno = (array) => { + const meses = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']; + return array.map((total, index) => ({ mes: meses[index], total })); +}; + +const formatarMes = (array, dataBase) => { + const ano = dataBase.getFullYear(); + const mes = dataBase.getMonth(); + const ultimoDiaDoMes = new Date(ano, mes + 1, 0).getDate(); + const formatoFinal = []; + + for (let i = 0; i < 5; i++) { + const diaInicio = (i * 7) + 1; + if (diaInicio > ultimoDiaDoMes) break; + + const diaFim = Math.min((i + 1) * 7, ultimoDiaDoMes); + const strInicio = `${String(diaInicio).padStart(2, '0')}/${String(mes + 1).padStart(2, '0')}`; + const strFim = `${String(diaFim).padStart(2, '0')}/${String(mes + 1).padStart(2, '0')}`; + + formatoFinal.push({ semana: `${strInicio} - ${strFim}`, total: array[i] }); + } + + return formatoFinal; +}; + +export const tomboInfo = async (request, response, next) => { + try { + const ID_HCF = 2; + + const condicaoBase = { + rascunho: false, + ativo: true + }; + + const hoje = new Date(); + + const inicioSemana = new Date(hoje.getFullYear(), hoje.getMonth(), hoje.getDate() - hoje.getDay()); + const fimSemana = new Date(inicioSemana); fimSemana.setDate(fimSemana.getDate() + 6); fimSemana.setHours(23, 59, 59, 999); + + const inicioSemanaPassada = new Date(inicioSemana); inicioSemanaPassada.setDate(inicioSemanaPassada.getDate() - 7); + const fimSemanaPassada = new Date(fimSemana); fimSemanaPassada.setDate(fimSemanaPassada.getDate() - 7); + + const inicioMes = new Date(hoje.getFullYear(), hoje.getMonth(), 1); + const fimMes = new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0, 23, 59, 59, 999); + + const inicioMesPassado = new Date(hoje.getFullYear(), hoje.getMonth() - 1, 1); + const fimMesPassado = new Date(hoje.getFullYear(), hoje.getMonth(), 0, 23, 59, 59, 999); + + const inicioAno = new Date(hoje.getFullYear(), 0, 1); + const fimAno = new Date(hoje.getFullYear(), 11, 31, 23, 59, 59, 999); + + const inicioAnoPassado = new Date(hoje.getFullYear() - 1, 0, 1); + const fimAnoPassado = new Date(hoje.getFullYear() - 1, 11, 31, 23, 59, 59, 999); + + const [ + total, + totalTombados, + tombosInternos, + tombosExternos, + totalEspecies, + totalFamilias, + totalGeneros, + totalMunicipios, + totalColetores, + totalHerbarios, + rankEspecies, + rankFamilias, + rankGeneros, + rankMunicipios, + rankColetores, + rankHerbarios, + querySemanaAtual, + querySemanaPassada, + queryMesAtual, + queryMesPassado, + queryAnoAtual, + queryAnoPassado + + ] = await Promise.all([ + Tombo.count(), // total + Tombo.count({ where: condicaoBase }), // totalTombados + Tombo.count({ where: { ...condicaoBase, [Op.or]: [{ entidade_id: null }, { entidade_id: ID_HCF }] } }), // tombosInternos + Tombo.count({ where: { ...condicaoBase, entidade_id: { [Op.not]: null, [Op.ne]: ID_HCF } } }), // tombosExternos + + Tombo.count({ where: { ...condicaoBase, especie_id: { [Op.not]: null } }, col: 'especie_id', distinct: true }), // totalEspecies + Tombo.count({ where: { ...condicaoBase, familia_id: { [Op.not]: null } }, col: 'familia_id', distinct: true }), // totalFamilias + Tombo.count({ where: { ...condicaoBase, genero_id: { [Op.not]: null } }, col: 'genero_id', distinct: true }), // totalGeneros + Tombo.count({ where: { ...condicaoBase, cidade_id: { [Op.not]: null } }, col: 'cidade_id', distinct: true }), // totalMunicipios + Tombo.count({ where: { ...condicaoBase, coletor_id: { [Op.not]: null } }, col: 'coletor_id', distinct: true }), // totalColetores + Tombo.count({ where: { ...condicaoBase, entidade_id: { [Op.not]: null } }, col: 'entidade_id', distinct: true }), // totalHerbarios + + Tombo.findAll({ + where: { ...condicaoBase, especie_id: { [Op.not]: null } }, + attributes: ['especie_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Especie, as: 'especie', attributes: ['nome'] }], + group: ['especie_id', 'especie.id'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + }), // rankEspecies + Tombo.findAll({ + where: { ...condicaoBase, familia_id: { [Op.not]: null } }, + attributes: ['familia_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Familia, as: 'familia', attributes: ['nome'] }], + group: ['familia_id', 'familia.id', 'familia.nome'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + }), // rankFamilias + Tombo.findAll({ + where: { ...condicaoBase, genero_id: { [Op.not]: null } }, + attributes: ['genero_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Genero, as: 'genero', attributes: ['nome'] }], + group: ['genero_id', 'genero.id', 'genero.nome'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + }), // rankGeneros + Tombo.findAll({ + where: { ...condicaoBase, cidade_id: { [Op.not]: null } }, + attributes: ['cidade_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Cidade, attributes: ['nome'] }], + group: ['cidade_id', 'cidade.id', 'cidade.nome'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + }), // rankMunicipios + Tombo.findAll({ + where: { ...condicaoBase, coletor_id: { [Op.not]: null } }, + attributes: ['coletor_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Coletor, as: 'coletor', attributes: ['nome'] }], + group: ['coletor_id', 'coletor.id', 'coletor.nome'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + }), // rankColetores + Tombo.findAll({ + where: { ...condicaoBase, entidade_id: { [Op.not]: null } }, + attributes: ['entidade_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], + include: [{ model: Herbario, attributes: ['nome', 'sigla'] }], + group: ['entidade_id', 'herbario.id', 'herbario.nome', 'herbario.sigla'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + }), // rankHerbarios + + Tombo.findAll({ + where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioSemana, fimSemana] } }, + attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('DOW FROM data_tombo')), 'dia_semana'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], + group: [Sequelize.fn('EXTRACT', Sequelize.literal('DOW FROM data_tombo'))] + }), // querySemanaAtual + Tombo.findAll({ + where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioSemanaPassada, fimSemanaPassada] } }, + attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('DOW FROM data_tombo')), 'dia_semana'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], + group: [Sequelize.fn('EXTRACT', Sequelize.literal('DOW FROM data_tombo'))] + }), // querySemanaPassada + Tombo.findAll({ + where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioMes, fimMes] } }, + attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('DAY FROM data_tombo')), 'dia'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], + group: [Sequelize.fn('EXTRACT', Sequelize.literal('DAY FROM data_tombo'))] + }), // queryMesAtual + Tombo.findAll({ + where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioMesPassado, fimMesPassado] } }, + attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('DAY FROM data_tombo')), 'dia'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], + group: [Sequelize.fn('EXTRACT', Sequelize.literal('DAY FROM data_tombo'))] + }), // queryMesPassado + Tombo.findAll({ + where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioAno, fimAno] } }, + attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')), 'mes'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], + group: [Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo'))] + }), // queryAnoAtual + Tombo.findAll({ + where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioAnoPassado, fimAnoPassado] } }, + attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')), 'mes'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], + group: [Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo'))] + }) // queryAnoPassado + ]); + + const semanaAtual = agruparPorIndice(querySemanaAtual, 7, 'dia_semana', 0); + const semanaPass = agruparPorIndice(querySemanaPassada, 7, 'dia_semana', 0); + const mesAtual = agruparMesPorSemana(queryMesAtual); + const mesPass = agruparMesPorSemana(queryMesPassado); + const anoAtual = agruparPorIndice(queryAnoAtual, 12, 'mes', 1); + const anoPass = agruparPorIndice(queryAnoPassado, 12, 'mes', 1); + + return response.status(200).json({ + dados: { + tombos: { + total: total, + tombados: totalTombados, + internos: tombosInternos, + externos: tombosExternos + }, + taxonomia: { + familias: { + total: totalFamilias, + ranking: formatarRanking(rankFamilias, 'familia') + }, + generos: { + total: totalGeneros, + ranking: formatarRanking(rankGeneros, 'genero') + }, + especies: { + total: totalEspecies, + ranking: formatarRanking(rankEspecies, 'especie') + } + }, + municipios: { + total: totalMunicipios, + ranking: formatarRanking(rankMunicipios, 'cidade') + }, + coletores: { + total: totalColetores, + ranking: formatarRanking(rankColetores, 'coletor') + }, + herbarios: { + total: totalHerbarios, + ranking: formatarRanking(rankHerbarios, 'herbario') + }, + serie_temporal: { + semana: { + dados: { + atual: formatarSemana(semanaAtual.array), + passada: formatarSemana(semanaPass.array), + }, + totais: { + atual: semanaAtual.total, + passada: semanaPass.total, + porcentagem: calcularPorcentagem(semanaAtual.total, semanaPass.total) + } + }, + mes: { + dados: { + atual: formatarMes(mesAtual.array, hoje), + passado: formatarMes(mesPass.array, inicioMesPassado), + }, + totais: { + atual: mesAtual.total, + passado: mesPass.total, + porcentagem: calcularPorcentagem(mesAtual.total, mesPass.total) + } + }, + ano: { + dados: { + atual: formatarAno(anoAtual.array), + passado: formatarAno(anoPass.array), + }, + totais: { + atual: anoAtual.total, + passado: anoPass.total, + porcentagem: calcularPorcentagem(anoAtual.total, anoPass.total) + }, + } + } + } + }); + + } catch (error) { + next(error); + } +}; diff --git a/src/models/Tombo.js b/src/models/Tombo.js index 29156fb2..da00a7e7 100644 --- a/src/models/Tombo.js +++ b/src/models/Tombo.js @@ -62,6 +62,7 @@ function associate(modelos) { }); Tombo.belongsTo(Coletor, { + as: 'coletor', foreignKey: 'coletor_id', }); diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js new file mode 100644 index 00000000..5deccfbb --- /dev/null +++ b/src/routes/dashboard.js @@ -0,0 +1,5 @@ +import * as controller from '../controllers/dashboard-controller'; + +export default app => { + app.route('/dashboard').get([controller.tomboInfo]) +}; From d53d450ad4578b4c4cf9674ffad7c3de06f166f2 Mon Sep 17 00:00:00 2001 From: als-v Date: Wed, 3 Jun 2026 20:29:14 -0300 Subject: [PATCH 2/4] HCF-API #453: separado metodo /temporal e /tombo e feito melhorias de performance nas querys --- src/controllers/dashboard-controller.js | 247 ++++++++++-------------- src/routes/dashboard.js | 3 +- 2 files changed, 107 insertions(+), 143 deletions(-) diff --git a/src/controllers/dashboard-controller.js b/src/controllers/dashboard-controller.js index 138ad1a5..25861119 100644 --- a/src/controllers/dashboard-controller.js +++ b/src/controllers/dashboard-controller.js @@ -1,7 +1,7 @@ import { Op, Sequelize } from 'sequelize'; import models from '../models/index.js'; -const { Tombo, Especie, Coletor, Cidade, Familia, Genero, Herbario } = models; +const { Tombo, Especie, Coletor, Cidade, Familia, Genero, Herbario, TomboFoto } = models; const agruparPorIndice = (dados, tamanho, indice, offset = 0) => { const array = Array(tamanho).fill(0); @@ -94,63 +94,37 @@ export const tomboInfo = async (request, response, next) => { ativo: true }; - const hoje = new Date(); - - const inicioSemana = new Date(hoje.getFullYear(), hoje.getMonth(), hoje.getDate() - hoje.getDay()); - const fimSemana = new Date(inicioSemana); fimSemana.setDate(fimSemana.getDate() + 6); fimSemana.setHours(23, 59, 59, 999); - - const inicioSemanaPassada = new Date(inicioSemana); inicioSemanaPassada.setDate(inicioSemanaPassada.getDate() - 7); - const fimSemanaPassada = new Date(fimSemana); fimSemanaPassada.setDate(fimSemanaPassada.getDate() - 7); - - const inicioMes = new Date(hoje.getFullYear(), hoje.getMonth(), 1); - const fimMes = new Date(hoje.getFullYear(), hoje.getMonth() + 1, 0, 23, 59, 59, 999); - - const inicioMesPassado = new Date(hoje.getFullYear(), hoje.getMonth() - 1, 1); - const fimMesPassado = new Date(hoje.getFullYear(), hoje.getMonth(), 0, 23, 59, 59, 999); - - const inicioAno = new Date(hoje.getFullYear(), 0, 1); - const fimAno = new Date(hoje.getFullYear(), 11, 31, 23, 59, 59, 999); - - const inicioAnoPassado = new Date(hoje.getFullYear() - 1, 0, 1); - const fimAnoPassado = new Date(hoje.getFullYear() - 1, 11, 31, 23, 59, 59, 999); - const [ - total, - totalTombados, - tombosInternos, - tombosExternos, - totalEspecies, - totalFamilias, - totalGeneros, - totalMunicipios, - totalColetores, - totalHerbarios, + totaisGerais, + distintos, rankEspecies, rankFamilias, rankGeneros, rankMunicipios, - rankColetores, + rankColetores, rankHerbarios, - querySemanaAtual, - querySemanaPassada, - queryMesAtual, - queryMesPassado, - queryAnoAtual, - queryAnoPassado - + totalImagens ] = await Promise.all([ - Tombo.count(), // total - Tombo.count({ where: condicaoBase }), // totalTombados - Tombo.count({ where: { ...condicaoBase, [Op.or]: [{ entidade_id: null }, { entidade_id: ID_HCF }] } }), // tombosInternos - Tombo.count({ where: { ...condicaoBase, entidade_id: { [Op.not]: null, [Op.ne]: ID_HCF } } }), // tombosExternos - - Tombo.count({ where: { ...condicaoBase, especie_id: { [Op.not]: null } }, col: 'especie_id', distinct: true }), // totalEspecies - Tombo.count({ where: { ...condicaoBase, familia_id: { [Op.not]: null } }, col: 'familia_id', distinct: true }), // totalFamilias - Tombo.count({ where: { ...condicaoBase, genero_id: { [Op.not]: null } }, col: 'genero_id', distinct: true }), // totalGeneros - Tombo.count({ where: { ...condicaoBase, cidade_id: { [Op.not]: null } }, col: 'cidade_id', distinct: true }), // totalMunicipios - Tombo.count({ where: { ...condicaoBase, coletor_id: { [Op.not]: null } }, col: 'coletor_id', distinct: true }), // totalColetores - Tombo.count({ where: { ...condicaoBase, entidade_id: { [Op.not]: null } }, col: 'entidade_id', distinct: true }), // totalHerbarios - + Tombo.findOne({ + attributes: [ + [Sequelize.literal('COUNT(*)'), 'total'], + [Sequelize.literal(`COUNT(*) FILTER (WHERE entidade_id IS NULL OR entidade_id = ${ID_HCF})`), 'tombos_internos'], + [Sequelize.literal(`COUNT(*) FILTER (WHERE entidade_id IS NOT NULL AND entidade_id != ${ID_HCF})`), 'tombos_externos'] + ], + raw: true + }), // totaisGerais + Tombo.findOne({ + where: condicaoBase, + attributes: [ + [Sequelize.literal('COUNT(DISTINCT especie_id)'), 'especies'], + [Sequelize.literal('COUNT(DISTINCT familia_id)'), 'familias'], + [Sequelize.literal('COUNT(DISTINCT genero_id)'), 'generos'], + [Sequelize.literal('COUNT(DISTINCT cidade_id)'), 'municipios'], + [Sequelize.literal('COUNT(DISTINCT coletor_id)'), 'coletores'], + [Sequelize.literal('COUNT(DISTINCT entidade_id)'), 'herbarios'] + ], + raw: true + }), // distintos Tombo.findAll({ where: { ...condicaoBase, especie_id: { [Op.not]: null } }, attributes: ['especie_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], @@ -187,114 +161,103 @@ export const tomboInfo = async (request, response, next) => { include: [{ model: Herbario, attributes: ['nome', 'sigla'] }], group: ['entidade_id', 'herbario.id', 'herbario.nome', 'herbario.sigla'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 }), // rankHerbarios - - Tombo.findAll({ - where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioSemana, fimSemana] } }, - attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('DOW FROM data_tombo')), 'dia_semana'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], - group: [Sequelize.fn('EXTRACT', Sequelize.literal('DOW FROM data_tombo'))] - }), // querySemanaAtual - Tombo.findAll({ - where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioSemanaPassada, fimSemanaPassada] } }, - attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('DOW FROM data_tombo')), 'dia_semana'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], - group: [Sequelize.fn('EXTRACT', Sequelize.literal('DOW FROM data_tombo'))] - }), // querySemanaPassada - Tombo.findAll({ - where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioMes, fimMes] } }, - attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('DAY FROM data_tombo')), 'dia'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], - group: [Sequelize.fn('EXTRACT', Sequelize.literal('DAY FROM data_tombo'))] - }), // queryMesAtual - Tombo.findAll({ - where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioMesPassado, fimMesPassado] } }, - attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('DAY FROM data_tombo')), 'dia'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], - group: [Sequelize.fn('EXTRACT', Sequelize.literal('DAY FROM data_tombo'))] - }), // queryMesPassado - Tombo.findAll({ - where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioAno, fimAno] } }, - attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')), 'mes'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], - group: [Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo'))] - }), // queryAnoAtual - Tombo.findAll({ - where: { ...condicaoBase, data_tombo: { [Op.between]: [inicioAnoPassado, fimAnoPassado] } }, - attributes: [[Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')), 'mes'], [Sequelize.fn('COUNT', Sequelize.col('hcf')), 'total']], - group: [Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo'))] - }) // queryAnoPassado + TomboFoto.count() // totalImagens ]); - const semanaAtual = agruparPorIndice(querySemanaAtual, 7, 'dia_semana', 0); - const semanaPass = agruparPorIndice(querySemanaPassada, 7, 'dia_semana', 0); - const mesAtual = agruparMesPorSemana(queryMesAtual); - const mesPass = agruparMesPorSemana(queryMesPassado); - const anoAtual = agruparPorIndice(queryAnoAtual, 12, 'mes', 1); - const anoPass = agruparPorIndice(queryAnoPassado, 12, 'mes', 1); - return response.status(200).json({ dados: { tombos: { - total: total, - tombados: totalTombados, - internos: tombosInternos, - externos: tombosExternos + total: parseInt(totaisGerais.total, 10) || 0, + internos: parseInt(totaisGerais.tombos_internos, 10) || 0, + externos: parseInt(totaisGerais.tombos_externos, 10) || 0, + fotos: totalImagens, }, taxonomia: { - familias: { - total: totalFamilias, - ranking: formatarRanking(rankFamilias, 'familia') - }, - generos: { - total: totalGeneros, - ranking: formatarRanking(rankGeneros, 'genero') - }, - especies: { - total: totalEspecies, - ranking: formatarRanking(rankEspecies, 'especie') - } + familias: { total: parseInt(distintos.familias, 10) || 0, ranking: formatarRanking(rankFamilias, 'familia') }, + generos: { total: parseInt(distintos.generos, 10) || 0, ranking: formatarRanking(rankGeneros, 'genero') }, + especies: { total: parseInt(distintos.especies, 10) || 0, ranking: formatarRanking(rankEspecies, 'especie') } }, municipios: { - total: totalMunicipios, + total: parseInt(distintos.municipios, 10) || 0, ranking: formatarRanking(rankMunicipios, 'cidade') }, coletores: { - total: totalColetores, + total: parseInt(distintos.coletores, 10) || 0, ranking: formatarRanking(rankColetores, 'coletor') }, herbarios: { - total: totalHerbarios, + total: parseInt(distintos.herbarios, 10) || 0, ranking: formatarRanking(rankHerbarios, 'herbario') + } + } + }); + + } catch (error) { + next(error); + } +}; + +export const tomboSerieTemporal = async (request, response, next) => { + try { + const anoBase = parseInt(request.query.ano) || new Date().getFullYear(); + const anoAnterior = anoBase - 1; + + const inicioRange = new Date(anoAnterior, 0, 1); + const fimRange = new Date(anoBase, 11, 31, 23, 59, 59, 999); + + const queryResult = await Tombo.findAll({ + where: { + rascunho: false, + ativo: true, + data_tombo: { [Op.between]: [inicioRange, fimRange] } + }, + attributes: [ + [Sequelize.fn('EXTRACT', Sequelize.literal('YEAR FROM data_tombo')), 'ano'], + [Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')), 'mes'], + [Sequelize.literal('COUNT(*)'), 'total'] + ], + group: [ + Sequelize.fn('EXTRACT', Sequelize.literal('YEAR FROM data_tombo')), + Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')) + ], + raw: true + }); + + const arrayAtual = Array(12).fill(0); + const arrayPassado = Array(12).fill(0); + let totalAtual = 0; + let totalPassado = 0; + + queryResult.forEach(item => { + const ano = parseInt(item.ano, 10); + const idx = parseInt(item.mes, 10) - 1; + const qtd = parseInt(item.total, 10) || 0; + + if (idx >= 0 && idx < 12) { + if (ano === anoBase) { + arrayAtual[idx] = qtd; + totalAtual += qtd; + } else if (ano === anoAnterior) { + arrayPassado[idx] = qtd; + totalPassado += qtd; + } + } + }); + + return response.status(200).json({ + meta: { + ano_referencia: anoBase, + ano_comparacao: anoAnterior + }, + serie_temporal: { + dados: { + atual: formatarAno(arrayAtual), + passado: formatarAno(arrayPassado), }, - serie_temporal: { - semana: { - dados: { - atual: formatarSemana(semanaAtual.array), - passada: formatarSemana(semanaPass.array), - }, - totais: { - atual: semanaAtual.total, - passada: semanaPass.total, - porcentagem: calcularPorcentagem(semanaAtual.total, semanaPass.total) - } - }, - mes: { - dados: { - atual: formatarMes(mesAtual.array, hoje), - passado: formatarMes(mesPass.array, inicioMesPassado), - }, - totais: { - atual: mesAtual.total, - passado: mesPass.total, - porcentagem: calcularPorcentagem(mesAtual.total, mesPass.total) - } - }, - ano: { - dados: { - atual: formatarAno(anoAtual.array), - passado: formatarAno(anoPass.array), - }, - totais: { - atual: anoAtual.total, - passado: anoPass.total, - porcentagem: calcularPorcentagem(anoAtual.total, anoPass.total) - }, - } + totais: { + atual: totalAtual, + passado: totalPassado, + porcentagem: calcularPorcentagem(totalAtual, totalPassado) } } }); diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js index 5deccfbb..ae315e46 100644 --- a/src/routes/dashboard.js +++ b/src/routes/dashboard.js @@ -1,5 +1,6 @@ import * as controller from '../controllers/dashboard-controller'; export default app => { - app.route('/dashboard').get([controller.tomboInfo]) + app.route('/analise/tombo').get([controller.tomboInfo]) + app.route('/analise/temporal').get([controller.tomboSerieTemporal]) }; From 4b45205173c7496beaf405a389f2fa692b5d7c07 Mon Sep 17 00:00:00 2001 From: als-v Date: Wed, 3 Jun 2026 20:42:55 -0300 Subject: [PATCH 3/4] HCF-API #453: adicionado documentacao via swagger --- src/routes/dashboard.js | 260 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 258 insertions(+), 2 deletions(-) diff --git a/src/routes/dashboard.js b/src/routes/dashboard.js index ae315e46..5e56dba4 100644 --- a/src/routes/dashboard.js +++ b/src/routes/dashboard.js @@ -1,6 +1,262 @@ import * as controller from '../controllers/dashboard-controller'; +/** + * @swagger + * tags: + * name: Dashboard + * description: Rotas para o dashboard do painel + */ export default app => { - app.route('/analise/tombo').get([controller.tomboInfo]) - app.route('/analise/temporal').get([controller.tomboSerieTemporal]) + /** + * @swagger + * /analise/tombo: + * get: + * summary: Retorna indicadores gerais dos tombos + * tags: [Dashboard] + * description: Retorna métricas consolidadas, totais e rankings utilizados no dashboard. + * responses: + * 200: + * description: Indicadores retornados com sucesso + * content: + * application/json: + * schema: + * type: object + * properties: + * dados: + * type: object + * properties: + * tombos: + * type: object + * properties: + * total: + * type: integer + * internos: + * type: integer + * externos: + * type: integer + * fotos: + * type: integer + * taxonomia: + * type: object + * properties: + * familias: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * generos: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * especies: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * municipios: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * coletores: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * herbarios: + * type: object + * properties: + * total: + * type: integer + * ranking: + * type: array + * items: + * type: object + * properties: + * nome: + * type: string + * total: + * type: integer + * example: + * dados: + * tombos: + * total: 15423 + * internos: 13211 + * externos: 2212 + * fotos: 48721 + * taxonomia: + * familias: + * total: 120 + * ranking: + * - nome: Asteraceae + * total: 2500 + * generos: + * total: 540 + * ranking: + * - nome: Solanum + * total: 800 + * especies: + * total: 1800 + * ranking: + * - nome: amalago + * total: 120 + * municipios: + * total: 150 + * ranking: + * - nome: Campo Mourao + * total: 392 + * coletores: + * total: 75 + * ranking: + * - nome: Coletor #1 + * total: 123 + * herbarios: + * total: 18 + * ranking: + * - nome: Herbario da Universidade Tecnologica Federal do Parana Campus Campo Mourao + * total: 567 + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/analise/tombo') + .get([ + controller.tomboInfo, + ]); + + /** + * @swagger + * /analise/temporal: + * get: + * summary: Retorna série temporal de tombos + * tags: [Dashboard] + * description: Retorna dados mensais comparando o ano informado com o ano anterior. + * parameters: + * - in: query + * name: ano + * required: false + * schema: + * type: integer + * description: Ano de referência para comparação. Quando não informado utiliza o ano atual. + * responses: + * 200: + * description: Série temporal retornada com sucesso + * content: + * application/json: + * schema: + * type: object + * properties: + * meta: + * type: object + * properties: + * ano_referencia: + * type: integer + * ano_comparacao: + * type: integer + * serie_temporal: + * type: object + * properties: + * dados: + * type: object + * properties: + * atual: + * type: array + * items: + * type: object + * properties: + * mes: + * type: string + * total: + * type: integer + * passado: + * type: array + * items: + * type: object + * properties: + * mes: + * type: string + * total: + * type: integer + * totais: + * type: object + * properties: + * atual: + * type: integer + * passado: + * type: integer + * porcentagem: + * type: number + * format: float + * example: + * meta: + * ano_referencia: 2026 + * ano_comparacao: 2025 + * serie_temporal: + * dados: + * atual: + * - mes: Janeiro + * total: 120 + * - mes: Fevereiro + * total: 95 + * passado: + * - mes: Janeiro + * total: 100 + * - mes: Fevereiro + * total: 90 + * totais: + * atual: 1250 + * passado: 980 + * porcentagem: 27.6 + * '500': + * $ref: '#/components/responses/InternalServerError' + */ + app.route('/analise/temporal') + .get([ + controller.tomboSerieTemporal, + ]); }; From 20b9ad546b6e57233e459f63f4c020dc214c0dbe Mon Sep 17 00:00:00 2001 From: als-v Date: Wed, 3 Jun 2026 20:50:20 -0300 Subject: [PATCH 4/4] HCF-API #453: ajustes pipeline lint --- src/controllers/dashboard-controller.js | 149 ++++++++---------------- 1 file changed, 51 insertions(+), 98 deletions(-) diff --git a/src/controllers/dashboard-controller.js b/src/controllers/dashboard-controller.js index 25861119..7aca54b3 100644 --- a/src/controllers/dashboard-controller.js +++ b/src/controllers/dashboard-controller.js @@ -1,42 +1,8 @@ import { Op, Sequelize } from 'sequelize'; -import models from '../models/index.js'; - -const { Tombo, Especie, Coletor, Cidade, Familia, Genero, Herbario, TomboFoto } = models; - -const agruparPorIndice = (dados, tamanho, indice, offset = 0) => { - const array = Array(tamanho).fill(0); - let total = 0; - - dados.forEach(item => { - const idx = parseInt(item.get(indice)) - offset; - const qtd = parseInt(item.get('total')); - - if (idx >= 0 && idx < tamanho) { - array[idx] += qtd; - total += qtd; - } - }); - - return { array, total }; -}; -const agruparMesPorSemana = (dados) => { - const array = Array(5).fill(0); - let total = 0; - - dados.forEach(item => { - const dia = parseInt(item.get('dia')); - const qtd = parseInt(item.get('total')); - const semanaIdx = Math.floor((dia - 1) / 7); +import models from '../models/index.js'; - if (semanaIdx >= 0 && semanaIdx < 5) { - array[semanaIdx] += qtd; - total += qtd; - } - }); - - return { array, total }; -}; +const { Tombo, Especie, Coletor, Cidade, Familia, Genero, Herbario, TomboFoto } = models; const calcularPorcentagem = (atual, passado) => { if (passado > 0) return parseFloat((((atual - passado) / passado) * 100).toFixed(1)); @@ -50,68 +16,43 @@ const formatarRanking = (dadosQuery, aliasTabela) => { return { nome: info?.nome || info?.sigla || 'N/A', - total: parseInt(item.get('quantidade'), 10) || 0 + total: parseInt(item.get('quantidade'), 10) || 0, }; }); }; -const formatarSemana = (array) => { - const dias = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']; - return array.map((total, index) => ({ dia: dias[index], total })); -}; - -const formatarAno = (array) => { +const formatarAno = array => { const meses = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro']; return array.map((total, index) => ({ mes: meses[index], total })); }; -const formatarMes = (array, dataBase) => { - const ano = dataBase.getFullYear(); - const mes = dataBase.getMonth(); - const ultimoDiaDoMes = new Date(ano, mes + 1, 0).getDate(); - const formatoFinal = []; - - for (let i = 0; i < 5; i++) { - const diaInicio = (i * 7) + 1; - if (diaInicio > ultimoDiaDoMes) break; - - const diaFim = Math.min((i + 1) * 7, ultimoDiaDoMes); - const strInicio = `${String(diaInicio).padStart(2, '0')}/${String(mes + 1).padStart(2, '0')}`; - const strFim = `${String(diaFim).padStart(2, '0')}/${String(mes + 1).padStart(2, '0')}`; - - formatoFinal.push({ semana: `${strInicio} - ${strFim}`, total: array[i] }); - } - - return formatoFinal; -}; - export const tomboInfo = async (request, response, next) => { try { - const ID_HCF = 2; + const ID_HCF = 2; const condicaoBase = { rascunho: false, - ativo: true + ativo: true, }; const [ totaisGerais, distintos, - rankEspecies, - rankFamilias, - rankGeneros, - rankMunicipios, - rankColetores, + rankEspecies, + rankFamilias, + rankGeneros, + rankMunicipios, + rankColetores, rankHerbarios, - totalImagens + totalImagens, ] = await Promise.all([ Tombo.findOne({ attributes: [ [Sequelize.literal('COUNT(*)'), 'total'], [Sequelize.literal(`COUNT(*) FILTER (WHERE entidade_id IS NULL OR entidade_id = ${ID_HCF})`), 'tombos_internos'], - [Sequelize.literal(`COUNT(*) FILTER (WHERE entidade_id IS NOT NULL AND entidade_id != ${ID_HCF})`), 'tombos_externos'] + [Sequelize.literal(`COUNT(*) FILTER (WHERE entidade_id IS NOT NULL AND entidade_id != ${ID_HCF})`), 'tombos_externos'], ], - raw: true + raw: true, }), // totaisGerais Tombo.findOne({ where: condicaoBase, @@ -121,47 +62,59 @@ export const tomboInfo = async (request, response, next) => { [Sequelize.literal('COUNT(DISTINCT genero_id)'), 'generos'], [Sequelize.literal('COUNT(DISTINCT cidade_id)'), 'municipios'], [Sequelize.literal('COUNT(DISTINCT coletor_id)'), 'coletores'], - [Sequelize.literal('COUNT(DISTINCT entidade_id)'), 'herbarios'] + [Sequelize.literal('COUNT(DISTINCT entidade_id)'), 'herbarios'], ], - raw: true + raw: true, }), // distintos Tombo.findAll({ where: { ...condicaoBase, especie_id: { [Op.not]: null } }, attributes: ['especie_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], include: [{ model: Especie, as: 'especie', attributes: ['nome'] }], - group: ['especie_id', 'especie.id'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + group: ['especie_id', 'especie.id'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, }), // rankEspecies Tombo.findAll({ where: { ...condicaoBase, familia_id: { [Op.not]: null } }, attributes: ['familia_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], include: [{ model: Familia, as: 'familia', attributes: ['nome'] }], - group: ['familia_id', 'familia.id', 'familia.nome'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + group: ['familia_id', 'familia.id', 'familia.nome'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, }), // rankFamilias Tombo.findAll({ where: { ...condicaoBase, genero_id: { [Op.not]: null } }, attributes: ['genero_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], include: [{ model: Genero, as: 'genero', attributes: ['nome'] }], - group: ['genero_id', 'genero.id', 'genero.nome'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + group: ['genero_id', 'genero.id', 'genero.nome'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, }), // rankGeneros Tombo.findAll({ where: { ...condicaoBase, cidade_id: { [Op.not]: null } }, attributes: ['cidade_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], include: [{ model: Cidade, attributes: ['nome'] }], - group: ['cidade_id', 'cidade.id', 'cidade.nome'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + group: ['cidade_id', 'cidade.id', 'cidade.nome'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, }), // rankMunicipios Tombo.findAll({ where: { ...condicaoBase, coletor_id: { [Op.not]: null } }, attributes: ['coletor_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], include: [{ model: Coletor, as: 'coletor', attributes: ['nome'] }], - group: ['coletor_id', 'coletor.id', 'coletor.nome'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + group: ['coletor_id', 'coletor.id', 'coletor.nome'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, }), // rankColetores Tombo.findAll({ where: { ...condicaoBase, entidade_id: { [Op.not]: null } }, attributes: ['entidade_id', [Sequelize.fn('COUNT', Sequelize.col('tombos.hcf')), 'quantidade']], include: [{ model: Herbario, attributes: ['nome', 'sigla'] }], - group: ['entidade_id', 'herbario.id', 'herbario.nome', 'herbario.sigla'], order: [[Sequelize.literal('quantidade'), 'DESC']], limit: 5 + group: ['entidade_id', 'herbario.id', 'herbario.nome', 'herbario.sigla'], + order: [[Sequelize.literal('quantidade'), 'DESC']], + limit: 5, }), // rankHerbarios - TomboFoto.count() // totalImagens + TomboFoto.count(), // totalImagens ]); return response.status(200).json({ @@ -175,21 +128,21 @@ export const tomboInfo = async (request, response, next) => { taxonomia: { familias: { total: parseInt(distintos.familias, 10) || 0, ranking: formatarRanking(rankFamilias, 'familia') }, generos: { total: parseInt(distintos.generos, 10) || 0, ranking: formatarRanking(rankGeneros, 'genero') }, - especies: { total: parseInt(distintos.especies, 10) || 0, ranking: formatarRanking(rankEspecies, 'especie') } + especies: { total: parseInt(distintos.especies, 10) || 0, ranking: formatarRanking(rankEspecies, 'especie') }, }, municipios: { - total: parseInt(distintos.municipios, 10) || 0, - ranking: formatarRanking(rankMunicipios, 'cidade') + total: parseInt(distintos.municipios, 10) || 0, + ranking: formatarRanking(rankMunicipios, 'cidade'), }, coletores: { total: parseInt(distintos.coletores, 10) || 0, - ranking: formatarRanking(rankColetores, 'coletor') + ranking: formatarRanking(rankColetores, 'coletor'), }, herbarios: { total: parseInt(distintos.herbarios, 10) || 0, - ranking: formatarRanking(rankHerbarios, 'herbario') - } - } + ranking: formatarRanking(rankHerbarios, 'herbario'), + }, + }, }); } catch (error) { @@ -199,7 +152,7 @@ export const tomboInfo = async (request, response, next) => { export const tomboSerieTemporal = async (request, response, next) => { try { - const anoBase = parseInt(request.query.ano) || new Date().getFullYear(); + const anoBase = parseInt(request.query.ano, 10) || new Date().getFullYear(); const anoAnterior = anoBase - 1; const inicioRange = new Date(anoAnterior, 0, 1); @@ -209,18 +162,18 @@ export const tomboSerieTemporal = async (request, response, next) => { where: { rascunho: false, ativo: true, - data_tombo: { [Op.between]: [inicioRange, fimRange] } + data_tombo: { [Op.between]: [inicioRange, fimRange] }, }, attributes: [ [Sequelize.fn('EXTRACT', Sequelize.literal('YEAR FROM data_tombo')), 'ano'], [Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')), 'mes'], - [Sequelize.literal('COUNT(*)'), 'total'] + [Sequelize.literal('COUNT(*)'), 'total'], ], group: [ Sequelize.fn('EXTRACT', Sequelize.literal('YEAR FROM data_tombo')), - Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')) + Sequelize.fn('EXTRACT', Sequelize.literal('MONTH FROM data_tombo')), ], - raw: true + raw: true, }); const arrayAtual = Array(12).fill(0); @@ -247,7 +200,7 @@ export const tomboSerieTemporal = async (request, response, next) => { return response.status(200).json({ meta: { ano_referencia: anoBase, - ano_comparacao: anoAnterior + ano_comparacao: anoAnterior, }, serie_temporal: { dados: { @@ -257,9 +210,9 @@ export const tomboSerieTemporal = async (request, response, next) => { totais: { atual: totalAtual, passado: totalPassado, - porcentagem: calcularPorcentagem(totalAtual, totalPassado) - } - } + porcentagem: calcularPorcentagem(totalAtual, totalPassado), + }, + }, }); } catch (error) {