fastcheck/server/src/controllers/snifaController.ts
2026-04-08 13:58:46 -04:00

487 lines
18 KiB
TypeScript

import { Request, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import XLSX from 'xlsx';
import mongoose from 'mongoose';
import { SnifaSancionatorio, ISnifaSancionatorio } from '../models/SnifaSancionatorio';
import { SnifaChangeLog } from '../models/SnifaChangeLog';
import { SnifaImportJob } from '../models/SnifaImportJob';
import { AuthenticatedRequest } from '../types/auth';
interface MulterRequest extends AuthenticatedRequest {
file?: Express.Multer.File;
}
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const uploadDir = path.join(process.cwd(), 'uploads', 'snifa');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, `snifa-${uniqueSuffix}${path.extname(file.originalname)}`);
}
});
const fileFilter = (req: Express.Request, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
const allowedMimes = ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/vnd.ms-excel'];
const allowedExts = ['.xlsx', '.xls'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowedMimes.includes(file.mimetype) || allowedExts.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Formato no válido. Solo XLSX/XLS'));
}
};
export const snifaUpload = multer({
storage,
fileFilter,
limits: { fileSize: 50 * 1024 * 1024 }
});
const parseDate = (val: any): Date | undefined => {
if (val instanceof Date && !isNaN(val.getTime())) return val;
if (typeof val === 'number') {
const base = new Date(Date.UTC(1899, 11, 30));
const d4 = new Date(base.getTime() + val * 86400000);
if (!isNaN(d4.getTime())) return d4;
}
const str = String(val ?? '').trim();
if (!str) return undefined;
const direct = new Date(str);
if (!isNaN(direct.getTime())) return direct;
const m1 = str.match(/^([0-3]?\d)[-/]([0-1]?\d)[-/](\d{2}|\d{4})$/);
if (m1) {
const day = parseInt(m1[1], 10);
const month = parseInt(m1[2], 10) - 1;
let year = parseInt(m1[3], 10);
if (year < 100) year += 2000;
const d2 = new Date(Date.UTC(year, month, day));
if (!isNaN(d2.getTime())) return d2;
}
const m2 = str.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
if (m2) {
const year = parseInt(m2[1], 10);
const month = parseInt(m2[2], 10) - 1;
const day = parseInt(m2[3], 10);
const d3 = new Date(Date.UTC(year, month, day));
if (!isNaN(d3.getTime())) return d3;
}
return undefined;
};
const readXLS = (filePath: string): any[] => {
if (!fs.existsSync(filePath)) {
throw new Error(`El archivo no existe en la ruta especificada: ${filePath}`);
}
const workbook = XLSX.readFile(filePath);
if (!workbook.SheetNames || workbook.SheetNames.length === 0) {
throw new Error('El archivo Excel no contiene hojas de cálculo');
}
const sheet = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheet];
return XLSX.utils.sheet_to_json(worksheet);
};
const normalizeString = (v: any): string => String(v ?? '').toString().trim();
const normalizeNumber = (v: any): number | undefined => {
if (v === null || v === undefined || v === '') return undefined;
const n = typeof v === 'string' ? parseFloat(v.replace(/[^\d.,-]/g, '').replace(',', '.')) : Number(v);
return isNaN(n) ? undefined : n;
};
const mapRowToModel = (row: any): Partial<ISnifaSancionatorio> | null => {
const keys = Object.keys(row).reduce((acc: Record<string, string>, k) => {
acc[k.trim().toLowerCase()] = k;
return acc;
}, {});
const get = (name: string) => row[keys[name]] ?? row[name];
const procesoId = normalizeNumber(get('#')) ?? normalizeNumber(get('proceso sancion id')) ?? normalizeNumber(get('procesosancionid'));
const expediente = normalizeString(get('expediente'));
// Campos nuevos y mapeo específico
const razonSocial = normalizeString(get('razón social')) || normalizeString(get('razon social'));
const rut = normalizeString(get('rut'));
const unidadFiscalizable = normalizeString(get('unidad fiscalizable'));
const lista = normalizeString(get('lista'));
const fastCheckProcesoSancionatorio = normalizeString(get('fast check proceso sancionatorio'));
const fastCheckSanciones = normalizeString(get('fast check sanciones')) || normalizeString(get('fast check sanciones'));
const confirmaPdC = normalizeString(get('confirma pdc')) || normalizeString(get('confirmapdc'));
// Mapeo existente ajustado
const nombre = normalizeString(get('nombre')) || normalizeString(get('unidad fiscalizable')) || normalizeString(get('nombre razón social'));
const categoria = normalizeString(get('categoría')) || normalizeString(get('categoria')) || normalizeString(get('categoriaeconomicanombre'));
const region = normalizeString(get('región')) || normalizeString(get('region')) || normalizeString(get('regionnombre'));
const estado = normalizeString(get('estado')) || normalizeString(get('proceso sancion estado')) || normalizeString(get('procesosancionestado')) || 'En curso';
const tipo = normalizeString(get('proceso sancion tipo nombre')) || normalizeString(get('procesosanciontiponombre')) || 'Sanción Ambiental';
const multaUTA = normalizeNumber(get('multa (uta)')) ?? normalizeNumber(get('multatotaluta'));
const fechaInicio = parseDate(get('fecha inicio')) ?? parseDate(get('fechainicio')) ?? new Date();
const fechaTermino = parseDate(get('fecha termino')) ?? parseDate(get('fechatermino'));
const comuna = normalizeString(get('comuna')) || normalizeString(get('comunanombre')) || 'No especificada';
const lat = normalizeNumber(get('latitud'));
const lon = normalizeNumber(get('longitud'));
const subCat = normalizeString(get('subcategoriaeconomicanombre')) || 'No especificada';
const linkSnifaUf = normalizeString(get('detalle')) || normalizeString(get('linksnifa_uf'));
const linkSnifa = normalizeString(get('linksnifa'));
const ufId = normalizeNumber(get('unidad fiscalizable id')) ?? normalizeNumber(get('unidadfiscalizableid')) ?? procesoId;
const fechaAct = parseDate(get('fechaactualizacion')) ?? new Date();
if (!procesoId || !expediente || !nombre || !categoria || !region) {
return null;
}
return {
procesoSancionId: procesoId,
expediente,
procesoSancionTipoNombre: tipo || 'Sanción Ambiental',
procesoSancionEstado: estado || 'En curso',
fechaInicio,
fechaTermino,
multaTotalUTA: multaUTA,
linkSNIFA: linkSnifa || 'https://snifa.sma.gob.cl/',
unidadFiscalizableId: ufId,
nombre,
regionNombre: region,
comunaNombre: comuna,
latitud: lat,
longitud: lon,
categoriaEconomicaNombre: categoria,
subCategoriaEconomicaNombre: subCat,
linkSNIFA_UF: linkSnifaUf || 'Ver detalles',
fechaActualizacion: fechaAct,
razonSocial,
rut,
unidadFiscalizable,
lista,
fastCheckProcesoSancionatorio,
fastCheckSanciones,
confirmaPdC
};
};
export const previewFile = async (req: MulterRequest, res: Response) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Ningún archivo subido' });
}
const filePath = req.file.path;
const rows = readXLS(filePath);
const headers = rows.length ? Object.keys(rows[0]) : [];
const mapped: Partial<ISnifaSancionatorio>[] = [];
const warnings: Array<{ rowIndex: number; warning: string }> = [];
for (let i = 0; i < Math.min(50, rows.length); i++) {
const r = rows[i];
const m = mapRowToModel(r);
if (m) {
mapped.push(m);
} else {
warnings.push({ rowIndex: i + 1, warning: 'Fila incompleta o con formatos inválidos' });
}
}
const summary = {
totalRows: rows.length,
previewCount: mapped.length,
missingCount: warnings.length,
distinctEstados: Array.from(new Set(rows.map(r => String(r['Estado'] || r['estado'] || '').trim()).filter(Boolean))),
distinctRegiones: Array.from(new Set(rows.map(r => String(r['Región'] || r['region'] || '').trim()).filter(Boolean)))
};
fs.unlinkSync(filePath);
return res.status(200).json({ headers, preview: mapped, warnings, summary });
} catch (error: any) {
if (req.file?.path && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
return res.status(500).json({ error: 'Error al previsualizar archivo', details: error?.message });
}
};
export const importFile = async (req: MulterRequest, res: Response) => {
try {
if (!req.user) {
return res.status(401).json({ error: 'No autenticado' });
}
if (!req.file) {
return res.status(400).json({ error: 'Ningún archivo subido' });
}
const filePath = req.file.path;
const rows = readXLS(filePath);
const job = await SnifaImportJob.create({
status: 'processing',
startedAt: new Date(),
total: rows.length,
createdBy: req.user.email
});
const batchSize = 1000;
let processed = 0;
let saved = 0;
let duplicates = 0;
const errors: Array<{ rowIndex: number; error: string }> = [];
const warnings: Array<{ rowIndex: number; warning: string }> = [];
for (let offset = 0; offset < rows.length; offset += batchSize) {
const slice = rows.slice(offset, Math.min(offset + batchSize, rows.length));
const ops: any[] = [];
for (let i = 0; i < slice.length; i++) {
const r = slice[i];
processed++;
const mapped = mapRowToModel(r);
if (!mapped) {
warnings.push({ rowIndex: offset + i + 1, warning: 'Fila incompleta o inválida' });
continue;
}
ops.push({
updateOne: {
filter: { procesoSancionId: mapped.procesoSancionId },
update: { $setOnInsert: mapped },
upsert: true
}
});
}
if (ops.length) {
const result = await SnifaSancionatorio.bulkWrite(ops, { ordered: false });
saved += result.upsertedCount;
duplicates += (ops.length - result.upsertedCount);
}
const currentJob = await SnifaImportJob.findById(job._id);
if (currentJob?.status === 'canceled') {
await SnifaImportJob.findByIdAndUpdate(job._id, {
status: 'canceled',
completedAt: new Date(),
processed,
saved,
duplicates,
errorCount: errors.length,
errorDetails: errors,
warnings
});
try { fs.unlinkSync(filePath); } catch {}
return res.status(200).json({ message: 'Importación cancelada', jobId: job._id });
}
}
await SnifaImportJob.findByIdAndUpdate(job._id, {
status: 'completed',
completedAt: new Date(),
processed,
saved,
duplicates,
errorCount: errors.length,
errorDetails: errors,
warnings
});
try { fs.unlinkSync(filePath); } catch {}
return res.status(200).json({
message: 'Archivo importado',
summary: { total: rows.length, processed, saved, duplicates, errors: errors.length, warnings: warnings.length },
jobId: job._id
});
} catch (error: any) {
try {
if (req.file?.path && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
} catch {}
return res.status(500).json({ error: 'Error al importar archivo', details: error?.message });
}
};
export const cancelImportJob = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const job = await SnifaImportJob.findByIdAndUpdate(id, { status: 'canceled' }, { new: true });
if (!job) return res.status(404).json({ error: 'Job no encontrado' });
return res.status(200).json({ success: true, job });
} catch (error: any) {
return res.status(500).json({ error: 'Error al cancelar job', details: error?.message });
}
};
export const getImportJobStatus = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const job = await SnifaImportJob.findById(id);
if (!job) return res.status(404).json({ error: 'Job no encontrado' });
return res.status(200).json({ success: true, job });
} catch (error: any) {
return res.status(500).json({ error: 'Error al obtener estado', details: error?.message });
}
};
export const getSnifaSummary = async (req: Request, res: Response) => {
try {
const total = await SnifaSancionatorio.countDocuments();
const byEstado = await SnifaSancionatorio.aggregate([
{ $group: { _id: '$procesoSancionEstado', count: { $sum: 1 } } }
]).then(res => res.reduce((acc, curr) => ({ ...acc, [curr._id]: curr.count }), {}));
const byRegion = await SnifaSancionatorio.aggregate([
{ $group: { _id: '$regionNombre', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 5 }
]).then(res => res.map(r => ({ region: r._id, count: r.count })));
const lastUpdate = await SnifaSancionatorio.findOne().sort({ fechaActualizacion: -1 }).select('fechaActualizacion');
res.json({
total,
byEstado,
byRegion,
lastUpdate: lastUpdate?.fechaActualizacion
});
} catch (error: any) {
res.status(500).json({ error: 'Error getting summary', details: error?.message });
}
};
export const listSnifaRecords = async (req: Request, res: Response) => {
try {
const page = parseInt(req.query.page as string) || 1;
const limit = parseInt(req.query.limit as string) || 50;
const search = (req.query.search as string || '').trim();
const sortField = (req.query.sortField as string) || 'fechaInicio';
const sortOrder = (req.query.sortOrder as string) === 'asc' ? 1 : -1;
const query: any = {};
if (search) {
query.$or = [
{ nombre: { $regex: search, $options: 'i' } },
{ expediente: { $regex: search, $options: 'i' } },
{ razonSocial: { $regex: search, $options: 'i' } },
{ rut: { $regex: search, $options: 'i' } }
];
}
const total = await SnifaSancionatorio.countDocuments(query);
const data = await SnifaSancionatorio.find(query)
.sort({ [sortField]: sortOrder })
.skip((page - 1) * limit)
.limit(limit);
res.json({
data,
total,
page,
totalPages: Math.ceil(total / limit)
});
} catch (error: any) {
res.status(500).json({ error: 'Error listing records', details: error?.message });
}
};
// CRUD Operations
export const getSanctionById = async (req: Request, res: Response) => {
try {
const { id } = req.params;
const record = await SnifaSancionatorio.findById(id);
if (!record) return res.status(404).json({ error: 'Registro no encontrado' });
res.json(record);
} catch (error: any) {
res.status(500).json({ error: 'Error getting record', details: error?.message });
}
};
export const updateSnifaRecord = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const body = (req.body || {}) as Record<string, unknown>;
const reason = typeof body.reason === 'string' ? body.reason : undefined;
const { reason: _ignoredReason, ...updates } = body;
// Track changes if needed, for now just update
const before = await SnifaSancionatorio.findById(id);
const record = await SnifaSancionatorio.findByIdAndUpdate(id, updates, { new: true });
if (!record) return res.status(404).json({ error: 'Registro no encontrado' });
// Create change log
if (req.user) {
await SnifaChangeLog.create({
recordId: record._id,
userId: req.user.id,
userEmail: req.user.email,
reason: typeof reason === 'string' && reason.trim() ? reason.trim() : undefined,
before: before ? before.toObject() : {},
after: record.toObject()
});
}
res.json(record);
} catch (error: any) {
res.status(500).json({ error: 'Error updating record', details: error?.message });
}
};
export const deleteSanction = async (req: AuthenticatedRequest, res: Response) => {
try {
const { id } = req.params;
const record = await SnifaSancionatorio.findByIdAndDelete(id);
if (!record) return res.status(404).json({ error: 'Registro no encontrado' });
if (req.user) {
const reasonRaw = req.query?.reason;
await SnifaChangeLog.create({
recordId: record._id,
userId: req.user.id,
userEmail: req.user.email,
reason: typeof reasonRaw === 'string' && reasonRaw.trim() ? reasonRaw.trim() : undefined,
before: record.toObject(),
after: { deleted: true }
});
}
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: 'Error deleting record', details: error?.message });
}
};
export const getSanctionStats = async (req: Request, res: Response) => {
try {
const totalSanctions = await SnifaSancionatorio.countDocuments();
const sanctionsByState = await SnifaSancionatorio.aggregate([
{ $group: { _id: '$procesoSancionEstado', count: { $sum: 1 } } }
]);
const sanctionsByRegion = await SnifaSancionatorio.aggregate([
{ $group: { _id: '$regionNombre', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 5 }
]);
const sanctionsByCategory = await SnifaSancionatorio.aggregate([
{ $group: { _id: '$categoriaEconomicaNombre', count: { $sum: 1 } } },
{ $sort: { count: -1 } },
{ $limit: 5 }
]);
const totalFinesResult = await SnifaSancionatorio.aggregate([
{ $group: { _id: null, total: { $sum: '$multaTotalUTA' } } }
]);
const totalFines = totalFinesResult[0]?.total || 0;
const recentSanctions = await SnifaSancionatorio.find()
.sort({ fechaInicio: -1 })
.limit(5)
.select('nombre fechaInicio procesoSancionEstado multaTotalUTA');
res.json({
totalSanctions,
sanctionsByState,
sanctionsByRegion,
sanctionsByCategory,
totalFines,
recentSanctions
});
} catch (error: any) {
res.status(500).json({ error: 'Error getting stats', details: error?.message });
}
};