feat: добавлен telegram_replay.php для публикации ответов поддержки в CRM

Новый endpoint для записи ответов поддержки как комментариев в CRM:
- Принимает JSON с полями: answer, contact_id, project_id (опц.), support_user_id (опц.), channel (опц.)
- Использует прямые INSERT запросы в vtiger_crmentity, vtiger_modcomments, vtiger_modcommentscf
- Обязательно создаёт запись в vtiger_modcommentscf (иначе комментарий не отображается)
- Устанавливает deleted=0 (иначе фильтруется при выборке)
- Полная проверка ошибок БД с детальным логированием
- Логи: logs/tg_replay_inbound.log

Исправлены проблемы:
- vtws_create падал без выброса исключения — заменён на прямой SQL
- Убраны несуществующие колонки (from_mailconverter, customer_email, from_mailroom)
- Добавлена обязательная запись в vtiger_modcommentscf
This commit is contained in:
Fedor
2026-02-03 14:02:12 +03:00
parent ea0edafba5
commit d7982931cd
2 changed files with 474 additions and 0 deletions

View File

@@ -0,0 +1,288 @@
<?php
/*********************************************************************************
* API-интерфейс для создания Заявки (HelpDesk) из Web-формы (V2 - JSON версия)
* Принимает JSON строку с данными заявки
* Автор: Фёдор, 2025-12-29
********************************************************************************/
include_once 'include/Webservices/Query.php';
include_once 'modules/Users/Users.php';
require_once('include/Webservices/Utils.php');
require_once 'include/Webservices/Create.php';
require_once 'includes/Loader.php';
vimport ('includes.runtime.Globals');
vimport ('includes.runtime.BaseModel');
vimport ('includes.runtime.LanguageHandler');
/**
* Создание заявки из web-формы ERV Platform (V2 - JSON версия)
*
* @param string $claim_json - JSON строка с данными заявки (обязательно)
* @param object $user - пользователь (опционально)
* @return array - {"ticket_id": "123", "ticket_number": "TT12345", "title": "...", "category": "...", "status": "..."}
*/
function vtws_createwebclaimv2($claim_json, $user = false) {
$logstring = date("Y-m-d H:i:s").' REQUEST: '.json_encode($_REQUEST);
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
// Проверка обязательного параметра
if(empty($claim_json)){
$logstring = date("Y-m-d H:i:s").' Не передан параметр claim_json';
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не передан параметр claim_json");
}
// Парсим JSON
$claimData = json_decode($claim_json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
// Пробуем очистить от возможных лишних символов
$cleanedJson = trim($claim_json);
$cleanedJson = preg_replace('/^[^{]*/', '', $cleanedJson); // Убираем всё до первой {
$cleanedJson = preg_replace('/[^}]*$/', '', $cleanedJson); // Убираем всё после последней }
$claimData = json_decode($cleanedJson, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$logstring = date("Y-m-d H:i:s").' Ошибка парсинга JSON: '.json_last_error_msg().', JSON: '.substr($claim_json, 0, 200);
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Ошибка парсинга JSON: ".json_last_error_msg());
}
}
$logstring = date("Y-m-d H:i:s").' CLEANED JSON: '.json_encode($claimData);
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
// Извлекаем обязательные поля
$project_id = isset($claimData['project_id']) ? $claimData['project_id'] : '';
$contact_id = isset($claimData['contact_id']) ? $claimData['contact_id'] : '';
$event_type = isset($claimData['cf_1726']) ? $claimData['cf_1726'] : '';
$description = isset($claimData['description']) ? $claimData['description'] : '';
// Проверка обязательных полей
if(empty($project_id)){
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: project_id';
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан ID проекта");
}
if(empty($contact_id)){
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: contact_id';
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан ID контакта");
}
if(empty($event_type)){
$logstring = date("Y-m-d H:i:s").' Не указано обязательное поле: cf_1726 (event_type)';
file_put_contents('logs/CreateWebClaimV2.log', $logstring.PHP_EOL, FILE_APPEND);
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, "Не указан тип страхового случая");
}
global $adb, $current_user;
// Нормализуем ID контакта и проекта
$contactIdNumeric = preg_replace('/[^0-9]/', '', $contact_id);
$projectIdNumeric = preg_replace('/[^0-9]/', '', $project_id);
$contactWsId = '12x' . $contactIdNumeric;
$projectWsId = '33x' . $projectIdNumeric;
$logstring = date('Y-m-d H:i:s').' Нормализовали ID: contact='.$contactIdNumeric.' (raw='.$contact_id.'), project='.$projectIdNumeric.' (raw='.$project_id.')'.PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
// Маппинг типов событий на русские названия для cf_2650
$eventTypeMap = array(
'delay_flight' => 'Задержка рейса',
'cancel_flight' => 'Отмена рейса',
'miss_connection' => 'Пропуск стыковки',
'missed_connection' => 'Пропуск стыковки',
'delay_train' => 'Задержка поезда',
'cancel_train' => 'Отмена поезда',
'delay_ferry' => 'Задержка парома',
'cancel_ferry' => 'Отмена парома'
);
// ticketcategories всегда "Цифровой адвокат ЕРВ"
$ticketCategory = 'Цифровой адвокат ЕРВ';
// Нормализуем event_type для cf_2650
$normalizedEventType = isset($eventTypeMap[$event_type]) ? $eventTypeMap[$event_type] : 'Цифровой адвокат ЕРВ';
// Извлекаем дополнительные поля
$incident_date = isset($claimData['cf_2566']) ? $claimData['cf_2566'] : '';
$transport_number = isset($claimData['cf_2568']) ? $claimData['cf_2568'] : '';
$cf_1885 = isset($claimData['cf_1885']) ? $claimData['cf_1885'] : '';
$lastname = isset($claimData['lastname']) ? $claimData['lastname'] : '';
$firstname = isset($claimData['firstname']) ? $claimData['firstname'] : '';
// Формируем ticket_title: event_type_cf_1885_lastname_firstname
$ticket_title = $event_type;
if (!empty($cf_1885)) {
$ticket_title .= '_' . $cf_1885;
}
if (!empty($lastname)) {
$ticket_title .= '_' . $lastname;
}
if (!empty($firstname)) {
$ticket_title .= '_' . $firstname;
}
// Формируем описание
$fullDescription = '';
if (!empty($description)) {
$fullDescription .= $description . "\n\n";
}
$fullDescription .= "Тип события: " . $normalizedEventType . "\n";
if (!empty($incident_date)) {
$fullDescription .= "Дата инцидента: " . $incident_date . "\n";
}
if (!empty($transport_number)) {
$fullDescription .= "Номер рейса: " . $transport_number . "\n";
}
// Добавляем cf_departure_flight и cf_departure_date, если есть
$cf_departure_flight = isset($claimData['cf_departure_flight']) ? $claimData['cf_departure_flight'] : '';
$cf_departure_date = isset($claimData['cf_departure_date']) ? $claimData['cf_departure_date'] : '';
if (!empty($cf_departure_flight)) {
$fullDescription .= "Рейс стыковки: " . $cf_departure_flight . "\n";
}
if (!empty($cf_departure_date)) {
$fullDescription .= "Дата стыковки: " . $cf_departure_date . "\n";
}
$fullDescription .= "\nИсточник: ERV Platform Web Form";
// Формируем массив параметров для создания заявки
$params = array(
'ticket_title' => $ticket_title,
'parent_id' => '11x67458', // Заявитель - контрагент
'ticketcategories' => $ticketCategory,
'ticketstatus' => 'рассмотрение',
'contact_id' => $contactWsId,
'cf_2066' => $projectWsId, // Связь с проектом
'ticketpriorities' => 'High',
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id),
'description' => $fullDescription,
'cf_1726' => $event_type, // Сырой тип события
'cf_2650' => $normalizedEventType // Нормализованный тип события
);
// Маппинг дополнительных полей
if (!empty($incident_date)) {
$params['cf_2566'] = $incident_date;
}
if (!empty($transport_number)) {
$params['cf_2568'] = $transport_number;
}
if (!empty($cf_departure_flight)) {
$params['cf_2630'] = $cf_departure_flight;
}
if (!empty($cf_departure_date)) {
$params['cf_2632'] = $cf_departure_date;
}
// Страна (cf_1909 → cf_2636)
if (isset($claimData['cf_1909']) && !empty($claimData['cf_1909'])) {
$params['cf_2636'] = $claimData['cf_1909'];
}
// cf_2502 → cf_2572
if (isset($claimData['cf_2502']) && !empty($claimData['cf_2502'])) {
$params['cf_2572'] = $claimData['cf_2502'];
}
// code → cf_2574
if (isset($claimData['code']) && !empty($claimData['code'])) {
$params['cf_2574'] = $claimData['code'];
}
// cf_1885 → cf_2642
if (!empty($cf_1885)) {
$params['cf_2642'] = $cf_1885;
}
// IP → cf_2634
if (isset($claimData['ip']) && !empty($claimData['ip'])) {
$params['cf_2634'] = $claimData['ip'];
}
// region → cf_2640
if (isset($claimData['region']) && !empty($claimData['region'])) {
$params['cf_2640'] = $claimData['region'];
}
// source → cf_2638
if (isset($claimData['source']) && !empty($claimData['source'])) {
$params['cf_2638'] = $claimData['source'];
}
// cf_2508 → cf_2508 (прямое маппирование)
if (isset($claimData['cf_2508']) && !empty($claimData['cf_2508'])) {
$params['cf_2508'] = $claimData['cf_2508'];
}
// cf_2648 → cf_2648 (прямое маппирование)
if (isset($claimData['cf_2648']) && !empty($claimData['cf_2648'])) {
$params['cf_2648'] = $claimData['cf_2648'];
}
$logstring = date('Y-m-d H:i:s').' Массив для создания Заявки: '.json_encode($params).PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
try {
$result = vtws_create('HelpDesk', $params, $current_user);
$ticketId = substr($result['id'], 3); // Убираем префикс "17x"
$ticketNumber = isset($result['ticket_no']) ? $result['ticket_no'] : 'N/A';
$logstring = date('Y-m-d H:i:s').' ✅ Создана Заявка id='.$ticketId.' ticket_no='.$ticketNumber.PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
// Создаём двустороннюю связь между Проектом и Заявкой
try {
$relationCheck = $adb->pquery(
"SELECT 1 FROM vtiger_crmentityrel
WHERE (crmid = ? AND relcrmid = ?)
OR (crmid = ? AND relcrmid = ?)
LIMIT 1",
array($projectIdNumeric, $ticketId, $ticketId, $projectIdNumeric)
);
if (!$relationCheck || $adb->num_rows($relationCheck) === 0) {
$adb->pquery(
"INSERT INTO vtiger_crmentityrel (crmid, module, relcrmid, relmodule) VALUES (?, ?, ?, ?)",
array($projectIdNumeric, 'Project', $ticketId, 'HelpDesk')
);
$logstring = date('Y-m-d H:i:s').' 🔗 Добавлена связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.')'.PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
} else {
$logstring = date('Y-m-d H:i:s').' 🔗 Связь Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.') уже существует'.PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
}
} catch (Exception $relEx) {
$logstring = date('Y-m-d H:i:s').' ⚠️ Ошибка связывания Project('.$projectIdNumeric.') ⇄ HelpDesk('.$ticketId.'): '.$relEx->getMessage().PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
}
// Возвращаем массив
$output = array(
'ticket_id' => $ticketId,
'ticket_number' => $ticketNumber,
'title' => $ticket_title,
'category' => $ticketCategory,
'status' => 'рассмотрение'
);
} catch (WebServiceException $ex) {
$logstring = date('Y-m-d H:i:s').' ❌ Ошибка создания: '.$ex->getMessage().PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
throw $ex;
}
$logstring = date('Y-m-d H:i:s').' Return: '.json_encode($output).PHP_EOL;
file_put_contents('logs/CreateWebClaimV2.log', $logstring, FILE_APPEND);
return $output;
}

