370 lines
18 KiB
PHP

<?php
/**
* Oficio.php — Modelo de oficios
*/
declare(strict_types=1);
require_once __DIR__ . '/../config/config.php';
class OficioModel {
private PDO $db;
public function __construct() {
$this->db = getDB();
}
// ── Generar número de oficio ──────────────────────────────────────────────
public function generarNumero(string $tipo = 'REC'): string {
$anio = date('Y');
$prefijo = strtoupper($tipo === 'enviado' ? 'ENV' : 'REC');
$stmt = $this->db->prepare(
"SELECT numero_oficio FROM oficios WHERE numero_oficio LIKE ? ORDER BY id DESC LIMIT 1"
);
$stmt->execute(["$prefijo-$anio-%"]);
$ultimo = $stmt->fetchColumn();
$secuencia = 1;
if ($ultimo) {
$partes = explode('-', $ultimo);
$secuencia = (int)end($partes) + 1;
}
return sprintf('%s-%s-%04d', $prefijo, $anio, $secuencia);
}
// ── Listar oficios con filtros ────────────────────────────────────────────
public function listar(array $filtros = [], bool $soloPropio = false, int $usuarioId = 0): array {
$sql = "SELECT * FROM v_oficios_completo WHERE 1=1";
$params = [];
if ($soloPropio && $usuarioId) {
$sql .= " AND responsable_id = ?"; $params[] = $usuarioId;
}
if (!empty($filtros['tipo'])) {
$sql .= " AND tipo = ?"; $params[] = $filtros['tipo'];
}
if (!empty($filtros['estado'])) {
$sql .= " AND estado = ?"; $params[] = $filtros['estado'];
}
if (!empty($filtros['prioridad'])) {
$sql .= " AND prioridad = ?"; $params[] = $filtros['prioridad'];
}
if (!empty($filtros['responsable_id'])) {
$sql .= " AND responsable_id = ?"; $params[] = $filtros['responsable_id'];
}
if (!empty($filtros['fecha_desde'])) {
$sql .= " AND fecha_recepcion >= ?"; $params[] = $filtros['fecha_desde'];
}
if (!empty($filtros['fecha_hasta'])) {
$sql .= " AND fecha_recepcion <= ?"; $params[] = $filtros['fecha_hasta'];
}
if (!empty($filtros['semaforo'])) {
$sql .= " AND semaforo = ?"; $params[] = $filtros['semaforo'];
}
if (!empty($filtros['etiqueta_id'])) {
$sql .= " AND id IN (SELECT oficio_id FROM oficio_etiquetas WHERE etiqueta_id = ?)";
$params[] = $filtros['etiqueta_id'];
}
if (!empty($filtros['busqueda'])) {
$sql .= " AND MATCH(numero_oficio, remitente, destinatario, asunto) AGAINST(? IN BOOLEAN MODE)";
$params[] = $filtros['busqueda'] . '*';
}
$sql .= " ORDER BY fecha_vencimiento ASC, prioridad DESC";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// ── Papelera de reciclaje ─────────────────────────────────────────────────
public function papelera(): array {
$stmt = $this->db->query(
"SELECT o.*, CONCAT(u.nombre,' ',u.apellido) AS responsable_nombre
FROM oficios o LEFT JOIN usuarios u ON u.id = o.responsable_id
WHERE o.deleted_at IS NOT NULL ORDER BY o.deleted_at DESC"
);
return $stmt->fetchAll();
}
// ── Buscar por ID ─────────────────────────────────────────────────────────
public function buscarPorId(int $id): ?array {
$stmt = $this->db->prepare("SELECT * FROM v_oficios_completo WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
// ── Verificar duplicado ───────────────────────────────────────────────────
public function existeNumero(string $numero, ?int $excepto = null): bool {
$sql = "SELECT id FROM oficios WHERE numero_oficio = ? AND deleted_at IS NULL";
$params = [$numero];
if ($excepto) { $sql .= " AND id != ?"; $params[] = $excepto; }
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return (bool)$stmt->fetchColumn();
}
// ── Crear ─────────────────────────────────────────────────────────────────
public function crear(array $datos, int $creadoPor): int {
$stmt = $this->db->prepare(
"INSERT INTO oficios
(numero_oficio, tipo, remitente, destinatario, asunto, descripcion,
fecha_recepcion, fecha_vencimiento, prioridad, estado,
responsable_id, creado_por, es_confidencial)
VALUES
(:numero_oficio, :tipo, :remitente, :destinatario, :asunto, :descripcion,
:fecha_recepcion, :fecha_vencimiento, :prioridad, :estado,
:responsable_id, :creado_por, :es_confidencial)"
);
$stmt->execute([
':numero_oficio' => $datos['numero_oficio'],
':tipo' => $datos['tipo'],
':remitente' => $datos['remitente'],
':destinatario' => $datos['destinatario'],
':asunto' => $datos['asunto'],
':descripcion' => $datos['descripcion'] ?? null,
':fecha_recepcion' => $datos['fecha_recepcion'],
':fecha_vencimiento'=> $datos['fecha_vencimiento'] ?: null,
':prioridad' => $datos['prioridad'],
':estado' => $datos['estado'] ?? 'recibido',
':responsable_id' => $datos['responsable_id'] ?: null,
':creado_por' => $creadoPor,
':es_confidencial' => $datos['es_confidencial'] ?? 0,
]);
$id = (int)$this->db->lastInsertId();
// Guardar etiquetas
if (!empty($datos['etiquetas'])) {
$this->sincronizarEtiquetas($id, $datos['etiquetas']);
}
$this->registrarHistorial($id, 'crear', $creadoPor, 'Oficio creado.');
return $id;
}
// ── Actualizar ────────────────────────────────────────────────────────────
public function actualizar(int $id, array $datos, int $usuarioId): bool {
// Capturar valor anterior para auditoría
$anterior = $this->buscarPorId($id);
$stmt = $this->db->prepare(
"UPDATE oficios SET
numero_oficio=:numero_oficio, tipo=:tipo, remitente=:remitente,
destinatario=:destinatario, asunto=:asunto, descripcion=:descripcion,
fecha_recepcion=:fecha_recepcion, fecha_vencimiento=:fecha_vencimiento,
prioridad=:prioridad, estado=:estado, responsable_id=:responsable_id,
es_confidencial=:es_confidencial
WHERE id=:id AND deleted_at IS NULL"
);
$ok = $stmt->execute([
':numero_oficio' => $datos['numero_oficio'],
':tipo' => $datos['tipo'],
':remitente' => $datos['remitente'],
':destinatario' => $datos['destinatario'],
':asunto' => $datos['asunto'],
':descripcion' => $datos['descripcion'] ?? null,
':fecha_recepcion' => $datos['fecha_recepcion'],
':fecha_vencimiento'=> $datos['fecha_vencimiento'] ?: null,
':prioridad' => $datos['prioridad'],
':estado' => $datos['estado'],
':responsable_id' => $datos['responsable_id'] ?: null,
':es_confidencial' => $datos['es_confidencial'] ?? 0,
':id' => $id,
]);
if (!empty($datos['etiquetas'])) {
$this->sincronizarEtiquetas($id, $datos['etiquetas']);
}
if ($ok && $anterior) {
$cambios = [];
$campos = ['estado', 'prioridad', 'responsable_id', 'fecha_vencimiento', 'asunto'];
foreach ($campos as $campo) {
if (($anterior[$campo] ?? '') != ($datos[$campo] ?? '')) {
$cambios[] = "$campo: '{$anterior[$campo]}' → '{$datos[$campo]}'";
}
}
$detalle = implode(' | ', $cambios) ?: 'Sin cambios relevantes';
$this->registrarHistorial($id, 'editar', $usuarioId, $detalle);
}
return $ok;
}
// ── Eliminación lógica ────────────────────────────────────────────────────
public function eliminarLogico(int $id, int $usuarioId): bool {
$stmt = $this->db->prepare("UPDATE oficios SET deleted_at=NOW() WHERE id=?");
$ok = $stmt->execute([$id]);
if ($ok) $this->registrarHistorial($id, 'eliminar', $usuarioId, 'Movido a papelera.');
return $ok;
}
// ── Eliminación física (solo admin) ───────────────────────────────────────
public function eliminarFisico(int $id): bool {
$stmt = $this->db->prepare("DELETE FROM oficios WHERE id=?");
return $stmt->execute([$id]);
}
// ── Restaurar desde papelera ──────────────────────────────────────────────
public function restaurar(int $id, int $usuarioId): bool {
$stmt = $this->db->prepare("UPDATE oficios SET deleted_at=NULL WHERE id=?");
$ok = $stmt->execute([$id]);
if ($ok) $this->registrarHistorial($id, 'restaurar', $usuarioId, 'Restaurado desde papelera.');
return $ok;
}
// ── Derivar oficio ────────────────────────────────────────────────────────
public function derivar(int $id, int $nuevoResponsable, int $usuarioId, ?string $comentario = null): bool {
$stmt = $this->db->prepare(
"UPDATE oficios SET responsable_id=?, derivado_a_id=?, comentario_derivacion=?, estado='en_proceso'
WHERE id=? AND deleted_at IS NULL"
);
$ok = $stmt->execute([$nuevoResponsable, $nuevoResponsable, $comentario, $id]);
if ($ok) {
$this->registrarHistorial($id, 'derivar', $usuarioId, "Derivado a usuario #$nuevoResponsable. Comentario: $comentario");
}
return $ok;
}
// ── Historial de cambios ──────────────────────────────────────────────────
public function historial(int $id): array {
$stmt = $this->db->prepare(
"SELECT h.*, CONCAT(u.nombre,' ',u.apellido) AS usuario_nombre
FROM historial_cambios h
LEFT JOIN usuarios u ON u.id = h.usuario_id
WHERE h.tabla='oficios' AND h.registro_id=?
ORDER BY h.created_at DESC"
);
$stmt->execute([$id]);
return $stmt->fetchAll();
}
// ── KPIs para dashboard ───────────────────────────────────────────────────
public function kpis(?int $usuarioId = null): array {
if ($usuarioId) {
$where = "AND o.responsable_id = $usuarioId";
} else {
$where = '';
}
$row = $this->db->query("SELECT * FROM v_dashboard_kpis")->fetch();
$stmt = $this->db->prepare(
"SELECT estado, COUNT(*) as total FROM oficios WHERE deleted_at IS NULL $where GROUP BY estado"
);
$stmt->execute();
$estados = [];
foreach ($stmt->fetchAll() as $r) {
$estados[$r['estado']] = (int)$r['total'];
}
$stmt2 = $this->db->prepare(
"SELECT prioridad, COUNT(*) as total FROM oficios WHERE deleted_at IS NULL $where GROUP BY prioridad"
);
$stmt2->execute();
$prioridades = [];
foreach ($stmt2->fetchAll() as $r) {
$prioridades[$r['prioridad']] = (int)$r['total'];
}
return ['global' => $row, 'estados' => $estados, 'prioridades' => $prioridades];
}
// ── Próximos a vencer (para cron y dashboard) ─────────────────────────────
public function proximosAVencer(int $dias = 3): array {
$stmt = $this->db->prepare(
"SELECT o.*, CONCAT(u.nombre,' ',u.apellido) AS responsable_nombre, u.email AS responsable_email
FROM oficios o
LEFT JOIN usuarios u ON u.id = o.responsable_id
WHERE o.deleted_at IS NULL
AND o.estado NOT IN ('respondido','archivado')
AND o.fecha_vencimiento BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
ORDER BY o.fecha_vencimiento ASC"
);
$stmt->execute([$dias]);
return $stmt->fetchAll();
}
// ── Vencidos sin respuesta (para escalación) ──────────────────────────────
public function vencidosSinRespuesta(): array {
$stmt = $this->db->query(
"SELECT o.*, CONCAT(u.nombre,' ',u.apellido) AS responsable_nombre,
u.email AS responsable_email, u.supervisor_id,
CONCAT(s.nombre,' ',s.apellido) AS supervisor_nombre, s.email AS supervisor_email
FROM oficios o
LEFT JOIN usuarios u ON u.id = o.responsable_id
LEFT JOIN usuarios s ON s.id = u.supervisor_id
WHERE o.deleted_at IS NULL
AND o.estado NOT IN ('respondido','archivado')
AND o.fecha_vencimiento < CURDATE()
ORDER BY o.fecha_vencimiento ASC"
);
return $stmt->fetchAll();
}
// ── Sincronizar etiquetas ─────────────────────────────────────────────────
private function sincronizarEtiquetas(int $oficioId, array $etiquetaIds): void {
$this->db->prepare("DELETE FROM oficio_etiquetas WHERE oficio_id = ?")->execute([$oficioId]);
$stmt = $this->db->prepare("INSERT INTO oficio_etiquetas (oficio_id, etiqueta_id) VALUES (?, ?)");
foreach ($etiquetaIds as $eId) {
if ((int)$eId > 0) $stmt->execute([$oficioId, (int)$eId]);
}
}
// ── Registrar historial ───────────────────────────────────────────────────
private function registrarHistorial(int $registroId, string $accion, int $usuarioId, string $detalle = ''): void {
$stmt = $this->db->prepare(
"INSERT INTO historial_cambios (tabla, registro_id, accion, usuario_id, ip_address, detalle)
VALUES ('oficios', ?, ?, ?, ?, ?)"
);
$stmt->execute([$registroId, $accion, $usuarioId, $_SERVER['REMOTE_ADDR'] ?? null, $detalle]);
}
// ── Etiquetas disponibles ─────────────────────────────────────────────────
public function etiquetas(): array {
return $this->db->query("SELECT * FROM etiquetas ORDER BY nombre")->fetchAll();
}
// ── Etiquetas de un oficio ────────────────────────────────────────────────
public function etiquetasDeOficio(int $id): array {
$stmt = $this->db->prepare(
"SELECT e.* FROM etiquetas e
JOIN oficio_etiquetas oe ON oe.etiqueta_id = e.id
WHERE oe.oficio_id = ?"
);
$stmt->execute([$id]);
return $stmt->fetchAll();
}
// ── Comentarios ───────────────────────────────────────────────────────────
public function comentarios(int $id, bool $soloPublicos = false): array {
$sql = "SELECT c.*, CONCAT(u.nombre,' ',u.apellido) AS autor_nombre
FROM comentarios c JOIN usuarios u ON u.id = c.usuario_id
WHERE c.oficio_id = ?";
if ($soloPublicos) $sql .= " AND c.es_privado = 0";
$sql .= " ORDER BY c.created_at DESC";
$stmt = $this->db->prepare($sql);
$stmt->execute([$id]);
return $stmt->fetchAll();
}
public function agregarComentario(int $oficioId, int $usuarioId, string $texto, bool $privado = false): int {
$stmt = $this->db->prepare(
"INSERT INTO comentarios (oficio_id, usuario_id, comentario, es_privado) VALUES (?,?,?,?)"
);
$stmt->execute([$oficioId, $usuarioId, $texto, (int)$privado]);
return (int)$this->db->lastInsertId();
}
// ── Estadísticas mensuales (para gráfico de línea) ───────────────────────
public function estadisticasMensuales(int $meses = 6): array {
$stmt = $this->db->prepare(
"SELECT DATE_FORMAT(created_at,'%Y-%m') AS mes,
estado, COUNT(*) AS total
FROM oficios
WHERE deleted_at IS NULL
AND created_at >= DATE_SUB(NOW(), INTERVAL ? MONTH)
GROUP BY mes, estado
ORDER BY mes"
);
$stmt->execute([$meses]);
return $stmt->fetchAll();
}
}