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,
|
type CustomField,
|
||||||
} from "../lib/queries/offers";
|
} from "../lib/queries/offers";
|
||||||
import { useApiMutation } from "../lib/queries/mutations";
|
import { useApiMutation } from "../lib/queries/mutations";
|
||||||
|
import AresAdornment, { type AresCompany } from "../components/AresLookup";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -267,6 +268,21 @@ export default function OffersCustomers() {
|
|||||||
return key;
|
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 = () => {
|
const openCreateModal = () => {
|
||||||
setEditingCustomer(null);
|
setEditingCustomer(null);
|
||||||
setForm({
|
setForm({
|
||||||
@@ -535,6 +551,15 @@ export default function OffersCustomers() {
|
|||||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||||
}
|
}
|
||||||
placeholder="Název firmy / jméno"
|
placeholder="Název firmy / jméno"
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<AresAdornment
|
||||||
|
mode="name"
|
||||||
|
query={form.name}
|
||||||
|
onFill={fillFromAres}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Ulice">
|
<Field label="Ulice">
|
||||||
@@ -596,6 +621,15 @@ export default function OffersCustomers() {
|
|||||||
company_id: e.target.value,
|
company_id: e.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
InputProps={{
|
||||||
|
endAdornment: (
|
||||||
|
<AresAdornment
|
||||||
|
mode="ico"
|
||||||
|
query={form.company_id}
|
||||||
|
onFill={fillFromAres}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="DIČ">
|
<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 warehouseRoutes from "./routes/admin/warehouse";
|
||||||
import planRoutes from "./routes/admin/plan";
|
import planRoutes from "./routes/admin/plan";
|
||||||
import aiRoutes from "./routes/admin/ai";
|
import aiRoutes from "./routes/admin/ai";
|
||||||
|
import aresRoutes from "./routes/admin/ares";
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -162,6 +163,7 @@ async function start() {
|
|||||||
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
await app.register(warehouseRoutes, { prefix: "/api/admin/warehouse" });
|
||||||
await app.register(planRoutes, { prefix: "/api/admin/plan" });
|
await app.register(planRoutes, { prefix: "/api/admin/plan" });
|
||||||
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
|
await app.register(aiRoutes, { prefix: "/api/admin/ai" });
|
||||||
|
await app.register(aresRoutes, { prefix: "/api/admin/ares" });
|
||||||
|
|
||||||
// --- Frontend: Vite dev middleware (dev only) ---
|
// --- Frontend: Vite dev middleware (dev only) ---
|
||||||
if (!config.isProduction) {
|
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