feat(forms): автоподстановка банков и улучшенная обработка телефона

- Step1Phone: добавлена обработка вставки телефона с автоматической очисткой от +7 и обрезкой до 10 цифр
- Step3Payment: заменён Select на AutoComplete для выбора банка с автоподстановкой
- generateConfirmationFormHTML: заменён select на input с datalist для автоподстановки банков в форме подтверждения
- Добавлены скрытые поля bank_id для сохранения ID банка отдельно от названия
- Добавлены файлы для проверки заявки 226564ce

Улучшения UX:
- Пользователь может вводить название банка вместо прокрутки длинного списка
- Автоматическая фильтрация списка банков при вводе
- Предупреждение при обрезке номера телефона при вставке
This commit is contained in:
Fedor
2025-12-02 17:12:25 +03:00
parent ee1c4af5c3
commit b7197e0da5
13 changed files with 476 additions and 58 deletions

View File

@@ -1 +1 @@
2025-12-01 14:55:09
2025-12-02 15:00:08

View File

@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Проверка заявки 226564ce-d7cf-48ee-a820-690e8f5ec8e5
"""
import asyncio
import asyncpg
import json
from datetime import datetime
POSTGRES_HOST = "147.45.189.234"
POSTGRES_PORT = 5432
POSTGRES_DB = "default_db"
POSTGRES_USER = "gen_user"
POSTGRES_PASSWORD = "2~~9_^kVsU?2\\S"
CLAIM_ID = "226564ce-d7cf-48ee-a820-690e8f5ec8e5"
async def check_claim():
conn = await asyncpg.connect(
host=POSTGRES_HOST,
port=POSTGRES_PORT,
database=POSTGRES_DB,
user=POSTGRES_USER,
password=POSTGRES_PASSWORD
)
try:
# 1. Проверяем заявку
claim_row = await conn.fetchrow("""
SELECT
id,
status_code,
created_at,
updated_at,
expires_at,
payload
FROM clpr_claims
WHERE id::text = $1 OR payload->>'claim_id' = $1
ORDER BY updated_at DESC
LIMIT 1
""", CLAIM_ID)
if not claim_row:
print(f"❌ Заявка {CLAIM_ID} не найдена!")
return
print(f"✅ Заявка найдена:")
print(f" ID: {claim_row['id']}")
print(f" Status: {claim_row['status_code']}")
print(f" Created: {claim_row['created_at']}")
print(f" Updated: {claim_row['updated_at']}")
print(f" Expires: {claim_row['expires_at']}")
payload = claim_row['payload'] if isinstance(claim_row['payload'], dict) else json.loads(claim_row['payload'])
# 2. Проверяем documents_meta
documents_meta = payload.get('documents_meta', [])
print(f"\n📋 documents_meta: {len(documents_meta)} записей")
if documents_meta:
# Проверяем на дубликаты
field_names = [doc.get('field_name') for doc in documents_meta]
duplicates = [name for name in field_names if field_names.count(name) > 1]
if duplicates:
print(f" ⚠️ Найдены дубликаты field_name: {set(duplicates)}")
for i, doc in enumerate(documents_meta):
print(f"\n {i+1}. field_name: {doc.get('field_name', 'N/A')}")
print(f" field_label: {doc.get('field_label', 'N/A')}")
print(f" file_id: {doc.get('file_id', 'N/A')}")
print(f" file_name: {doc.get('file_name', 'N/A')}")
print(f" uploaded_at: {doc.get('uploaded_at', 'N/A')}")
else:
print(" ⚠️ documents_meta пуст!")
# 3. Проверяем документы в таблице
claim_uuid = claim_row['id']
docs_rows = await conn.fetch("""
SELECT
id,
claim_id,
field_name,
file_id,
file_name,
original_file_name,
uploaded_at,
file_hash
FROM clpr_claim_documents
WHERE claim_id = $1
ORDER BY uploaded_at DESC
""", str(claim_uuid))
print(f"\n📄 Документы в clpr_claim_documents: {len(docs_rows)} записей")
for i, row in enumerate(docs_rows):
print(f"\n {i+1}. field_name: {row['field_name']}")
print(f" file_id: {row['file_id']}")
print(f" file_name: {row['file_name']}")
print(f" file_hash: {row['file_hash'] or 'NULL'}")
print(f" uploaded_at: {row['uploaded_at']}")
# 4. Сравниваем documents_meta и clpr_claim_documents
print(f"\n🔍 Сравнение:")
meta_field_names = set(doc.get('field_name') for doc in documents_meta)
table_field_names = set(row['field_name'] for row in docs_rows)
only_in_meta = meta_field_names - table_field_names
only_in_table = table_field_names - meta_field_names
if only_in_meta:
print(f" ⚠️ Только в documents_meta: {only_in_meta}")
if only_in_table:
print(f" ⚠️ Только в clpr_claim_documents: {only_in_table}")
if not only_in_meta and not only_in_table:
print(f"Все field_name совпадают")
# 5. Проверяем поле upload_description
upload_description = payload.get('upload_description')
print(f"\n📝 upload_description: {upload_description}")
# 6. Проверяем answers
answers = payload.get('answers', {})
print(f"\n💬 answers: {len(answers)} полей")
if answers:
for key, value in list(answers.items())[:5]: # Первые 5
print(f" {key}: {str(value)[:50]}...")
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(check_claim())

View File

@@ -0,0 +1,94 @@
# Статус заявки 226564ce-d7cf-48ee-a820-690e8f5ec8e5
## ✅ Общая информация
- **ID**: `226564ce-d7cf-48ee-a820-690e8f5ec8e5`
- **Status**: `draft_docs_complete`
- **Unified ID**: `usr_b1fbffa0-477b-4abb-95d6-8d6f849ddc71`
- **Session Token**: `sess_c278abf8-1603-484d-af98-8b93843e5253`
- **Phone**: `71234543212`
- **Channel**: `web_form`
- **Is Confirmed**: `false` (должна отображаться в списке)
- **Created**: `2025-12-01 14:38:11`
- **Updated**: `2025-12-01 20:06:18`
- **Expires**: `2025-12-15 19:35:30`
## ✅ Документы
### documents_meta (2 записи)
1. **uploads[1][0]**
- `field_label`: "Чек или подтверждение оплаты" ✅ (правильно, не "group-2")
- `file_id`: `/f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c/crm2/CRM_Active_Files/Documents/Project/ERV_3212_КлиентПрав_399543/e34f2f9e-e48d-47f4-9c2d-6957012c0800__chek-ili-podtverzhdenie-oplaty.pdf`
- `file_name`: `e34f2f9e-e48d-47f4-9c2d-6957012c0800__chek-ili-podtverzhdenie-oplaty.pdf`
- `uploaded_at`: `2025-12-01T14:15:54.122Z`
2. **uploads[0][0]**
- `field_label`: "Договор или заказ" ✅ (правильно)
- `file_id`: `/f9825c87-4e3558f6-f9b6-405c-ad3d-d1535c49b61c/crm2/CRM_Active_Files/Documents/Project/ERV_3212_КлиентПрав_399543/344deab2-1a3a-46ce-931b-5a29bb2c40a3__dogovor-ili-zakaz.pdf`
- `file_name`: `344deab2-1a3a-46ce-931b-5a29bb2c40a3__dogovor-ili-zakaz.pdf`
- `uploaded_at`: `2025-12-01T13:47:15.772Z`
### clpr_claim_documents (2 записи)
1. **uploads[1][0]**
- `id`: `e34f2f9e-e48d-47f4-9c2d-6957012c0800`
- `file_hash`: `3e1f1332a76b7f26df1628c49579f30a873de9170f3b8007b0bac5e4a439ca67`
2. **uploads[0][0]**
- `id`: `344deab2-1a3a-46ce-931b-5a29bb2c40a3`
- `file_hash`: `83822e59662aa2037977dc5a8661d8a057ae6572e6f99936a31c6cdd7d66f1d9`
## ✅ Проверки
-**Дубликатов нет** — все `field_name` уникальны
-**field_label правильные** — не "group-2", а реальные названия
-**Синхронизация**`documents_meta` и `clpr_claim_documents` совпадают
-**file_hash заполнен**оба документа имеют хеш
-**Заявка должна отображаться**`is_confirmed = false`, `status_code != 'approved'`
## 📋 Payload структура
Заявка содержит следующие ключи в `payload`:
- `body`
- `email`
- `phone`
- `tg_id`
- `answers`
- `claim_id`
- `applicant`
- `contact_id`
- `form_draft`
- `ai_analysis`
- `claim_ready`
- `wizard_plan`
- `wizard_ready`
- `ai_agent13_rag`
- `documents_meta`
- `ai_agent1_facts`
- `answers_prefill`
- `current_doc_index`
- `documents_skipped`
- `documents_required`
- `documents_uploaded`
- `problem_description`
## 🔍 Возможные проблемы с отображением
Если заявка не отображается или отображается неправильно, проверьте:
1. **API endpoint `/drafts/list`** — должен находить заявку по `unified_id`, `phone` или `session_token`
2. **Фронтенд фильтрация** — возможно, фильтруется по `status_code`
3. **Отображение `field_label`** — должно использовать `documents_meta[].field_label`, а не вычислять из `field_name`
## ✅ Вывод
**Заявка в порядке!** Все данные корректны:
- ✅ Нет дубликатов в `documents_meta`
-`field_label` правильные
- ✅ Документы синхронизированы
-`file_hash` заполнен
- ✅ Заявка должна отображаться в списке
Если есть проблемы с отображением, они скорее всего на стороне фронтенда или API фильтрации.

View File

@@ -278,6 +278,25 @@ export default function Step1Phone({
maxLength={10}
size="large"
style={{ flex: 1 }}
onPaste={(e) => {
// Обработка вставки: очищаем от +7, пробелов и других символов
e.preventDefault();
const pastedText = (e.clipboardData || (window as any).clipboardData).getData('text');
// Убираем все нецифровые символы
let cleanText = pastedText.replace(/\D/g, '');
// Если начинается с 7 или 8, убираем первую цифру (код страны)
if (cleanText.length === 11 && (cleanText.startsWith('7') || cleanText.startsWith('8'))) {
cleanText = cleanText.substring(1);
}
// Оставляем только первые 10 цифр
cleanText = cleanText.substring(0, 10);
// Устанавливаем очищенное значение
form.setFieldValue('phone', cleanText);
// Показываем предупреждение, если номер был обрезан
if (pastedText.replace(/\D/g, '').length > 10) {
message.warning('Номер автоматически обрезан до 10 цифр');
}
}}
/>
</Space.Compact>
</Form.Item>

View File

@@ -1,12 +1,10 @@
import { useState, useEffect } from 'react';
import { Form, Input, Button, Select, message, Space, Divider } from 'antd';
import { Form, Input, Button, AutoComplete, message, Space, Divider } from 'antd';
import { PhoneOutlined, SafetyOutlined, QrcodeOutlined, MailOutlined, CopyOutlined } from '@ant-design/icons';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8200';
const NSPK_BANKS_API = 'http://212.193.27.93/api/payouts/dictionaries/nspk-banks';
const { Option } = Select;
interface Bank {
bankid: string;
bankname: string;
@@ -61,15 +59,34 @@ export default function Step3Payment({
setBanks(banksData);
addDebugEvent?.('banks', 'success', `✅ Загружено ${banksData.length} банков`, { count: banksData.length });
// Если есть сохранённый bankName, но нет bankId - пытаемся найти по названию
if (formData.bankName && !formData.bankId) {
// Если есть сохранённый bankName или bankId - восстанавливаем значения
if (formData.bankName) {
const foundBank = banksData.find(b =>
b.bankname.toLowerCase() === formData.bankName.toLowerCase() ||
b.bankname.toLowerCase().includes(formData.bankName.toLowerCase())
);
if (foundBank) {
updateFormData({ bankId: foundBank.bankid });
form.setFieldsValue({ bankId: foundBank.bankid });
updateFormData({
bankId: foundBank.bankid,
bankName: foundBank.bankname
});
form.setFieldsValue({
bankId: foundBank.bankid,
bankName: foundBank.bankname
});
}
} else if (formData.bankId) {
// Если есть только bankId, находим по ID
const foundBank = banksData.find(b => b.bankid === formData.bankId);
if (foundBank) {
updateFormData({
bankId: foundBank.bankid,
bankName: foundBank.bankname
});
form.setFieldsValue({
bankId: foundBank.bankid,
bankName: foundBank.bankname
});
}
}
} catch (error: any) {
@@ -189,12 +206,15 @@ export default function Step3Payment({
}
};
// Инициализация формы с bankId если есть
// Инициализация формы с bankId и bankName если есть
useEffect(() => {
if (formData.bankId) {
form.setFieldsValue({ bankId: formData.bankId });
if (formData.bankId || formData.bankName) {
form.setFieldsValue({
bankId: formData.bankId,
bankName: formData.bankName
});
}
}, [formData.bankId, form]);
}, [formData.bankId, formData.bankName, form]);
return (
<Form
@@ -202,7 +222,8 @@ export default function Step3Payment({
layout="vertical"
initialValues={{
...formData,
bankId: formData.bankId || formData.bankName, // Fallback на bankName для совместимости
bankId: formData.bankId,
bankName: formData.bankName,
}}
style={{ marginTop: 24 }}
>
@@ -377,40 +398,78 @@ export default function Step3Payment({
</div>
</Form.Item>
{/* Скрытое поле для bankId */}
<Form.Item name="bankId" hidden>
<Input />
</Form.Item>
<Form.Item
label="Выберите ваш банк"
name="bankId"
rules={[{ required: true, message: 'Выберите банк для получения выплаты' }]}
>
<Select
placeholder={banksLoading ? "Загрузка списка банков..." : "Выберите банк"}
size="large"
showSearch
loading={banksLoading}
notFoundContent={banksLoading ? "Загрузка..." : "Банк не найден"}
filterOption={(input: string, option: any) => {
const label = option?.label || option?.children;
if (typeof label === 'string') {
return label.toLowerCase().includes(input.toLowerCase());
label="Банк для получения выплаты"
name="bankName"
rules={[
{ required: true, message: 'Выберите банк для получения выплаты' },
{
validator: (_, value) => {
if (!value) {
return Promise.resolve();
}
const foundBank = banks.find(b =>
b.bankname.toLowerCase() === value.toLowerCase()
);
if (!foundBank) {
return Promise.reject(new Error('Выберите банк из списка'));
}
return Promise.resolve();
}
return false;
}
]}
>
<AutoComplete
placeholder={banksLoading ? "Загрузка списка банков..." : "Начните вводить название банка"}
size="large"
loading={banksLoading}
notFoundContent={banksLoading ? "Загрузка..." : "Банк не найден. Попробуйте ввести другое название"}
options={banks.map((bank) => ({
value: bank.bankname,
label: bank.bankname,
}))}
filterOption={(inputValue, option) => {
if (!option?.label) return false;
return option.label.toLowerCase().includes(inputValue.toLowerCase());
}}
onChange={(value) => {
const selectedBank = banks.find(b => b.bankid === value);
onSelect={(value) => {
// При выборе из списка находим банк и сохраняем оба поля
const selectedBank = banks.find(b => b.bankname === value);
if (selectedBank) {
updateFormData({
bankId: selectedBank.bankid,
bankName: selectedBank.bankname
});
// Устанавливаем bankId в скрытое поле
form.setFieldsValue({ bankId: selectedBank.bankid });
}
}}
>
{banks.map((bank) => (
<Option key={bank.bankid} value={bank.bankid} label={bank.bankname}>
{bank.bankname}
</Option>
))}
</Select>
onChange={(value) => {
// При вводе текста ищем точное совпадение по названию
if (typeof value === 'string') {
const foundBank = banks.find(b =>
b.bankname.toLowerCase() === value.toLowerCase()
);
if (foundBank) {
updateFormData({
bankId: foundBank.bankid,
bankName: foundBank.bankname
});
form.setFieldsValue({ bankId: foundBank.bankid });
} else if (value === '') {
// Если поле очищено, очищаем и bankId
updateFormData({ bankId: undefined, bankName: undefined });
form.setFieldsValue({ bankId: undefined });
}
}
}}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item>

View File

@@ -775,10 +775,16 @@ export function generateConfirmationFormHTML(data: any): string {
function createBankSelect(root, key, value) {
var id = 'field_' + root + '_' + key + '_' + Math.random().toString(36).slice(2);
var selectHtml = '<select class="inline-field bind bank-select" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '">';
selectHtml += '<option value="">Загрузка списка банков...</option>';
selectHtml += '</select>';
return selectHtml;
var datalistId = 'bank-datalist-' + id;
// Создаём input с datalist для автоподстановки
var inputHtml = '<input type="text" class="inline-field bind bank-select" data-root="' + esc(root) + '" data-key="' + esc(key) + '" id="' + id + '" list="' + datalistId + '" placeholder="Начните вводить название банка" autocomplete="off" />';
inputHtml += '<datalist id="' + datalistId + '" class="bank-datalist">';
inputHtml += '<option value="">Загрузка списка банков...</option>';
inputHtml += '</datalist>';
// Скрытое поле для bank_id
var hiddenId = id + '_id';
inputHtml += '<input type="hidden" class="bank-id-field" data-root="' + esc(root) + '" data-key="bank_id" id="' + hiddenId + '" />';
return inputHtml;
}
function createCheckbox(root, key, checked, labelText, required) {
@@ -1240,6 +1246,19 @@ export function generateConfirmationFormHTML(data: any): string {
var fields = document.querySelectorAll('.bind');
console.log('Found fields:', fields.length);
// Обработка скрытых полей bank_id
var bankIdFields = document.querySelectorAll('.bank-id-field');
Array.prototype.forEach.call(bankIdFields, function(field) {
field.addEventListener('change', function() {
var root = this.getAttribute('data-root');
var value = this.value;
if (root === 'user') {
state.user = state.user || {};
state.user.bank_id = value;
}
});
});
// ✅ Устанавливаем начальный стиль для всех полей и форматируем телефоны
Array.prototype.forEach.call(fields, function(field) {
var key = field.getAttribute('data-key');
@@ -1335,6 +1354,12 @@ export function generateConfirmationFormHTML(data: any): string {
// Обновляем состояние
if (root === 'user') {
state.user = state.user || {};
// Для bank_id не сохраняем название банка, только ID из скрытого поля
if (key === 'bank_id' && this.classList.contains('bank-select')) {
// Это текстовое поле для названия банка - не сохраняем в state
// bank_id будет сохранён из скрытого поля
return;
}
state.user[key] = value;
// Обновляем телефон в СБП
@@ -1437,8 +1462,8 @@ export function generateConfirmationFormHTML(data: any): string {
// Загрузка списка банков СБП
function loadBanks() {
var bankSelects = document.querySelectorAll('.bank-select');
if (bankSelects.length === 0) {
var bankInputs = document.querySelectorAll('.bank-select');
if (bankInputs.length === 0) {
console.log('Bank select fields not found');
return;
}
@@ -1458,32 +1483,109 @@ export function generateConfirmationFormHTML(data: any): string {
return a.bankname.localeCompare(b.bankname, 'ru');
});
// Заполняем все bank-select элементы
Array.prototype.forEach.call(bankSelects, function(select) {
var currentValue = select.getAttribute('data-selected') || state.user?.bank_id || '';
select.innerHTML = '<option value="">Выберите банк</option>';
// Сохраняем список банков глобально для поиска
window.__banksList = banks;
// Заполняем все datalist элементы
Array.prototype.forEach.call(bankInputs, function(input) {
var datalistId = input.getAttribute('list');
var datalist = document.getElementById(datalistId);
var hiddenId = input.id + '_id';
var hiddenField = document.getElementById(hiddenId);
var currentBankId = state.user?.bank_id || '';
var currentBankName = '';
if (!datalist) {
console.error('Datalist not found for input:', input.id);
return;
}
// Очищаем datalist
datalist.innerHTML = '';
// Заполняем datalist опциями
banks.forEach(function(bank) {
var option = document.createElement('option');
option.value = bank.bankid;
option.textContent = bank.bankname;
if (bank.bankid === currentValue) {
option.selected = true;
option.value = bank.bankname;
option.setAttribute('data-bank-id', bank.bankid);
datalist.appendChild(option);
// Если это текущий банк, устанавливаем значение
if (bank.bankid === currentBankId) {
currentBankName = bank.bankname;
}
select.appendChild(option);
});
// Если выбран банк, обновляем стиль
if (currentValue && select.value) {
select.classList.add('filled');
updateFieldStyle(select);
// Устанавливаем текущее значение если есть
if (currentBankName) {
input.value = currentBankName;
if (hiddenField) {
hiddenField.value = currentBankId;
}
input.classList.add('filled');
updateFieldStyle(input);
}
// Обработчик изменения для поиска банка по названию
input.addEventListener('input', function() {
var inputValue = this.value.trim();
var foundBank = null;
// Ищем точное совпадение
if (inputValue) {
foundBank = banks.find(function(b) {
return b.bankname.toLowerCase() === inputValue.toLowerCase();
});
}
if (foundBank) {
// Найден банк - сохраняем ID
if (hiddenField) {
hiddenField.value = foundBank.bankid;
}
state.user = state.user || {};
state.user.bank_id = foundBank.bankid;
this.classList.add('filled');
} else {
// Банк не найден - очищаем ID
if (hiddenField) {
hiddenField.value = '';
}
state.user = state.user || {};
state.user.bank_id = '';
this.classList.remove('filled');
}
updateFieldStyle(this);
updateSubmitButton();
});
// Обработчик выбора из списка
input.addEventListener('change', function() {
var inputValue = this.value.trim();
var foundBank = banks.find(function(b) {
return b.bankname.toLowerCase() === inputValue.toLowerCase();
});
if (foundBank) {
if (hiddenField) {
hiddenField.value = foundBank.bankid;
}
state.user = state.user || {};
state.user.bank_id = foundBank.bankid;
this.classList.add('filled');
updateFieldStyle(this);
}
});
});
})
.catch(function(error) {
console.error('Error loading banks:', error);
Array.prototype.forEach.call(bankSelects, function(select) {
select.innerHTML = '<option value="">Ошибка загрузки банков. Обновите страницу.</option>';
Array.prototype.forEach.call(bankInputs, function(input) {
var datalistId = input.getAttribute('list');
var datalist = document.getElementById(datalistId);
if (datalist) {
datalist.innerHTML = '<option value="">Ошибка загрузки банков. Обновите страницу.</option>';
}
});
});
}
@@ -1552,6 +1654,17 @@ export function generateConfirmationFormHTML(data: any): string {
return;
}
// Собираем bank_id из скрытых полей перед отправкой
var bankIdFields = document.querySelectorAll('.bank-id-field');
Array.prototype.forEach.call(bankIdFields, function(field) {
var root = field.getAttribute('data-root');
var bankId = field.value;
if (root === 'user' && bankId) {
state.user = state.user || {};
state.user.bank_id = bankId;
}
});
window.parent.postMessage({
type: 'claim_confirmed',
data: {