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:
116
src/__tests__/ares.test.ts
Normal file
116
src/__tests__/ares.test.ts
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
153
src/admin/components/AresLookup.tsx
Normal file
153
src/admin/components/AresLookup.tsx
Normal file
@@ -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<AresCompany[]>([]);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(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<AresCompany>(
|
||||
`/api/admin/ares/ico/${encodeURIComponent(trimmed.replace(/\s+/g, ""))}`,
|
||||
);
|
||||
onFill(company);
|
||||
} else {
|
||||
const found = await jsonQuery<AresCompany[]>(
|
||||
`/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 (
|
||||
<InputAdornment position="end">
|
||||
<Tooltip title={tooltip}>
|
||||
<span>
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
size="small"
|
||||
onClick={lookup}
|
||||
disabled={!enabled || loading}
|
||||
aria-label="Načíst z ARES"
|
||||
edge="end"
|
||||
>
|
||||
{loading ? (
|
||||
<CircularProgress size={16} color="inherit" />
|
||||
) : (
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: "0.65rem",
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.06em",
|
||||
color: enabled ? "primary.main" : "text.disabled",
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
ARES
|
||||
</Typography>
|
||||
)}
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorRef.current}
|
||||
open={menuOpen}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
>
|
||||
{results.map((c) => (
|
||||
<MenuItem
|
||||
key={c.ico}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onFill(c);
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{c.name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
IČO {c.ico}
|
||||
{c.city ? ` · ${c.city}` : ""}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</InputAdornment>
|
||||
);
|
||||
}
|
||||
@@ -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: (
|
||||
<AresAdornment
|
||||
mode="name"
|
||||
query={form.name}
|
||||
onFill={fillFromAres}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Ulice">
|
||||
@@ -596,6 +621,15 @@ export default function OffersCustomers() {
|
||||
company_id: e.target.value,
|
||||
}))
|
||||
}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<AresAdornment
|
||||
mode="ico"
|
||||
query={form.company_id}
|
||||
onFill={fillFromAres}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="DIČ">
|
||||
|
||||
63
src/routes/admin/ares.ts
Normal file
63
src/routes/admin/ares.ts
Normal file
@@ -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<string, { message: string; status: number }> = {
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
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