Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions src/controllers/relatorios-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
agruparPorGenero,
} from '~/helpers/formata-dados-relatorio';
import { generateReport } from '~/reports/reports';
import ReportCoordenadaForaPoligono from '~/reports/templates/CoordenadaForaPoligono';
import ReportInventario from '~/reports/templates/InventarioEspecies';
import ReportLocalColeta from '~/reports/templates/LocaisColeta';
import ReportFamiliasGeneros from '~/reports/templates/RelacaoFamiliasGenero';
Expand Down Expand Up @@ -1034,3 +1035,144 @@ export const obtemDadosDoRelatorioDeQuantidade = async (req, res, next) => {
next(error);
}
};

/// ////// Relatório de Coordenadas Fora do Polígono //////////
export const obtemDadosDoRelatorioDeCoordenadaForaPoligono = async (req, res, next) => {
const { incluirSemCoordenadas } = req.query;
const incluirSem = incluirSemCoordenadas === 'true';

try {
let query = `
SELECT
t.hcf,
t.latitude,
t.longitude,
t.nome_cientifico,
c.nome AS cidade_nome,
e.nome AS estado_nome,
e.sigla AS estado_sigla,
CASE
WHEN t.latitude IS NULL OR t.longitude IS NULL THEN 'SEM_COORDENADA'
WHEN c.poligono IS NULL THEN 'SEM_POLIGONO'
ELSE 'FORA_DO_POLIGONO'
END AS motivo
FROM tombos t
LEFT JOIN cidades c ON t.cidade_id = c.id
LEFT JOIN estados e ON c.estado_id = e.id
LEFT JOIN paises p ON e.pais_id = p.id
WHERE t.rascunho = false
AND t.ativo = true
AND (
(
t.latitude IS NOT NULL
AND t.longitude IS NOT NULL
AND c.poligono IS NOT NULL
AND ST_Contains(
c.poligono,
ST_SetSRID(ST_Point(t.longitude, t.latitude), 4674)
) = false
)
`;

if (incluirSem) {
query += `
OR (t.latitude IS NULL OR t.longitude IS NULL)
`;
}

query += `
)
`;

const replacements = {};

if (req.query.pais_id) {
query += ' AND p.id = :pais_id';
replacements.pais_id = req.query.pais_id;
}

if (req.query.estado_id) {
query += ' AND e.id = :estado_id';
replacements.estado_id = req.query.estado_id;
}

if (req.query.cidade_id) {
query += ' AND c.id = :cidade_id';
replacements.cidade_id = req.query.cidade_id;
}

query += `
ORDER BY e.nome ASC, c.nome ASC, t.hcf ASC
`;

const results = await sequelize.query(query, {
type: models.Sequelize.QueryTypes.SELECT,
replacements,
});

// Agrupar por estado e cidade
const agrupado = {};
results.forEach(row => {
const estadoKey = row.estado_nome || 'Sem estado';
const cidadeKey = row.cidade_nome || 'Sem cidade';

if (!agrupado[estadoKey]) {
agrupado[estadoKey] = {
estado: estadoKey,
sigla: row.estado_sigla || '',
cidades: {},
};
}

if (!agrupado[estadoKey].cidades[cidadeKey]) {
agrupado[estadoKey].cidades[cidadeKey] = {
cidade: cidadeKey,
tombos: [],
};
}

agrupado[estadoKey].cidades[cidadeKey].tombos.push({
hcf: row.hcf,
latitude: row.latitude,
longitude: row.longitude,
nome_cientifico: row.nome_cientifico,
motivo: row.motivo,
});
});

// Converter para array
const resultado = Object.values(agrupado).map(estado => ({
...estado,
cidades: Object.values(estado.cidades),
}));

if (req.method === 'GET') {
res.status(codigosHttp.LISTAGEM).json({
metadados: {
total: results.length,
},
resultado,
});
return;
}

try {
const buffer = await generateReport(
ReportCoordenadaForaPoligono, {
dados: resultado,
total: results.length,
});
const readable = new Readable();

readable._read = () => { };
readable.push(buffer);
readable.push(null);
res.setHeader('Content-Type', 'application/pdf');
readable.pipe(res);
} catch (e) {
next(e);
}
} catch (error) {
next(error);
}
};
9 changes: 5 additions & 4 deletions src/controllers/tombos-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ import {
converteParaDecimal, converteDecimalParaGraus, converteDecimalParaGMSGrau, converteDecimalParaGMSMinutos, converteDecimalParaGMSSegundos,
} from '../helpers/coordenadas';
import pick from '../helpers/pick';

