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();
|
||||
|
||||
@@ -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.
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user