commit 9b598697c57d1ee293fb75c0e01b8afd38857778 Author: Poziloy Date: Sun May 10 16:49:04 2026 +0300 Первоначальная версия проекта chinilich-1.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f64e0e9 --- /dev/null +++ b/README.md @@ -0,0 +1,187 @@ +# 🧙‍♂️ Чинилыч – интеграция с GLPI API + +Современный веб-интерфейс для создания и отслеживания заявок, интеграции с GLPI (версия 11+), с собственной базой знаний, тёмной темой и адаптивным дизайном. + +## 📌 Основные возможности + +- **Авторизация пользователей** (роли: user, admin) +- **Создание заявок**: + - ИТ-заявка (с выбором оборудования, категории, автоматической подстановкой локации) + - Анонимная жалоба +- **Просмотр своих заявок** с синхронизацией статусов из GLPI +- **Детальный просмотр заявки** с комментариями (ITILFollowup) +- **База знаний** (синхронизация статей и документов из GLPI) + - Полнотекстовый поиск (FULLTEXT) + - Ленивая загрузка документов (скачивание по требованию) + - Сортировка по дате/названию +- **Тёмная тема** (сохраняется в localStorage) +- **Адаптивный интерфейс** (мобильные устройства) +- **Поддержка прикрепления файлов** (изображения, PDF) + +## 🛠 Технологии + +- PHP 8.3+ +- MySQL 8.0+ +- GLPI API (REST) +- HTML5 / CSS3 (CSS-переменные) +- JavaScript (Fetch API, динамические формы) + +## 📁 Установка и настройка + +### 1. Клонирование репозитория + +```bash +git clone https://your-repo/service-desk.git +cd service-desk +``` + +### 2. Настройка базы данных + +Создайте базу данных и импортируйте структуру: + +```sql +-- Выполните SQL из файла my_database.sql +-- Дополнительные колонки для знаний +ALTER TABLE knowledge_base ADD COLUMN glpi_updated_at DATETIME DEFAULT NULL; +ALTER TABLE knowledge_base ADD FULLTEXT INDEX ft_title_content (title, content); + +-- Колонка для локации в формах (исправление ошибки) +ALTER TABLE forms ADD COLUMN location_id INT DEFAULT NULL; +``` + +### 3. Настройка конфигурации + +Отредактируйте `config.php`: + +```php +// База данных +define('DB_HOST', '-'); +define('DB_NAME', 'my_database'); +define('DB_USER', 'root'); +define('DB_PASS', 'your_password'); + +// GLPI API +define('GLPI_API_URL', '-'); +define('GLPI_APP_TOKEN', 'your_app_token'); +define('GLPI_USERNAME', 'api_user'); +define('GLPI_PASSWORD', 'api_password'); + +// Папки для файлов +define('UPLOAD_DIR', __DIR__ . '/uploads/'); +define('KB_CACHE_DIR', __DIR__ . '/uploads/kb/'); +``` + +Убедитесь, что папки `uploads/` и `uploads/kb/` созданы и доступны для записи: + +```bash +mkdir -p uploads uploads/kb +chmod 755 uploads uploads/kb +``` + +### 4. Настройка веб-сервера (Nginx/Apache) + +Пример для Nginx (корневая директория `/var/www/html`): + +```nginx +server { + listen 80; + server_name service-desk.local; + root /var/www/html; + index index.php; + + location / { + try_files $uri $uri/ /index.php?$args; + } + + location ~ \.php$ { + include fastcgi_params; + fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + } + + location ~* \.(jpg|jpeg|png|gif|css|js|pdf)$ { + expires 30d; + } +} +``` + +### 5. Создание первого пользователя + +Вставьте в БД администратора (пароль хешируется через `password_hash`): + +```sql +INSERT INTO users (username, password, email, role, last_name, first_name) +VALUES ('admin', '$2y$10$...hash...', 'admin@example.com', 'superadmin', 'Admin', 'Администратор'); +``` + +Для генерации хеша можно использовать скрипт: + +```php + +``` + +## 🚀 Использование + +### Панель пользователя +- **Создание заявки**: выберите тип, заполните поля, прикрепите файл. +- **Список заявок**: отображаются все заявки текущего пользователя с актуальным статусом. +- **Подробности**: кнопка «Подробнее» открывает модальное окно с полной информацией и комментариями. + +### База знаний +- **Поиск статей** (полнотекстовый) +- **Сортировка** (по дате или по названию) +- **Просмотр статьи** (с сохранённым HTML-форматированием) +- **Скачивание файлов** (при наличии документов в GLPI) + +### Для администратора +- Кнопка **«Обновить базу»** на странице знаний – выполняет инкрементальную синхронизацию статей и документов из GLPI. +- Можно настроить cron для автоматической синхронизации: + ```bash + */30 * * * * php /var/www/html/admin_sync_kb.php + ``` + +## 🔄 Синхронизация с GLPI + +- **Заявки**: статусы и приоритеты синхронизируются из GLPI при каждом просмотре списка. +- **База знаний**: синхронизируются только изменённые статьи (по полю `date_mod`). Документы скачиваются лениво – при первом запросе к файлу. +- Для корректной работы у API-пользователя GLPI должны быть права на чтение `KnowbaseItem`, `Document`, `Ticket`, `ITILFollowup`. + +## 🎨 Тёмная тема + +Переключатель в шапке сайта. Выбор сохраняется в `localStorage` и автоматически применяется при следующем визите. + +## 📱 Адаптивность + +Интерфейс корректно отображается на мобильных устройствах (ширина экрана < 640px): меню сгибается, таблицы получают горизонтальную прокрутку, модальные окна сужаются. + +## 🧩 Структура проекта + +``` +├── config.php # Настройки БД, GLPI, константы +├── functions.php # Основные функции (аутентификация, работа с формами, знание) +├── glpi_api.php # Класс для работы с GLPI REST API +├── index.php # Страница входа +├── dashboard.php # Главная панель (заявки) +├── knowledge.php # База знаний +├── search_knowledge.php # AJAX-поиск по базе знаний +├── get_article.php # AJAX-получение статьи +├── get_document.php # Ленивая загрузка документа +├── get_ticket_details.php # Детали заявки (AJAX) +├── get_glpi_data.php # Справочные данные (локации, компьютеры, категории) +├── get_computer_location.php # Получение локации компьютера по ID +├── logout.php # Завершение сессии +├── style.css # Стили (светлая/тёмная темы) +├── admin_sync_kb.php # Скрипт для cron-синхронизации знаний +├── uploads/ # Папка для вложений пользователей +└── uploads/kb/ # Кеш документов из GLPI +``` + +## ⚠️ Возможные проблемы и решения + +| Проблема | Решение | +|----------|---------| +| `Fatal error: Column not found 'glpi_updated_at'` | Выполните `ALTER TABLE knowledge_base ADD COLUMN glpi_updated_at DATETIME` | +| `Data truncated for column 'status'` | Исправлена в `functions.php` – маппинг статусов GLPI в допустимые значения `pending/processing/completed/rejected` | +| Файлы в базе знаний не скачиваются | Проверьте права на папку `uploads/kb/` (755) и наличие у API-пользователя доступа к документам GLPI | +| Не работает автоподстановка локации | Убедитесь, что у компьютера в GLPI заполнено поле `locations_id` | +| Синхронизация долгая | Установите cron и уберите автоматический вызов `syncKnowledgeBaseIncremental` из `knowledge.php` (сейчас синхронизация только по кнопке) | diff --git a/admin_sync_kb.php b/admin_sync_kb.php new file mode 100644 index 0000000..f82d663 --- /dev/null +++ b/admin_sync_kb.php @@ -0,0 +1,11 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] + ); +} catch (PDOException $e) { + die("Ошибка подключения к БД: " . $e->getMessage()); +} + +require_once __DIR__ . '/functions.php'; \ No newline at end of file diff --git a/dashboard.php b/dashboard.php new file mode 100644 index 0000000..37c6291 --- /dev/null +++ b/dashboard.php @@ -0,0 +1,611 @@ + 'Новая', + 'processing' => 'В обработке', + 'incoming' => 'Входящая', + 'waiting' => 'Ожидание', + 'pending' => 'На рассмотрении', + 'closed' => 'Закрыта', + 'solved' => 'Решена', + 'assigned' => 'Назначена', + 'rejected' => 'Отклонена' +]; + +// Синхронизация с GLPI +$updatedForms = []; +foreach ($userForms as $form) { + if (syncFormWithGlpi($pdo, $form, $glpi)) { + $updatedForms[] = $form; + } +} +$userForms = $updatedForms; + +// Обработка создания заявки +if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_request'])) { + $formType = $_POST['form_type'] ?? ''; + $priority = $_POST['priority'] ?? 'medium'; + $locationId = !empty($_POST['location']) ? (int)$_POST['location'] : null; + + $allowedTypes = ['it_request', 'anonymous_complaint']; + if (!in_array($formType, $allowedTypes)) { + $message = 'Выберите корректный тип заявки'; + $messageType = 'danger'; + } else { + $formData = []; + $glpiDescription = ''; + $uploadedFilePath = null; + + if (!empty($_FILES['attachment']) && $_FILES['attachment']['error'] !== UPLOAD_ERR_NO_FILE) { + $uploadedFilePath = uploadFile($_FILES['attachment']); + if (!$uploadedFilePath) { + $message = 'Ошибка загрузки файла. Проверьте размер и тип.'; + $messageType = 'danger'; + } + } + + if (!$message) { + if ($formType === 'it_request') { + $computerId = $_POST['computer_id'] ?? ''; + $categoryId = $_POST['itil_category'] ?? ''; + $description = trim($_POST['description'] ?? ''); + + if (!$computerId || !$categoryId || empty($description)) { + $message = 'Заполните все обязательные поля IT-заявки'; + $messageType = 'danger'; + } else { + // Получаем читаемые имена из GLPI + $computerName = $glpi->getComputerName((int)$computerId); + $categoryName = $glpi->getCategoryName((int)$categoryId); + + // Данные пользователя + $userFullName = trim($user['last_name'] . ' ' . $user['first_name'] . ' ' . $user['patronymic']); + $userDepartment = $user['department'] ?? 'не указан'; + $userPosition = $user['position'] ?? 'не указана'; + + // Формируем красивое описание + $glpiDescription = "Пользователь: $userFullName\n"; + $glpiDescription .= "Отдел: $userDepartment\n"; + $glpiDescription .= "Должность: $userPosition\n"; + $glpiDescription .= "Оборудование: $computerName\n"; + $glpiDescription .= "Категория: $categoryName\n"; + $glpiDescription .= "Описание проблемы:\n$description"; + + if ($uploadedFilePath) { + $glpiDescription .= "\n\nПрикреплённый файл: " . $_SERVER['HTTP_HOST'] . '/' . $uploadedFilePath; + } + + $formData = [ + 'computer_id' => $computerId, + 'computer_name' => $computerName, + 'itil_category' => $categoryId, + 'category_name' => $categoryName, + 'description' => $description, + 'location_id' => $locationId, + 'user_info' => [ + 'name' => $userFullName, + 'department' => $userDepartment, + 'position' => $userPosition + ] + ]; + if ($uploadedFilePath) $formData['attachment'] = $uploadedFilePath; + } + } else { + $subject = trim($_POST['subject'] ?? ''); + $complaintText = trim($_POST['complaint_text'] ?? ''); + + if (empty($subject) || empty($complaintText)) { + $message = 'Укажите тему и текст жалобы'; + $messageType = 'danger'; + } else { + // Для анонимной жалобы не пишем данные пользователя + $glpiDescription = "Тема: $subject\n\nТекст жалобы:\n$complaintText"; + if ($uploadedFilePath) { + $glpiDescription .= "\n\nПрикреплённый файл: " . $_SERVER['HTTP_HOST'] . '/' . $uploadedFilePath; + } + $formData = ['subject' => $subject, 'text' => $complaintText]; + if ($uploadedFilePath) $formData['attachment'] = $uploadedFilePath; + } + } + } + + if (!$message) { + $glpiData = [ + 'name' => $formType === 'it_request' ? 'IT-заявка' : 'Анонимная жалоба', + 'content' => $glpiDescription, + 'priority' => $priority === 'high' ? 4 : ($priority === 'medium' ? 2 : 1), // 1-5, обычно 3 средний, но уточните по вашей конфигурации + 'type' => 1, // инцидент + 'requesttypes_id' => 1, // по умолчанию + ]; + + if ($formType === 'it_request' && !empty($computerId)) { + $glpiData['items_id'] = ['itemtype' => 'Computer', 'items_id' => (int)$computerId]; + } + if ($locationId) { + $glpiData['locations_id'] = $locationId; + } + + // Попробуем установить автора заявки (если текущий пользователь может создавать заявки от своего имени) + // В GLPI API можно передать _users_id_requester. Но для этого нужны права. + // Оставим как есть, GLPI сам подставит автора из сессии API. + + $glpiResponse = $glpi->createTicket($glpiData); + $userIdForDb = $formType === 'it_request' ? $user['id'] : null; + $localData = [ + 'glpi_response' => $glpiResponse ? 'success' : 'error', + 'glpi_ticket_id' => $glpiResponse['id'] ?? null, + 'form_fields' => $formData, + ]; + createLocalForm($pdo, $userIdForDb, $formType, $localData, 'pending', $priority, $locationId); + + if ($glpiResponse) { + $message = "Заявка успешно отправлена! ID GLPI: " . ($glpiResponse['id'] ?? 'N/A'); + $messageType = 'success'; + } else { + $message = "Ошибка при отправке в GLPI. Заявка сохранена локально."; + $messageType = 'warning'; + } + } + } +} + +// Повторно получаем формы после возможного обновления +$userForms = getUserForms($pdo, $user['id']); +?> + + + + + + Панель заявок – Service Desk + + + + + + +
+
+

