- find_employee: name -> user_id with route-parity population scoping
(attendance perms see plan users, trips perms see drivers sans usernames,
full directory only with users.view; trips.history unlocks nothing)
- get_attendance_summary: day-range detail with worked hours (shared
calcWorkedHours, break subtracted) + totals; attendance.manage also
unlocks the tool
- list_trips: kniha jizd with route-identical non-manager self-scoping and
the stats-endpoint km coalesce; bare month defaults to current year
- get_work_plan: resolved plan grid rows; off-grid users need
attendance.manage (grid-route parity)
- executeTool flags handler {error} results not-ok (consistent denial chips)
- system prompt: resolve employee names via find_employee first (gated on
the tool being present)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1046 lines
35 KiB
TypeScript
1046 lines
35 KiB
TypeScript
import type Anthropic from "@anthropic-ai/sdk";
|
||
import prisma from "../config/database";
|
||
import { listInvoices, getInvoiceStats } from "./invoices.service";
|
||
import { listOffers } from "./offers.service";
|
||
import { listOrders } from "./orders.service";
|
||
import { listIssuedOrders } from "./issued-orders.service";
|
||
import { listProjects } from "./projects.service";
|
||
import { getBelowMinimumItems } from "./warehouse.service";
|
||
import { calcWorkedHours } from "./attendance.service";
|
||
import { resolveGrid, listPlanUsers } from "./plan.service";
|
||
|
||
/**
|
||
* Odin Phase 2a — READ-ONLY tools over the existing service layer.
|
||
*
|
||
* Security model (see docs/superpowers/specs/2026-06-08-odin-phase2-…):
|
||
* - The assistant is the USER'S DELEGATE: every handler re-checks the same
|
||
* permission the equivalent route requires, against the caller's authData.
|
||
* The tool list sent to the model is ALSO pre-filtered by permission, but
|
||
* the handler check is the actual boundary (defense in depth).
|
||
* - No write tools exist in this layer at all — a prompt-injected "create an
|
||
* order" cannot do anything, because there is no tool to call.
|
||
* - Handlers call the SAME service functions the routes use (or replicate a
|
||
* route's plain read), so ownership/visibility logic cannot diverge.
|
||
* - Outputs are deliberately compact (capped rows, plain fields): tool
|
||
* results are model input and bill as tokens on every loop iteration.
|
||
*/
|
||
|
||
export interface AiAuthCtx {
|
||
userId: number;
|
||
roleName: string;
|
||
permissions: string[];
|
||
}
|
||
|
||
export function ctxCan(ctx: AiAuthCtx, permission: string): boolean {
|
||
return ctx.roleName === "admin" || ctx.permissions.includes(permission);
|
||
}
|
||
|
||
/** Any-of check for tools whose route equivalent uses requireAnyPermission. */
|
||
function canUse(ctx: AiAuthCtx, permission: string | string[]): boolean {
|
||
return Array.isArray(permission)
|
||
? permission.some((p) => ctxCan(ctx, p))
|
||
: ctxCan(ctx, permission);
|
||
}
|
||
|
||
/** Uniform list paging the tools use — first page, newest first, small. */
|
||
const LIST = { page: 1, limit: 20, skip: 0, order: "desc" as const };
|
||
const DENIED = (what: string) =>
|
||
`Uživatel nemá oprávnění zobrazit ${what} — odpověz mu, že na to nemá oprávnění.`;
|
||
|
||
interface ToolSpec {
|
||
/**
|
||
* Permission whose holder may use this tool (admin bypasses). An array
|
||
* means any-of — mirroring routes guarded by requireAnyPermission.
|
||
*/
|
||
permission: string | string[];
|
||
definition: Anthropic.Tool;
|
||
handler: (input: Record<string, unknown>, ctx: AiAuthCtx) => Promise<unknown>;
|
||
}
|
||
|
||
const num = (v: unknown): number | undefined => {
|
||
const n = Number(v);
|
||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||
};
|
||
const str = (v: unknown): string | undefined =>
|
||
typeof v === "string" && v.trim() ? v.trim() : undefined;
|
||
|
||
/**
|
||
* Parse "YYYY-MM-DD" → UTC-midnight instant. @db.Date columns compare by the
|
||
* UTC date part, so these are the only safe filter boundaries (CLAUDE.md
|
||
* dates rule #2); a local-midnight Date would shift the window a day back.
|
||
*/
|
||
const isoDay = (v: unknown): Date | undefined => {
|
||
if (typeof v !== "string") return undefined;
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v.trim());
|
||
if (!m) return undefined;
|
||
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||
};
|
||
const addDays = (d: Date, n: number): Date =>
|
||
new Date(d.getTime() + n * 86400000);
|
||
const dayStr = (d: Date): string => d.toISOString().slice(0, 10);
|
||
/** Local wall-clock HH:MM (process TZ is Europe/Prague). */
|
||
const hhmm = (d: Date | null): string | null =>
|
||
d
|
||
? `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`
|
||
: null;
|
||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||
|
||
const TOOLS: ToolSpec[] = [
|
||
{
|
||
permission: "invoices.view",
|
||
definition: {
|
||
name: "list_invoices",
|
||
description:
|
||
"Vydané faktury (faktury, které firma vystavila zákazníkům). Vrací max 20 nejnovějších odpovídajících faktur s částkami včetně DPH. Volej při dotazech na fakturace, tržby konkrétních faktur, splatnosti, neuhrazené faktury.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
status: {
|
||
type: "string",
|
||
enum: ["draft", "issued", "overdue", "paid"],
|
||
description: "Filtr stavu (vynech pro všechny)",
|
||
},
|
||
month: { type: "number", description: "Měsíc vystavení 1-12" },
|
||
year: { type: "number", description: "Rok vystavení" },
|
||
search: {
|
||
type: "string",
|
||
description: "Hledání podle čísla faktury nebo zákazníka",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
handler: async (input) => {
|
||
const res = await listInvoices({
|
||
...LIST,
|
||
sort: "id",
|
||
search: str(input.search) ?? "",
|
||
status: str(input.status),
|
||
month: num(input.month),
|
||
year: num(input.year),
|
||
});
|
||
return {
|
||
total_matching: res.total,
|
||
shown: res.data.length,
|
||
invoices: res.data.map((i) => ({
|
||
number: i.invoice_number,
|
||
customer: i.customer_name,
|
||
status: i.status,
|
||
issue_date: i.issue_date,
|
||
due_date: i.due_date,
|
||
total_with_vat: i.total,
|
||
currency: i.currency,
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
permission: "invoices.view",
|
||
definition: {
|
||
name: "get_invoice_stats",
|
||
description:
|
||
"Měsíční přehled vydaných faktur: zaplaceno tento měsíc, čekající a po splatnosti (vč. přepočtu do CZK) a DPH za měsíc. Volej při dotazech typu 'kolik jsme letos/tento měsíc fakturovali', 'kolik nám dluží'.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
month: {
|
||
type: "number",
|
||
description: "Měsíc 1-12 (výchozí aktuální)",
|
||
},
|
||
year: { type: "number", description: "Rok (výchozí aktuální)" },
|
||
},
|
||
},
|
||
},
|
||
handler: async (input) =>
|
||
getInvoiceStats(num(input.month), num(input.year)),
|
||
},
|
||
{
|
||
permission: "invoices.view",
|
||
definition: {
|
||
name: "list_received_invoices",
|
||
description:
|
||
"Přijaté faktury (faktury od dodavatelů, výdaje firmy). Vrací max 20 nejnovějších; částky jsou VČETNĚ DPH. Volej při dotazech na výdaje, faktury od dodavatele, co je k úhradě.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
month: { type: "number", description: "Měsíc vystavení 1-12" },
|
||
year: { type: "number", description: "Rok vystavení" },
|
||
search: {
|
||
type: "string",
|
||
description: "Hledání podle dodavatele nebo čísla faktury",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
handler: async (input) => {
|
||
// Mirrors the received-invoices route's plain read (no dedicated list
|
||
// service exists); read-only, same visibility (no per-user scoping).
|
||
const where: Record<string, unknown> = {};
|
||
const month = num(input.month);
|
||
const year = num(input.year);
|
||
if (month && year) {
|
||
where.issue_date = {
|
||
gte: new Date(Date.UTC(year, month - 1, 1)),
|
||
lt: new Date(Date.UTC(year, month, 1)),
|
||
};
|
||
}
|
||
const search = str(input.search);
|
||
if (search) {
|
||
where.OR = [
|
||
{ supplier_name: { contains: search } },
|
||
{ invoice_number: { contains: search } },
|
||
];
|
||
}
|
||
const [rows, total] = await Promise.all([
|
||
prisma.received_invoices.findMany({
|
||
where,
|
||
orderBy: [{ issue_date: "desc" }, { id: "desc" }],
|
||
take: 20,
|
||
select: {
|
||
supplier_name: true,
|
||
invoice_number: true,
|
||
amount: true,
|
||
currency: true,
|
||
issue_date: true,
|
||
due_date: true,
|
||
status: true,
|
||
},
|
||
}),
|
||
prisma.received_invoices.count({ where }),
|
||
]);
|
||
return {
|
||
total_matching: total,
|
||
shown: rows.length,
|
||
note: "Částky jsou včetně DPH (gross).",
|
||
invoices: rows.map((r) => ({
|
||
supplier: r.supplier_name,
|
||
number: r.invoice_number,
|
||
amount_with_vat: Number(r.amount),
|
||
currency: r.currency,
|
||
issue_date: r.issue_date,
|
||
due_date: r.due_date,
|
||
status: r.status,
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
permission: "offers.view",
|
||
definition: {
|
||
name: "list_offers",
|
||
description:
|
||
"Nabídky (cenové nabídky zákazníkům). Vrací max 20 nejnovějších; ceny jsou BEZ DPH (nabídka není daňový doklad). Stavy: draft (koncept), active (aktivní), ordered (objednaná zákazníkem), invalidated (zneplatněná).",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
status: {
|
||
type: "string",
|
||
enum: ["draft", "active", "ordered", "invalidated"],
|
||
},
|
||
search: {
|
||
type: "string",
|
||
description: "Hledání podle čísla, projektu nebo zákazníka",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
handler: async (input) => {
|
||
const res = await listOffers({
|
||
...LIST,
|
||
sort: "id",
|
||
search: str(input.search) ?? "",
|
||
status: str(input.status),
|
||
});
|
||
return {
|
||
total_matching: res.total,
|
||
shown: res.data.length,
|
||
note: "Ceny jsou bez DPH.",
|
||
offers: res.data.map((o) => ({
|
||
number: o.quotation_number,
|
||
customer: o.customer_name,
|
||
status: o.status,
|
||
project_code: o.project_code,
|
||
valid_until: o.valid_until,
|
||
total_excl_vat: o.total,
|
||
currency: o.currency,
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
permission: "orders.view",
|
||
definition: {
|
||
name: "list_orders",
|
||
description:
|
||
"Přijaté objednávky (objednávky od zákazníků). Vrací max 20 nejnovějších; ceny BEZ DPH.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
search: {
|
||
type: "string",
|
||
description: "Hledání podle čísla objednávky nebo zákazníka",
|
||
},
|
||
month: { type: "number", description: "Měsíc vytvoření 1-12" },
|
||
year: { type: "number", description: "Rok vytvoření" },
|
||
},
|
||
},
|
||
},
|
||
handler: async (input) => {
|
||
const res = await listOrders({
|
||
...LIST,
|
||
sort: "id",
|
||
search: str(input.search) ?? "",
|
||
month: num(input.month),
|
||
year: num(input.year),
|
||
});
|
||
return {
|
||
total_matching: res.total,
|
||
shown: res.data.length,
|
||
note: "Ceny jsou bez DPH.",
|
||
orders: res.data.map((o) => ({
|
||
number: o.order_number,
|
||
customer_order_number: o.customer_order_number,
|
||
customer: o.customer_name,
|
||
status: o.status,
|
||
created_at: o.created_at,
|
||
total_excl_vat: o.total,
|
||
currency: o.currency,
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
permission: "orders.view",
|
||
definition: {
|
||
name: "list_issued_orders",
|
||
description:
|
||
"Vydané objednávky (objednávky firmy u dodavatelů, nákupy). Vrací max 20 nejnovějších; ceny BEZ DPH. Stavy: draft (koncept), sent (odeslaná), confirmed (potvrzená), completed (dokončená), cancelled (zrušená).",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
status: { type: "string" },
|
||
search: {
|
||
type: "string",
|
||
description: "Hledání podle čísla nebo dodavatele",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
handler: async (input) => {
|
||
const res = await listIssuedOrders({
|
||
...LIST,
|
||
sort: "id",
|
||
search: str(input.search) ?? "",
|
||
status: str(input.status),
|
||
});
|
||
return {
|
||
total_matching: res.total,
|
||
shown: res.data.length,
|
||
note: "Ceny jsou bez DPH.",
|
||
issued_orders: res.data.map((o) => ({
|
||
number: o.po_number,
|
||
supplier: o.supplier_name,
|
||
status: o.status,
|
||
order_date: o.order_date,
|
||
total_excl_vat: o.total,
|
||
currency: o.currency,
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
permission: "projects.view",
|
||
definition: {
|
||
name: "list_projects",
|
||
description:
|
||
"Projekty firmy. Vrací max 20 nejnovějších. Stavy: aktivni, dokonceny, zruseny.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
status: { type: "string", enum: ["aktivni", "dokonceny", "zruseny"] },
|
||
search: {
|
||
type: "string",
|
||
description: "Hledání podle čísla, názvu nebo zákazníka",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
handler: async (input) => {
|
||
const res = await listProjects({
|
||
...LIST,
|
||
sort: "id",
|
||
search: str(input.search) ?? "",
|
||
status: str(input.status),
|
||
});
|
||
return {
|
||
total_matching: res.total,
|
||
shown: res.data.length,
|
||
projects: res.data.map((p) => ({
|
||
number: p.project_number,
|
||
name: p.name,
|
||
customer: p.customer_name,
|
||
responsible: p.responsible_user_name,
|
||
status: p.status,
|
||
start_date: p.start_date,
|
||
end_date: p.end_date,
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
permission: "customers.view",
|
||
definition: {
|
||
name: "list_customers",
|
||
description:
|
||
"Zákazníci firmy (adresy, IČO, DIČ). Vrací max 20 odpovídajících.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
search: {
|
||
type: "string",
|
||
description: "Hledání podle názvu nebo IČO",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
handler: async (input) => {
|
||
// Mirrors the customers route's read (search on name/company_id).
|
||
const search = str(input.search);
|
||
const where = search
|
||
? {
|
||
OR: [
|
||
{ name: { contains: search } },
|
||
{ company_id: { contains: search } },
|
||
],
|
||
}
|
||
: {};
|
||
const [rows, total] = await Promise.all([
|
||
prisma.customers.findMany({
|
||
where,
|
||
orderBy: { name: "asc" },
|
||
take: 20,
|
||
select: {
|
||
name: true,
|
||
city: true,
|
||
company_id: true,
|
||
vat_id: true,
|
||
},
|
||
}),
|
||
prisma.customers.count({ where }),
|
||
]);
|
||
return {
|
||
total_matching: total,
|
||
shown: rows.length,
|
||
customers: rows.map((c) => ({
|
||
name: c.name,
|
||
city: c.city,
|
||
ico: c.company_id,
|
||
dic: c.vat_id,
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
permission: "warehouse.view",
|
||
definition: {
|
||
name: "get_stock_overview",
|
||
description:
|
||
"Sklad: položky pod minimální zásobou, nebo vyhledání skladové položky s aktuální dostupností. Volej při dotazech na sklad, zásoby, co dochází.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
search: {
|
||
type: "string",
|
||
description:
|
||
"Hledání položky podle názvu nebo kódu (vynech pro přehled položek pod minimem)",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
handler: async (input) => {
|
||
const search = str(input.search);
|
||
if (!search) {
|
||
const below = await getBelowMinimumItems();
|
||
return {
|
||
below_minimum_count: below.length,
|
||
items: below.slice(0, 20).map((i) => ({
|
||
item_number: i.item_number,
|
||
name: i.name,
|
||
current_stock: i.current_stock,
|
||
minimum: i.min_quantity,
|
||
unit: i.unit,
|
||
})),
|
||
};
|
||
}
|
||
// Stock math mirrors the service's total-stock rule: sum of
|
||
// unconsumed batch quantities.
|
||
const rows = await prisma.sklad_items.findMany({
|
||
where: {
|
||
is_active: true,
|
||
OR: [
|
||
{ name: { contains: search } },
|
||
{ item_number: { contains: search } },
|
||
],
|
||
},
|
||
take: 20,
|
||
select: {
|
||
item_number: true,
|
||
name: true,
|
||
unit: true,
|
||
batches: {
|
||
where: { is_consumed: false },
|
||
select: { quantity: true },
|
||
},
|
||
},
|
||
});
|
||
return {
|
||
shown: rows.length,
|
||
items: rows.map((r) => ({
|
||
item_number: r.item_number,
|
||
name: r.name,
|
||
unit: r.unit,
|
||
on_stock: r.batches.reduce((s, b) => s + Number(b.quantity), 0),
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
// Own attendance is always visible; other users need attendance.manage —
|
||
// enforced inside the handler. attendance.manage alone (HR profile
|
||
// without own attendance) also unlocks the tool, like the routes.
|
||
permission: ["attendance.record", "attendance.manage"],
|
||
definition: {
|
||
name: "get_attendance_summary",
|
||
description:
|
||
"Docházka: denní záznamy s odpracovanými hodinami (příchod, odchod, hodiny po odečtení pauzy) + souhrn za období. Bez user_id vrací docházku PŘIHLÁŠENÉHO uživatele; cizí docházka vyžaduje oprávnění správy docházky — user_id jiného zaměstnance zjisti nástrojem find_employee. Pro konkrétní den/dny zadej date_from a date_to (klidně stejný den), jinak month/year.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
date_from: {
|
||
type: "string",
|
||
description: "Od, YYYY-MM-DD (má přednost před month/year)",
|
||
},
|
||
date_to: { type: "string", description: "Do, YYYY-MM-DD (včetně)" },
|
||
month: {
|
||
type: "number",
|
||
description: "Měsíc 1-12 (výchozí aktuální)",
|
||
},
|
||
year: { type: "number", description: "Rok (výchozí aktuální)" },
|
||
user_id: {
|
||
type: "number",
|
||
description: "ID jiného uživatele (jen se správou docházky)",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
handler: async (input, ctx) => {
|
||
const targetUserId = num(input.user_id) ?? ctx.userId;
|
||
if (targetUserId !== ctx.userId && !ctxCan(ctx, "attendance.manage")) {
|
||
return { error: DENIED("docházku jiných uživatelů") };
|
||
}
|
||
// Window: explicit day range wins over month/year.
|
||
let from = isoDay(input.date_from);
|
||
let to = isoDay(input.date_to);
|
||
if (from || to) {
|
||
from = from ?? to!;
|
||
to = to ?? from;
|
||
if (from > to) [from, to] = [to, from];
|
||
// Inclusive window: to may be at most from + 61 days = 62 days.
|
||
if (addDays(from, 61) < to) {
|
||
return { error: "Rozsah je příliš velký — maximum je 62 dní." };
|
||
}
|
||
} else {
|
||
const now = new Date();
|
||
const month = num(input.month) ?? now.getMonth() + 1;
|
||
const year = num(input.year) ?? now.getFullYear();
|
||
from = new Date(Date.UTC(year, month - 1, 1));
|
||
to = addDays(new Date(Date.UTC(year, month, 1)), -1);
|
||
}
|
||
// shift_date is @db.Date → UTC-midnight boundaries, half-open upper.
|
||
const rows = await prisma.attendance.findMany({
|
||
where: {
|
||
user_id: targetUserId,
|
||
shift_date: { gte: from, lt: addDays(to, 1) },
|
||
},
|
||
orderBy: [{ shift_date: "asc" }, { id: "asc" }],
|
||
select: {
|
||
shift_date: true,
|
||
arrival_time: true,
|
||
departure_time: true,
|
||
break_start: true,
|
||
break_end: true,
|
||
leave_type: true,
|
||
leave_hours: true,
|
||
},
|
||
});
|
||
const byType: Record<string, number> = {};
|
||
let workedTotal = 0;
|
||
let leaveTotal = 0;
|
||
const days = rows.map((r) => {
|
||
const type = (r.leave_type as string) ?? "work";
|
||
byType[type] = (byType[type] || 0) + 1;
|
||
let worked: number | null = null;
|
||
let leave: number | null = null;
|
||
if (type === "work") {
|
||
if (r.arrival_time && r.departure_time) {
|
||
// Same hours math as the attendance pages (break subtracted).
|
||
worked = round2(
|
||
calcWorkedHours(
|
||
r.arrival_time,
|
||
r.departure_time,
|
||
r.break_start,
|
||
r.break_end,
|
||
),
|
||
);
|
||
workedTotal += worked;
|
||
}
|
||
} else {
|
||
leave = Number(r.leave_hours) || 8;
|
||
leaveTotal += leave;
|
||
}
|
||
return {
|
||
date: dayStr(r.shift_date),
|
||
type,
|
||
arrival: hhmm(r.arrival_time),
|
||
departure: hhmm(r.departure_time),
|
||
worked_hours: worked,
|
||
leave_hours: leave,
|
||
};
|
||
});
|
||
return {
|
||
user_id: targetUserId,
|
||
date_from: dayStr(from),
|
||
date_to: dayStr(to),
|
||
days_recorded: rows.length,
|
||
days_by_type: byType,
|
||
worked_hours_total: round2(workedTotal),
|
||
leave_hours_total: leaveTotal,
|
||
days,
|
||
note: "worked_hours = příchod→odchod minus pauza. Den typu work s worked_hours null je neuzavřená směna (chybí odchod).",
|
||
};
|
||
},
|
||
},
|
||
{
|
||
// Employee-name resolution for the people-centric tools. The VISIBLE
|
||
// POPULATION is scoped per caller permission inside the handler to match
|
||
// the people-picker routes exactly (GET /plan/users, GET /trips/users,
|
||
// GET /users) — the gate alone is not the boundary. trips.history grants
|
||
// no picker route, so it deliberately does not unlock this tool.
|
||
permission: [
|
||
"attendance.record",
|
||
"attendance.manage",
|
||
"trips.record",
|
||
"trips.manage",
|
||
"users.view",
|
||
],
|
||
definition: {
|
||
name: "find_employee",
|
||
description:
|
||
"Najde zaměstnance podle jména, příjmení nebo uživatelského jména a vrátí jeho user_id. Volej VŽDY jako první krok, když se dotaz týká konkrétního zaměstnance (docházka, kniha jízd, plán práce) a neznáš jeho user_id.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
search: {
|
||
type: "string",
|
||
description: "Jméno, příjmení nebo username (stačí část)",
|
||
},
|
||
},
|
||
required: ["search"],
|
||
},
|
||
},
|
||
handler: async (input, ctx) => {
|
||
const search = str(input.search);
|
||
if (!search) {
|
||
return { error: "Zadej jméno nebo uživatelské jméno k vyhledání." };
|
||
}
|
||
const parts = search.split(/\s+/).filter(Boolean);
|
||
const or: Record<string, unknown>[] = [
|
||
{ first_name: { contains: search } },
|
||
{ last_name: { contains: search } },
|
||
{ username: { contains: search } },
|
||
];
|
||
if (parts.length >= 2) {
|
||
// "Dominik Novák" — match first+last in either order.
|
||
const a = parts[0];
|
||
const b = parts.slice(1).join(" ");
|
||
or.push(
|
||
{
|
||
AND: [
|
||
{ first_name: { contains: a } },
|
||
{ last_name: { contains: b } },
|
||
],
|
||
},
|
||
{
|
||
AND: [
|
||
{ first_name: { contains: b } },
|
||
{ last_name: { contains: a } },
|
||
],
|
||
},
|
||
);
|
||
}
|
||
// Population parity with the picker routes: attendance perms see the
|
||
// plan users (active attendance.record role-holders, GET /plan/users),
|
||
// trips perms see the drivers (active trips.record role-holders,
|
||
// GET /trips/users); the full directory incl. inactive accounts only
|
||
// with users.view (GET /users). Admin bypasses via ctxCan.
|
||
const fullDirectory = ctxCan(ctx, "users.view");
|
||
const populations: Record<string, unknown>[] = [];
|
||
if (
|
||
ctxCan(ctx, "attendance.record") ||
|
||
ctxCan(ctx, "attendance.manage")
|
||
) {
|
||
populations.push({
|
||
roles: {
|
||
role_permissions: {
|
||
some: { permissions: { name: "attendance.record" } },
|
||
},
|
||
},
|
||
});
|
||
}
|
||
if (ctxCan(ctx, "trips.record") || ctxCan(ctx, "trips.manage")) {
|
||
populations.push({
|
||
roles: {
|
||
role_permissions: {
|
||
some: { permissions: { name: "trips.record" } },
|
||
},
|
||
},
|
||
});
|
||
}
|
||
if (!fullDirectory && populations.length === 0) {
|
||
return { error: DENIED("seznam zaměstnanců") };
|
||
}
|
||
const rows = await prisma.users.findMany({
|
||
where: fullDirectory
|
||
? { OR: or }
|
||
: { AND: [{ OR: or }, { is_active: true }, { OR: populations }] },
|
||
orderBy: [{ is_active: "desc" }, { last_name: "asc" }, { id: "asc" }],
|
||
take: 10,
|
||
select: {
|
||
id: true,
|
||
first_name: true,
|
||
last_name: true,
|
||
username: true,
|
||
is_active: true,
|
||
},
|
||
});
|
||
// GET /trips/users deliberately omits usernames (login identifiers);
|
||
// only the attendance picker and the users admin expose them.
|
||
const showUsername =
|
||
fullDirectory ||
|
||
ctxCan(ctx, "attendance.record") ||
|
||
ctxCan(ctx, "attendance.manage");
|
||
return {
|
||
shown: rows.length,
|
||
employees: rows.map((u) => ({
|
||
user_id: u.id,
|
||
name: `${u.first_name} ${u.last_name}`.trim(),
|
||
...(showUsername ? { username: u.username } : {}),
|
||
active: u.is_active !== false,
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
// Mirrors GET /trips: any trips permission may read; non-managers are
|
||
// hard-scoped to their own trips (same rule as buildTripsWhere).
|
||
permission: ["trips.record", "trips.history", "trips.manage"],
|
||
definition: {
|
||
name: "list_trips",
|
||
description:
|
||
"Kniha jízd (jízdy firemních vozidel). Vrací max 20 nejnovějších odpovídajících jízd + součty km za CELÝ filtr. Bez oprávnění správy knihy jízd vidí uživatel jen své jízdy. user_id jiného řidiče zjisti nástrojem find_employee.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
user_id: {
|
||
type: "number",
|
||
description: "ID řidiče (cizí jen se správou knihy jízd)",
|
||
},
|
||
vehicle: {
|
||
type: "string",
|
||
description: "Hledání podle názvu vozidla nebo SPZ",
|
||
},
|
||
date_from: {
|
||
type: "string",
|
||
description: "Od, YYYY-MM-DD (má přednost před month/year)",
|
||
},
|
||
date_to: { type: "string", description: "Do, YYYY-MM-DD (včetně)" },
|
||
month: { type: "number", description: "Měsíc 1-12" },
|
||
year: { type: "number", description: "Rok" },
|
||
},
|
||
},
|
||
},
|
||
handler: async (input, ctx) => {
|
||
const isManager = ctxCan(ctx, "trips.manage");
|
||
const requested = num(input.user_id);
|
||
if (requested && requested !== ctx.userId && !isManager) {
|
||
return { error: DENIED("knihu jízd jiných uživatelů") };
|
||
}
|
||
const where: Record<string, unknown> = {};
|
||
if (!isManager) where.user_id = ctx.userId;
|
||
else if (requested) where.user_id = requested;
|
||
|
||
// trip_date is @db.Date → UTC-midnight boundaries, half-open upper.
|
||
let from = isoDay(input.date_from);
|
||
let to = isoDay(input.date_to);
|
||
if (from || to) {
|
||
from = from ?? to!;
|
||
to = to ?? from;
|
||
if (from > to) [from, to] = [to, from];
|
||
where.trip_date = { gte: from, lt: addDays(to, 1) };
|
||
} else {
|
||
const month = num(input.month);
|
||
// A bare month means the current year (like the attendance tool) —
|
||
// silently dropping the filter would let all-time totals masquerade
|
||
// as the asked-about month.
|
||
const year =
|
||
num(input.year) ?? (month ? new Date().getFullYear() : undefined);
|
||
if (month && year) {
|
||
where.trip_date = {
|
||
gte: new Date(Date.UTC(year, month - 1, 1)),
|
||
lt: new Date(Date.UTC(year, month, 1)),
|
||
};
|
||
}
|
||
}
|
||
const vehicle = str(input.vehicle);
|
||
if (vehicle) {
|
||
where.vehicles = {
|
||
is: {
|
||
OR: [
|
||
{ name: { contains: vehicle } },
|
||
{ spz: { contains: vehicle } },
|
||
],
|
||
},
|
||
};
|
||
}
|
||
|
||
const [rows, all] = await Promise.all([
|
||
prisma.trips.findMany({
|
||
where,
|
||
orderBy: [{ trip_date: "desc" }, { id: "desc" }],
|
||
take: 20,
|
||
select: {
|
||
trip_date: true,
|
||
route_from: true,
|
||
route_to: true,
|
||
distance: true,
|
||
start_km: true,
|
||
end_km: true,
|
||
is_business: true,
|
||
users: { select: { first_name: true, last_name: true } },
|
||
vehicles: { select: { name: true, spz: true } },
|
||
},
|
||
}),
|
||
prisma.trips.findMany({
|
||
where,
|
||
select: {
|
||
distance: true,
|
||
start_km: true,
|
||
end_km: true,
|
||
is_business: true,
|
||
},
|
||
}),
|
||
]);
|
||
// Same km coalesce as the /trips/stats endpoint: legacy rows may carry
|
||
// a stored distance differing from end_km - start_km.
|
||
const km = (t: {
|
||
distance: unknown;
|
||
start_km: unknown;
|
||
end_km: unknown;
|
||
}): number =>
|
||
t.distance != null
|
||
? Number(t.distance)
|
||
: Number(t.end_km) - Number(t.start_km);
|
||
let businessKm = 0;
|
||
let privateKm = 0;
|
||
for (const t of all) {
|
||
if (t.is_business) businessKm += km(t);
|
||
else privateKm += km(t);
|
||
}
|
||
return {
|
||
total_matching: all.length,
|
||
shown: rows.length,
|
||
total_km: businessKm + privateKm,
|
||
business_km: businessKm,
|
||
private_km: privateKm,
|
||
trips: rows.map((t) => ({
|
||
date: dayStr(t.trip_date),
|
||
driver: `${t.users.first_name} ${t.users.last_name}`.trim(),
|
||
vehicle: t.vehicles.name,
|
||
spz: t.vehicles.spz,
|
||
from: t.route_from,
|
||
to: t.route_to,
|
||
km: km(t),
|
||
business: t.is_business,
|
||
})),
|
||
};
|
||
},
|
||
},
|
||
{
|
||
// Mirrors GET /plan/grid: the plan is a shared team view readable by
|
||
// anyone who records attendance — no per-user scoping by design.
|
||
permission: ["attendance.record", "attendance.manage"],
|
||
definition: {
|
||
name: "get_work_plan",
|
||
description:
|
||
"Plán práce: kdo má kdy co naplánováno (projekt, dovolená, kategorie dne). Výchozí období je dnešek až +7 dní. Volitelně jen jeden zaměstnanec — jeho user_id zjisti nástrojem find_employee.",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
date_from: {
|
||
type: "string",
|
||
description: "Od, YYYY-MM-DD (výchozí dnes)",
|
||
},
|
||
date_to: {
|
||
type: "string",
|
||
description: "Do, YYYY-MM-DD včetně (výchozí od + 7 dní)",
|
||
},
|
||
user_id: {
|
||
type: "number",
|
||
description: "Jen tento uživatel (vynech pro celý tým)",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
handler: async (input, ctx) => {
|
||
const now = new Date();
|
||
// Local calendar day as UTC midnight (the plan module's date regime).
|
||
const todayUtc = new Date(
|
||
Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()),
|
||
);
|
||
let from = isoDay(input.date_from) ?? todayUtc;
|
||
let to = isoDay(input.date_to) ?? addDays(from, 7);
|
||
if (from > to) [from, to] = [to, from];
|
||
// Inclusive window: to may be at most from + 30 days = 31 days.
|
||
if (addDays(from, 30) < to) {
|
||
return { error: "Rozsah je příliš velký — maximum je 31 dní." };
|
||
}
|
||
const planUsers = await listPlanUsers();
|
||
const nameById = new Map(planUsers.map((u) => [u.id, u.full_name]));
|
||
const requested = num(input.user_id);
|
||
let userIds = planUsers.map((u) => u.id);
|
||
if (requested) {
|
||
userIds = [requested];
|
||
if (!nameById.has(requested)) {
|
||
// Not a plan-grid user (inactive / no attendance role). The grid
|
||
// route never shows these, so reading their historical plan is a
|
||
// management action — attendance.manage only (admin bypasses).
|
||
if (!ctxCan(ctx, "attendance.manage")) {
|
||
return { error: DENIED("plán tohoto uživatele") };
|
||
}
|
||
const u = await prisma.users.findUnique({
|
||
where: { id: requested },
|
||
select: { first_name: true, last_name: true },
|
||
});
|
||
if (!u) return { error: "Uživatel s tímto ID neexistuje." };
|
||
nameById.set(requested, `${u.first_name} ${u.last_name}`.trim());
|
||
}
|
||
}
|
||
const [cells, cats] = await Promise.all([
|
||
resolveGrid(userIds, dayStr(from), dayStr(to)),
|
||
prisma.plan_categories.findMany({
|
||
select: { key: true, label: true },
|
||
}),
|
||
]);
|
||
const labelByKey = new Map(cats.map((c) => [c.key, c.label]));
|
||
const rows: {
|
||
date: string;
|
||
user: string;
|
||
plan: string;
|
||
project: string | null;
|
||
note: string | null;
|
||
}[] = [];
|
||
for (const uid of userIds) {
|
||
const byDate = cells[uid] ?? {};
|
||
for (const date of Object.keys(byDate)) {
|
||
for (const rec of byDate[date]) {
|
||
rows.push({
|
||
date,
|
||
user: nameById.get(uid) ?? `Uživatel #${uid}`,
|
||
plan: labelByKey.get(rec.category) ?? rec.category,
|
||
project: rec.project_number
|
||
? `${rec.project_number} – ${rec.project_name ?? ""}`.trim()
|
||
: (rec.project_name ?? null),
|
||
note: rec.note,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
rows.sort(
|
||
(a, b) =>
|
||
a.date.localeCompare(b.date) || a.user.localeCompare(b.user, "cs"),
|
||
);
|
||
const truncated = rows.length > 60;
|
||
return {
|
||
date_from: dayStr(from),
|
||
date_to: dayStr(to),
|
||
records: rows.length,
|
||
truncated,
|
||
plan: rows.slice(0, 60),
|
||
note: "Dny bez záznamu v plánu nejsou uvedeny.",
|
||
};
|
||
},
|
||
},
|
||
];
|
||
|
||
/** Tool definitions the caller may use — filtered by their permissions. */
|
||
export function toolDefinitionsFor(ctx: AiAuthCtx): Anthropic.Tool[] {
|
||
return TOOLS.filter((t) => canUse(ctx, t.permission)).map(
|
||
(t) => t.definition,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Execute one tool call AS the user. Returns the JSON-serializable result;
|
||
* permission failures and handler errors come back as `{ error }` results
|
||
* (is_error on the tool_result), never as thrown exceptions — the model
|
||
* should relay them, not crash the loop.
|
||
*/
|
||
export async function executeTool(
|
||
name: string,
|
||
input: Record<string, unknown>,
|
||
ctx: AiAuthCtx,
|
||
): Promise<{ ok: boolean; result: unknown }> {
|
||
const tool = TOOLS.find((t) => t.definition.name === name);
|
||
if (!tool)
|
||
return { ok: false, result: { error: `Neznámý nástroj: ${name}` } };
|
||
// The boundary check: even if the model hallucinated a tool it wasn't
|
||
// given, the user's permissions decide.
|
||
if (!canUse(ctx, tool.permission)) {
|
||
return { ok: false, result: { error: DENIED("tato data") } };
|
||
}
|
||
try {
|
||
const result = await tool.handler(input, ctx);
|
||
// Handlers signal in-handler denials/validation as { error } — flag them
|
||
// is_error like the coarse gate does, so the model and the trace chips
|
||
// treat every refusal the same way.
|
||
const isError =
|
||
typeof result === "object" && result !== null && "error" in result;
|
||
return { ok: !isError, result };
|
||
} catch (e) {
|
||
console.error(`[ai-tools] ${name} failed`, e);
|
||
return {
|
||
ok: false,
|
||
result: {
|
||
error: "Nástroj selhal — zkus to jinak nebo to uživateli sděl.",
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
/** Czech display labels for the frontend tool-activity chips. */
|
||
export const TOOL_LABELS: Record<string, string> = {
|
||
list_invoices: "Faktury vydané",
|
||
get_invoice_stats: "Statistiky fakturace",
|
||
list_received_invoices: "Faktury přijaté",
|
||
list_offers: "Nabídky",
|
||
list_orders: "Objednávky přijaté",
|
||
list_issued_orders: "Objednávky vydané",
|
||
list_projects: "Projekty",
|
||
list_customers: "Zákazníci",
|
||
get_stock_overview: "Sklad",
|
||
get_attendance_summary: "Docházka",
|
||
find_employee: "Zaměstnanci",
|
||
list_trips: "Kniha jízd",
|
||
get_work_plan: "Plán práce",
|
||
};
|