From c39b12630e218996a460fa63641e651444af1294 Mon Sep 17 00:00:00 2001 From: Fedor Date: Fri, 27 Feb 2026 18:31:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C:=20?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=86=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D1=8C,=20?= =?UTF-8?q?=D0=98=D0=9D=D0=9D=2012=20=D1=86=D0=B8=D1=84=D1=80,=20email,=20?= =?UTF-8?q?DaData=20=D0=B0=D0=B4=D1=80=D0=B5=D1=81=D0=B0,=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=BD=D0=BA=D0=B8=20=D0=B8=D0=B7=20BANK=5FIP,=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=D1=81=D0=BA=D0=B0=D0=B7=D0=BA=D0=B0=20=D0=98=D0=9D=D0=9D?= =?UTF-8?q?=20(=D0=A4=D0=9D=D0=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: N8N_AUTH_WEBHOOK из env (fallback), банки из BANK_IP, эндпоинт /api/v1/profile/dadata/address для подсказок адресов (FORMA_DADATA_*). - Config: bank_ip, bank_api_url, forma_dadata_api_key, forma_dadata_secret. - Frontend Profile: DatePicker для даты рождения, ИНН 12 цифр + ссылка на ФНС, валидация email, чекбокс «Совпадает с адресом регистрации», AutoComplete адресов через DaData, Select банков из /api/v1/banks/nspk (bankId/bankName). Подробности в CHANGELOG_PROFILE_VALIDATION.md. --- CHANGELOG_PROFILE_VALIDATION.md | 28 ++++ backend/app/api/auth_universal.py | 41 ++++- backend/app/api/banks.py | 7 +- backend/app/api/profile.py | 71 ++++---- backend/app/config.py | 27 +++- frontend/src/pages/Profile.tsx | 261 ++++++++++++++++++++++++++++-- 6 files changed, 379 insertions(+), 56 deletions(-) create mode 100644 CHANGELOG_PROFILE_VALIDATION.md diff --git a/CHANGELOG_PROFILE_VALIDATION.md b/CHANGELOG_PROFILE_VALIDATION.md new file mode 100644 index 0000000..1c54989 --- /dev/null +++ b/CHANGELOG_PROFILE_VALIDATION.md @@ -0,0 +1,28 @@ +# Изменения: форма профиля, валидация, DaData, банки + +## Backend + +### auth_universal.py +- Чтение N8N_AUTH_WEBHOOK: fallback на `os.environ.get("N8N_AUTH_WEBHOOK")`, если в config нет поля `n8n_auth_webhook` (чтобы webhook auth_miniapp вызывался при отсутствии config.py на хосте). + +### banks.py +- URL списка банков берётся из .env: `BANK_IP` (в config — `bank_ip`), fallback на `bank_api_url` и запасной URL. Прокси запроса к внешнему API для мини-аппа. + +### profile.py +- Новый эндпоинт `GET /api/v1/profile/dadata/address?query=...&count=10` — подсказки адресов через DaData API (ключи FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env). Ответ: `{ "suggestions": [ { "value", "unrestricted_value" } ] }`. + +### config.py +- Добавлены поля: `bank_ip` (BANK_IP), `bank_api_url`; `forma_dadata_api_key`, `forma_dadata_secret` (FORMA_DADATA_*). + +## Frontend (Profile.tsx) + +- **Дата рождения:** календарь (DatePicker), формат DD.MM.YYYY, нельзя выбрать будущую дату. +- **ИНН:** строго 12 цифр, валидация и ввод только цифр; подсказка «Узнать свой ИНН вы можете здесь» со ссылкой на сервис ФНС (service.nalog.ru). +- **Email:** валидация формата (type: email). +- **Адрес регистрации / Почтовый адрес:** чекбокс «Совпадает с адресом регистрации» — при включении почтовый подставляется и блокируется; оба поля — AutoComplete с подсказками из DaData (запрос к /api/v1/profile/dadata/address). +- **Банк для возмещения:** выпадающий список (Select) с поиском, данные с /api/v1/banks/nspk (API из BANK_IP); учтён формат ответа с полями bankId, bankName (camelCase). + +## .env + +- BANK_IP — URL API списка банков (например http://212.193.27.93/api/payouts/dictionaries/nspk-banks). +- FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET — ключи DaData для подсказок адресов. diff --git a/backend/app/api/auth_universal.py b/backend/app/api/auth_universal.py index edc20be..fc25004 100644 --- a/backend/app/api/auth_universal.py +++ b/backend/app/api/auth_universal.py @@ -7,7 +7,7 @@ import logging import os import uuid -from typing import Optional, Any, Dict +from typing import Optional, Any, Dict, Union import httpx from fastapi import APIRouter, HTTPException @@ -37,6 +37,27 @@ class AuthUniversalResponse(BaseModel): phone: Optional[str] = None contact_id: Optional[str] = None has_drafts: Optional[bool] = None + need_profile_confirm: Optional[bool] = None + profile_needs_attention: Optional[bool] = None + + +def _to_bool(v: Any) -> Optional[bool]: + if v is None: + return None + if isinstance(v, bool): + return v + if isinstance(v, (int, float)): + if v == 1: + return True + if v == 0: + return False + if isinstance(v, str): + s = v.strip().lower() + if s in ("1", "true", "yes", "y", "да"): + return True + if s in ("0", "false", "no", "n", "нет", ""): + return False + return None @router.post("", response_model=AuthUniversalResponse) @@ -152,6 +173,20 @@ async def auth_universal(request: AuthUniversalRequest): logger.info("[AUTH] data: success=%s, need_contact=%s, unified_id=%s", data.get("success"), data.get("need_contact"), data.get("unified_id")) + # Флаг «профиль требует внимания»: приходит из n8n, прокидываем в сессию и на фронт + need_profile_confirm = _to_bool( + data.get("need_profile_confirm") + if "need_profile_confirm" in data + else data.get("needProfileConfirm") + ) + profile_needs_attention = _to_bool( + data.get("profile_needs_attention") + if "profile_needs_attention" in data + else data.get("profileNeedsAttention") + ) + if profile_needs_attention is None: + profile_needs_attention = need_profile_confirm + # 3) need_contact — только если n8n явно вернул need_contact (закрыть приложение и попросить контакт в чате) need_contact = ( data.get("need_contact") is True @@ -198,6 +233,8 @@ async def auth_universal(request: AuthUniversalRequest): "contact_id": _contact_id, "has_drafts": data.get("has_drafts", False) or (data.get("result") or {}).get("has_drafts", False) if isinstance(data.get("result"), dict) else False, "chat_id": channel_user_id, + "need_profile_confirm": need_profile_confirm, + "profile_needs_attention": profile_needs_attention, } logger.info("[AUTH] session_data: unified_id=%s, phone=%s", unified_id, session_data.get("phone")) try: @@ -222,4 +259,6 @@ async def auth_universal(request: AuthUniversalRequest): phone=session_data.get("phone"), contact_id=session_data.get("contact_id"), has_drafts=session_data.get("has_drafts", False), + need_profile_confirm=need_profile_confirm, + profile_needs_attention=profile_needs_attention, ) diff --git a/backend/app/api/banks.py b/backend/app/api/banks.py index 705e1c8..49b74bd 100644 --- a/backend/app/api/banks.py +++ b/backend/app/api/banks.py @@ -14,13 +14,10 @@ router = APIRouter(prefix="/api/v1/banks", tags=["Banks"]) @router.get("/nspk") async def get_nspk_banks(): """ - Получить список банков СБП из внешнего API - Проксирует запрос для избежания Mixed Content ошибок (HTTPS -> HTTP) + Получить список банков из внешнего API (BANK_IP в .env или nspk_banks_api_url). """ + external_api_url = (getattr(settings, "bank_ip", None) or getattr(settings, "bank_api_url", None) or "").strip() or "http://212.193.27.93/api/payouts/dictionaries/nspk-banks" try: - # URL внешнего API - external_api_url = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks" - async with httpx.AsyncClient(timeout=10.0) as client: response = await client.get(external_api_url) diff --git a/backend/app/api/profile.py b/backend/app/api/profile.py index 1f9d07f..97a16d2 100644 --- a/backend/app/api/profile.py +++ b/backend/app/api/profile.py @@ -2,36 +2,7 @@ Профиль пользователя: контактные данные из CRM через n8n webhook. GET/POST /api/v1/profile/contact — возвращает массив контактных данных по unified_id. -unified_id берётся из сессии по session_token или передаётся явно. - ------ Что уходит на N8N_CONTACT_WEBHOOK (POST body) ----- - - unified_id (str): идентификатор пользователя в CRM - - entry_channel (str): "telegram" | "max" | "web" - - chat_id (str, опционально): Telegram user id или Max user id - - session_token, contact_id, phone (опционально) - ------ Как n8n должен возвращать ответ ----- - -1) Ничего не нашло (контакт не найден в CRM или нет данных): - - HTTP 200 - - Тело: пустой массив [] ИЛИ объект {"items": []} - Пример: [] или {"items": []} - -2) Нашло контакт(ы): - - HTTP 200 - - Тело: массив контактов ИЛИ объект с полем items/contact/data: - • [] → нормализуется в {"items": []} - • {"items": [...]} → как есть - • {"contact": {...}} → один контакт в items - • {"contact": [...]} → массив в items - • {"data": [...]} → массив в items - • один объект {...} → один элемент в items - - Поля контакта (snake_case или camelCase, фронт смотрит оба): - last_name/lastName, first_name/firstName, middle_name/middleName, - birth_date/birthDate, birth_place/birthPlace, inn, email, - registration_address/address/mailingstreet, mailing_address/postal_address, - bank_for_compensation/bank, phone/mobile/mobile_phone. +GET /api/v1/profile/dadata/address — подсказки адресов через DaData (FORMA_DADATA_* в .env). """ import logging @@ -128,6 +99,46 @@ class ProfileContactUpdateRequest(BaseModel): phone: Optional[str] = Field(None, description="Телефон (read-only на фронте, передаётся в n8n)") +DADATA_SUGGEST_URL = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + + +@router.get("/dadata/address") +async def get_dadata_address_suggestions( + query: str = Query(..., min_length=1, description="Строка поиска адреса"), + count: int = Query(10, ge=1, le=20), +): + """ + Подсказки адресов через DaData (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET в .env). + Возвращает список { value, unrestricted_value } для подстановки в форму профиля. + """ + api_key = (getattr(settings, "forma_dadata_api_key", None) or "").strip() + secret = (getattr(settings, "forma_dadata_secret", None) or "").strip() + if not api_key or not secret: + raise HTTPException(status_code=503, detail="DaData не настроен (FORMA_DADATA_API_KEY, FORMA_DADATA_SECRET)") + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post( + DADATA_SUGGEST_URL, + json={"query": query.strip(), "count": count}, + headers={ + "Authorization": f"Token {api_key}", + "X-Secret": secret, + "Content-Type": "application/json", + }, + ) + if response.status_code != 200: + logger.warning("DaData address suggest вернул %s: %s", response.status_code, response.text[:300]) + return {"suggestions": []} + data = response.json() + suggestions = data.get("suggestions") or [] + return {"suggestions": [{"value": s.get("value", ""), "unrestricted_value": s.get("unrestricted_value", "")} for s in suggestions]} + except httpx.TimeoutException: + return {"suggestions": []} + except Exception as e: + logger.exception("Ошибка DaData suggest: %s", e) + return {"suggestions": []} + + @router.get("/contact") async def get_profile_contact( session_token: Optional[str] = Query(None, description="Токен сессии"), diff --git a/backend/app/config.py b/backend/app/config.py index 9524c1a..28b070b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -2,6 +2,7 @@ Конфигурация приложения """ import os +import json from pathlib import Path from pydantic_settings import BaseSettings from typing import List, Optional @@ -138,9 +139,17 @@ class Settings(BaseSettings): aviationstack_base_url: str = "http://api.aviationstack.com/v1" # ============================================ - # NSPK BANKS API + # NSPK BANKS API (и альтернативный BANK_IP из .env) # ============================================ nspk_banks_api_url: str = "https://qr.nspk.ru/proxyapp/c2bmembers.json" + bank_ip: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks" + bank_api_url: str = "http://212.193.27.93/api/payouts/dictionaries/nspk-banks" + + # ============================================ + # DADATA (подсказки адресов в форме профиля) + # ============================================ + forma_dadata_api_key: str = "" # FORMA_DADATA_API_KEY + forma_dadata_secret: str = "" # FORMA_DADATA_SECRET # ============================================ # SMS SERVICE (SigmaSMS) @@ -221,11 +230,21 @@ class Settings(BaseSettings): # ============================================ # MAX (мессенджер) — Mini App auth # ============================================ - max_bot_token: str = "" # Токен бота MAX для проверки initData WebApp + max_bot_token: str = "" # Токен бота MAX (один бот) + max_bot_tokens: str = "" # Мультибот: JSON {"bot_id": "token", ...}. Если задан — используется вместо max_bot_token. def get_max_bot_tokens(self) -> List[tuple]: - """Список (bot_id, token) для проверки подписи MAX initData. Один токен — [('default', token)].""" - token = (self.max_bot_token or "").strip() + """Список (bot_id, token) для проверки подписи MAX initData. Из MAX_BOT_TOKENS (JSON) или [('default', MAX_BOT_TOKEN)].""" + s = (self.max_bot_tokens or os.environ.get("MAX_BOT_TOKENS") or "").strip() + if s: + try: + d = json.loads(s) + out = [(k, str(v).strip()) for k, v in d.items() if v and str(v).strip()] + if out: + return out + except Exception: + pass + token = (self.max_bot_token or os.environ.get("MAX_BOT_TOKEN") or "").strip() if token: return [("default", token)] return [] diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 5a19deb..ccb2531 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from 'react'; -import { Button, Card, Descriptions, Form, Input, Spin, Typography, message } from 'antd'; +import { useEffect, useState, useCallback } from 'react'; +import { Button, Card, Checkbox, Descriptions, Form, Input, Select, DatePicker, AutoComplete, Spin, Typography, message } from 'antd'; import { User } from 'lucide-react'; +import dayjs from 'dayjs'; import './Profile.css'; const { Title, Text } = Typography; +const DATE_FORMAT = 'DD.MM.YYYY'; /** Поля профиля из CRM (поддержка snake_case и camelCase). Все кроме phone редактируемые при verification="0". */ const PROFILE_FIELDS: Array<{ key: string; keys: string[]; label: string; editable: boolean }> = [ @@ -28,12 +30,30 @@ function getValue(obj: Record, keys: string[]): string { return ''; } +/** Парсим дату из строки (DD.MM.YYYY, YYYY-MM-DD и т.д.) в dayjs или null */ +function parseBirthDate(s: string): dayjs.Dayjs | null { + if (!s || typeof s !== 'string') return null; + const trimmed = s.trim(); + if (!trimmed) return null; + const d = dayjs(trimmed, [DATE_FORMAT, 'YYYY-MM-DD', 'DD.MM.YYYY'], true); + return d.isValid() ? d : null; +} + /** verification === "0" — профиль можно редактировать (ответ n8n). Иначе — только просмотр. */ function canEditProfile(contact: Record): boolean { const v = contact?.verification ?? contact?.Verification; return v === '0' || v === 0; } +interface BankOption { + id?: string; + name?: string; + bankid?: string; + bankname?: string; + value?: string; + label?: string; +} + interface ProfileProps { onNavigate?: (path: string) => void; } @@ -44,6 +64,61 @@ export default function Profile({ onNavigate }: ProfileProps) { const [error, setError] = useState(null); const [contact, setContact] = useState | null>(null); const [form] = Form.useForm(); + const [sameAsRegistration, setSameAsRegistration] = useState(false); + const [banks, setBanks] = useState([]); + const [banksLoading, setBanksLoading] = useState(false); + const [addressOptionsReg, setAddressOptionsReg] = useState<{ value: string }[]>([]); + const [addressOptionsMail, setAddressOptionsMail] = useState<{ value: string }[]>([]); + const [dadataLoadingReg, setDadataLoadingReg] = useState(false); + const [dadataLoadingMail, setDadataLoadingMail] = useState(false); + + const loadBanks = useCallback(async () => { + setBanksLoading(true); + try { + const res = await fetch('/api/v1/banks/nspk'); + if (!res.ok) { + setBanks([]); + return; + } + const data = await res.json().catch(() => []); + const list = Array.isArray(data) ? data : (data?.data || data?.items || []); + const normalized: BankOption[] = list.map((b: Record | string) => { + if (typeof b === 'string') return { value: b, label: b }; + const name = (b?.bankName ?? b?.bankname ?? b?.name ?? b?.title ?? b?.value ?? '').toString().trim(); + const id = (b?.bankId ?? b?.bankid ?? b?.id ?? b?.value ?? name).toString().trim(); + return { bankid: id, bankname: name, value: name, label: name }; + }).filter((b: BankOption) => b.value || b.label); + setBanks(normalized); + } catch { + setBanks([]); + } finally { + setBanksLoading(false); + } + }, []); + + const searchAddress = useCallback(async (query: string, setOptions: (o: { value: string }[]) => void, setLoading: (v: boolean) => void) => { + if (!query || query.length < 2) { + setOptions([]); + return; + } + setLoading(true); + try { + const res = await fetch(`/api/v1/profile/dadata/address?query=${encodeURIComponent(query)}&count=10`); + const data = await res.json().catch(() => ({})); + const suggestions = (data?.suggestions || []).map((s: { value?: string; unrestricted_value?: string }) => ({ + value: (s.unrestricted_value || s.value || '').trim(), + })).filter((s: { value: string }) => s.value); + setOptions(suggestions); + } catch { + setOptions([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (canEditProfile(contact || {})) loadBanks(); + }, [contact, loadBanks]); useEffect(() => { let cancelled = false; @@ -90,10 +165,18 @@ export default function Profile({ onNavigate }: ProfileProps) { : null; setContact(first); if (first && canEditProfile(first)) { - const initial: Record = {}; + const initial: Record = {}; PROFILE_FIELDS.forEach(({ key, keys }) => { - initial[key] = getValue(first, keys) || ''; + const raw = getValue(first, keys) || ''; + if (key === 'birth_date') { + initial[key] = parseBirthDate(raw) || (raw ? dayjs(raw) : null); + } else { + initial[key] = raw; + } }); + const regAddr = (initial.registration_address as string) || ''; + const mailAddr = (initial.mailing_address as string) || ''; + setSameAsRegistration(!!regAddr && regAddr === mailAddr); form.setFieldsValue(initial); } }) @@ -106,6 +189,22 @@ export default function Profile({ onNavigate }: ProfileProps) { return () => { cancelled = true; }; }, [onNavigate, form]); + const handleSameAsRegistrationChange = (e: { target: { checked: boolean } }) => { + const checked = e.target.checked; + setSameAsRegistration(checked); + if (checked) { + const reg = form.getFieldValue('registration_address') || ''; + form.setFieldsValue({ mailing_address: reg }); + } + }; + + const handleRegistrationAddressChange = () => { + if (sameAsRegistration) { + const reg = form.getFieldValue('registration_address') || ''; + form.setFieldsValue({ mailing_address: reg }); + } + }; + const handleSave = async () => { if (!contact || !canEditProfile(contact)) return; const token = sessionStorage.getItem('session_token') || localStorage.getItem('session_token'); @@ -124,6 +223,8 @@ export default function Profile({ onNavigate }: ProfileProps) { } catch { return; } + const birthDateVal = values.birth_date; + const birthDateStr = dayjs.isDayjs(birthDateVal) ? birthDateVal.format(DATE_FORMAT) : (birthDateVal && String(birthDateVal).trim()) || ''; setSaving(true); try { const res = await fetch('/api/v1/profile/contact/update', { @@ -135,7 +236,7 @@ export default function Profile({ onNavigate }: ProfileProps) { last_name: values.last_name ?? '', first_name: values.first_name ?? '', middle_name: values.middle_name ?? '', - birth_date: values.birth_date ?? '', + birth_date: birthDateStr, birth_place: values.birth_place ?? '', inn: values.inn ?? '', email: values.email ?? '', @@ -152,7 +253,7 @@ export default function Profile({ onNavigate }: ProfileProps) { return; } message.success('Профиль сохранён'); - setContact({ ...contact, ...values }); + setContact({ ...contact, ...values, birth_date: birthDateStr }); } catch (e) { message.error('Не удалось сохранить профиль, попробуйте позже'); } finally { @@ -209,16 +310,144 @@ export default function Profile({ onNavigate }: ProfileProps) { title={<> Профиль} >
- {PROFILE_FIELDS.map(({ key, keys, label, editable }) => ( - - - - ))} + {PROFILE_FIELDS.map(({ key, keys, label, editable }) => { + if (key === 'birth_date') { + return ( + + current && current > dayjs().endOf('day')} + /> + + ); + } + if (key === 'inn') { + return ( + + Узнать свой ИНН вы можете{' '} + + здесь + + {' '}(сервис ФНС России). + + } + > + { + const v = e.target.value.replace(/\D/g, '').slice(0, 12); + form.setFieldValue('inn', v); + }} + /> + + ); + } + if (key === 'email') { + return ( + + + + ); + } + if (key === 'registration_address') { + return ( + + searchAddress(q, setAddressOptionsReg, setDadataLoadingReg)} + onChange={handleRegistrationAddressChange} + notFoundContent={dadataLoadingReg ? 'Загрузка...' : null} + /> + + ); + } + if (key === 'mailing_address') { + return ( + <> + + + Совпадает с адресом регистрации + + + + searchAddress(q, setAddressOptionsMail, setDadataLoadingMail)} + disabled={sameAsRegistration} + notFoundContent={dadataLoadingMail ? 'Загрузка...' : null} + /> + + + ); + } + if (key === 'bank_for_compensation') { + return ( + + + + ); + })}