Files
app/src/services/ai-tools.ts
BOHA 608ebb02bd feat(odin): people tools - employee lookup, attendance hours, trips, work plan
- 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>
2026-06-11 00:03:39 +02:00

1046 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
};