const SRID = {
SIRGAS_2000: 4674,
};
import { converteInteiroParaRomano } from '../helpers/tombo';
import models from '../models';
import codigos from '../resources/codigos-http';
import verifyRecaptcha from '../utils/verify-recaptcha';
import { aprovarPendencia } from './pendencias-controller';

const SRID = {
SIRGAS_2000: 4674,
};

const {
Solo, Relevo, Cidade, Estado, Vegetacao, FaseSucessional, Pais, Tipo, LocalColeta, Familia, sequelize,
Genero, Subfamilia, Autor, Coletor, Variedade, Subespecie, TomboFoto, Identificador,
Expand Down
21 changes: 21 additions & 0 deletions src/helpers/coordenadas.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ export const converteParaDecimal = coordenada => {
return sinal * (graus + (minutos / 60) + (segundos / 3600));
};

export const converteDecimalParaDMS = (decimal, isLatitude = true) => {
if (decimal === null || decimal === undefined || decimal === '') {
return '';
}

const abs = Math.abs(decimal);
const graus = Math.floor(abs);
const minutosDecimal = (abs - graus) * 60;
const minutos = Math.floor(minutosDecimal);
const segundos = ((minutosDecimal - minutos) * 60).toFixed(2);

let hemisferio;
if (isLatitude) {
hemisferio = decimal >= 0 ? 'N' : 'S';
} else {
hemisferio = decimal >= 0 ? 'E' : 'W';
}

return `${graus}° ${minutos}' ${segundos}" ${hemisferio}`;
};

export const converteDecimalParaGraus = (decimal, isLat) => {
let max = 180;

Expand Down
154 changes: 154 additions & 0 deletions src/reports/templates/CoordenadaForaPoligono.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import React from "react";
import { Page } from "../components/Page";
import { converteDecimalParaDMS } from "../../helpers/coordenadas";

interface TomboItem {
hcf: number;
latitude: number | null;
longitude: number | null;
nome_cientifico: string | null;
motivo: string;
}

interface CidadeGroup {
cidade: string;
tombos: TomboItem[];
}

interface EstadoGroup {
estado: string;
sigla: string;
cidades: CidadeGroup[];
}

interface RelatorioCoordenadaForaPoligonoProps {
dados: EstadoGroup[];
total?: number;
}

function renderMotivo(motivo: string) {
switch (motivo) {
case 'SEM_COORDENADA':
return 'Sem coordenada';
case 'SEM_POLIGONO':
return 'Sem polígono';
case 'FORA_DO_POLIGONO':
return 'Fora do polígono';
default:
return motivo;
}
}

function RelatorioCoordenadaForaPoligono({ dados, total }: RelatorioCoordenadaForaPoligonoProps) {

const renderTabelaTombos = (tombos: TomboItem[]) => (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem', marginTop: '0.25em' }}>
<colgroup>
<col style={{ width: '7%' }} />
<col style={{ width: '33%' }} />
<col style={{ width: '20%' }} />
<col style={{ width: '20%' }} />
<col style={{ width: '20%' }} />
</colgroup>
<thead>
<tr style={{ backgroundColor: '#e0e0e0' }}>
<th style={{ textAlign: 'right', padding: '4px 6px', borderBottom: '1px solid #aaa' }}>HCF</th>
<th style={{ textAlign: 'left', padding: '4px 6px', borderBottom: '1px solid #aaa' }}>Nome Científico</th>
<th style={{ textAlign: 'left', padding: '4px 6px', borderBottom: '1px solid #aaa' }}>Latitude</th>
<th style={{ textAlign: 'left', padding: '4px 6px', borderBottom: '1px solid #aaa' }}>Longitude</th>
<th style={{ textAlign: 'left', padding: '4px 6px', borderBottom: '1px solid #aaa' }}>Motivo</th>
</tr>
</thead>
<tbody>
{tombos.map((tombo, i) => (
<tr
key={`${i}-${tombo.hcf}`}
style={{ backgroundColor: i % 2 === 0 ? '#ffffff' : '#f5f5f5' }}
>
<td style={{ textAlign: 'right', padding: '3px 6px' }}>{tombo.hcf}</td>
<td style={{ fontStyle: 'italic', padding: '3px 6px' }}>{tombo.nome_cientifico || '—'}</td>
<td style={{ padding: '3px 6px', fontFamily: 'monospace', fontSize: '0.7rem' }}>
{converteDecimalParaDMS(tombo.latitude, true)}
</td>
<td style={{ padding: '3px 6px', fontFamily: 'monospace', fontSize: '0.7rem' }}>
{converteDecimalParaDMS(tombo.longitude, false)}
</td>
<td style={{ padding: '3px 6px' }}>{renderMotivo(tombo.motivo)}</td>
</tr>
))}
</tbody>
</table>
);

const renderCidade = (cidade: CidadeGroup, estadoSigla: string) => (
<div
key={`${estadoSigla}-${cidade.cidade}`}
style={{ marginBottom: '1.25em' }}
>
{/* pageBreakInside: avoid só no cabeçalho — evita que o nome da cidade
fique sozinho no final de uma página, mas a tabela pode quebrar livremente */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: '#d4d4d4',
padding: '0.3em 0.6em',
borderRadius: '3px',
marginBottom: '0.2em',
pageBreakInside: 'avoid',
pageBreakAfter: 'avoid',
}}>
<span style={{ fontWeight: 'bold', fontSize: '0.75rem' }}>
Município: {cidade.cidade}
</span>
<span style={{ fontSize: '0.7rem', color: '#555' }}>
{cidade.tombos.length} tombo(s)
</span>
</div>
{renderTabelaTombos(cidade.tombos)}
</div>
);

const renderEstado = (estado: EstadoGroup) => {
const totalTombos = estado.cidades.reduce((acc, c) => acc + c.tombos.length, 0);
return (
<div key={estado.estado} style={{ marginBottom: '2em' }}>
<div style={{
backgroundColor: '#444',
color: '#ffffff',
padding: '0.4em 0.6em',
borderRadius: '3px',
marginBottom: '0.6em',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}>
<span style={{ fontWeight: 'bold', fontSize: '0.875rem' }}>
{estado.estado} ({estado.sigla})
</span>
<span style={{ fontSize: '0.75rem' }}>
{totalTombos} tombo(s)
</span>
</div>
{estado.cidades.map(cidade => renderCidade(cidade, estado.sigla))}
</div>
);
};

return (
<Page title="Diagnóstico de Erros de Posicionamento">
{dados.map(renderEstado)}
<div style={{
marginTop: '1.5em',
paddingTop: '0.5em',
borderTop: '1px solid #555',
fontSize: '0.75rem',
fontWeight: 'bold',
}}>
Total geral: {total || 0} registro(s)
</div>
</Page>
);
}

export default RelatorioCoordenadaForaPoligono;
19 changes: 19 additions & 0 deletions src/routes/relatorio.js
Original file line number Diff line number Diff line change
Expand Up @@ -420,4 +420,23 @@ export default app => {
controller.obtemDadosDoRelatorioDeQuantidade,
]);

app.route('/relatorio/coordenadas-fora-poligono')
.get([
tokensMiddleware([
TIPOS_USUARIOS.CURADOR,
TIPOS_USUARIOS.OPERADOR,
TIPOS_USUARIOS.IDENTIFICADOR,
]),
listagensMiddleware,
controller.obtemDadosDoRelatorioDeCoordenadaForaPoligono,
])
.post([
tokensMiddleware([
TIPOS_USUARIOS.CURADOR,
TIPOS_USUARIOS.OPERADOR,
TIPOS_USUARIOS.IDENTIFICADOR,
]),
listagensMiddleware,
controller.obtemDadosDoRelatorioDeCoordenadaForaPoligono,
]);
};
Loading