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:
@@ -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 отказывается стартовать с этой переменной.
|
||||
|
||||
@@ -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 ниже.
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user