feat(ares): ARES company lookup with prefill in customer/supplier modals
New /api/admin/ares proxy (browser cannot reach ares.gov.cz - CORS+CSP): GET /ico/:ico and GET /search?q= map the MFCR REST API to the form shape (street incl. orientation numbers, "511 01" PSC format, DIC). Guarded by customers.create/edit + warehouse.manage; token errors map to Czech messages. Shared AresAdornment button sits in the Nazev and ICO fields of both modals: ICO mode fills directly, name mode searches with a result picker. Tested against live ARES (BOHA, ICO 22599851). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
134
src/services/ares.service.ts
Normal file
134
src/services/ares.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* ARES (Administrativní registr ekonomických subjektů) lookups — the public
|
||||
* MFČR REST API for Czech company data. No auth required. Used by the
|
||||
* customer/supplier modals to prefill company data from IČO or name.
|
||||
*
|
||||
* Docs: https://ares.gov.cz/swagger-ui (REST v3, ekonomicke-subjekty).
|
||||
* The browser cannot call ares.gov.cz directly (CORS + our CSP connect-src),
|
||||
* so these run server-side behind /api/admin/ares.
|
||||
*/
|
||||
|
||||
const ARES_BASE = "https://ares.gov.cz/ekonomicke-subjekty-v-be/rest";
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
|
||||
/** Normalized subject shape consumed by the frontend prefill. */
|
||||
export interface AresSubject {
|
||||
ico: string;
|
||||
dic: string | null;
|
||||
name: string;
|
||||
street: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface AresSidlo {
|
||||
nazevStatu?: string;
|
||||
nazevObce?: string;
|
||||
nazevCastiObce?: string;
|
||||
nazevUlice?: string;
|
||||
cisloDomovni?: number;
|
||||
cisloOrientacni?: number;
|
||||
cisloOrientacniPismeno?: string;
|
||||
psc?: number;
|
||||
textovaAdresa?: string;
|
||||
}
|
||||
|
||||
interface AresEkonomickySubjekt {
|
||||
ico?: string;
|
||||
obchodniJmeno?: string;
|
||||
dic?: string;
|
||||
sidlo?: AresSidlo;
|
||||
}
|
||||
|
||||
/** "51101" → "511 01" (Czech postal code display format). */
|
||||
function formatPsc(psc: number | undefined): string {
|
||||
if (!psc) return "";
|
||||
const s = String(psc);
|
||||
return s.length === 5 ? `${s.slice(0, 3)} ${s.slice(3)}` : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Street line from the registered office: "Nádražní 485", Prague-style
|
||||
* orientation numbers become "Ulice 485/12a". Villages without street names
|
||||
* fall back to the municipality part name, then the municipality itself
|
||||
* (matching how ARES itself builds textovaAdresa).
|
||||
*/
|
||||
function buildStreet(sidlo: AresSidlo): string {
|
||||
const streetName =
|
||||
sidlo.nazevUlice || sidlo.nazevCastiObce || sidlo.nazevObce || "";
|
||||
let num = sidlo.cisloDomovni != null ? String(sidlo.cisloDomovni) : "";
|
||||
if (sidlo.cisloOrientacni != null) {
|
||||
num += `/${sidlo.cisloOrientacni}${sidlo.cisloOrientacniPismeno || ""}`;
|
||||
}
|
||||
return [streetName, num].filter(Boolean).join(" ").trim();
|
||||
}
|
||||
|
||||
function mapSubject(s: AresEkonomickySubjekt): AresSubject {
|
||||
const sidlo = s.sidlo || {};
|
||||
return {
|
||||
ico: s.ico || "",
|
||||
dic: s.dic || null,
|
||||
name: s.obchodniJmeno || "",
|
||||
street: buildStreet(sidlo),
|
||||
city: sidlo.nazevObce || "",
|
||||
postal_code: formatPsc(sidlo.psc),
|
||||
country: sidlo.nazevStatu || "Česká republika",
|
||||
};
|
||||
}
|
||||
|
||||
export type AresError = "invalid_ico" | "not_found" | "ares_unavailable";
|
||||
|
||||
/** Lookup a single subject by its 8-digit IČO. */
|
||||
export async function aresLookupByIco(
|
||||
ico: string,
|
||||
): Promise<AresSubject | { error: AresError }> {
|
||||
const normalized = ico.replace(/\s+/g, "");
|
||||
if (!/^\d{8}$/.test(normalized)) return { error: "invalid_ico" };
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${ARES_BASE}/ekonomicke-subjekty/${normalized}`,
|
||||
{ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) },
|
||||
);
|
||||
if (response.status === 404) return { error: "not_found" };
|
||||
if (!response.ok) {
|
||||
console.error(`[ares] lookup ${normalized} failed: ${response.status}`);
|
||||
return { error: "ares_unavailable" };
|
||||
}
|
||||
const data = (await response.json()) as AresEkonomickySubjekt;
|
||||
if (!data.ico) return { error: "not_found" };
|
||||
return mapSubject(data);
|
||||
} catch (err) {
|
||||
console.error("[ares] lookup failed:", err);
|
||||
return { error: "ares_unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Search subjects by (part of) the business name; returns up to 10 matches. */
|
||||
export async function aresSearchByName(
|
||||
name: string,
|
||||
): Promise<AresSubject[] | { error: AresError }> {
|
||||
const query = name.trim();
|
||||
if (!query) return [];
|
||||
|
||||
try {
|
||||
const response = await fetch(`${ARES_BASE}/ekonomicke-subjekty/vyhledat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ obchodniJmeno: query, pocet: 10, start: 0 }),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error(`[ares] search "${query}" failed: ${response.status}`);
|
||||
return { error: "ares_unavailable" };
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
ekonomickeSubjekty?: AresEkonomickySubjekt[];
|
||||
};
|
||||
return (data.ekonomickeSubjekty ?? []).map(mapSubject);
|
||||
} catch (err) {
|
||||
console.error("[ares] search failed:", err);
|
||||
return { error: "ares_unavailable" };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user