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:
|
else:
|
||||||
payload = {}
|
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({
|
drafts.append({
|
||||||
"id": str(row['id']),
|
"id": str(row['id']),
|
||||||
"claim_id": row.get('claim_id'),
|
"claim_id": row.get('claim_id'),
|
||||||
"session_token": row.get('session_token'),
|
"session_token": row.get('session_token'),
|
||||||
"status_code": row.get('status_code'),
|
"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,
|
"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,
|
"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_plan": payload.get('wizard_plan') is not None,
|
||||||
"wizard_answers": payload.get('answers') 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 {
|
return {
|
||||||
|
|||||||
@@ -22,6 +22,22 @@ logger = logging.getLogger(__name__)
|
|||||||
N8N_DOCUMENT_UPLOAD_WEBHOOK = "https://n8n.clientright.pro/webhook/webform_document_upload"
|
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")
|
@router.post("/upload")
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -67,10 +83,7 @@ async def upload_document(
|
|||||||
file_size = len(file_content)
|
file_size = len(file_content)
|
||||||
|
|
||||||
# Получаем IP клиента
|
# Получаем IP клиента
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = get_client_ip(request)
|
||||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
||||||
if forwarded_for:
|
|
||||||
client_ip = forwarded_for
|
|
||||||
|
|
||||||
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
||||||
form_data = {
|
form_data = {
|
||||||
@@ -223,10 +236,7 @@ async def upload_multiple_documents(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Получаем IP клиента
|
# Получаем IP клиента
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = get_client_ip(request)
|
||||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
||||||
if forwarded_for:
|
|
||||||
client_ip = forwarded_for
|
|
||||||
|
|
||||||
# Генерируем ID для каждого файла и читаем контент
|
# Генерируем ID для каждого файла и читаем контент
|
||||||
file_ids = []
|
file_ids = []
|
||||||
@@ -426,10 +436,7 @@ async def skip_document(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Получаем IP клиента
|
# Получаем IP клиента
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = get_client_ip(request)
|
||||||
forwarded_for = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
||||||
if forwarded_for:
|
|
||||||
client_ip = forwarded_for
|
|
||||||
|
|
||||||
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
# Формируем данные в формате совместимом с существующим n8n воркфлоу
|
||||||
form_data = {
|
form_data = {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import logging
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(prefix="/api/v1", tags=["Events"])
|
||||||
|
|
||||||
|
|
||||||
class EventPublish(BaseModel):
|
class EventPublish(BaseModel):
|
||||||
@@ -215,11 +215,71 @@ async def stream_events(task_id: str):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error loading wizard data from PostgreSQL: {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_type_sent = actual_event.get('event_type', 'unknown')
|
||||||
event_status = actual_event.get('status', '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"
|
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']:
|
if event_type_sent in ['claim_ready', 'claim_plan_ready']:
|
||||||
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
logger.info(f"✅ Final event {event_type_sent} sent, closing SSE")
|
||||||
break
|
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:
|
else:
|
||||||
logger.info(f"⏰ Timeout waiting for message on {channel}")
|
logger.info(f"⏰ Timeout waiting for message on {channel}")
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ERV Insurance Platform</title>
|
<title>Clientright — защита прав потребителей</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>ERV Insurance Platform</title>
|
<title>Clientright — защита прав потребителей</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ const getRelativeTime = (dateStr: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface DocumentStatus {
|
||||||
|
name: string;
|
||||||
|
required: boolean;
|
||||||
|
uploaded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
id: string;
|
id: string;
|
||||||
claim_id: string;
|
claim_id: string;
|
||||||
@@ -74,7 +80,9 @@ interface Draft {
|
|||||||
channel: string;
|
channel: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
problem_title?: string; // Краткое описание (заголовок)
|
||||||
problem_description?: string;
|
problem_description?: string;
|
||||||
|
category?: string; // Категория проблемы
|
||||||
wizard_plan: boolean;
|
wizard_plan: boolean;
|
||||||
wizard_answers: boolean;
|
wizard_answers: boolean;
|
||||||
has_documents: boolean;
|
has_documents: boolean;
|
||||||
@@ -82,6 +90,7 @@ interface Draft {
|
|||||||
documents_total?: number;
|
documents_total?: number;
|
||||||
documents_uploaded?: number;
|
documents_uploaded?: number;
|
||||||
documents_skipped?: number;
|
documents_skipped?: number;
|
||||||
|
documents_list?: DocumentStatus[]; // Список документов со статусами
|
||||||
wizard_ready?: boolean;
|
wizard_ready?: boolean;
|
||||||
claim_ready?: boolean;
|
claim_ready?: boolean;
|
||||||
is_legacy?: boolean; // Старый формат без documents_required
|
is_legacy?: boolean; // Старый формат без documents_required
|
||||||
@@ -322,7 +331,7 @@ export default function StepDraftSelection({
|
|||||||
size="large"
|
size="large"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
>
|
>
|
||||||
+ Создать новую заявку
|
Создать новую заявку
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -346,32 +355,14 @@ export default function StepDraftSelection({
|
|||||||
<List.Item
|
<List.Item
|
||||||
style={{
|
style={{
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
border: `1px solid ${draft.is_legacy ? '#faad14' : '#d9d9d9'}`,
|
border: `1px solid ${draft.is_legacy ? '#faad14' : '#e8e8e8'}`,
|
||||||
borderRadius: 8,
|
borderRadius: 12,
|
||||||
marginBottom: 12,
|
marginBottom: 16,
|
||||||
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
background: draft.is_legacy ? '#fffbe6' : '#fff',
|
||||||
overflow: 'hidden',
|
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
|
<List.Item.Meta
|
||||||
avatar={
|
avatar={
|
||||||
@@ -393,28 +384,46 @@ export default function StepDraftSelection({
|
|||||||
title={
|
title={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
|
<Tag color={config.color} style={{ margin: 0 }}>{config.label}</Tag>
|
||||||
|
{draft.category && (
|
||||||
|
<Tag color="purple" style={{ margin: 0 }}>{draft.category}</Tag>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
{/* Описание проблемы */}
|
{/* Заголовок - краткое описание проблемы */}
|
||||||
{draft.problem_description && (
|
{draft.problem_title && (
|
||||||
<Text
|
<Text strong style={{
|
||||||
style={{
|
fontSize: 15,
|
||||||
fontSize: 14,
|
color: '#1a1a1a',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
whiteSpace: 'nowrap',
|
marginBottom: 4,
|
||||||
overflow: 'hidden',
|
}}>
|
||||||
textOverflow: 'ellipsis',
|
{draft.problem_title}
|
||||||
maxWidth: '100%',
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Полное описание проблемы */}
|
||||||
|
{draft.problem_description && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
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}
|
title={draft.problem_description}
|
||||||
>
|
>
|
||||||
{draft.problem_description.length > 60
|
{draft.problem_description.length > 250
|
||||||
? draft.problem_description.substring(0, 60) + '...'
|
? draft.problem_description.substring(0, 250) + '...'
|
||||||
: draft.problem_description
|
: draft.problem_description
|
||||||
}
|
}
|
||||||
</Text>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Время обновления */}
|
{/* Время обновления */}
|
||||||
@@ -437,41 +446,106 @@ export default function StepDraftSelection({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Прогресс документов */}
|
{/* Список документов со статусами */}
|
||||||
{docsProgress && (
|
{draft.documents_list && draft.documents_list.length > 0 && (
|
||||||
<div>
|
<div style={{
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
marginTop: 8,
|
||||||
📎 Документы: {docsProgress.uploaded} из {docsProgress.total} загружено
|
background: '#fafafa',
|
||||||
{docsProgress.skipped > 0 && ` (${docsProgress.skipped} пропущено)`}
|
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>
|
||||||
|
<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
|
<Progress
|
||||||
percent={docsProgress.percent}
|
percent={docsProgress.percent}
|
||||||
size="small"
|
size="small"
|
||||||
showInfo={false}
|
showInfo={false}
|
||||||
strokeColor="#52c41a"
|
strokeColor={{
|
||||||
|
'0%': '#1890ff',
|
||||||
|
'100%': '#52c41a',
|
||||||
|
}}
|
||||||
|
trailColor="#f0f0f0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{config.description}
|
{config.description}
|
||||||
</Text>
|
</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>
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ export default function StepWizardPlan({
|
|||||||
new Set(formData.wizardSkippedDocuments || [])
|
new Set(formData.wizardSkippedDocuments || [])
|
||||||
);
|
);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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 }>({
|
const [progressState, setProgressState] = useState<{ done: number; total: number }>({
|
||||||
done: 0,
|
done: 0,
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -1896,8 +1898,242 @@ export default function StepWizardPlan({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message.loading('Формируем заявление...', 0);
|
// ✅ Показываем экран ожидания
|
||||||
|
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();
|
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 (
|
return (
|
||||||
@@ -2013,7 +2249,7 @@ export default function StepWizardPlan({
|
|||||||
|
|
||||||
{/* Кнопки */}
|
{/* Кнопки */}
|
||||||
<Space style={{ marginTop: 16 }}>
|
<Space style={{ marginTop: 16 }}>
|
||||||
<Button onClick={onPrev}>← Назад</Button>
|
<Button onClick={onPrev}>← К списку заявок</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={handleDocContinue}
|
onClick={handleDocContinue}
|
||||||
@@ -2059,8 +2295,57 @@ export default function StepWizardPlan({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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
|
// Правильно считаем загруженные и пропущенные документы из documentsRequired
|
||||||
const uploadedCount = documentsRequired.filter((doc: any) => {
|
const uploadedCount = documentsRequired.filter((doc: any) => {
|
||||||
const docId = doc.id || doc.name;
|
const docId = doc.id || doc.name;
|
||||||
|
|||||||
@@ -362,6 +362,16 @@ export function generateConfirmationFormHTML(data: any): string {
|
|||||||
border-color:#10b981;
|
border-color:#10b981;
|
||||||
background-color:#f0fdf4;
|
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{
|
.inline-field.large{
|
||||||
min-width:200px;max-width:500px;
|
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 +
|
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 + ' />';
|
' id="' + id + '" value="' + esc(value || '') + '" placeholder="' + esc(placeholder || '') + '"' + extra + ' />';
|
||||||
return fieldHtml;
|
return fieldHtml;
|
||||||
@@ -940,10 +955,6 @@ export function generateConfirmationFormHTML(data: any): string {
|
|||||||
html += createField('offender', 'inn', offender.inn, 'ИНН организации (10 или 12 цифр)', i);
|
html += createField('offender', 'inn', offender.inn, 'ИНН организации (10 или 12 цифр)', i);
|
||||||
html += '</p>';
|
html += '</p>';
|
||||||
|
|
||||||
html += '<p><strong>ОГРН:</strong> ';
|
|
||||||
html += createField('offender', 'ogrn', offender.ogrn, 'ОГРН', i);
|
|
||||||
html += '</p>';
|
|
||||||
|
|
||||||
html += '<p><strong>Адрес:</strong> ';
|
html += '<p><strong>Адрес:</strong> ';
|
||||||
html += createField('offender', 'address', offender.address, 'Адрес', i);
|
html += createField('offender', 'address', offender.address, 'Адрес', i);
|
||||||
html += '</p>';
|
html += '</p>';
|
||||||
@@ -1172,15 +1183,43 @@ export function generateConfirmationFormHTML(data: any): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Функция для обновления стиля заполненных полей
|
// ✅ Функция для обновления стиля заполненных полей с валидацией
|
||||||
function updateFieldStyle(field) {
|
function updateFieldStyle(field) {
|
||||||
var value = field.type === 'checkbox' ? field.checked : (field.value || '').trim();
|
var value = field.type === 'checkbox' ? field.checked : (field.value || '').trim();
|
||||||
var hasValue = field.type === 'checkbox' ? value : value.length > 0;
|
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) {
|
if (hasValue) {
|
||||||
|
// Проверяем валидность для телефона и 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');
|
field.classList.add('filled');
|
||||||
} else {
|
} else {
|
||||||
field.classList.remove('filled');
|
field.classList.add('invalid');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,14 +1229,78 @@ export function generateConfirmationFormHTML(data: any): string {
|
|||||||
var fields = document.querySelectorAll('.bind');
|
var fields = document.querySelectorAll('.bind');
|
||||||
console.log('Found fields:', fields.length);
|
console.log('Found fields:', fields.length);
|
||||||
|
|
||||||
// ✅ Устанавливаем начальный стиль для всех полей
|
// ✅ Устанавливаем начальный стиль для всех полей и форматируем телефоны
|
||||||
Array.prototype.forEach.call(fields, function(field) {
|
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);
|
updateFieldStyle(field);
|
||||||
});
|
});
|
||||||
|
|
||||||
Array.prototype.forEach.call(fields, function(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() {
|
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);
|
updateFieldStyle(this);
|
||||||
// Автозамена запятой на точку для денежных полей
|
// Автозамена запятой на точку для денежных полей
|
||||||
@@ -1206,7 +1309,6 @@ export function generateConfirmationFormHTML(data: any): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var root = this.getAttribute('data-root');
|
var root = this.getAttribute('data-root');
|
||||||
var key = this.getAttribute('data-key');
|
|
||||||
var value = this.type === 'checkbox' ? this.checked : this.value;
|
var value = this.type === 'checkbox' ? this.checked : this.value;
|
||||||
|
|
||||||
// Для полей дат конвертируем YYYY-MM-DD в DD.MM.YYYY для сохранения
|
// Для полей дат конвертируем YYYY-MM-DD в DD.MM.YYYY для сохранения
|
||||||
|
|||||||
@@ -723,37 +723,73 @@ export default function ClaimForm() {
|
|||||||
// ✅ НОВОЕ: Если есть form_draft — используем его!
|
// ✅ НОВОЕ: Если есть form_draft — используем его!
|
||||||
if (hasFormDraft && formDraft) {
|
if (hasFormDraft && formDraft) {
|
||||||
console.log('✅ Используем form_draft из БД:', 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 = {
|
claimPlanData = {
|
||||||
propertyName: {
|
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: {
|
case: {
|
||||||
category: formDraft.project?.category || '',
|
category: project.category || '',
|
||||||
description: formDraft.project?.description || problemDescription || '',
|
direction: project.direction || '',
|
||||||
},
|
},
|
||||||
contract_or_service: {
|
contract_or_service: {
|
||||||
subject: formDraft.project?.subject || '',
|
subject: project.subject || '',
|
||||||
agrprice: formDraft.project?.agrprice || '',
|
amount_paid: project.agrprice || '',
|
||||||
agrdate: formDraft.project?.agrdate || '',
|
agreement_date: project.agrdate || '',
|
||||||
startdate: formDraft.project?.startdate || '',
|
period_start: project.startdate || '',
|
||||||
finishdate: formDraft.project?.finishdate || '',
|
period_end: project.finishdate || '',
|
||||||
country: formDraft.project?.country || '',
|
country: project.country || '',
|
||||||
hotel: formDraft.project?.hotel || '',
|
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: {
|
meta: {
|
||||||
claim_id: finalClaimId,
|
claim_id: finalClaimId,
|
||||||
unified_id: formData.unified_id || '',
|
unified_id: formData.unified_id || '',
|
||||||
session_token: actualSessionId,
|
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,
|
session_token: actualSessionId,
|
||||||
claim_id: finalClaimId,
|
claim_id: finalClaimId,
|
||||||
prefix: 'clpr_',
|
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 {
|
} else {
|
||||||
// Старый способ: преобразуем данные из БД
|
// Старый способ: преобразуем данные из БД
|
||||||
claimPlanData = transformDraftToClaimPlanFormat({
|
claimPlanData = transformDraftToClaimPlanFormat({
|
||||||
@@ -1028,8 +1064,8 @@ export default function ClaimForm() {
|
|||||||
|
|
||||||
// Шаг 1: Phone (телефон + SMS верификация)
|
// Шаг 1: Phone (телефон + SMS верификация)
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: 'Телефон',
|
title: 'Вход',
|
||||||
description: 'Подтверждение по SMS',
|
description: 'Подтверждение телефона',
|
||||||
content: (
|
content: (
|
||||||
<Step1Phone
|
<Step1Phone
|
||||||
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
|
formData={{ ...formData, session_id: formData.session_id || sessionIdRef.current }} // ✅ Используем session_id из formData (от n8n) или временный
|
||||||
@@ -1112,8 +1148,8 @@ export default function ClaimForm() {
|
|||||||
|
|
||||||
// Шаг 2: свободное описание
|
// Шаг 2: свободное описание
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: 'Описание',
|
title: 'Обращение',
|
||||||
description: 'Что случилось?',
|
description: 'Опишите ситуацию',
|
||||||
content: (
|
content: (
|
||||||
<StepDescription
|
<StepDescription
|
||||||
formData={formData}
|
formData={formData}
|
||||||
@@ -1126,13 +1162,18 @@ export default function ClaimForm() {
|
|||||||
|
|
||||||
// Шаг 3: AI Рекомендации
|
// Шаг 3: AI Рекомендации
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: 'Рекомендации',
|
title: 'Документы',
|
||||||
description: 'AI ассистент',
|
description: 'Загрузка файлов',
|
||||||
content: (
|
content: (
|
||||||
<StepWizardPlan
|
<StepWizardPlan
|
||||||
formData={formData}
|
formData={formData}
|
||||||
updateFormData={updateFormData}
|
updateFormData={updateFormData}
|
||||||
onPrev={prevStep}
|
onPrev={() => {
|
||||||
|
// Возвращаемся к списку заявок
|
||||||
|
setShowDraftSelection(true);
|
||||||
|
setSelectedDraftId(null);
|
||||||
|
setCurrentStep(0);
|
||||||
|
}}
|
||||||
onNext={nextStep}
|
onNext={nextStep}
|
||||||
addDebugEvent={addDebugEvent}
|
addDebugEvent={addDebugEvent}
|
||||||
/>
|
/>
|
||||||
@@ -1154,13 +1195,17 @@ export default function ClaimForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шаг 3: Policy (всегда)
|
// Шаги для СТАРОГО флоу (страхование ERV) — НЕ показываем для нового флоу защиты прав
|
||||||
|
const isNewClaimFlow = formData.documents_required && formData.documents_required.length > 0;
|
||||||
|
|
||||||
|
if (!isNewClaimFlow) {
|
||||||
|
// Шаг 3: Policy (только для старого флоу)
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: 'Проверка полиса',
|
title: 'Проверка полиса',
|
||||||
description: 'Полис ERV',
|
description: 'Полис ERV',
|
||||||
content: (
|
content: (
|
||||||
<Step1Policy
|
<Step1Policy
|
||||||
formData={{ ...formData, session_id: sessionIdRef.current }} // ✅ claim_id уже в formData от n8n
|
formData={{ ...formData, session_id: sessionIdRef.current }}
|
||||||
updateFormData={updateFormData}
|
updateFormData={updateFormData}
|
||||||
onNext={nextStep}
|
onNext={nextStep}
|
||||||
addDebugEvent={addDebugEvent}
|
addDebugEvent={addDebugEvent}
|
||||||
@@ -1168,7 +1213,7 @@ export default function ClaimForm() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Шаг 4: Event Type Selection (всегда)
|
// Шаг 4: Event Type Selection (только для старого флоу)
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: 'Тип события',
|
title: 'Тип события',
|
||||||
description: 'Выбор случая',
|
description: 'Выбор случая',
|
||||||
@@ -1182,9 +1227,10 @@ export default function ClaimForm() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Шаги 3+: Document Upload (динамически, если выбран eventType)
|
// Шаги Document Upload (только для старого флоу — если выбран eventType)
|
||||||
if (formData.eventType && documentConfigs.length > 0) {
|
if (!isNewClaimFlow && formData.eventType && documentConfigs.length > 0) {
|
||||||
documentConfigs.forEach((docConfig, index) => {
|
documentConfigs.forEach((docConfig, index) => {
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: `Документ ${index + 1}`,
|
title: `Документ ${index + 1}`,
|
||||||
@@ -1208,8 +1254,8 @@ export default function ClaimForm() {
|
|||||||
|
|
||||||
// Последний шаг: Payment (всегда)
|
// Последний шаг: Payment (всегда)
|
||||||
stepsArray.push({
|
stepsArray.push({
|
||||||
title: 'Оплата',
|
title: 'Заявление',
|
||||||
description: 'Контакты и выплата',
|
description: 'Подтверждение',
|
||||||
content: (
|
content: (
|
||||||
<Step3Payment
|
<Step3Payment
|
||||||
formData={formData} // ✅ claim_id уже в formData
|
formData={formData} // ✅ claim_id уже в formData
|
||||||
@@ -1281,7 +1327,7 @@ export default function ClaimForm() {
|
|||||||
{/* Левая часть - Форма */}
|
{/* Левая часть - Форма */}
|
||||||
<Col xs={24} lg={14}>
|
<Col xs={24} lg={14}>
|
||||||
<Card
|
<Card
|
||||||
title="Подать заявку на выплату"
|
title="Подать обращение о защите прав потребителя"
|
||||||
className="claim-form-card"
|
className="claim-form-card"
|
||||||
extra={
|
extra={
|
||||||
!isSubmitted && (
|
!isSubmitted && (
|
||||||
|
|||||||
Reference in New Issue
Block a user