feat: DaData ИНН lookup for clients and companies

- 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) <noreply@anthropic.com>
This commit is contained in:
admin
2026-05-01 11:16:20 +03:00
parent 524789facc
commit 624d378bb5
10 changed files with 252 additions and 4 deletions
+5
View File
@@ -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 отказывается стартовать с этой переменной.
+4
View File
@@ -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 ниже.
+115
View File
@@ -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<string, unknown> }): LookupResult {
const d = s.data;
const get = <T = string>(...path: string[]): T | null => {
let cur: unknown = d;
for (const k of path) {
if (cur && typeof cur === 'object' && k in (cur as Record<string, unknown>)) {
cur = (cur as Record<string, unknown>)[k];
} else {
return null;
}
}
return (cur ?? null) as T | null;
};
const type = get<string>('type'); // LEGAL | INDIVIDUAL
const kind: LookupResult['kind'] = type === 'INDIVIDUAL' ? 'ip' : 'ul';
const fullName = get<string>('name', 'full_with_opf') ?? get<string>('name', 'full') ?? '';
const shortName = get<string>('name', 'short_with_opf') ?? get<string>('name', 'short');
return {
inn: get<string>('inn') ?? '',
kpp: get<string>('kpp'),
ogrn: get<string>('ogrn'),
kind,
name: fullName,
shortName,
legalAddress: get<string>('address', 'unrestricted_value') ?? get<string>('address', 'value'),
email: get<string>('emails', '0', 'value' as never) ?? null,
phone: get<string>('phones', '0', 'value' as never) ?? null,
signatoryName: get<string>('management', 'name'),
signatoryPosition: get<string>('management', 'post'),
status: get<string>('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<string, unknown> }[] };
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' });
}
});
}
+2
View File
@@ -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();
+15
View File
@@ -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.
};
+62
View File
@@ -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<string | null>(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<DadataParty>(`/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 (
<div className="inn-lookup">
<Button onClick={lookup} disabled={disabled || loading || !inn}>
{loading ? 'Ищу…' : '🔍 Найти в ЕГРЮЛ'}
</Button>
{error ? <span className="inn-lookup__error">{error}</span> : null}
</div>
);
}
+23 -2
View File
@@ -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<Client['kind'], string> = {
ul: 'Юр. лицо',
@@ -102,6 +103,23 @@ export function ClientsPage() {
}
};
function applyDadata(p: DadataParty) {
setEditing((d) => {
if (!d) return d;
const next: Partial<Client> = { ...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 (
<main className="content">
<header className="page-head">
@@ -189,7 +207,10 @@ export function ClientsPage() {
]}
/>
<Field label="Название" value={editing?.name ?? ''} onChange={(e) => set('name', e.target.value)} error={fieldErrors.name} />
<Field label="ИНН" value={editing?.inn ?? ''} onChange={(e) => set('inn', e.target.value)} error={fieldErrors.inn} />
<div>
<Field label="ИНН" value={editing?.inn ?? ''} onChange={(e) => set('inn', e.target.value)} error={fieldErrors.inn} />
<InnLookupButton inn={editing?.inn ?? ''} onResult={applyDadata} />
</div>
<Field label="КПП" value={editing?.kpp ?? ''} onChange={(e) => set('kpp', e.target.value)} error={fieldErrors.kpp} />
<Field label="Адрес" value={editing?.address ?? ''} onChange={(e) => set('address', e.target.value)} error={fieldErrors.address} />
<Field label="Email" type="email" value={editing?.email ?? ''} onChange={(e) => set('email', e.target.value)} error={fieldErrors.email} />
+21 -2
View File
@@ -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
<section className="form-grid">
<Field label="Название" value={draft.name} onChange={(e) => set('name', e.target.value)} error={fieldErrors.name} />
<Field label="Короткое имя (для шапки)" value={draft.shortName ?? ''} onChange={(e) => set('shortName', e.target.value)} placeholder='напр. "Моя компания"' error={fieldErrors.shortName} />
<Field label="ИНН" value={draft.inn} onChange={(e) => set('inn', e.target.value)} error={fieldErrors.inn} />
<div>
<Field label="ИНН" value={draft.inn} onChange={(e) => set('inn', e.target.value)} error={fieldErrors.inn} />
<InnLookupButton inn={draft.inn} onResult={applyDadata} />
</div>
<Field label="КПП" value={draft.kpp ?? ''} onChange={(e) => set('kpp', e.target.value)} error={fieldErrors.kpp} />
<Field label="ОГРН/ОГРНИП" value={draft.ogrn ?? ''} onChange={(e) => set('ogrn', e.target.value)} error={fieldErrors.ogrn} />
<Field label="Юр. адрес" value={draft.legalAddress ?? ''} onChange={(e) => set('legalAddress', e.target.value)} error={fieldErrors.legalAddress} />
+4
View File
@@ -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;
+1
View File
@@ -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: