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
+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;