186
telegram_replay.php Normal file
View File

@@ -0,0 +1,186 @@
<?php
/*********************************************************************************
* telegram_replay.php — публикация в CRM ответов поддержки на вопросы пользователя
*
* Вход (POST JSON):
* answer — текст ответа поддержки (обязательно)
* contact_id — ID контакта в CRM, кому отвечаем (обязательно)
* project_id — ID проекта (необязательно; для ответственного и уведомлений)
* support_user_id — ID сотрудника CRM, кто ответил (необязательно; иначе админ/ответственный по проекту)
* channel — канал, например 'Support' или 'Telegram' (необязательно, по умолчанию 'Support')
*
* Логи: logs/tg_replay_inbound.log
********************************************************************************/
error_reporting(E_ALL);
ini_set('display_errors', '1');
include_once 'modules/Users/Users.php';
include_once 'include/utils/CommonUtils.php';
include_once 'include/utils/utils.php';
require_once 'include/Webservices/Utils.php';
require_once 'include/Webservices/Create.php';
require_once 'include/Webservices/Revise.php';
require_once 'include/utils/WhatsApp.php';
require_once 'includes/Loader.php';
vimport('includes.runtime.Globals');
vimport('includes.runtime.BaseModel');
vimport('includes.runtime.LanguageHandler');
$logFile = 'logs/tg_replay_inbound.log';
$str = file_get_contents('php://input');
$logstring = date('Y-m-d H:i:s') . ' ' . $str . PHP_EOL;
file_put_contents($logFile, $logstring, FILE_APPEND);
$data = json_decode($str, true);
if (!is_array($data)) {
file_put_contents($logFile, date('Y-m-d H:i:s') . ' Ошибка: невалидный JSON' . PHP_EOL, FILE_APPEND);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'Invalid JSON']);
exit;
}
$answer = isset($data['answer']) ? trim($data['answer']) : (isset($data['message']) ? trim($data['message']) : '');
$contact_id = isset($data['contact_id']) ? (int) $data['contact_id'] : 0;
$project_id = isset($data['project_id']) ? (int) $data['project_id'] : 0;
$support_user_id = isset($data['support_user_id']) ? $data['support_user_id'] : null; // может быть "19x123"
$channel = isset($data['channel']) ? trim($data['channel']) : 'Support';
if (empty($answer)) {
file_put_contents($logFile, date('Y-m-d H:i:s') . ' Ошибка: не передан answer/message' . PHP_EOL, FILE_APPEND);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'Missing answer or message']);
exit;
}
if ($contact_id <= 0) {
file_put_contents($logFile, date('Y-m-d H:i:s') . ' Ошибка: не передан или неверный contact_id' . PHP_EOL, FILE_APPEND);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'Missing or invalid contact_id']);
exit;
}
global $adb;
if (empty($adb)) {
$adb = PearDatabase::getInstance();
}
file_put_contents($logFile, date('Y-m-d H:i:s') . ' $adb ok' . PHP_EOL, FILE_APPEND);
// Проверяем, что контакт существует
$check = $adb->pquery(
'SELECT c.contactid, e.smownerid FROM vtiger_contactdetails c
INNER JOIN vtiger_crmentity e ON e.crmid = c.contactid
WHERE e.deleted = 0 AND c.contactid = ?',
array($contact_id)
);
if ($adb->num_rows($check) === 0) {
file_put_contents($logFile, date('Y-m-d H:i:s') . ' Ошибка: контакт с ID ' . $contact_id . ' не найден' . PHP_EOL, FILE_APPEND);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['success' => false, 'error' => 'Contact not found']);
exit;
}
file_put_contents($logFile, date('Y-m-d H:i:s') . ' Контакт ' . $contact_id . ' найден' . PHP_EOL, FILE_APPEND);
$owner_id = $adb->query_result($check, 0, 'smownerid');
$crmid = '12x' . $contact_id;
$setype = 'Contacts';
// Кто создаёт комментарий: переданный support_user_id, иначе ответственный по проекту, иначе по контакту, иначе админ
$user = null;
if (!empty($support_user_id)) {
$user = (strpos($support_user_id, 'x') !== false) ? $support_user_id : ('19x' . $support_user_id);
} elseif ($project_id > 0) {
$proj = $adb->pquery(
'SELECT e.smownerid FROM vtiger_project p INNER JOIN vtiger_crmentity e ON e.crmid = p.projectid
WHERE e.deleted = 0 AND p.projectid = ? AND p.linktoaccountscontacts = ?',
array($project_id, $contact_id)
);
if ($adb->num_rows($proj) > 0) {
$uid = $adb->query_result($proj, 0, 'smownerid');
$user = '19x' . $uid;
}
}
if (empty($user)) {
$user = '19x' . $owner_id;
}
if (empty($user) || $user === '19x') {
$user = Users::getActiveAdminUser();
}
$owner_id_num = (strpos($user, 'x') !== false) ? (int) substr($user, strpos($user, 'x') + 1) : (int) $user;
file_put_contents($logFile, date('Y-m-d H:i:s') . ' user=' . (string)$user . ' (owner_id_num=' . $owner_id_num . ')' . PHP_EOL, FILE_APPEND);
// Запись комментария напрямую в БД (без vtws_create — тот падает внутри без выброса исключения)
$commentCrmId = $adb->getUniqueID('vtiger_crmentity');
$date_var = date('Y-m-d H:i:s');
// deleted=0 обязательно — иначе запись не попадёт в выборку (WHERE vtiger_crmentity.deleted = 0)
$sql_crmentity = "INSERT INTO vtiger_crmentity (crmid, smcreatorid, smownerid, smgroupid, setype, description, modifiedby, createdtime, modifiedtime, source, deleted) VALUES (?, ?, ?, 0, 'ModComments', '', ?, ?, ?, 'CRM', 0)";
$result1 = $adb->pquery($sql_crmentity, array($commentCrmId, $owner_id_num, $owner_id_num, $owner_id_num, $date_var, $date_var));
if (!$result1) {
$error = $adb->database->ErrorMsg();
file_put_contents($logFile, date('Y-m-d H:i:s') . ' ❌ Ошибка INSERT vtiger_crmentity: ' . $error . PHP_EOL, FILE_APPEND);
echo json_encode(['success' => false, 'error' => 'DB error: crmentity - ' . $error]);
exit;
}
file_put_contents($logFile, date('Y-m-d H:i:s') . ' ✅ vtiger_crmentity OK' . PHP_EOL, FILE_APPEND);
$sql_modcomments = "INSERT INTO vtiger_modcomments (modcommentsid, commentcontent, related_to, parent_comments, customer, userid, reasontoedit, is_private, filename, related_email_id, channel, messageid) VALUES (?, ?, ?, '', 0, 0, NULL, 0, NULL, NULL, ?, NULL)";
$result2 = $adb->pquery($sql_modcomments, array($commentCrmId, $answer, $contact_id, $channel));
if (!$result2) {
$error = $adb->database->ErrorMsg();
file_put_contents($logFile, date('Y-m-d H:i:s') . ' ❌ Ошибка INSERT vtiger_modcomments: ' . $error . PHP_EOL, FILE_APPEND);
echo json_encode(['success' => false, 'error' => 'DB error: modcomments - ' . $error]);
exit;
}
file_put_contents($logFile, date('Y-m-d H:i:s') . ' ✅ vtiger_modcomments OK' . PHP_EOL, FILE_APPEND);
// Без строки в vtiger_modcommentscf комментарий не попадёт в список (INNER JOIN в getListQuery)
$result3 = $adb->pquery('INSERT INTO vtiger_modcommentscf (modcommentsid) VALUES (?)', array($commentCrmId));
if (!$result3) {
$error = $adb->database->ErrorMsg();
file_put_contents($logFile, date('Y-m-d H:i:s') . ' ❌ Ошибка INSERT vtiger_modcommentscf: ' . $error . PHP_EOL, FILE_APPEND);
echo json_encode(['success' => false, 'error' => 'DB error: modcommentscf - ' . $error]);
exit;
}
file_put_contents($logFile, date('Y-m-d H:i:s') . ' ✅ vtiger_modcommentscf OK' . PHP_EOL, FILE_APPEND);
file_put_contents($logFile, date('Y-m-d H:i:s') . ' ✅ Создан комментарий ID ' . $commentCrmId . ' для контакта ' . $contact_id . PHP_EOL, FILE_APPEND);
$comment_id_ws = '20x' . $commentCrmId; // ModComments в vtiger обычно 20x
// Уведомление ответственного (всплывашка)
$notify_user_id = $owner_id;
if ($project_id > 0) {
$proj = $adb->pquery(
'SELECT e.smownerid FROM vtiger_project p INNER JOIN vtiger_crmentity e ON e.crmid = p.projectid
WHERE e.deleted = 0 AND p.projectid = ? AND p.linktoaccountscontacts = ? AND p.projectstatus <> ?',
array($project_id, $contact_id, 'completed')
);
if ($adb->num_rows($proj) === 1) {
$notify_user_id = $adb->query_result($proj, 0, 'smownerid');
}
}
$link = 'module=Contacts&view=Detail&record=' . $contact_id . '&app=MARKETING';
$title = 'Ответ поддержки добавлен';
$exist = $adb->pquery(
'SELECT id FROM vtiger_vdnotifierpro WHERE userid = ? AND crmid = ? AND title = ? AND status = 5',
array($notify_user_id, $contact_id, $title)
);
if ($adb->num_rows($exist) > 0) {
$id = $adb->query_result($exist, 0, 'id');
$adb->pquery('UPDATE vtiger_vdnotifierpro SET modifiedtime = ? WHERE id = ?', array($date_var, $id));
} else {
$adb->pquery(
'INSERT INTO vtiger_vdnotifierpro (userid, modulename, crmid, modiuserid, link, title, action, modifiedtime, status) VALUES (?, ?, ?, 0, ?, ?, "", ?, 5)',
array($notify_user_id, $setype, $contact_id, $link, $title, $date_var)
);
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => true,
'comment_id' => $comment_id_ws,
'contact_id' => $contact_id
]);