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>
This commit is contained in:
BOHA
2026-06-11 00:03:39 +02:00
parent 3a3485d029
commit 608ebb02bd
4 changed files with 855 additions and 33 deletions

View File

@@ -6,6 +6,8 @@ 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.
@@ -33,14 +35,24 @@ 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). */
permission: string;
/**
* 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>;
}
@@ -52,6 +64,28 @@ const num = (v: unknown): number | 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",
@@ -472,15 +506,21 @@ const TOOLS: ToolSpec[] = [
},
{
// Own attendance is always visible; other users need attendance.manage —
// enforced inside the handler (the coarse gate is "has attendance.record").
permission: "attendance.record",
// 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:
"Souhrn docházky za měsíc: počty dnů podle typu (práce, dovolená, nemoc…). Bez user_id vrací docházku PŘIHLÁŠENÉHO uživatele; cizí docházka vyžaduje oprávnění správy docházky.",
"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í)",
@@ -494,36 +534,449 @@ const TOOLS: ToolSpec[] = [
},
},
handler: async (input, ctx) => {
const now = new Date();
const month = num(input.month) ?? now.getMonth() + 1;
const year = num(input.year) ?? now.getFullYear();
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ů") };
}
// shift_date is @db.Date → UTC-midnight month boundaries, half-open.
// 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: new Date(Date.UTC(year, month - 1, 1)),
lt: new Date(Date.UTC(year, month, 1)),
},
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,
},
select: { leave_type: true, shift_date: true },
});
const byType: Record<string, number> = {};
for (const r of rows) {
const t = r.leave_type ?? "work";
byType[t] = (byType[t] || 0) + 1;
}
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,
month,
year,
date_from: dayStr(from),
date_to: dayStr(to),
days_recorded: rows.length,
days_by_type: byType,
note: "Počty dnů se záznamem podle typu; ne odpracované hodiny.",
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.",
};
},
},
@@ -531,7 +984,7 @@ const TOOLS: ToolSpec[] = [
/** Tool definitions the caller may use — filtered by their permissions. */
export function toolDefinitionsFor(ctx: AiAuthCtx): Anthropic.Tool[] {
return TOOLS.filter((t) => ctxCan(ctx, t.permission)).map(
return TOOLS.filter((t) => canUse(ctx, t.permission)).map(
(t) => t.definition,
);
}
@@ -552,11 +1005,17 @@ export async function executeTool(
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 (!ctxCan(ctx, tool.permission)) {
if (!canUse(ctx, tool.permission)) {
return { ok: false, result: { error: DENIED("tato data") } };
}
try {
return { ok: true, result: await tool.handler(input, ctx) };
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 {
@@ -580,4 +1039,7 @@ export const TOOL_LABELS: Record<string, string> = {
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",
};