🧙‍♂️ Чинилыч

+ +
+
+ +
+ + + +
+ + +
+

📌 Как это работает? Выберите тип заявки: ИТ‑заявка или анонимная жалоба. Для ИТ‑заявок поля подгружаются из GLPI. При выборе оборудования локация подставится автоматически. Заполните все поля, при необходимости прикрепите файл, и нажмите «Отправить».

+
+ +
+

➕ Новая заявка

+
+
+ + +
+ +
+ + +
+ +
+ +
+ + +
Максимальный размер: 5 МБ. Допустимые типы: JPG, PNG, GIF, PDF.
+
+ + +
+
+ +
+

📋 Мои заявки

+
+ +

У вас пока нет заявок.

+ + + + + + + + + + + + + + + + + +
IDНазваниеПриоритетСтатусДата
#
GLPI #
Ожидает GLPI
+ +
+
+ + +
+

❓ Часто задаваемые вопросы

+

Обычно заявки рассматриваются в течение 2 рабочих часов.

+

Создайте ИТ‑заявку с категорией «Аппаратная неисправность».

+

Да, для этого выберите тип «Анонимная жалоба».

+
+ + +
+ + + + + + + + + \ No newline at end of file diff --git a/functions.php b/functions.php new file mode 100644 index 0000000..ea5c7ef --- /dev/null +++ b/functions.php @@ -0,0 +1,264 @@ +prepare("SELECT * FROM users WHERE id = ?"); + $stmt->execute([$_SESSION['user_id']]); + $user = $stmt->fetch(); + return $user ?: null; +} + +function redirect(string $url): void { + header("Location: $url"); + exit; +} + +function getUserForms(PDO $pdo, int $userId): array { + $stmt = $pdo->prepare("SELECT * FROM forms WHERE user_id = ? ORDER BY created_at DESC"); + $stmt->execute([$userId]); + return $stmt->fetchAll(); +} + +function createLocalForm(PDO $pdo, ?int $userId, string $type, array $data, string $status = 'pending', ?string $priority = null, ?int $locationId = null): int { + $jsonData = json_encode($data, JSON_UNESCAPED_UNICODE); + $stmt = $pdo->prepare("INSERT INTO forms (user_id, type, data, status, priority, location_id) VALUES (?, ?, ?, ?, ?, ?)"); + $stmt->execute([$userId, $type, $jsonData, $status, $priority, $locationId]); + return (int)$pdo->lastInsertId(); +} + +function uploadFile(array $file): string|false { + if ($file['error'] !== UPLOAD_ERR_OK) return false; + if ($file['size'] > MAX_FILE_SIZE) return false; + $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']; + if (!in_array($file['type'], $allowedTypes)) return false; + + $ext = pathinfo($file['name'], PATHINFO_EXTENSION); + $basename = bin2hex(random_bytes(16)) . '.' . $ext; + $destination = UPLOAD_DIR . $basename; + + if (move_uploaded_file($file['tmp_name'], $destination)) { + return 'uploads/' . $basename; + } + return false; +} + +function syncFormWithGlpi(PDO $pdo, array &$form, GlpiApi $glpi): bool { + $data = json_decode($form['data'], true); + $glpiId = $data['glpi_ticket_id'] ?? null; + if (!$glpiId) return true; + + $ticket = $glpi->getTicket($glpiId); + if (!$ticket) { + $stmt = $pdo->prepare("DELETE FROM forms WHERE id = ?"); + $stmt->execute([$form['id']]); + return false; + } + + $data['glpi_status'] = $ticket['status'] ?? null; + $data['glpi_priority'] = $ticket['priority'] ?? null; + $data['glpi_name'] = $ticket['name'] ?? ''; + $newData = json_encode($data, JSON_UNESCAPED_UNICODE); + + // ----- МАППИНГ СТАТУСОВ GLPI В ДОПУСТИМЫЕ ЗНАЧЕНИЯ ----- + $statusGlpi = strtolower($ticket['status_name'] ?? $ticket['status'] ?? 'unknown'); + + $statusMap = [ + 'new' => 'pending', + 'incoming' => 'pending', + 'waiting' => 'pending', + 'processing' => 'processing', + 'assigned' => 'processing', + 'solved' => 'completed', + 'closed' => 'completed', + 'rejected' => 'rejected', + 'pending' => 'pending' + ]; + + $newStatus = $statusMap[$statusGlpi] ?? 'pending'; + // Дополнительная проверка на допустимость + $allowedStatuses = ['pending', 'processing', 'completed', 'rejected']; + if (!in_array($newStatus, $allowedStatuses)) { + $newStatus = 'pending'; + } + + // ----- МАППИНГ ПРИОРИТЕТОВ (ЧИСЛО -> 'low','medium','high') ----- + $priorityGlpi = $ticket['priority'] ?? 3; // число 1..5 + $priorityMap = [ + 1 => 'low', + 2 => 'low', + 3 => 'medium', + 4 => 'high', + 5 => 'high' + ]; + $newPriority = $priorityMap[$priorityGlpi] ?? 'medium'; + + // Обновляем запись в БД + $stmt = $pdo->prepare("UPDATE forms SET data = ?, status = ?, priority = ? WHERE id = ?"); + $stmt->execute([$newData, $newStatus, $newPriority, $form['id']]); + + $form['data'] = $newData; + $form['status'] = $newStatus; + $form['priority'] = $newPriority; + return true; +} + +/* ========== БАЗА ЗНАНИЙ (исправленная) ========== */ + +// Получение всех статей +function getAllLocalKnowledgeArticles(PDO $pdo): array { + return $pdo->query("SELECT * FROM knowledge_base ORDER BY updated_at DESC")->fetchAll(); +} + +// Поиск с FULLTEXT или LIKE +function searchLocalKnowledgeFulltext(PDO $pdo, string $query, int $limit = 30): array { + if (strlen($query) < 3) { + $stmt = $pdo->prepare("SELECT id, title, SUBSTRING(content, 1, 200) AS preview, updated_at FROM knowledge_base WHERE title LIKE :q1 OR content LIKE :q2 ORDER BY updated_at DESC LIMIT $limit"); + $like = '%' . $query . '%'; + $stmt->execute(['q1' => $like, 'q2' => $like]); + return $stmt->fetchAll(); + } + $stmt = $pdo->prepare("SELECT id, title, SUBSTRING(content, 1, 200) AS preview, updated_at, MATCH(title, content) AGAINST(:query IN NATURAL LANGUAGE MODE) AS relevance FROM knowledge_base WHERE MATCH(title, content) AGAINST(:query IN NATURAL LANGUAGE MODE) ORDER BY relevance DESC LIMIT $limit"); + $stmt->execute(['query' => $query]); + return $stmt->fetchAll(); +} + +// Инкрементальная синхронизация (с проверкой существования колонки) +function syncKnowledgeBaseIncremental(PDO $pdo, GlpiApi $glpi, array &$log = []): void { + $log[] = "=== Инкрементальная синхронизация ==="; + + // Проверяем наличие колонки glpi_updated_at + try { + $pdo->query("SELECT glpi_updated_at FROM knowledge_base LIMIT 1"); + } catch (PDOException $e) { + $log[] = "Ошибка: колонка glpi_updated_at отсутствует. Выполните ALTER TABLE knowledge_base ADD COLUMN glpi_updated_at DATETIME DEFAULT NULL;"; + return; + } + + $stmt = $pdo->query("SELECT MAX(glpi_updated_at) as last FROM knowledge_base"); + $lastSync = $stmt->fetch()['last']; + $since = $lastSync ? $lastSync : '1970-01-01 00:00:00'; + + $glpiItems = $glpi->searchKnowbaseItemsSince($since); + $log[] = "Найдено изменённых статей в GLPI: " . count($glpiItems); + + foreach ($glpiItems as $glpiArticle) { + $glpiId = (int)$glpiArticle['id']; + $fullArticle = $glpi->getKnowbaseItem($glpiId); + if (!$fullArticle) continue; + + $title = $fullArticle['name']; + $content = $fullArticle['answer'] ?? ''; + $content = html_entity_decode($content, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $content = strip_tags($content, '