feat: UI/UX improvements + CRM integration methods + documents_meta deduplication
Frontend: - Changed main title to 'Подать обращение о защите прав потребителя' - Changed browser title to 'Clientright — защита прав потребителей' - Enhanced draft cards: show problem_description (250 chars), category tag, document progress bar - Fixed 'Назад' button to always return to draft selection - Added SSE connection for OCR status updates - Renamed steps: Вход, Обращение, Документы, Заявление - Skip 'Проверка полиса' and 'Тип события' steps for new claim flow Backend: - Fixed client IP extraction (X-Forwarded-For, X-Real-IP) - Added problem_title, category, documents_required_list to draft list API - Fixed documents_uploaded count to count unique field_labels CRM Webservices: - Added UpsertContact.php - create/update contacts with tgid support - Added UpsertAccounts.php - batch upsert offenders by INN - Added UpsertProject.php - create/update projects with offender mapping Database: - Fixed documents_meta duplicates in existing claims - SQL query for deduplication by field_name provided
This commit is contained in:
225
include/Webservices/UpsertAccounts.php
Normal file
225
include/Webservices/UpsertAccounts.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
/*********************************************************************************
|
||||
* API-интерфейс для создания/поиска нескольких Контрагентов (Upsert Batch)
|
||||
*
|
||||
* Принимает JSON массив offenders, для каждого:
|
||||
* - Ищет по ИНН
|
||||
* - Если найден — возвращает ID (БЕЗ обновления)
|
||||
* - Если не найден — создаёт новый
|
||||
*
|
||||
* Возвращает массив результатов с account_id для каждого offender
|
||||
*
|
||||
* Автор: Фёдор, 2025-12-01
|
||||
********************************************************************************/
|
||||
|
||||
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');
|
||||
|
||||
/**
|
||||
* Upsert нескольких контрагентов
|
||||
*
|
||||
* @param string $offenders_json - JSON массив offenders:
|
||||
* [
|
||||
* {
|
||||
* "accountname": "ООО Рога и Копыта",
|
||||
* "address": "Москва, ул. Ленина 1",
|
||||
* "email": "info@example.com",
|
||||
* "website": "example.com",
|
||||
* "phone": "+7 999 123-45-67",
|
||||
* "inn": "7712345678",
|
||||
* "ogrn": "1234567890123",
|
||||
* "role": "Турагент" // опционально, для информации
|
||||
* },
|
||||
* ...
|
||||
* ]
|
||||
* @param mixed $user - пользователь CRM
|
||||
* @return string JSON с результатами
|
||||
*/
|
||||
function vtws_upsertaccounts($offenders_json, $user = false) {
|
||||
$logFile = 'logs/UpsertAccounts.log';
|
||||
$logstring = date("Y-m-d H:i:s") . ' REQUEST: ' . substr($offenders_json, 0, 2000);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
global $adb, $current_user;
|
||||
|
||||
// Очистка JSON от мусора (лишние кавычки, BOM, пробелы)
|
||||
$offenders_json = trim($offenders_json);
|
||||
$offenders_json = preg_replace('/^\xEF\xBB\xBF/', '', $offenders_json); // Убираем BOM
|
||||
|
||||
// Если строка обёрнута в кавычки — убираем
|
||||
if (preg_match('/^".*"$/s', $offenders_json)) {
|
||||
$offenders_json = substr($offenders_json, 1, -1);
|
||||
$offenders_json = stripcslashes($offenders_json); // Убираем экранирование
|
||||
}
|
||||
|
||||
// Убираем лишнюю кавычку в конце (баг n8n)
|
||||
$offenders_json = preg_replace('/"\s*$/', '', rtrim($offenders_json, '"'));
|
||||
if (substr($offenders_json, -1) !== ']' && substr($offenders_json, -1) !== '}') {
|
||||
// Пробуем найти конец массива/объекта
|
||||
if (($pos = strrpos($offenders_json, ']')) !== false) {
|
||||
$offenders_json = substr($offenders_json, 0, $pos + 1);
|
||||
}
|
||||
}
|
||||
|
||||
$logstring = date("Y-m-d H:i:s") . ' CLEANED JSON: ' . substr($offenders_json, 0, 500);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
// Парсим JSON
|
||||
$offenders = json_decode($offenders_json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$error = 'Ошибка парсинга JSON: ' . json_last_error_msg();
|
||||
file_put_contents($logFile, date("Y-m-d H:i:s") . ' ❌ ' . $error . PHP_EOL, FILE_APPEND);
|
||||
file_put_contents($logFile, date("Y-m-d H:i:s") . ' RAW: ' . $offenders_json . PHP_EOL, FILE_APPEND);
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $error);
|
||||
}
|
||||
|
||||
if (!is_array($offenders)) {
|
||||
$offenders = [$offenders]; // Если передан один объект — оборачиваем в массив
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' Получено offenders: ' . count($offenders);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
// Результаты
|
||||
$results = array(
|
||||
'success' => true,
|
||||
'total' => count($offenders),
|
||||
'created' => 0,
|
||||
'found' => 0,
|
||||
'errors' => 0,
|
||||
'accounts' => array()
|
||||
);
|
||||
|
||||
// Обрабатываем каждого offender
|
||||
foreach ($offenders as $index => $offender) {
|
||||
$accountResult = array(
|
||||
'index' => $index,
|
||||
'success' => false,
|
||||
'account_id' => null,
|
||||
'action' => null,
|
||||
'accountname' => $offender['accountname'] ?? '',
|
||||
'inn' => $offender['inn'] ?? '',
|
||||
'role' => $offender['role'] ?? null,
|
||||
'message' => ''
|
||||
);
|
||||
|
||||
try {
|
||||
// Извлекаем данные
|
||||
$accountname = trim($offender['accountname'] ?? '');
|
||||
$address = trim($offender['address'] ?? '');
|
||||
$email = trim($offender['email'] ?? '');
|
||||
$website = trim($offender['website'] ?? '');
|
||||
$phone = trim($offender['phone'] ?? '');
|
||||
$inn = preg_replace('/[^0-9]/', '', $offender['inn'] ?? ''); // Только цифры
|
||||
$ogrn = preg_replace('/[^0-9]/', '', $offender['ogrn'] ?? ''); // Только цифры
|
||||
$role = trim($offender['role'] ?? '');
|
||||
|
||||
// Проверка обязательных полей
|
||||
if (empty($accountname)) {
|
||||
throw new Exception('Не указано наименование контрагента (accountname)');
|
||||
}
|
||||
if (empty($inn)) {
|
||||
throw new Exception('Не указан ИНН');
|
||||
}
|
||||
|
||||
// Валидация ИНН (10 или 12 цифр)
|
||||
if (strlen($inn) != 10 && strlen($inn) != 12) {
|
||||
$logstring = date('Y-m-d H:i:s') . " ⚠️ Нестандартный ИНН: $inn (длина " . strlen($inn) . ')';
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
// Не падаем, просто логируем
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ПОИСК ПО ИНН
|
||||
// ========================================
|
||||
$query = "SELECT a.accountid, a.accountname
|
||||
FROM vtiger_account a
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = a.accountid
|
||||
WHERE e.deleted = 0 AND a.inn = ?
|
||||
LIMIT 1";
|
||||
$res = $adb->pquery($query, array($inn));
|
||||
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
// === НАЙДЕН — просто возвращаем ID ===
|
||||
$existingId = $adb->query_result($res, 0, 'accountid');
|
||||
$existingName = $adb->query_result($res, 0, 'accountname');
|
||||
|
||||
$accountResult['success'] = true;
|
||||
$accountResult['account_id'] = $existingId;
|
||||
$accountResult['action'] = 'found';
|
||||
$accountResult['message'] = 'Контрагент найден по ИНН';
|
||||
$accountResult['existing_name'] = $existingName;
|
||||
|
||||
$results['found']++;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " ✓ [$index] Найден: $existingId ($existingName) по ИНН $inn";
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
} else {
|
||||
// === НЕ НАЙДЕН — создаём ===
|
||||
$params = array(
|
||||
'accountname' => $accountname,
|
||||
'bill_street' => $address,
|
||||
'email1' => $email,
|
||||
'website' => $website,
|
||||
'phone' => $phone,
|
||||
'inn' => $inn,
|
||||
'cf_1951' => $ogrn, // ОГРН в кастомном поле
|
||||
'assigned_user_id' => vtws_getWebserviceEntityId('Users', $current_user->id)
|
||||
);
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " 🆕 [$index] Создаём: " . json_encode($params, JSON_UNESCAPED_UNICODE);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$account = vtws_create('Accounts', $params, $current_user);
|
||||
$newAccountId = substr($account['id'], 3); // Убираем 11x
|
||||
|
||||
$accountResult['success'] = true;
|
||||
$accountResult['account_id'] = $newAccountId;
|
||||
$accountResult['action'] = 'created';
|
||||
$accountResult['message'] = 'Контрагент создан';
|
||||
|
||||
$results['created']++;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " ✅ [$index] Создан: $newAccountId";
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
} catch (WebServiceException $ex) {
|
||||
$accountResult['success'] = false;
|
||||
$accountResult['message'] = $ex->getMessage();
|
||||
$results['errors']++;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " ❌ [$index] WebService ошибка: " . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
} catch (Exception $ex) {
|
||||
$accountResult['success'] = false;
|
||||
$accountResult['message'] = $ex->getMessage();
|
||||
$results['errors']++;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " ❌ [$index] Ошибка: " . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
$results['accounts'][] = $accountResult;
|
||||
}
|
||||
|
||||
// Итоговый статус
|
||||
$results['success'] = ($results['errors'] == 0);
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' RESULT: total=' . $results['total']
|
||||
. ', created=' . $results['created']
|
||||
. ', found=' . $results['found']
|
||||
. ', errors=' . $results['errors'] . PHP_EOL;
|
||||
file_put_contents($logFile, $logstring, FILE_APPEND);
|
||||
|
||||
return json_encode($results, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
235
include/Webservices/UpsertContact.php
Normal file
235
include/Webservices/UpsertContact.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
/*********************************************************************************
|
||||
* API-интерфейс для создания/обновления Контакта (Upsert)
|
||||
* Гибкий метод: обновляет если найден, создаёт если нет
|
||||
*
|
||||
* Приоритет поиска:
|
||||
* 1. contact_id (если передан - сразу обновляем)
|
||||
* 2. mobile (ищем по мобильному)
|
||||
* 3. tgid (ищем по полю phone, где хранится telegram_id)
|
||||
*
|
||||
* Все поля опциональны, кроме хотя бы одного идентификатора
|
||||
*
|
||||
* Автор: Фёдор, 2025-12-01
|
||||
********************************************************************************/
|
||||
|
||||
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 'include/Webservices/Revise.php';
|
||||
require_once 'includes/Loader.php';
|
||||
vimport('includes.runtime.Globals');
|
||||
vimport('includes.runtime.BaseModel');
|
||||
vimport('includes.runtime.LanguageHandler');
|
||||
|
||||
/**
|
||||
* Upsert контакта - создание или обновление
|
||||
*
|
||||
* @param string $contact_id - ID контакта в CRM (если известен)
|
||||
* @param string $mobile - мобильный телефон
|
||||
* @param string $tgid - telegram ID
|
||||
* @param string $firstname - имя
|
||||
* @param string $secondname - отчество
|
||||
* @param string $lastname - фамилия
|
||||
* @param string $email - email
|
||||
* @param string $birthday - дата рождения
|
||||
* @param string $birthplace - место рождения
|
||||
* @param string $mailingstreet - адрес
|
||||
* @param string $inn - ИНН
|
||||
* @param string $requisites - реквизиты
|
||||
* @param string $code - SMS код верификации
|
||||
* @param mixed $user - пользователь CRM
|
||||
* @return string JSON с результатом
|
||||
*/
|
||||
function vtws_upsertcontact(
|
||||
$contact_id = '',
|
||||
$mobile = '',
|
||||
$tgid = '',
|
||||
$firstname = '',
|
||||
$secondname = '',
|
||||
$lastname = '',
|
||||
$email = '',
|
||||
$birthday = '',
|
||||
$birthplace = '',
|
||||
$mailingstreet = '',
|
||||
$inn = '',
|
||||
$requisites = '',
|
||||
$code = '',
|
||||
$user = false
|
||||
) {
|
||||
$logFile = 'logs/UpsertContact.log';
|
||||
$logstring = date("Y-m-d H:i:s") . ' REQUEST: ' . json_encode($_REQUEST);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
global $adb, $current_user;
|
||||
|
||||
// Результат
|
||||
$result = array(
|
||||
'success' => false,
|
||||
'contact_id' => null,
|
||||
'action' => null, // 'created', 'updated', 'found'
|
||||
'message' => ''
|
||||
);
|
||||
|
||||
// ========================================
|
||||
// 1. ФОРМАТИРОВАНИЕ ТЕЛЕФОНА
|
||||
// ========================================
|
||||
if (!empty($mobile)) {
|
||||
$mobile = preg_replace('/[^0-9]/', '', $mobile);
|
||||
if (strlen($mobile) == 11 && $mobile[0] == '8') {
|
||||
$mobile = "7" . substr($mobile, 1);
|
||||
} else if (strlen($mobile) == 10) {
|
||||
$mobile = "7" . $mobile;
|
||||
} else if (strlen($mobile) != 11) {
|
||||
// Некорректный номер - логируем, но не падаем
|
||||
$logstring = date("Y-m-d H:i:s") . ' ⚠️ Некорректный номер телефона: ' . $mobile . ' (игнорируем)';
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
$mobile = ''; // Обнуляем некорректный номер
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 2. ПОИСК СУЩЕСТВУЮЩЕГО КОНТАКТА
|
||||
// ========================================
|
||||
$existingContactId = null;
|
||||
$searchMethod = '';
|
||||
|
||||
// 2.1 По contact_id (приоритет 1)
|
||||
if (!empty($contact_id)) {
|
||||
$contact_id = preg_replace('/[^0-9]/', '', $contact_id); // Очищаем от 12x префикса
|
||||
$query = "SELECT c.contactid FROM vtiger_contactdetails c
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
|
||||
WHERE e.deleted = 0 AND c.contactid = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($contact_id));
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
$existingContactId = $adb->query_result($res, 0, 'contactid');
|
||||
$searchMethod = 'by_contact_id';
|
||||
}
|
||||
}
|
||||
|
||||
// 2.2 По mobile (приоритет 2)
|
||||
if (empty($existingContactId) && !empty($mobile)) {
|
||||
$query = "SELECT c.contactid FROM vtiger_contactdetails c
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
|
||||
WHERE e.deleted = 0 AND c.mobile = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($mobile));
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
$existingContactId = $adb->query_result($res, 0, 'contactid');
|
||||
$searchMethod = 'by_mobile';
|
||||
}
|
||||
}
|
||||
|
||||
// 2.3 По tgid (приоритет 3) - tgid хранится в поле phone
|
||||
if (empty($existingContactId) && !empty($tgid)) {
|
||||
$query = "SELECT c.contactid FROM vtiger_contactdetails c
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
|
||||
WHERE e.deleted = 0 AND c.phone = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($tgid));
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
$existingContactId = $adb->query_result($res, 0, 'contactid');
|
||||
$searchMethod = 'by_tgid';
|
||||
}
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' Поиск: contact_id=' . $contact_id . ', mobile=' . $mobile . ', tgid=' . $tgid;
|
||||
$logstring .= ' → Найден: ' . ($existingContactId ? $existingContactId . ' (' . $searchMethod . ')' : 'НЕТ');
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
// ========================================
|
||||
// 3. ФОРМИРУЕМ ПАРАМЕТРЫ
|
||||
// ========================================
|
||||
$params = array();
|
||||
|
||||
// Только непустые поля добавляем в params
|
||||
if (!empty($firstname)) $params['firstname'] = $firstname;
|
||||
if (!empty($secondname)) $params['cf_1157'] = $secondname; // Отчество
|
||||
if (!empty($lastname)) $params['lastname'] = $lastname;
|
||||
if (!empty($mobile)) $params['mobile'] = $mobile;
|
||||
if (!empty($email)) $params['email'] = $email;
|
||||
if (!empty($tgid)) $params['phone'] = $tgid; // TG ID в поле phone
|
||||
if (!empty($birthday)) $params['birthday'] = $birthday;
|
||||
if (!empty($birthplace)) $params['cf_1263'] = $birthplace; // Место рождения
|
||||
if (!empty($mailingstreet)) $params['mailingstreet'] = $mailingstreet;
|
||||
if (!empty($inn)) $params['cf_1257'] = $inn; // ИНН
|
||||
if (!empty($requisites)) $params['cf_1849'] = $requisites; // Реквизиты
|
||||
if (!empty($code)) $params['cf_1580'] = $code; // SMS код
|
||||
|
||||
// ========================================
|
||||
// 4. СОЗДАНИЕ ИЛИ ОБНОВЛЕНИЕ
|
||||
// ========================================
|
||||
try {
|
||||
if (!empty($existingContactId)) {
|
||||
// === ОБНОВЛЕНИЕ ===
|
||||
$params['id'] = '12x' . $existingContactId;
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' 📝 Обновляем контакт ' . $existingContactId . ': ' . json_encode($params);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$contact = vtws_revise($params, $current_user);
|
||||
|
||||
$result['success'] = true;
|
||||
$result['contact_id'] = $existingContactId;
|
||||
$result['action'] = 'updated';
|
||||
$result['search_method'] = $searchMethod;
|
||||
$result['message'] = 'Контакт обновлён';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ✅ Контакт ' . $existingContactId . ' обновлён';
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
} else {
|
||||
// === СОЗДАНИЕ ===
|
||||
|
||||
// Проверяем минимальные данные для создания
|
||||
if (empty($mobile) && empty($tgid)) {
|
||||
throw new WebServiceException(
|
||||
WebServiceErrorCode::$INVALIDID,
|
||||
"Для создания контакта нужен хотя бы mobile или tgid"
|
||||
);
|
||||
}
|
||||
|
||||
// Дефолтные значения для обязательных полей CRM
|
||||
if (empty($params['firstname'])) {
|
||||
$params['firstname'] = 'Клиент';
|
||||
}
|
||||
if (empty($params['lastname'])) {
|
||||
$suffix = !empty($mobile) ? substr($mobile, -4) : substr($tgid, -4);
|
||||
$params['lastname'] = 'Web_' . $suffix;
|
||||
}
|
||||
if (empty($params['birthday'])) {
|
||||
$params['birthday'] = '01-01-1990';
|
||||
}
|
||||
|
||||
// Назначаем ответственного
|
||||
$params['assigned_user_id'] = vtws_getWebserviceEntityId('Users', $current_user->id);
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' 🆕 Создаём контакт: ' . json_encode($params);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$contact = vtws_create('Contacts', $params, $current_user);
|
||||
$newContactId = substr($contact['id'], 3); // Убираем 12x
|
||||
|
||||
$result['success'] = true;
|
||||
$result['contact_id'] = $newContactId;
|
||||
$result['action'] = 'created';
|
||||
$result['message'] = 'Контакт создан';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ✅ Создан контакт ' . $newContactId;
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
} catch (WebServiceException $ex) {
|
||||
$result['success'] = false;
|
||||
$result['message'] = $ex->getMessage();
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ❌ Ошибка: ' . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
throw $ex;
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' RESULT: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
||||
file_put_contents($logFile, $logstring, FILE_APPEND);
|
||||
|
||||
return json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
297
include/Webservices/UpsertProject.php
Normal file
297
include/Webservices/UpsertProject.php
Normal file
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
/*********************************************************************************
|
||||
* API-интерфейс для создания/обновления Проекта (Upsert)
|
||||
*
|
||||
* Логика:
|
||||
* - Если передан project_id → обновляем существующий проект
|
||||
* - Если project_id не передан → создаём новый
|
||||
*
|
||||
* Принимает JSON с данными проекта
|
||||
*
|
||||
* Автор: Фёдор, 2025-12-01
|
||||
********************************************************************************/
|
||||
|
||||
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 'include/Webservices/Revise.php';
|
||||
require_once 'includes/Loader.php';
|
||||
vimport('includes.runtime.Globals');
|
||||
vimport('includes.runtime.BaseModel');
|
||||
vimport('includes.runtime.LanguageHandler');
|
||||
|
||||
/**
|
||||
* Upsert проекта
|
||||
*
|
||||
* @param string $project_json - JSON с данными проекта:
|
||||
* {
|
||||
* "project_id": "12345", // Опционально - если есть, обновляем
|
||||
* "claim_id": "uuid", // ID заявки из PostgreSQL
|
||||
* "contact_id": "320096", // ID контакта (обязательно для создания)
|
||||
* "result": "JSON string", // Результат UpsertAccounts (парсится автоматически)
|
||||
* "offender_ids": ["390680"], // Альтернатива result - массив ID контрагентов
|
||||
* "projectdata": { // Данные проекта (cf_* поля)
|
||||
* "cf_2206": "SMS код",
|
||||
* "cf_1830": "категория",
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Контрагенты распределяются:
|
||||
* - accounts[0] → cf_2274 (основной ответчик)
|
||||
* - accounts[1] → cf_2276 (агент/второй ответчик)
|
||||
*
|
||||
* @param mixed $user - пользователь CRM
|
||||
* @return string JSON с результатом
|
||||
*/
|
||||
function vtws_upsertproject($project_json, $user = false) {
|
||||
$logFile = 'logs/UpsertProject.log';
|
||||
$logstring = date("Y-m-d H:i:s") . ' REQUEST: ' . substr($project_json, 0, 2000);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
global $adb, $current_user;
|
||||
|
||||
// Очистка JSON
|
||||
$project_json = trim($project_json);
|
||||
$project_json = preg_replace('/^\xEF\xBB\xBF/', '', $project_json);
|
||||
if (preg_match('/^".*"$/s', $project_json)) {
|
||||
$project_json = substr($project_json, 1, -1);
|
||||
$project_json = stripcslashes($project_json);
|
||||
}
|
||||
|
||||
// Парсим JSON
|
||||
$data = json_decode($project_json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$error = 'Ошибка парсинга JSON: ' . json_last_error_msg();
|
||||
file_put_contents($logFile, date("Y-m-d H:i:s") . ' ❌ ' . $error . PHP_EOL, FILE_APPEND);
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $error);
|
||||
}
|
||||
|
||||
// Результат
|
||||
$result = array(
|
||||
'success' => false,
|
||||
'project_id' => null,
|
||||
'claim_id' => null,
|
||||
'action' => null,
|
||||
'offender_id' => null,
|
||||
'agent_id' => null,
|
||||
'message' => ''
|
||||
);
|
||||
|
||||
// Извлекаем данные
|
||||
$project_id = trim($data['project_id'] ?? '');
|
||||
$claim_id = trim($data['claim_id'] ?? '');
|
||||
$contact_id = trim($data['contact_id'] ?? '');
|
||||
$projectdata = $data['projectdata'] ?? [];
|
||||
|
||||
// Извлекаем контрагентов из result (если передан) или из offender_ids
|
||||
$offender_ids = [];
|
||||
|
||||
if (!empty($data['result'])) {
|
||||
// Парсим result от UpsertAccounts
|
||||
$accountsResult = $data['result'];
|
||||
if (is_string($accountsResult)) {
|
||||
$accountsResult = json_decode($accountsResult, true);
|
||||
}
|
||||
|
||||
// Извлекаем account_id из accounts[]
|
||||
if (isset($accountsResult['accounts']) && is_array($accountsResult['accounts'])) {
|
||||
foreach ($accountsResult['accounts'] as $account) {
|
||||
if (!empty($account['account_id'])) {
|
||||
$offender_ids[] = $account['account_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' Извлечены offender_ids из result: ' . json_encode($offender_ids);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
} elseif (!empty($data['offender_ids'])) {
|
||||
$offender_ids = $data['offender_ids'];
|
||||
}
|
||||
|
||||
// cf_2274 = первый контрагент (основной ответчик)
|
||||
// cf_2276 = второй контрагент (агент/второй ответчик)
|
||||
$offender_id = count($offender_ids) > 0 ? $offender_ids[0] : '';
|
||||
$agent_id = count($offender_ids) > 1 ? $offender_ids[1] : '';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . " Данные: project_id=$project_id, claim_id=$claim_id, contact_id=$contact_id, offender_id=$offender_id, agent_id=$agent_id";
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
try {
|
||||
// ========================================
|
||||
// ПРОВЕРКА СУЩЕСТВОВАНИЯ ПРОЕКТА
|
||||
// ========================================
|
||||
$existingProjectId = null;
|
||||
|
||||
if (!empty($project_id)) {
|
||||
$project_id = preg_replace('/[^0-9]/', '', $project_id);
|
||||
$query = "SELECT p.projectid FROM vtiger_project p
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = p.projectid
|
||||
WHERE e.deleted = 0 AND p.projectid = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($project_id));
|
||||
if ($adb->num_rows($res) > 0) {
|
||||
$existingProjectId = $adb->query_result($res, 0, 'projectid');
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// ФОРМИРУЕМ ПАРАМЕТРЫ
|
||||
// ========================================
|
||||
$params = array();
|
||||
|
||||
// Если создаём новый проект - нужны contact_id и offender_id
|
||||
if (empty($existingProjectId)) {
|
||||
if (empty($contact_id) || empty($offender_id)) {
|
||||
throw new Exception('Для создания проекта нужны contact_id и offender_ids');
|
||||
}
|
||||
|
||||
// Получаем название контакта
|
||||
$query = "SELECT c.lastname FROM vtiger_contactdetails c
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = c.contactid
|
||||
WHERE e.deleted = 0 AND c.contactid = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($contact_id));
|
||||
$contactName = $adb->num_rows($res) > 0 ? $adb->query_result($res, 0, 'lastname') : 'Клиент';
|
||||
|
||||
// Получаем название контрагента
|
||||
$query = "SELECT a.accountname FROM vtiger_account a
|
||||
LEFT JOIN vtiger_crmentity e ON e.crmid = a.accountid
|
||||
WHERE e.deleted = 0 AND a.accountid = ? LIMIT 1";
|
||||
$res = $adb->pquery($query, array($offender_id));
|
||||
$accountName = $adb->num_rows($res) > 0 ? $adb->query_result($res, 0, 'accountname') : 'Контрагент';
|
||||
|
||||
// Название проекта
|
||||
$params['projectname'] = $contactName . ' ' . $accountName;
|
||||
$params['linktoaccountscontacts'] = '12x' . $contact_id;
|
||||
$params['cf_2274'] = '11x' . $offender_id; // Основной ответчик
|
||||
$params['projectstatus'] = 'модерация';
|
||||
$params['projecttype'] = 'Претензионно - исковая работа';
|
||||
$params['assigned_user_id'] = vtws_getWebserviceEntityId('Users', $current_user->id);
|
||||
|
||||
// Заявитель по умолчанию
|
||||
if (!isset($projectdata['cf_1994'])) {
|
||||
$params['cf_1994'] = vtws_getWebserviceEntityId('Accounts', 62345); // МОО КЛИЕНТПРАВ
|
||||
}
|
||||
}
|
||||
|
||||
// Агент (второй ответчик)
|
||||
if (!empty($agent_id)) {
|
||||
$params['cf_2276'] = '11x' . $agent_id;
|
||||
}
|
||||
|
||||
// Связь контакт/оффендер для обновления тоже можно передать
|
||||
if (!empty($contact_id) && !empty($existingProjectId)) {
|
||||
$params['linktoaccountscontacts'] = '12x' . $contact_id;
|
||||
}
|
||||
if (!empty($offender_id) && !empty($existingProjectId)) {
|
||||
$params['cf_2274'] = '11x' . $offender_id;
|
||||
}
|
||||
|
||||
// Маппинг полей из projectdata
|
||||
$fieldMapping = array(
|
||||
'cf_2206' => 'cf_2206', // SMS код
|
||||
'cf_2210' => 'cf_2210', // IP
|
||||
'cf_2212' => 'cf_2212', // Источник
|
||||
'cf_2214' => 'cf_2214', // Регион
|
||||
'cf_2208' => 'cf_2208', // Form ID
|
||||
'cf_1830' => 'cf_1830', // Категория
|
||||
'cf_1469' => 'cf_1469', // Направление
|
||||
'cf_1191' => 'cf_1191', // Цена договора
|
||||
'cf_1189' => 'cf_1189', // Предмет договора
|
||||
'cf_1203' => 'cf_1203', // Дата договора
|
||||
'cf_1839' => 'cf_1839', // Дата начала
|
||||
'cf_1841' => 'cf_1841', // Дата окончания
|
||||
'cf_1207' => 'cf_1207', // Ущерб
|
||||
'cf_1479' => 'cf_1479', // Стоимость услуг
|
||||
'cf_1227' => 'cf_1227', // Прогресс
|
||||
'cf_1231' => 'cf_1231', // Страна
|
||||
'cf_1239' => 'cf_1239', // Отель
|
||||
'cf_1566' => 'cf_1566', // Транспорт
|
||||
'cf_1564' => 'cf_1564', // Страховка
|
||||
'cf_1249' => 'cf_1249', // Прочее
|
||||
'cf_1471' => 'cf_1471', // Самостоятельно
|
||||
'cf_1473' => 'cf_1473', // Дата претензии
|
||||
'cf_1475' => 'cf_1475', // Возвращено
|
||||
'cf_1994' => 'cf_1994', // Заявитель
|
||||
'description' => 'description'
|
||||
);
|
||||
|
||||
foreach ($fieldMapping as $input => $crm) {
|
||||
if (isset($projectdata[$input]) && $projectdata[$input] !== null) {
|
||||
$value = $projectdata[$input];
|
||||
// Для cf_1994 (Заявитель) нужен формат 11xID
|
||||
if ($crm === 'cf_1994' && !empty($value) && strpos($value, 'x') === false) {
|
||||
$value = vtws_getWebserviceEntityId('Accounts', $value);
|
||||
}
|
||||
$params[$crm] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// СОЗДАНИЕ ИЛИ ОБНОВЛЕНИЕ
|
||||
// ========================================
|
||||
if (!empty($existingProjectId)) {
|
||||
// === ОБНОВЛЕНИЕ ===
|
||||
$params['id'] = '33x' . $existingProjectId; // 33x для Project
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' 📝 Обновляем проект ' . $existingProjectId . ': ' . json_encode($params, JSON_UNESCAPED_UNICODE);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$project = vtws_revise($params, $current_user);
|
||||
|
||||
$result['success'] = true;
|
||||
$result['project_id'] = $existingProjectId;
|
||||
$result['claim_id'] = $claim_id;
|
||||
$result['action'] = 'updated';
|
||||
$result['offender_id'] = $offender_id;
|
||||
$result['agent_id'] = $agent_id ?: null;
|
||||
$result['message'] = 'Проект обновлён';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ✅ Проект ' . $existingProjectId . ' обновлён';
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
} else {
|
||||
// === СОЗДАНИЕ ===
|
||||
$logstring = date('Y-m-d H:i:s') . ' 🆕 Создаём проект: ' . json_encode($params, JSON_UNESCAPED_UNICODE);
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
$project = vtws_create('Project', $params, $current_user);
|
||||
$newProjectId = substr($project['id'], strpos($project['id'], 'x') + 1); // Убираем префикс (63x)
|
||||
|
||||
$result['success'] = true;
|
||||
$result['project_id'] = $newProjectId;
|
||||
$result['claim_id'] = $claim_id;
|
||||
$result['action'] = 'created';
|
||||
$result['offender_id'] = $offender_id;
|
||||
$result['agent_id'] = $agent_id ?: null;
|
||||
$result['message'] = 'Проект создан';
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ✅ Создан проект ' . $newProjectId;
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
} catch (WebServiceException $ex) {
|
||||
$result['success'] = false;
|
||||
$result['message'] = $ex->getMessage();
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ❌ WebService ошибка: ' . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
throw $ex;
|
||||
|
||||
} catch (Exception $ex) {
|
||||
$result['success'] = false;
|
||||
$result['message'] = $ex->getMessage();
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' ❌ Ошибка: ' . $ex->getMessage();
|
||||
file_put_contents($logFile, $logstring . PHP_EOL, FILE_APPEND);
|
||||
|
||||
throw new WebServiceException(WebServiceErrorCode::$INVALIDID, $ex->getMessage());
|
||||
}
|
||||
|
||||
$logstring = date('Y-m-d H:i:s') . ' RESULT: ' . json_encode($result, JSON_UNESCAPED_UNICODE) . PHP_EOL;
|
||||
file_put_contents($logFile, $logstring, FILE_APPEND);
|
||||
|
||||
return json_encode($result, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
@@ -316,18 +316,68 @@ async def list_drafts(
|
||||
else:
|
||||
payload = {}
|
||||
|
||||
# Извлекаем данные из ai_analysis или wizard_plan
|
||||
ai_analysis = payload.get('ai_analysis') or {}
|
||||
wizard_plan = payload.get('wizard_plan') or {}
|
||||
|
||||
# Краткое описание проблемы (заголовок)
|
||||
problem_title = ai_analysis.get('problem') or payload.get('problem') or None
|
||||
|
||||
# Категория проблемы
|
||||
category = ai_analysis.get('category') or wizard_plan.get('category') or None
|
||||
|
||||
# Подробное описание (для превью)
|
||||
problem_text = payload.get('problem_description', '')
|
||||
|
||||
# Считаем документы
|
||||
documents_meta = payload.get('documents_meta') or []
|
||||
documents_required = payload.get('documents_required') or []
|
||||
|
||||
# Считаем загруженные (уникальные по field_label)
|
||||
uploaded_labels = set()
|
||||
for doc in documents_meta:
|
||||
label = doc.get('field_label') or doc.get('field_name')
|
||||
if label:
|
||||
uploaded_labels.add(label)
|
||||
|
||||
documents_uploaded = len(uploaded_labels)
|
||||
documents_total = len(documents_required) if documents_required else 0
|
||||
|
||||
# Формируем список документов со статусами
|
||||
documents_list = []
|
||||
for doc_req in documents_required:
|
||||
doc_name = doc_req.get('name', 'Документ')
|
||||
doc_id = doc_req.get('id', '')
|
||||
is_required = doc_req.get('required', False)
|
||||
# Проверяем загружен ли (по name или id)
|
||||
is_uploaded = doc_name in uploaded_labels or doc_id in uploaded_labels
|
||||
documents_list.append({
|
||||
"name": doc_name,
|
||||
"required": is_required,
|
||||
"uploaded": is_uploaded,
|
||||
})
|
||||
|
||||
drafts.append({
|
||||
"id": str(row['id']),
|
||||
"claim_id": row.get('claim_id'),
|
||||
"session_token": row.get('session_token'),
|
||||
"status_code": row.get('status_code'),
|
||||
"channel": row.get('channel'), # Добавляем канал в ответ
|
||||
"channel": row.get('channel'),
|
||||
"created_at": row['created_at'].isoformat() if row.get('created_at') else None,
|
||||
"updated_at": row['updated_at'].isoformat() if row.get('updated_at') else None,
|
||||
"problem_description": payload.get('problem_description', '')[:100] if payload.get('problem_description') else None,
|
||||
# Заголовок - краткое описание проблемы из AI
|
||||
"problem_title": problem_title[:150] if problem_title else None,
|
||||
# Полное описание
|
||||
"problem_description": problem_text[:500] if problem_text else None,
|
||||
"category": category,
|
||||
"wizard_plan": payload.get('wizard_plan') is not None,
|
||||
"wizard_answers": payload.get('answers') is not None,
|
||||
"has_documents": len(payload.get('documents_meta', [])) > 0 if payload.get('documents_meta') else False,
|
||||
"has_documents": documents_uploaded > 0,
|
||||
# Прогресс документов
|
||||
"documents_total": documents_total,
|
||||
"documents_uploaded": documents_uploaded,
|
||||
"documents_skipped": 0, # TODO: считать пропущенные
|
||||
"documents_list": documents_list, # Список со статусами
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -22,6 +22,22 @@ logger = logging.getLogger(__name__)
|
||||
N8N_DOCUMENT_UPLOAD_WEBHOOK = "https://n8n.clientright.pro/webhook/webform_document_upload"
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Получить реальный IP клиента (с учётом proxy заголовков)"""
|
||||
# Сначала проверяем заголовки от reverse proxy
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
real_ip = request.headers.get("x-real-ip", "").strip()
|
||||
|
||||
# X-Forwarded-For имеет приоритет
|
||||
if forwarded_for and forwarded_for not in ("127.0.0.1", "192.168.0.1", "::1"):
|
||||
return forwarded_for
|
||||
if real_ip and real_ip not in ("127.0.0.1", "192.168.0.1", "::1"):
|
||||
return real_ip
|
||||
|
||||
# Fallback на request.client
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_document(
|
||||
request: Request,
|
||||
@@ -67,10 +83,7 @@ async def upload_document(
|
||||
file_size = len(file_content)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
||||
form_data = {
|
||||
@@ -223,10 +236,7 @@ async def upload_multiple_documents(
|
||||
)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Генерируем ID для каждого файла и читаем контент
|
||||
file_ids = []
|
||||
@@ -426,10 +436,7 @@ async def skip_document(
|
||||
)
|
||||
|
||||
# Получаем IP клиента
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||
if forwarded_for:
|
||||
client_ip = forwarded_for
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
||||
form_data = {
|
||||
|
||||
@@ -13,7 +13,7 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
router = APIRouter(prefix="/api/v1", tags=["Events"])
|
||||
|
||||
|
||||
class EventPublish(BaseModel):
|
||||
@@ -215,11 +215,71 @@ async def stream_events(task_id: str):
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading wizard data from PostgreSQL: {e}")
|
||||
|
||||
# ✅ Обработка ocr_status ready: загружаем form_draft из PostgreSQL
|
||||
if actual_event.get('event_type') == 'ocr_status' and actual_event.get('status') == 'ready':
|
||||
claim_id = actual_event.get('claim_id') or actual_event.get('data', {}).get('claim_id')
|
||||
if claim_id:
|
||||
logger.info(f"🔍 OCR ready event received, loading form_draft for claim_id={claim_id}")
|
||||
|
||||
try:
|
||||
# Загружаем form_draft и documents из PostgreSQL
|
||||
query = """
|
||||
SELECT
|
||||
c.id,
|
||||
c.payload->'form_draft' as form_draft,
|
||||
c.payload->'documents_required' as documents_required,
|
||||
c.payload->'documents_meta' as documents_meta
|
||||
FROM clpr_claims c
|
||||
WHERE c.id::text = $1
|
||||
LIMIT 1
|
||||
"""
|
||||
|
||||
row = await db.fetch_one(query, claim_id)
|
||||
|
||||
if row:
|
||||
# Парсим JSONB поля (могут быть строками)
|
||||
form_draft_raw = row.get('form_draft')
|
||||
documents_required_raw = row.get('documents_required')
|
||||
documents_meta_raw = row.get('documents_meta')
|
||||
|
||||
# Парсим если строка
|
||||
def parse_json_field(val):
|
||||
if val is None:
|
||||
return None
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
return json.loads(val)
|
||||
except:
|
||||
return val
|
||||
return val
|
||||
|
||||
form_draft = parse_json_field(form_draft_raw)
|
||||
documents_required = parse_json_field(documents_required_raw)
|
||||
documents_meta = parse_json_field(documents_meta_raw)
|
||||
|
||||
# Обогащаем событие данными из БД
|
||||
actual_event['data'] = {
|
||||
'claim_id': claim_id,
|
||||
'all_ready': True,
|
||||
'form_draft': form_draft,
|
||||
'documents_required': documents_required,
|
||||
'documents_meta': documents_meta,
|
||||
}
|
||||
|
||||
logger.info(f"✅ Form draft loaded from PostgreSQL for claim_id={claim_id}, has_form_draft={form_draft is not None}")
|
||||
else:
|
||||
logger.warning(f"⚠️ Claim not found in PostgreSQL: claim_id={claim_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading form_draft from PostgreSQL: {e}")
|
||||
|
||||
# Отправляем событие клиенту (плоский формат)
|
||||
event_json = json.dumps(actual_event, ensure_ascii=False)
|
||||
event_json = json.dumps(actual_event, ensure_ascii=False, default=str)
|
||||
event_type_sent = actual_event.get('event_type', 'unknown')
|
||||
event_status = actual_event.get('status', 'unknown')
|
||||
logger.info(f"📤 Sending event to client: type={event_type_sent}, status={event_status}")
|
||||
# Логируем размер и наличие данных
|
||||
data_info = actual_event.get('data', {})
|
||||
has_form_draft = 'form_draft' in data_info if isinstance(data_info, dict) else False
|
||||
logger.info(f"📤 Sending event to client: type={event_type_sent}, status={event_status}, json_len={len(event_json)}, has_form_draft={has_form_draft}")
|
||||
yield f"data: {event_json}\n\n"
|
||||
|
||||
# Если обработка завершена - закрываем соединение
|
||||
@@ -232,6 +292,11 @@ async def stream_events(task_id: str):
|
||||
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
|
||||
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
||||
break
|
||||
|
||||
# Закрываем для ocr_status ready (форма заявления готова)
|
||||
if event_type_sent == 'ocr_status' and event_status == 'ready':
|
||||
logger.info(f"✅ OCR ready event sent, closing SSE")
|
||||
break
|
||||
else:
|
||||
logger.info(f"⏰ Timeout waiting for message on {channel}")
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ERV Insurance Platform</title>
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ERV Insurance Platform</title>
|
||||
<title>Clientright — защита прав потребителей</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -66,6 +66,12 @@ const getRelativeTime = (dateStr: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
interface DocumentStatus {
|
||||
name: string;
|
||||
required: boolean;
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
interface Draft {
|
||||
id: string;
|
||||
claim_id: string;
|
||||
@@ -74,7 +80,9 @@ interface Draft {
|
||||
channel: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
problem_title?: string; // Краткое описание (заголовок)
|
||||
problem_description?: string;
|
||||
category?: string; // Категория проблемы
|
||||
wizard_plan: boolean;
|
||||
wizard_answers: boolean;
|
||||
has_documents: boolean;
|
||||
@@ -82,6 +90,7 @@ interface Draft {
|
||||
documents_total?: number;
|
||||
documents_uploaded?: number;
|
||||
documents_skipped?: number;
|
||||
documents_list?: DocumentStatus[]; // Список документов со статусами
|
||||
wizard_ready?: boolean;
|
||||
claim_ready?: boolean;
|
||||
is_legacy?: boolean; // Старый формат без documents_required
|
||||
@@ -322,7 +331,7 @@ export default function StepDraftSelection({
|
||||
size="large"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
+ Создать новую заявку
|
||||
Создать новую заявку
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
@@ -346,32 +355,14 @@ export default function StepDraftSelection({
|
||||
<List.Item
|
||||
style={{
|
||||
padding: '16px',
|
||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#d9d9d9'}`,
|
||||
borderRadius: 8,
|
||||
marginBottom: 12,
|
||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#e8e8e8'}`,
|
||||
borderRadius: 12,
|
||||
marginBottom: 16,
|
||||
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
||||
overflow: 'hidden',
|
||||
display: 'block', // Вертикальный layout
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
actions={[
|
||||
getActionButton(draft),
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
]}
|
||||
>
|
||||
<List.Item.Meta
|
||||
avatar={
|
||||
@@ -393,28 +384,46 @@ export default function StepDraftSelection({
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
|
||||
{draft.category && (
|
||||
<Tag color="purple" style={{ margin: 0 }}>{draft.category}</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
{/* Описание проблемы */}
|
||||
{/* Заголовок - краткое описание проблемы */}
|
||||
{draft.problem_title && (
|
||||
<Text strong style={{
|
||||
fontSize: 15,
|
||||
color: '#1a1a1a',
|
||||
display: 'block',
|
||||
marginBottom: 4,
|
||||
}}>
|
||||
{draft.problem_title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Полное описание проблемы */}
|
||||
{draft.problem_description && (
|
||||
<Text
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
display: 'block',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '100%',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
color: '#262626',
|
||||
background: '#f5f5f5',
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
borderLeft: '4px solid #1890ff',
|
||||
marginTop: 4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={draft.problem_description}
|
||||
>
|
||||
{draft.problem_description.length > 60
|
||||
? draft.problem_description.substring(0, 60) + '...'
|
||||
{draft.problem_description.length > 250
|
||||
? draft.problem_description.substring(0, 250) + '...'
|
||||
: draft.problem_description
|
||||
}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Время обновления */}
|
||||
@@ -437,41 +446,106 @@ export default function StepDraftSelection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Прогресс документов */}
|
||||
{docsProgress && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
📎 Документы: {docsProgress.uploaded} из {docsProgress.total} загружено
|
||||
{docsProgress.skipped > 0 && ` (${docsProgress.skipped} пропущено)`}
|
||||
</Text>
|
||||
{/* Список документов со статусами */}
|
||||
{draft.documents_list && draft.documents_list.length > 0 && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
background: '#fafafa',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
<Text type="secondary" style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
📄 Документы
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: '#1890ff', fontWeight: 500 }}>
|
||||
{draft.documents_uploaded || 0} / {draft.documents_total || 0}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{draft.documents_list.map((doc, idx) => (
|
||||
<div key={idx} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 12,
|
||||
}}>
|
||||
{doc.uploaded ? (
|
||||
<CheckCircleOutlined style={{ color: '#52c41a', fontSize: 14 }} />
|
||||
) : (
|
||||
<span style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '50%',
|
||||
border: `2px solid ${doc.required ? '#ff4d4f' : '#d9d9d9'}`,
|
||||
display: 'inline-block',
|
||||
}} />
|
||||
)}
|
||||
<span style={{
|
||||
color: doc.uploaded ? '#52c41a' : (doc.required ? '#262626' : '#8c8c8c'),
|
||||
textDecoration: doc.uploaded ? 'none' : 'none',
|
||||
}}>
|
||||
{doc.name}
|
||||
{doc.required && !doc.uploaded && <span style={{ color: '#ff4d4f' }}> *</span>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Прогрессбар (если нет списка) */}
|
||||
{(!draft.documents_list || draft.documents_list.length === 0) && docsProgress && docsProgress.total > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<Progress
|
||||
percent={docsProgress.percent}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor="#52c41a"
|
||||
strokeColor={{
|
||||
'0%': '#1890ff',
|
||||
'100%': '#52c41a',
|
||||
}}
|
||||
trailColor="#f0f0f0"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Старые теги прогресса (для обратной совместимости) */}
|
||||
{!docsProgress && !draft.is_legacy && (
|
||||
<Space size="small" wrap>
|
||||
<Tag color={draft.problem_description ? 'green' : 'default'}>
|
||||
{draft.problem_description ? '✓ Описание' : 'Описание'}
|
||||
</Tag>
|
||||
<Tag color={draft.wizard_plan ? 'green' : 'default'}>
|
||||
{draft.wizard_plan ? '✓ План' : 'План'}
|
||||
</Tag>
|
||||
<Tag color={draft.has_documents ? 'green' : 'default'}>
|
||||
{draft.has_documents ? '✓ Документы' : 'Документы'}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{/* Описание статуса */}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{config.description}
|
||||
</Text>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
marginTop: 12,
|
||||
paddingTop: 12,
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
}}>
|
||||
{getActionButton(draft)}
|
||||
<Popconfirm
|
||||
title="Удалить заявку?"
|
||||
description="Это действие нельзя отменить"
|
||||
onConfirm={() => handleDelete(draft.claim_id || draft.id)}
|
||||
okText="Да, удалить"
|
||||
cancelText="Отмена"
|
||||
>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={deletingId === (draft.claim_id || draft.id)}
|
||||
disabled={deletingId === (draft.claim_id || draft.id)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -133,6 +133,8 @@ export default function StepWizardPlan({
|
||||
new Set(formData.wizardSkippedDocuments || [])
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [isFormingClaim, setIsFormingClaim] = useState(false); // Состояние ожидания формирования заявления
|
||||
const [ragError, setRagError] = useState<string | null>(null); // Ошибка RAG
|
||||
const [progressState, setProgressState] = useState<{ done: number; total: number }>({
|
||||
done: 0,
|
||||
total: 0,
|
||||
@@ -1896,8 +1898,242 @@ export default function StepWizardPlan({
|
||||
}
|
||||
}
|
||||
|
||||
message.loading('Формируем заявление...', 0);
|
||||
onNext();
|
||||
// ✅ Показываем экран ожидания
|
||||
setIsFormingClaim(true);
|
||||
setRagError(null); // Сбрасываем предыдущую ошибку
|
||||
|
||||
// ✅ Запускаем RAG через check-ocr-status
|
||||
try {
|
||||
const response = await fetch('/api/v1/documents/check-ocr-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
claim_id: formData.claim_id,
|
||||
session_id: formData.session_id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('✅ OCR status check:', data);
|
||||
|
||||
// Если есть кэш — сразу переходим
|
||||
if (data.from_cache && data.form_draft) {
|
||||
console.log('✅ Используем кэшированные данные:', data.form_draft);
|
||||
const formDraft = data.form_draft;
|
||||
const user = formDraft.user || {};
|
||||
const project = formDraft.project || {};
|
||||
|
||||
// ✅ Используем тот же маппинг что и в ClaimForm.tsx
|
||||
const claimPlanData = {
|
||||
propertyName: {
|
||||
applicant: {
|
||||
first_name: user.firstname || '',
|
||||
middle_name: user.secondname || '',
|
||||
last_name: user.lastname || '',
|
||||
phone: user.mobile || formData.phone || '',
|
||||
email: user.email || '',
|
||||
birth_date: user.birthday || '',
|
||||
birth_place: user.birthplace || '',
|
||||
address: user.mailingstreet || '',
|
||||
inn: user.inn || '',
|
||||
},
|
||||
case: {
|
||||
category: project.category || '',
|
||||
direction: project.direction || '',
|
||||
},
|
||||
contract_or_service: {
|
||||
subject: project.subject || '',
|
||||
amount_paid: project.agrprice || '',
|
||||
agreement_date: project.agrdate || '',
|
||||
period_start: project.startdate || '',
|
||||
period_end: project.finishdate || '',
|
||||
country: project.country || '',
|
||||
hotel: project.hotel || '',
|
||||
},
|
||||
offenders: (formDraft.offenders || []).map((o: any) => ({
|
||||
name: o.accountname || '',
|
||||
accountname: o.accountname || '',
|
||||
address: o.address || '',
|
||||
email: o.email || '',
|
||||
website: o.website || '',
|
||||
phone: o.phone || '',
|
||||
inn: o.inn || '',
|
||||
ogrn: o.ogrn || '',
|
||||
role: o.role || '',
|
||||
})),
|
||||
claim: {
|
||||
description: project.description || formData.problem_description || '',
|
||||
reason: project.category || '',
|
||||
},
|
||||
meta: {
|
||||
claim_id: formData.claim_id,
|
||||
unified_id: formData.unified_id || '',
|
||||
session_token: formData.session_id,
|
||||
},
|
||||
attachments_names: Array.isArray(data.documents_meta)
|
||||
? [...new Set(data.documents_meta.map((d: any) =>
|
||||
d.field_label || d.original_file_name || d.file_name || 'Документ'
|
||||
))]
|
||||
: [],
|
||||
},
|
||||
session_token: formData.session_id,
|
||||
claim_id: formData.claim_id,
|
||||
prefix: 'clpr_',
|
||||
};
|
||||
|
||||
updateFormData({
|
||||
form_draft: formDraft,
|
||||
claimPlanData: claimPlanData,
|
||||
showClaimConfirmation: true,
|
||||
claim_ready: true,
|
||||
});
|
||||
|
||||
setIsFormingClaim(false);
|
||||
message.success('Данные загружены из кэша');
|
||||
onNext();
|
||||
return;
|
||||
}
|
||||
|
||||
// Иначе подключаемся к SSE и ждём результат от n8n
|
||||
const sessionId = formData.session_id;
|
||||
console.log('📡 Подключаемся к SSE:', `/api/v1/events/${sessionId}`);
|
||||
|
||||
const eventSource = new EventSource(`/api/v1/events/${sessionId}`);
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const eventData = JSON.parse(event.data);
|
||||
console.log('📥 SSE event:', eventData);
|
||||
|
||||
// Обрабатываем событие ocr_status
|
||||
if (eventData.event_type === 'ocr_status') {
|
||||
if (eventData.status === 'ready') {
|
||||
// ✅ Успех — данные готовы
|
||||
console.log('✅ Заявление готово:', eventData.data);
|
||||
const formDraft = eventData.data?.form_draft;
|
||||
|
||||
// Формируем claimPlanData для StepClaimConfirmation
|
||||
if (formDraft) {
|
||||
const user = formDraft.user || {};
|
||||
const project = formDraft.project || {};
|
||||
|
||||
// ✅ Используем тот же маппинг что и в ClaimForm.tsx
|
||||
const claimPlanData = {
|
||||
propertyName: {
|
||||
applicant: {
|
||||
first_name: user.firstname || '',
|
||||
middle_name: user.secondname || '',
|
||||
last_name: user.lastname || '',
|
||||
phone: user.mobile || formData.phone || '',
|
||||
email: user.email || '',
|
||||
birth_date: user.birthday || '',
|
||||
birth_place: user.birthplace || '',
|
||||
address: user.mailingstreet || '',
|
||||
inn: user.inn || '',
|
||||
},
|
||||
case: {
|
||||
category: project.category || '',
|
||||
direction: project.direction || '',
|
||||
},
|
||||
contract_or_service: {
|
||||
subject: project.subject || '',
|
||||
amount_paid: project.agrprice || '',
|
||||
agreement_date: project.agrdate || '',
|
||||
period_start: project.startdate || '',
|
||||
period_end: project.finishdate || '',
|
||||
country: project.country || '',
|
||||
hotel: project.hotel || '',
|
||||
},
|
||||
offenders: (formDraft.offenders || []).map((o: any) => ({
|
||||
name: o.accountname || '',
|
||||
accountname: o.accountname || '',
|
||||
address: o.address || '',
|
||||
email: o.email || '',
|
||||
website: o.website || '',
|
||||
phone: o.phone || '',
|
||||
inn: o.inn || '',
|
||||
ogrn: o.ogrn || '',
|
||||
role: o.role || '',
|
||||
})),
|
||||
claim: {
|
||||
description: project.description || formData.problem_description || '',
|
||||
reason: project.category || '',
|
||||
},
|
||||
meta: {
|
||||
claim_id: formData.claim_id,
|
||||
unified_id: formData.unified_id || '',
|
||||
session_token: formData.session_id,
|
||||
},
|
||||
attachments_names: Array.isArray(eventData.data?.documents_meta)
|
||||
? [...new Set(eventData.data.documents_meta.map((d: any) =>
|
||||
d.field_label || d.original_file_name || d.file_name || 'Документ'
|
||||
))]
|
||||
: [],
|
||||
},
|
||||
session_token: formData.session_id,
|
||||
claim_id: formData.claim_id,
|
||||
prefix: 'clpr_',
|
||||
};
|
||||
|
||||
updateFormData({
|
||||
form_draft: formDraft,
|
||||
claimPlanData: claimPlanData,
|
||||
showClaimConfirmation: true,
|
||||
claim_ready: true,
|
||||
});
|
||||
} else {
|
||||
updateFormData({
|
||||
claim_ready: true,
|
||||
});
|
||||
}
|
||||
|
||||
setIsFormingClaim(false);
|
||||
message.success(eventData.message || 'Заявление сформировано!');
|
||||
eventSource.close();
|
||||
onNext();
|
||||
} else if (eventData.status === 'error' || eventData.status === 'timeout') {
|
||||
// ❌ Ошибка — показываем кнопку повторить
|
||||
console.error('❌ Ошибка RAG:', eventData.message);
|
||||
setIsFormingClaim(false);
|
||||
setRagError(eventData.message || 'Ошибка формирования заявления');
|
||||
eventSource.close();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('❌ Ошибка парсинга SSE:', e);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('❌ SSE error:', error);
|
||||
message.destroy();
|
||||
setIsFormingClaim(false);
|
||||
setRagError('Потеряно соединение с сервером');
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
// Таймаут 3 минуты (RAG может занять время)
|
||||
setTimeout(() => {
|
||||
if (eventSource.readyState !== EventSource.CLOSED) {
|
||||
console.warn('⏰ SSE timeout');
|
||||
message.destroy();
|
||||
setIsFormingClaim(false);
|
||||
setRagError('Превышено время ожидания. Попробуйте ещё раз.');
|
||||
eventSource.close();
|
||||
}
|
||||
}, 180000); // 3 минуты для RAG
|
||||
|
||||
} else {
|
||||
console.warn('⚠️ OCR status check failed:', await response.text());
|
||||
message.destroy();
|
||||
onNext();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error calling check-ocr-status:', error);
|
||||
message.destroy();
|
||||
onNext();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -2013,7 +2249,7 @@ export default function StepWizardPlan({
|
||||
|
||||
{/* Кнопки */}
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button onClick={onPrev}>← Назад</Button>
|
||||
<Button onClick={onPrev}>← К списку заявок</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleDocContinue}
|
||||
@@ -2059,8 +2295,57 @@ export default function StepWizardPlan({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Формируем заявление (экран ожидания) */}
|
||||
{hasNewFlowDocs && allDocsProcessed && isFormingClaim && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<img
|
||||
src={AiWorkingIllustration}
|
||||
alt="Формируем заявление"
|
||||
style={{ maxWidth: 280, width: '100%', marginBottom: 24 }}
|
||||
/>
|
||||
<Title level={4}>📝 Формируем заявление...</Title>
|
||||
<Paragraph type="secondary" style={{ maxWidth: 420, margin: '0 auto 24px' }}>
|
||||
Анализируем документы и собираем данные для вашего заявления.
|
||||
Это займёт до 1-2 минут.
|
||||
</Paragraph>
|
||||
<LoadingOutlined style={{ fontSize: 32, color: '#1890ff' }} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ❌ ОШИБКА: Показываем кнопку повторить */}
|
||||
{hasNewFlowDocs && allDocsProcessed && ragError && !isFormingClaim && (
|
||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
||||
<Result
|
||||
status="warning"
|
||||
title="Не удалось сформировать заявление"
|
||||
subTitle={ragError}
|
||||
extra={[
|
||||
<Button
|
||||
type="primary"
|
||||
key="retry"
|
||||
onClick={() => {
|
||||
setRagError(null);
|
||||
handleAllDocsComplete();
|
||||
}}
|
||||
>
|
||||
🔄 Повторить
|
||||
</Button>,
|
||||
<Button
|
||||
key="skip"
|
||||
onClick={() => {
|
||||
setRagError(null);
|
||||
onNext();
|
||||
}}
|
||||
>
|
||||
Пропустить и продолжить
|
||||
</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ✅ НОВЫЙ ФЛОУ: Все документы загружены */}
|
||||
{hasNewFlowDocs && allDocsProcessed && (() => {
|
||||
{hasNewFlowDocs && allDocsProcessed && !isFormingClaim && !ragError && (() => {
|
||||
// Правильно считаем загруженные и пропущенные документы из documentsRequired
|
||||
const uploadedCount = documentsRequired.filter((doc: any) => {
|
||||
const docId = doc.id || doc.name;
|
||||
|
||||
@@ -362,6 +362,16 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
border-color:#10b981;
|
||||
background-color:#f0fdf4;
|
||||
}
|
||||
/* ❌ Красная рамка для невалидных полей */
|
||||
.inline-field.invalid{
|
||||
border-color:#ef4444 !important;
|
||||
background-color:#fef2f2 !important;
|
||||
}
|
||||
.inline-field.invalid:focus{
|
||||
border-color:#ef4444 !important;
|
||||
box-shadow:0 0 0 2px rgba(239,68,68,0.1) !important;
|
||||
background-color:#fef2f2 !important;
|
||||
}
|
||||
.inline-field.large{
|
||||
min-width:200px;max-width:500px;
|
||||
}
|
||||
@@ -718,6 +728,11 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Телефон: только цифры, максимум 11 символов
|
||||
if (key === 'mobile' || key === 'phone') {
|
||||
extra = ' inputmode="numeric" pattern="[0-9]{10,11}" maxlength="11" autocomplete="tel"';
|
||||
}
|
||||
|
||||
var fieldHtml = '<input class="inline-field bind" data-root="' + esc(root) + '" data-key="' + esc(key) + '"' + dataIndex +
|
||||
' id="' + id + '" value="' + esc(value || '') + '" placeholder="' + esc(placeholder || '') + '"' + extra + ' />';
|
||||
return fieldHtml;
|
||||
@@ -940,10 +955,6 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
html += createField('offender', 'inn', offender.inn, 'ИНН организации (10 или 12 цифр)', i);
|
||||
html += '</p>';
|
||||
|
||||
html += '<p><strong>ОГРН:</strong> ';
|
||||
html += createField('offender', 'ogrn', offender.ogrn, 'ОГРН', i);
|
||||
html += '</p>';
|
||||
|
||||
html += '<p><strong>Адрес:</strong> ';
|
||||
html += createField('offender', 'address', offender.address, 'Адрес', i);
|
||||
html += '</p>';
|
||||
@@ -1172,15 +1183,43 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Функция для обновления стиля заполненных полей
|
||||
// ✅ Функция для обновления стиля заполненных полей с валидацией
|
||||
function updateFieldStyle(field) {
|
||||
var value = field.type === 'checkbox' ? field.checked : (field.value || '').trim();
|
||||
var hasValue = field.type === 'checkbox' ? value : value.length > 0;
|
||||
var key = field.getAttribute('data-key');
|
||||
var root = field.getAttribute('data-root');
|
||||
|
||||
// Убираем оба класса сначала
|
||||
field.classList.remove('filled');
|
||||
field.classList.remove('invalid');
|
||||
|
||||
if (hasValue) {
|
||||
field.classList.add('filled');
|
||||
} else {
|
||||
field.classList.remove('filled');
|
||||
// Проверяем валидность для телефона и email
|
||||
var isValid = true;
|
||||
|
||||
if (key === 'mobile' || key === 'phone') {
|
||||
// Валидация телефона: только цифры, 10-11 символов
|
||||
var cleanPhone = value.replace(/\D/g, '');
|
||||
isValid = cleanPhone.length >= 10 && cleanPhone.length <= 11;
|
||||
} else if (key === 'email') {
|
||||
// Валидация email: должен содержать @ и .
|
||||
isValid = value.includes('@') && value.includes('.') && value.length > 5;
|
||||
} else if (key === 'inn') {
|
||||
// Валидация ИНН: 10 или 12 цифр
|
||||
var cleanInn = value.replace(/\D/g, '');
|
||||
if (root === 'user') {
|
||||
isValid = cleanInn.length === 12;
|
||||
} else {
|
||||
isValid = cleanInn.length === 10 || cleanInn.length === 12;
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid) {
|
||||
field.classList.add('filled');
|
||||
} else {
|
||||
field.classList.add('invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1190,14 +1229,78 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
var fields = document.querySelectorAll('.bind');
|
||||
console.log('Found fields:', fields.length);
|
||||
|
||||
// ✅ Устанавливаем начальный стиль для всех полей
|
||||
// ✅ Устанавливаем начальный стиль для всех полей и форматируем телефоны
|
||||
Array.prototype.forEach.call(fields, function(field) {
|
||||
var key = field.getAttribute('data-key');
|
||||
// Автоформатирование телефона при загрузке: убираем + и нецифровые символы
|
||||
if ((key === 'mobile' || key === 'phone') && field.value) {
|
||||
field.value = field.value.replace(/\D/g, '');
|
||||
}
|
||||
updateFieldStyle(field);
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(fields, function(field) {
|
||||
var fieldKey = field.getAttribute('data-key');
|
||||
|
||||
// ✅ Блокируем ввод нецифровых символов для телефона
|
||||
if (fieldKey === 'mobile' || fieldKey === 'phone') {
|
||||
field.addEventListener('keypress', function(e) {
|
||||
// Разрешаем: цифры, Backspace, Delete, Tab, стрелки
|
||||
if (!/[0-9]/.test(e.key) && !['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Блокируем вставку нецифровых символов
|
||||
field.addEventListener('paste', function(e) {
|
||||
e.preventDefault();
|
||||
var pastedText = (e.clipboardData || window.clipboardData).getData('text');
|
||||
var cleanText = pastedText.replace(/\D/g, '').slice(0, 11);
|
||||
document.execCommand('insertText', false, cleanText);
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Блокируем ввод кириллицы для email (только латиница, цифры и @._-)
|
||||
if (fieldKey === 'email') {
|
||||
field.addEventListener('keypress', function(e) {
|
||||
// Разрешаем: латиница, цифры, @, ., _, -, служебные клавиши
|
||||
if (!/[a-zA-Z0-9@._\-]/.test(e.key) && !['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Блокируем вставку кириллицы
|
||||
field.addEventListener('paste', function(e) {
|
||||
e.preventDefault();
|
||||
var pastedText = (e.clipboardData || window.clipboardData).getData('text');
|
||||
var cleanText = pastedText.replace(/[^a-zA-Z0-9@._\-]/g, ''); // Только латиница и допустимые символы
|
||||
document.execCommand('insertText', false, cleanText);
|
||||
});
|
||||
|
||||
// Автоочистка при вводе
|
||||
field.addEventListener('input', function() {
|
||||
var cursorPos = this.selectionStart;
|
||||
var oldLen = this.value.length;
|
||||
this.value = this.value.replace(/[^a-zA-Z0-9@._\-]/g, '');
|
||||
var newLen = this.value.length;
|
||||
this.setSelectionRange(Math.min(cursorPos, newLen), Math.min(cursorPos, newLen));
|
||||
});
|
||||
}
|
||||
|
||||
// Обработка ввода
|
||||
field.addEventListener('input', function() {
|
||||
var key = this.getAttribute('data-key');
|
||||
|
||||
// ✅ Автоформатирование телефона: убираем + и нецифровые символы (на всякий случай)
|
||||
if (key === 'mobile' || key === 'phone') {
|
||||
var cursorPos = this.selectionStart;
|
||||
var oldLen = this.value.length;
|
||||
this.value = this.value.replace(/\D/g, '').slice(0, 11); // Оставляем только цифры, макс 11
|
||||
var newLen = this.value.length;
|
||||
// Корректируем позицию курсора
|
||||
this.setSelectionRange(Math.min(cursorPos, newLen), Math.min(cursorPos, newLen));
|
||||
}
|
||||
|
||||
// ✅ Обновляем стиль при изменении
|
||||
updateFieldStyle(this);
|
||||
// Автозамена запятой на точку для денежных полей
|
||||
@@ -1206,7 +1309,6 @@ export function generateConfirmationFormHTML(data: any): string {
|
||||
}
|
||||
|
||||
var root = this.getAttribute('data-root');
|
||||
var key = this.getAttribute('data-key');
|
||||
var value = this.type === 'checkbox' ? this.checked : this.value;
|
||||
|
||||
// Для полей дат конвертируем YYYY-MM-DD в DD.MM.YYYY для сохранения
|
||||
|
||||
@@ -723,37 +723,73 @@ export default function ClaimForm() {
|
||||
// ✅ НОВОЕ: Если есть form_draft — используем его!
|
||||
if (hasFormDraft && formDraft) {
|
||||
console.log('✅ Используем form_draft из БД:', formDraft);
|
||||
console.log('✅ project.description:', formDraft.project?.description);
|
||||
console.log('✅ offenders:', formDraft.offenders);
|
||||
console.log('✅ documentsMeta:', documentsMeta);
|
||||
console.log('✅ documentsMeta[0]?.field_label:', documentsMeta[0]?.field_label);
|
||||
|
||||
// Преобразуем form_draft в формат propertyName
|
||||
const user = formDraft.user || {};
|
||||
const project = formDraft.project || {};
|
||||
|
||||
// Преобразуем form_draft в формат propertyName (с правильными именами полей!)
|
||||
claimPlanData = {
|
||||
propertyName: {
|
||||
applicant: formDraft.user || {},
|
||||
applicant: {
|
||||
// Маппинг полей user → applicant
|
||||
first_name: user.firstname || '',
|
||||
middle_name: user.secondname || '',
|
||||
last_name: user.lastname || '',
|
||||
phone: user.mobile || '',
|
||||
email: user.email || '',
|
||||
birth_date: user.birthday || '',
|
||||
birth_place: user.birthplace || '',
|
||||
address: user.mailingstreet || '',
|
||||
inn: user.inn || '',
|
||||
},
|
||||
case: {
|
||||
category: formDraft.project?.category || '',
|
||||
description: formDraft.project?.description || problemDescription || '',
|
||||
category: project.category || '',
|
||||
direction: project.direction || '',
|
||||
},
|
||||
contract_or_service: {
|
||||
subject: formDraft.project?.subject || '',
|
||||
agrprice: formDraft.project?.agrprice || '',
|
||||
agrdate: formDraft.project?.agrdate || '',
|
||||
startdate: formDraft.project?.startdate || '',
|
||||
finishdate: formDraft.project?.finishdate || '',
|
||||
country: formDraft.project?.country || '',
|
||||
hotel: formDraft.project?.hotel || '',
|
||||
subject: project.subject || '',
|
||||
amount_paid: project.agrprice || '',
|
||||
agreement_date: project.agrdate || '',
|
||||
period_start: project.startdate || '',
|
||||
period_end: project.finishdate || '',
|
||||
country: project.country || '',
|
||||
hotel: project.hotel || '',
|
||||
},
|
||||
offenders: (formDraft.offenders || []).map((o: any) => ({
|
||||
name: o.accountname || '', // ✅ Форма ожидает 'name', а не 'accountname'
|
||||
accountname: o.accountname || '', // Дублируем для совместимости
|
||||
address: o.address || '',
|
||||
email: o.email || '',
|
||||
website: o.website || '',
|
||||
phone: o.phone || '',
|
||||
inn: o.inn || '',
|
||||
ogrn: o.ogrn || '',
|
||||
role: o.role || '',
|
||||
})),
|
||||
claim: {
|
||||
description: project.description || problemDescription || '', // ✅ Описание проблемы
|
||||
reason: project.category || '', // ✅ Причина обращения
|
||||
},
|
||||
offenders: formDraft.offenders || [],
|
||||
claim: {},
|
||||
meta: {
|
||||
claim_id: finalClaimId,
|
||||
unified_id: formData.unified_id || '',
|
||||
session_token: actualSessionId,
|
||||
},
|
||||
attachments_names: documentsMeta.map((d: any) => d.original_file_name || d.file_name || ''),
|
||||
// ✅ Используем field_label (человекочитаемые названия) вместо имён файлов
|
||||
attachments_names: documentsMeta.map((d: any) => d.field_label || d.original_file_name || d.file_name || 'Документ'),
|
||||
},
|
||||
session_token: actualSessionId,
|
||||
claim_id: finalClaimId,
|
||||
prefix: 'clpr_',
|
||||
};
|
||||
|
||||
console.log('✅ claimPlanData сформирован:', claimPlanData);
|
||||
console.log('✅ claimPlanData.propertyName.claim.description:', claimPlanData.propertyName.claim.description);
|
||||
console.log('✅ claimPlanData.propertyName.offenders:', claimPlanData.propertyName.offenders);
|
||||
} else {
|
||||
// Старый способ: преобразуем данные из БД
|
||||
claimPlanData = transformDraftToClaimPlanFormat({
|
||||
@@ -1028,8 +1064,8 @@ export default function ClaimForm() {
|
||||
|
||||
// Шаг 1: Phone (телефон + SMS верификация)
|
||||
stepsArray.push({
|
||||
title: 'Телефон',
|
||||
description: 'Подтверждение по SMS',
|
||||
title: 'Вход',
|
||||
description: 'Подтверждение телефона',
|
||||
content: (
|
||||
<Step1Phone
|
||||
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
|
||||
@@ -1112,8 +1148,8 @@ export default function ClaimForm() {
|
||||
|
||||
// Шаг 2: свободное описание
|
||||
stepsArray.push({
|
||||
title: 'Описание',
|
||||
description: 'Что случилось?',
|
||||
title: 'Обращение',
|
||||
description: 'Опишите ситуацию',
|
||||
content: (
|
||||
<StepDescription
|
||||
formData={formData}
|
||||
@@ -1126,13 +1162,18 @@ export default function ClaimForm() {
|
||||
|
||||
// Шаг 3: AI Рекомендации
|
||||
stepsArray.push({
|
||||
title: 'Рекомендации',
|
||||
description: 'AI ассистент',
|
||||
title: 'Документы',
|
||||
description: 'Загрузка файлов',
|
||||
content: (
|
||||
<StepWizardPlan
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onPrev={prevStep}
|
||||
onPrev={() => {
|
||||
// Возвращаемся к списку заявок
|
||||
setShowDraftSelection(true);
|
||||
setSelectedDraftId(null);
|
||||
setCurrentStep(0);
|
||||
}}
|
||||
onNext={nextStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
@@ -1154,37 +1195,42 @@ export default function ClaimForm() {
|
||||
});
|
||||
}
|
||||
|
||||
// Шаг 3: Policy (всегда)
|
||||
stepsArray.push({
|
||||
title: 'Проверка полиса',
|
||||
description: 'Полис ERV',
|
||||
content: (
|
||||
<Step1Policy
|
||||
formData={{ ...formData, session_id: sessionIdRef.current }} // ✅ claim_id уже в formData от n8n
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
// Шаги для СТАРОГО флоу (страхование ERV) — НЕ показываем для нового флоу защиты прав
|
||||
const isNewClaimFlow = formData.documents_required && formData.documents_required.length > 0;
|
||||
|
||||
if (!isNewClaimFlow) {
|
||||
// Шаг 3: Policy (только для старого флоу)
|
||||
stepsArray.push({
|
||||
title: 'Проверка полиса',
|
||||
description: 'Полис ERV',
|
||||
content: (
|
||||
<Step1Policy
|
||||
formData={{ ...formData, session_id: sessionIdRef.current }}
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Шаг 4: Event Type Selection (всегда)
|
||||
stepsArray.push({
|
||||
title: 'Тип события',
|
||||
description: 'Выбор случая',
|
||||
content: (
|
||||
<Step2EventType
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
onPrev={prevStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
// Шаг 4: Event Type Selection (только для старого флоу)
|
||||
stepsArray.push({
|
||||
title: 'Тип события',
|
||||
description: 'Выбор случая',
|
||||
content: (
|
||||
<Step2EventType
|
||||
formData={formData}
|
||||
updateFormData={updateFormData}
|
||||
onNext={nextStep}
|
||||
onPrev={prevStep}
|
||||
addDebugEvent={addDebugEvent}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Шаги 3+: Document Upload (динамически, если выбран eventType)
|
||||
if (formData.eventType && documentConfigs.length > 0) {
|
||||
// Шаги Document Upload (только для старого флоу — если выбран eventType)
|
||||
if (!isNewClaimFlow && formData.eventType && documentConfigs.length > 0) {
|
||||
documentConfigs.forEach((docConfig, index) => {
|
||||
stepsArray.push({
|
||||
title: `Документ ${index + 1}`,
|
||||
@@ -1208,8 +1254,8 @@ export default function ClaimForm() {
|
||||
|
||||
// Последний шаг: Payment (всегда)
|
||||
stepsArray.push({
|
||||
title: 'Оплата',
|
||||
description: 'Контакты и выплата',
|
||||
title: 'Заявление',
|
||||
description: 'Подтверждение',
|
||||
content: (
|
||||
<Step3Payment
|
||||
formData={formData} // ✅ claim_id уже в formData
|
||||
@@ -1281,7 +1327,7 @@ export default function ClaimForm() {
|
||||
{/* Левая часть - Форма */}
|
||||
<Col xs={24} lg={14}>
|
||||
<Card
|
||||
title="Подать заявку на выплату"
|
||||
title="Подать обращение о защите прав потребителя"
|
||||
className="claim-form-card"
|
||||
extra={
|
||||
!isSubmitted && (
|
||||
|
||||
Reference in New Issue
Block a user