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(); } }