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
+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' });
}
});
}