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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user