From 624d378bb502cd922ea84ecc00f4cd9bf2cb1f6b Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 1 May 2026 11:16:20 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20DaData=20=D0=98=D0=9D=D0=9D=20lookup=20?= =?UTF-8?q?for=20clients=20and=20companies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New API endpoint GET /api/lookup/party?inn=... — proxies to DaData API, returns parsed party (name, kpp, ogrn, address, signatory, status) - Env DADATA_API_KEY (optional) — without it endpoint returns 503/no_dadata_key - Web: InnLookupButton component shown next to ИНН field in Clients form and Company requisites; on click fetches DaData and fills all matching fields. Warns if status is not ACTIVE (liquidated, etc.) Free DaData tier: 10000 requests/day. Get key at https://dadata.ru/api/find-party/ — paste into DADATA_API_KEY in docker/.env on queoserver. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/.env.example | 5 ++ apps/api/src/env.ts | 4 + apps/api/src/modules/dadata/routes.ts | 115 ++++++++++++++++++++++++++ apps/api/src/server.ts | 2 + apps/web/src/api.ts | 15 ++++ apps/web/src/components/InnLookup.tsx | 62 ++++++++++++++ apps/web/src/pages/Clients.tsx | 25 +++++- apps/web/src/pages/CompanyEdit.tsx | 23 +++++- apps/web/src/styles.css | 4 + docker/docker-compose.yml | 1 + 10 files changed, 252 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/modules/dadata/routes.ts create mode 100644 apps/web/src/components/InnLookup.tsx diff --git a/apps/api/.env.example b/apps/api/.env.example index 2ba13dd..a388b72 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -30,6 +30,11 @@ TOCHKA_WEBHOOK_SECRET= # UUID единственной организации — сидится в M2. DEFAULT_ORGANIZATION_ID=00000000-0000-0000-0000-000000000001 +# DaData — заполнение карточки клиента/реквизитов компании по ИНН из ЕГРЮЛ. +# Получить «API-ключ для приложения» (не secret-key): https://dadata.ru/api/find-party/ +# Бесплатно до 10000 запросов/сутки. +DADATA_API_KEY= + # --- Dev-only --- # Если 1 — пропускает проверку JWT и подсовывает фейкового admin'а. # В production отказывается стартовать с этой переменной. diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 0c4e64a..58246dd 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -21,6 +21,10 @@ const EnvSchema = z.object({ TOCHKA_JWT_KEY: z.string().optional(), TOCHKA_WEBHOOK_SECRET: z.string().optional(), + // DaData — реестр ЕГРЮЛ/ЕГРИП. Бесплатно до 10k запросов/день. + // Получить: https://dadata.ru/api/find-party/ (нужен «API-ключ для приложения», не secret) + DADATA_API_KEY: z.string().optional(), + DEFAULT_ORGANIZATION_ID: z.string().uuid().default('00000000-0000-0000-0000-000000000001'), // Только для локальной разработки. В проде — НИКОГДА. Hard-check ниже. diff --git a/apps/api/src/modules/dadata/routes.ts b/apps/api/src/modules/dadata/routes.ts new file mode 100644 index 0000000..f877deb --- /dev/null +++ b/apps/api/src/modules/dadata/routes.ts @@ -0,0 +1,115 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { env } from '../../env.js'; + +const Query = z.object({ + inn: z.string().regex(/^\d{10}$|^\d{12}$/), +}); + +const DADATA_URL = 'https://suggestions.dadata.ru/suggestions/api/4_1/rs/findById/party'; + +// Что нам нужно из ответа DaData (поля для заполнения формы). +type LookupResult = { + inn: string; + kpp: string | null; + ogrn: string | null; + // Тип: ul | ip | fl + kind: 'ul' | 'ip' | 'fl'; + name: string; // ООО «...» + shortName: string | null; // если есть короткое + legalAddress: string | null; + // Контакты (если опубликованы — редкость для ЕГРЮЛ) + email: string | null; + phone: string | null; + // Подписант + signatoryName: string | null; + signatoryPosition: string | null; + status: string; // ACTIVE / LIQUIDATED / etc. +}; + +function pickResult(s: { data: Record }): LookupResult { + const d = s.data; + const get = (...path: string[]): T | null => { + let cur: unknown = d; + for (const k of path) { + if (cur && typeof cur === 'object' && k in (cur as Record)) { + cur = (cur as Record)[k]; + } else { + return null; + } + } + return (cur ?? null) as T | null; + }; + + const type = get('type'); // LEGAL | INDIVIDUAL + const kind: LookupResult['kind'] = type === 'INDIVIDUAL' ? 'ip' : 'ul'; + const fullName = get('name', 'full_with_opf') ?? get('name', 'full') ?? ''; + const shortName = get('name', 'short_with_opf') ?? get('name', 'short'); + + return { + inn: get('inn') ?? '', + kpp: get('kpp'), + ogrn: get('ogrn'), + kind, + name: fullName, + shortName, + legalAddress: get('address', 'unrestricted_value') ?? get('address', 'value'), + email: get('emails', '0', 'value' as never) ?? null, + phone: get('phones', '0', 'value' as never) ?? null, + signatoryName: get('management', 'name'), + signatoryPosition: get('management', 'post'), + status: get('state', 'status') ?? 'UNKNOWN', + }; +} + +export async function dadataRoutes(app: FastifyInstance) { + app.get('/api/lookup/party', { preHandler: app.requireDocPermission('user') }, async (req, reply) => { + const parsed = Query.safeParse(req.query); + if (!parsed.success) { + reply.code(400).send({ error: 'validation_error', issues: parsed.error.flatten() }); + return; + } + if (!env.DADATA_API_KEY) { + reply.code(503).send({ + error: 'no_dadata_key', + message: 'DaData API ключ не настроен. Добавьте DADATA_API_KEY в .env на сервере.', + }); + return; + } + try { + const res = await fetch(DADATA_URL, { + method: 'POST', + headers: { + Authorization: `Token ${env.DADATA_API_KEY}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ query: parsed.data.inn }), + signal: AbortSignal.timeout(8000), + }); + if (res.status === 401 || res.status === 403) { + reply.code(503).send({ error: 'dadata_auth_error', message: 'Неверный DaData API ключ.' }); + return; + } + if (res.status === 429) { + reply.code(429).send({ error: 'rate_limited', message: 'Превышен лимит запросов DaData. Попробуйте позже.' }); + return; + } + if (!res.ok) { + reply.code(502).send({ error: 'dadata_error', status: res.status }); + return; + } + const body = (await res.json()) as { suggestions?: { data: Record }[] }; + const suggestions = body.suggestions ?? []; + if (suggestions.length === 0) { + reply.code(404).send({ error: 'not_found', message: 'Не найдено в ЕГРЮЛ/ЕГРИП.' }); + return; + } + const result = pickResult(suggestions[0]!); + return result; + } catch (e) { + app.log.error({ err: e }, 'dadata lookup failed'); + reply.code(502).send({ error: 'dadata_unreachable' }); + } + }); +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 7b9cea0..178b9a6 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -14,6 +14,7 @@ import { servicesRoutes } from './modules/services/routes.js'; import { documentsRoutes } from './modules/documents/routes.js'; import { documentsPdfRoutes } from './modules/documents/pdf.routes.js'; import { templatesRoutes } from './modules/templates/routes.js'; +import { dadataRoutes } from './modules/dadata/routes.js'; import { shutdownBrowser } from './modules/documents/pdf.js'; import activeOrgPlugin from './plugins/activeOrg.js'; @@ -49,6 +50,7 @@ async function main() { await app.register(documentsRoutes); await app.register(documentsPdfRoutes); await app.register(templatesRoutes); + await app.register(dadataRoutes); app.addHook('onClose', async () => { await shutdownBrowser(); diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 6d184be..2d28595 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -257,3 +257,18 @@ export type LineHistoryItem = { lastUsed: string; useCount: number; }; + +export type DadataParty = { + inn: string; + kpp: string | null; + ogrn: string | null; + kind: 'ul' | 'ip' | 'fl'; + name: string; + shortName: string | null; + legalAddress: string | null; + email: string | null; + phone: string | null; + signatoryName: string | null; + signatoryPosition: string | null; + status: string; // ACTIVE / LIQUIDATED / etc. +}; diff --git a/apps/web/src/components/InnLookup.tsx b/apps/web/src/components/InnLookup.tsx new file mode 100644 index 0000000..7b4e450 --- /dev/null +++ b/apps/web/src/components/InnLookup.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { api, ApiError, type DadataParty } from '../api.js'; +import { Button } from './ui.js'; + +/** + * Кнопка "Найти в ЕГРЮЛ" рядом с полем ИНН. + * При клике запрашивает /api/lookup/party — если найдено, вызывает onResult с распарсенными + * полями. Сама кнопка не правит state формы — это решает родительский компонент. + */ +export function InnLookupButton({ + inn, + onResult, + disabled, +}: { + inn: string; + onResult: (p: DadataParty) => void; + disabled?: boolean; +}) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function lookup() { + setError(null); + if (!/^\d{10}$|^\d{12}$/.test(inn)) { + setError('Введите ИНН (10 или 12 цифр) перед поиском.'); + return; + } + setLoading(true); + try { + const r = await api.get(`/api/lookup/party?inn=${encodeURIComponent(inn)}`); + onResult(r); + if (r.status && r.status !== 'ACTIVE') { + setError(`Внимание: статус «${r.status}» (компания может быть ликвидирована).`); + } + } catch (e) { + if (e instanceof ApiError) { + if (e.code === 'no_dadata_key') { + setError('DaData ключ не настроен на сервере. Попроси админа добавить DADATA_API_KEY в .env.'); + } else if (e.code === 'not_found') { + setError('Не найдено в ЕГРЮЛ/ЕГРИП.'); + } else if (e.code === 'rate_limited') { + setError('Превышен дневной лимит DaData (10000 запросов). Попробуй завтра.'); + } else { + setError(e.prettyMessage()); + } + } else { + setError(String(e)); + } + } finally { + setLoading(false); + } + } + + return ( +
+ + {error ? {error} : null} +
+ ); +} diff --git a/apps/web/src/pages/Clients.tsx b/apps/web/src/pages/Clients.tsx index 9541d38..e6cae48 100644 --- a/apps/web/src/pages/Clients.tsx +++ b/apps/web/src/pages/Clients.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; -import { api, ApiError, type Client } from '../api.js'; +import { api, ApiError, type Client, type DadataParty } from '../api.js'; import { Button, EmptyState, Field, Modal, Select } from '../components/ui.js'; +import { InnLookupButton } from '../components/InnLookup.js'; const KIND_LABEL: Record = { ul: 'Юр. лицо', @@ -102,6 +103,23 @@ export function ClientsPage() { } }; + function applyDadata(p: DadataParty) { + setEditing((d) => { + if (!d) return d; + const next: Partial = { ...d }; + next.kind = p.kind; + if (p.name) next.name = p.name; + if (p.inn) next.inn = p.inn; + if (p.kpp) next.kpp = p.kpp; + if (p.legalAddress) next.address = p.legalAddress; + if (p.email) next.email = p.email; + if (p.phone) next.phone = p.phone; + if (p.signatoryName) next.contactPerson = p.signatoryName; + return next; + }); + setFieldErrors({}); + } + return (
@@ -189,7 +207,10 @@ export function ClientsPage() { ]} /> set('name', e.target.value)} error={fieldErrors.name} /> - set('inn', e.target.value)} error={fieldErrors.inn} /> +
+ set('inn', e.target.value)} error={fieldErrors.inn} /> + +
set('kpp', e.target.value)} error={fieldErrors.kpp} /> set('address', e.target.value)} error={fieldErrors.address} /> set('email', e.target.value)} error={fieldErrors.email} /> diff --git a/apps/web/src/pages/CompanyEdit.tsx b/apps/web/src/pages/CompanyEdit.tsx index f643141..b5085df 100644 --- a/apps/web/src/pages/CompanyEdit.tsx +++ b/apps/web/src/pages/CompanyEdit.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { api, ApiError, type BankAccount, type Organization } from '../api.js'; +import { api, ApiError, type BankAccount, type DadataParty, type Organization } from '../api.js'; import { Button, EmptyState, Field, Modal } from '../components/ui.js'; +import { InnLookupButton } from '../components/InnLookup.js'; type Tab = 'requisites' | 'banks' | 'integrations'; @@ -59,6 +60,21 @@ function RequisitesTab({ org, onChange }: { org: Organization; onChange: (o: Org } }; + function applyDadata(p: DadataParty) { + setDraft((d) => ({ + ...d, + name: p.name || d.name, + shortName: p.shortName ?? d.shortName ?? null, + inn: p.inn || d.inn, + kpp: p.kpp ?? d.kpp ?? null, + ogrn: p.ogrn ?? d.ogrn ?? null, + legalAddress: p.legalAddress ?? d.legalAddress ?? null, + signatoryName: p.signatoryName ?? d.signatoryName ?? null, + signatoryPosition: p.signatoryPosition ?? d.signatoryPosition ?? null, + })); + setFieldErrors({}); + } + async function save() { setSaving(true); setError(null); @@ -95,7 +111,10 @@ function RequisitesTab({ org, onChange }: { org: Organization; onChange: (o: Org
set('name', e.target.value)} error={fieldErrors.name} /> set('shortName', e.target.value)} placeholder='напр. "Моя компания"' error={fieldErrors.shortName} /> - set('inn', e.target.value)} error={fieldErrors.inn} /> +
+ set('inn', e.target.value)} error={fieldErrors.inn} /> + +
set('kpp', e.target.value)} error={fieldErrors.kpp} /> set('ogrn', e.target.value)} error={fieldErrors.ogrn} /> set('legalAddress', e.target.value)} error={fieldErrors.legalAddress} /> diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index ee7a88e..38461a9 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -275,6 +275,10 @@ body { .tabs { border-bottom-color: #2a2e35; } } +/* === inn lookup === */ +.inn-lookup { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; } +.inn-lookup__error { font-size: 12px; color: #c0392b; } + /* === document status pills === */ .status { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 12px; diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 467f207..bf01ab7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -41,6 +41,7 @@ services: CORS_ORIGINS: ${CORS_ORIGINS:-https://doc.queo.ru} TOCHKA_JWT_KEY: ${TOCHKA_JWT_KEY:-} TOCHKA_WEBHOOK_SECRET: ${TOCHKA_WEBHOOK_SECRET:-} + DADATA_API_KEY: ${DADATA_API_KEY:-} DEFAULT_ORGANIZATION_ID: ${DEFAULT_ORGANIZATION_ID:-00000000-0000-0000-0000-000000000001} DEV_BYPASS_AUTH: "0" expose: