From da82100b60c1c4477544e568c1a8be06b2e0d629 Mon Sep 17 00:00:00 2001 From: Fedor Date: Mon, 1 Dec 2025 22:18:21 +0300 Subject: [PATCH] feat: UI/UX improvements + CRM integration methods + documents_meta deduplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- include/Webservices/UpsertAccounts.php | 225 +++++++++++++ include/Webservices/UpsertContact.php | 235 ++++++++++++++ include/Webservices/UpsertProject.php | 297 ++++++++++++++++++ ticket_form/backend/app/api/claims.py | 56 +++- ticket_form/backend/app/api/documents.py | 31 +- ticket_form/backend/app/api/events.py | 71 ++++- ticket_form/frontend/index.html | 2 +- ticket_form/frontend/public/index.html | 2 +- .../components/form/StepDraftSelection.tsx | 190 +++++++---- .../src/components/form/StepWizardPlan.tsx | 293 ++++++++++++++++- .../form/generateConfirmationFormHTML.ts | 122 ++++++- ticket_form/frontend/src/pages/ClaimForm.tsx | 152 +++++---- 12 files changed, 1531 insertions(+), 145 deletions(-) create mode 100644 include/Webservices/UpsertAccounts.php create mode 100644 include/Webservices/UpsertContact.php create mode 100644 include/Webservices/UpsertProject.php diff --git a/include/Webservices/UpsertAccounts.php b/include/Webservices/UpsertAccounts.php new file mode 100644 index 00000000..7488e156 --- /dev/null +++ b/include/Webservices/UpsertAccounts.php @@ -0,0 +1,225 @@ + 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); +} \ No newline at end of file diff --git a/include/Webservices/UpsertContact.php b/include/Webservices/UpsertContact.php new file mode 100644 index 00000000..621fe34d --- /dev/null +++ b/include/Webservices/UpsertContact.php @@ -0,0 +1,235 @@ + 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); +} \ No newline at end of file diff --git a/include/Webservices/UpsertProject.php b/include/Webservices/UpsertProject.php new file mode 100644 index 00000000..111e4897 --- /dev/null +++ b/include/Webservices/UpsertProject.php @@ -0,0 +1,297 @@ + 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); +} diff --git a/ticket_form/backend/app/api/claims.py b/ticket_form/backend/app/api/claims.py index 4bbc25e0..9d2d3735 100644 --- a/ticket_form/backend/app/api/claims.py +++ b/ticket_form/backend/app/api/claims.py @@ -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 { diff --git a/ticket_form/backend/app/api/documents.py b/ticket_form/backend/app/api/documents.py index 0723247f..6a35796a 100644 --- a/ticket_form/backend/app/api/documents.py +++ b/ticket_form/backend/app/api/documents.py @@ -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 = { diff --git a/ticket_form/backend/app/api/events.py b/ticket_form/backend/app/api/events.py index 88240a43..7fe452c9 100644 --- a/ticket_form/backend/app/api/events.py +++ b/ticket_form/backend/app/api/events.py @@ -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}") diff --git a/ticket_form/frontend/index.html b/ticket_form/frontend/index.html index 05d12287..9ba69bc4 100644 --- a/ticket_form/frontend/index.html +++ b/ticket_form/frontend/index.html @@ -4,7 +4,7 @@ - ERV Insurance Platform + Clientright — защита прав потребителей
diff --git a/ticket_form/frontend/public/index.html b/ticket_form/frontend/public/index.html index 05d12287..9ba69bc4 100644 --- a/ticket_form/frontend/public/index.html +++ b/ticket_form/frontend/public/index.html @@ -4,7 +4,7 @@ - ERV Insurance Platform + Clientright — защита прав потребителей
diff --git a/ticket_form/frontend/src/components/form/StepDraftSelection.tsx b/ticket_form/frontend/src/components/form/StepDraftSelection.tsx index bfbeaacc..c4275269 100644 --- a/ticket_form/frontend/src/components/form/StepDraftSelection.tsx +++ b/ticket_form/frontend/src/components/form/StepDraftSelection.tsx @@ -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%' }} > - + Создать новую заявку + Создать новую заявку {loading ? ( @@ -346,32 +355,14 @@ export default function StepDraftSelection({ handleDelete(draft.claim_id || draft.id)} - okText="Да, удалить" - cancelText="Отмена" - > - - , - ]} > {config.label} + {draft.category && ( + {draft.category} + )} } description={ - {/* Описание проблемы */} + {/* Заголовок - краткое описание проблемы */} + {draft.problem_title && ( + + {draft.problem_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 } - + )} {/* Время обновления */} @@ -437,41 +446,106 @@ export default function StepDraftSelection({ /> )} - {/* Прогресс документов */} - {docsProgress && ( -
- - 📎 Документы: {docsProgress.uploaded} из {docsProgress.total} загружено - {docsProgress.skipped > 0 && ` (${docsProgress.skipped} пропущено)`} - + {/* Список документов со статусами */} + {draft.documents_list && draft.documents_list.length > 0 && ( +
+
+ + 📄 Документы + + + {draft.documents_uploaded || 0} / {draft.documents_total || 0} + +
+
+ {draft.documents_list.map((doc, idx) => ( +
+ {doc.uploaded ? ( + + ) : ( + + )} + + {doc.name} + {doc.required && !doc.uploaded && *} + +
+ ))} +
+
+ )} + + {/* Прогрессбар (если нет списка) */} + {(!draft.documents_list || draft.documents_list.length === 0) && docsProgress && docsProgress.total > 0 && ( +
)} - {/* Старые теги прогресса (для обратной совместимости) */} - {!docsProgress && !draft.is_legacy && ( - - - {draft.problem_description ? '✓ Описание' : 'Описание'} - - - {draft.wizard_plan ? '✓ План' : 'План'} - - - {draft.has_documents ? '✓ Документы' : 'Документы'} - - - )} - {/* Описание статуса */} {config.description} + + {/* Кнопки действий */} +
+ {getActionButton(draft)} + handleDelete(draft.claim_id || draft.id)} + okText="Да, удалить" + cancelText="Отмена" + > + + +
} /> diff --git a/ticket_form/frontend/src/components/form/StepWizardPlan.tsx b/ticket_form/frontend/src/components/form/StepWizardPlan.tsx index 75d64ac3..b673111a 100644 --- a/ticket_form/frontend/src/components/form/StepWizardPlan.tsx +++ b/ticket_form/frontend/src/components/form/StepWizardPlan.tsx @@ -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(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({ {/* Кнопки */} - + , + , + ]} + /> +
+ )} + {/* ✅ НОВЫЙ ФЛОУ: Все документы загружены */} - {hasNewFlowDocs && allDocsProcessed && (() => { + {hasNewFlowDocs && allDocsProcessed && !isFormingClaim && !ragError && (() => { // Правильно считаем загруженные и пропущенные документы из documentsRequired const uploadedCount = documentsRequired.filter((doc: any) => { const docId = doc.id || doc.name; diff --git a/ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts b/ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts index 07e55f19..76c5e62e 100644 --- a/ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts +++ b/ticket_form/frontend/src/components/form/generateConfirmationFormHTML.ts @@ -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 = ''; return fieldHtml; @@ -940,10 +955,6 @@ export function generateConfirmationFormHTML(data: any): string { html += createField('offender', 'inn', offender.inn, 'ИНН организации (10 или 12 цифр)', i); html += '

'; - html += '

ОГРН: '; - html += createField('offender', 'ogrn', offender.ogrn, 'ОГРН', i); - html += '

'; - html += '

Адрес: '; html += createField('offender', 'address', offender.address, 'Адрес', i); html += '

'; @@ -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 для сохранения diff --git a/ticket_form/frontend/src/pages/ClaimForm.tsx b/ticket_form/frontend/src/pages/ClaimForm.tsx index 2369fab3..380425a9 100644 --- a/ticket_form/frontend/src/pages/ClaimForm.tsx +++ b/ticket_form/frontend/src/pages/ClaimForm.tsx @@ -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: ( { + // Возвращаемся к списку заявок + 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: ( - - ), - }); + // Шаги для СТАРОГО флоу (страхование ERV) — НЕ показываем для нового флоу защиты прав + const isNewClaimFlow = formData.documents_required && formData.documents_required.length > 0; + + if (!isNewClaimFlow) { + // Шаг 3: Policy (только для старого флоу) + stepsArray.push({ + title: 'Проверка полиса', + description: 'Полис ERV', + content: ( + + ), + }); - // Шаг 4: Event Type Selection (всегда) - stepsArray.push({ - title: 'Тип события', - description: 'Выбор случая', - content: ( - - ), - }); + // Шаг 4: Event Type Selection (только для старого флоу) + stepsArray.push({ + title: 'Тип события', + description: 'Выбор случая', + content: ( + + ), + }); + } - // Шаги 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: (