From f1ce76d21df464d43990e4f077129003bef81cb3 Mon Sep 17 00:00:00 2001 From: BOHA Date: Wed, 10 Jun 2026 20:11:08 +0200 Subject: [PATCH] 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 --- src/__tests__/ares.test.ts | 116 +++++++++++++++++++++ src/admin/components/AresLookup.tsx | 153 ++++++++++++++++++++++++++++ src/admin/pages/OffersCustomers.tsx | 34 +++++++ src/routes/admin/ares.ts | 63 ++++++++++++ src/server.ts | 2 + src/services/ares.service.ts | 134 ++++++++++++++++++++++++ 6 files changed, 502 insertions(+) create mode 100644 src/__tests__/ares.test.ts create mode 100644 src/admin/components/AresLookup.tsx create mode 100644 src/routes/admin/ares.ts create mode 100644 src/services/ares.service.ts diff --git a/src/__tests__/ares.test.ts b/src/__tests__/ares.test.ts new file mode 100644 index 0000000..289830b --- /dev/null +++ b/src/__tests__/ares.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { aresLookupByIco, aresSearchByName } from "../services/ares.service"; + +// ARES is a true external — mock global fetch (the only allowed mock class). +const SUBJECT = { + ico: "22599851", + obchodniJmeno: "BOHA Automation s.r.o.", + dic: "CZ22599851", + sidlo: { + nazevStatu: "Česká republika", + nazevObce: "Turnov", + nazevCastiObce: "Turnov", + nazevUlice: "Nádražní", + cisloDomovni: 485, + psc: 51101, + textovaAdresa: "Nádražní 485, 51101 Turnov", + }, +}; + +function mockFetchOnce(status: number, body?: unknown) { + return vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + } as Response); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("aresLookupByIco", () => { + it("maps a subject to the normalized prefill shape", async () => { + mockFetchOnce(200, SUBJECT); + const res = await aresLookupByIco("22599851"); + expect(res).toEqual({ + ico: "22599851", + dic: "CZ22599851", + name: "BOHA Automation s.r.o.", + street: "Nádražní 485", + city: "Turnov", + postal_code: "511 01", + country: "Česká republika", + }); + }); + + it("builds Prague-style orientation numbers as 'Ulice 485/12a'", async () => { + mockFetchOnce(200, { + ...SUBJECT, + sidlo: { + ...SUBJECT.sidlo, + cisloOrientacni: 12, + cisloOrientacniPismeno: "a", + }, + }); + const res = await aresLookupByIco("22599851"); + expect("street" in res && res.street).toBe("Nádražní 485/12a"); + }); + + it("rejects a non-8-digit IČO without calling ARES", async () => { + const spy = vi.spyOn(globalThis, "fetch"); + expect(await aresLookupByIco("123")).toEqual({ error: "invalid_ico" }); + expect(await aresLookupByIco("abcdefgh")).toEqual({ + error: "invalid_ico", + }); + expect(spy).not.toHaveBeenCalled(); + }); + + it("accepts an IČO with stray whitespace", async () => { + const spy = mockFetchOnce(200, SUBJECT); + const res = await aresLookupByIco(" 225 99851 "); + expect("ico" in res && res.ico).toBe("22599851"); + expect(String(spy.mock.calls[0][0])).toContain("/22599851"); + }); + + it("maps 404 to not_found and network failure to ares_unavailable", async () => { + mockFetchOnce(404); + expect(await aresLookupByIco("12345678")).toEqual({ error: "not_found" }); + + vi.spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("ETIMEDOUT")); + expect(await aresLookupByIco("12345678")).toEqual({ + error: "ares_unavailable", + }); + }); +}); + +describe("aresSearchByName", () => { + it("maps the result list and sends the name in the POST body", async () => { + const spy = mockFetchOnce(200, { ekonomickeSubjekty: [SUBJECT] }); + const res = await aresSearchByName("BOHA Automation"); + expect(Array.isArray(res)).toBe(true); + if (Array.isArray(res)) { + expect(res).toHaveLength(1); + expect(res[0].name).toBe("BOHA Automation s.r.o."); + expect(res[0].postal_code).toBe("511 01"); + } + const init = spy.mock.calls[0][1] as RequestInit; + expect(JSON.parse(String(init.body))).toMatchObject({ + obchodniJmeno: "BOHA Automation", + pocet: 10, + }); + }); + + it("returns [] for a blank query without calling ARES", async () => { + const spy = vi.spyOn(globalThis, "fetch"); + expect(await aresSearchByName(" ")).toEqual([]); + expect(spy).not.toHaveBeenCalled(); + }); + + it("maps upstream failure to ares_unavailable", async () => { + mockFetchOnce(503); + expect(await aresSearchByName("BOHA")).toEqual({ + error: "ares_unavailable", + }); + }); +}); diff --git a/src/admin/components/AresLookup.tsx b/src/admin/components/AresLookup.tsx new file mode 100644 index 0000000..c15740f --- /dev/null +++ b/src/admin/components/AresLookup.tsx @@ -0,0 +1,153 @@ +import { useState, useRef } from "react"; +import InputAdornment from "@mui/material/InputAdornment"; +import IconButton from "@mui/material/IconButton"; +import Tooltip from "@mui/material/Tooltip"; +import Menu from "@mui/material/Menu"; +import MenuItem from "@mui/material/MenuItem"; +import Typography from "@mui/material/Typography"; +import Box from "@mui/material/Box"; +import CircularProgress from "@mui/material/CircularProgress"; +import { jsonQuery } from "../lib/apiAdapter"; +import { useAlert } from "../context/AlertContext"; + +/** Normalized company record returned by the /api/admin/ares proxy. */ +export interface AresCompany { + ico: string; + dic: string | null; + name: string; + street: string; + city: string; + postal_code: string; + country: string; +} + +interface AresAdornmentProps { + /** "ico" looks the query up directly; "name" searches and offers a picker. */ + mode: "ico" | "name"; + /** Current value of the host field (IČO or name fragment). */ + query: string; + /** Called with the chosen company — the modal prefills its form from it. */ + onFill: (company: AresCompany) => void; +} + +/** + * "ARES" end-adornment button for the Název/IČO fields of the customer and + * supplier modals. IČO mode fetches the subject directly; name mode searches + * ARES and shows a result picker (filling straight away on a single hit). + * Pass via the TextField's `InputProps.endAdornment`. + */ +export default function AresAdornment({ + mode, + query, + onFill, +}: AresAdornmentProps) { + const alert = useAlert(); + const [loading, setLoading] = useState(false); + const [results, setResults] = useState([]); + const [menuOpen, setMenuOpen] = useState(false); + const anchorRef = useRef(null); + + const trimmed = query.trim(); + const enabled = + mode === "ico" + ? /^\d{8}$/.test(trimmed.replace(/\s+/g, "")) + : trimmed.length >= 2; + const tooltip = + mode === "ico" + ? enabled + ? "Načíst údaje z ARES podle IČO" + : "Vyplňte 8místné IČO" + : enabled + ? "Vyhledat firmu v ARES podle názvu" + : "Vyplňte alespoň 2 znaky názvu"; + + const lookup = async () => { + if (!enabled || loading) return; + setLoading(true); + try { + if (mode === "ico") { + const company = await jsonQuery( + `/api/admin/ares/ico/${encodeURIComponent(trimmed.replace(/\s+/g, ""))}`, + ); + onFill(company); + } else { + const found = await jsonQuery( + `/api/admin/ares/search?q=${encodeURIComponent(trimmed)}`, + ); + if (found.length === 0) { + alert.error("Žádná firma tohoto názvu nebyla v ARES nalezena"); + } else if (found.length === 1) { + onFill(found[0]); + } else { + setResults(found); + setMenuOpen(true); + } + } + } catch (e) { + alert.error( + e instanceof Error ? e.message : "Registr ARES je nedostupný", + ); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {loading ? ( + + ) : ( + + ARES + + )} + + + + setMenuOpen(false)} + > + {results.map((c) => ( + { + setMenuOpen(false); + onFill(c); + }} + > + + + {c.name} + + + IČO {c.ico} + {c.city ? ` · ${c.city}` : ""} + + + + ))} + + + ); +} diff --git a/src/admin/pages/OffersCustomers.tsx b/src/admin/pages/OffersCustomers.tsx index 5ac6002..7ba8309 100644 --- a/src/admin/pages/OffersCustomers.tsx +++ b/src/admin/pages/OffersCustomers.tsx @@ -12,6 +12,7 @@ import { type CustomField, } from "../lib/queries/offers"; import { useApiMutation } from "../lib/queries/mutations"; +import AresAdornment, { type AresCompany } from "../components/AresLookup"; import { Button, Card, @@ -267,6 +268,21 @@ export default function OffersCustomers() { return key; }; + // ARES prefill — overwrite the company fields with registry data (the + // button is an explicit user action, so overwriting is the expected UX). + const fillFromAres = (c: AresCompany) => { + setForm((prev) => ({ + ...prev, + name: c.name || prev.name, + street: c.street, + city: c.city, + postal_code: c.postal_code, + country: c.country, + company_id: c.ico, + vat_id: c.dic || "", + })); + }; + const openCreateModal = () => { setEditingCustomer(null); setForm({ @@ -535,6 +551,15 @@ export default function OffersCustomers() { setForm((prev) => ({ ...prev, name: e.target.value })) } placeholder="Název firmy / jméno" + InputProps={{ + endAdornment: ( + + ), + }} /> @@ -596,6 +621,15 @@ export default function OffersCustomers() { company_id: e.target.value, })) } + InputProps={{ + endAdornment: ( + + ), + }} /> diff --git a/src/routes/admin/ares.ts b/src/routes/admin/ares.ts new file mode 100644 index 0000000..0764fe1 --- /dev/null +++ b/src/routes/admin/ares.ts @@ -0,0 +1,63 @@ +import { FastifyInstance } from "fastify"; +import { requireAnyPermission } from "../../middleware/auth"; +import { success, error } from "../../utils/response"; +import { aresLookupByIco, aresSearchByName } from "../../services/ares.service"; + +// Czech messages for the service's token errors. +const ARES_ERRORS: Record = { + invalid_ico: { message: "IČO musí být 8 číslic", status: 400 }, + not_found: { + message: "Subjekt s tímto IČO nebyl v ARES nalezen", + status: 404, + }, + ares_unavailable: { + message: "Registr ARES je momentálně nedostupný, zkuste to později", + status: 502, + }, +}; + +/** + * ARES proxy — the browser cannot call ares.gov.cz directly (CORS + CSP), so + * the customer/supplier modals go through these endpoints. Guarded by the + * permissions of the two modals that use the prefill (customer editing and + * warehouse supplier management); ARES data itself is public. + */ +export default async function aresRoutes(fastify: FastifyInstance) { + const guard = requireAnyPermission( + "customers.create", + "customers.edit", + "warehouse.manage", + ); + + // GET /ico/:ico — single subject by IČO + fastify.get<{ Params: { ico: string } }>( + "/ico/:ico", + { preHandler: guard }, + async (request, reply) => { + const result = await aresLookupByIco(request.params.ico); + if ("error" in result) { + const e = ARES_ERRORS[result.error]; + return error(reply, e.message, e.status); + } + return success(reply, result); + }, + ); + + // GET /search?q=… — subjects by (part of) business name, max 10 + fastify.get<{ Querystring: { q?: string } }>( + "/search", + { preHandler: guard }, + async (request, reply) => { + const q = String(request.query.q ?? "").trim(); + if (q.length < 2) { + return error(reply, "Zadejte alespoň 2 znaky názvu", 400); + } + const result = await aresSearchByName(q); + if ("error" in result && !Array.isArray(result)) { + const e = ARES_ERRORS[result.error]; + return error(reply, e.message, e.status); + } + return success(reply, result); + }, + ); +} diff --git a/src/server.ts b/src/server.ts index 29678fe..9010fa5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,6 +38,7 @@ import projectFilesRoutes from "./routes/admin/project-files"; import warehouseRoutes from "./routes/admin/warehouse"; import planRoutes from "./routes/admin/plan"; import aiRoutes from "./routes/admin/ai"; +import aresRoutes from "./routes/admin/ares"; const app = Fastify({ logger: { @@ -162,6 +163,7 @@ async function start() { await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" }); await app.register(planRoutes, { prefix: "/api/admin/plan" }); await app.register(aiRoutes, { prefix: "/api/admin/ai" }); + await app.register(aresRoutes, { prefix: "/api/admin/ares" }); // --- Frontend: Vite dev middleware (dev only) --- if (!config.isProduction) { diff --git a/src/services/ares.service.ts b/src/services/ares.service.ts new file mode 100644 index 0000000..96cb68f --- /dev/null +++ b/src/services/ares.service.ts @@ -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 { + 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 { + 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" }; + } +}