Compare commits
2 Commits
aa7b97689b
...
v2.4.23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0928b3636e | ||
|
|
738f852168 |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.22",
|
"version": "2.4.23",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.22",
|
"version": "2.4.23",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.102.0",
|
"@anthropic-ai/sdk": "^0.102.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "app-ts",
|
"name": "app-ts",
|
||||||
"version": "2.4.22",
|
"version": "2.4.23",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ beforeAll(async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
fixUser2Id = u2.id;
|
fixUser2Id = u2.id;
|
||||||
|
// Leave balance for the HR tools (far-future year per fixture hygiene).
|
||||||
|
await prisma.leave_balances.create({
|
||||||
|
data: {
|
||||||
|
user_id: fixUserId,
|
||||||
|
year: 2098,
|
||||||
|
vacation_total: 25,
|
||||||
|
vacation_used: 5,
|
||||||
|
sick_used: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
await prisma.attendance.create({
|
await prisma.attendance.create({
|
||||||
data: {
|
data: {
|
||||||
user_id: fixUserId,
|
user_id: fixUserId,
|
||||||
@@ -177,12 +187,40 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
||||||
|
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
||||||
|
const FIX_RI_SUPPLIER = "AiTest Supplier s.r.o.";
|
||||||
let fixOfferId = 0;
|
let fixOfferId = 0;
|
||||||
|
let fixSupplierId = 0;
|
||||||
|
let fixRiId = 0;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await prisma.quotations.deleteMany({
|
await prisma.quotations.deleteMany({
|
||||||
where: { quotation_number: FIX_OFFER_NUMBER },
|
where: { quotation_number: FIX_OFFER_NUMBER },
|
||||||
});
|
});
|
||||||
|
await prisma.sklad_suppliers.deleteMany({ where: { name: FIX_SUPPLIER } });
|
||||||
|
await prisma.received_invoices.deleteMany({
|
||||||
|
where: { supplier_name: FIX_RI_SUPPLIER },
|
||||||
|
});
|
||||||
|
const sup = await prisma.sklad_suppliers.create({
|
||||||
|
data: { name: FIX_SUPPLIER, ico: "99999998", city: "Brno" },
|
||||||
|
});
|
||||||
|
fixSupplierId = sup.id;
|
||||||
|
const ri = await prisma.received_invoices.create({
|
||||||
|
data: {
|
||||||
|
month: 6,
|
||||||
|
year: 2098,
|
||||||
|
supplier_name: FIX_RI_SUPPLIER,
|
||||||
|
invoice_number: "AITEST-RI-1",
|
||||||
|
amount: 1210,
|
||||||
|
vat_rate: 21,
|
||||||
|
vat_amount: 210,
|
||||||
|
currency: "CZK",
|
||||||
|
issue_date: new Date(Date.UTC(2098, 5, 1)),
|
||||||
|
due_date: new Date(Date.UTC(2098, 5, 15)),
|
||||||
|
status: "unpaid",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fixRiId = ri.id;
|
||||||
const q = await prisma.quotations.create({
|
const q = await prisma.quotations.create({
|
||||||
data: {
|
data: {
|
||||||
quotation_number: FIX_OFFER_NUMBER,
|
quotation_number: FIX_OFFER_NUMBER,
|
||||||
@@ -225,6 +263,8 @@ beforeAll(async () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
// Items/sections cascade with the quotation.
|
// Items/sections cascade with the quotation.
|
||||||
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
||||||
|
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
||||||
|
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -511,6 +551,195 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
|||||||
expect(denied.ok).toBe(false);
|
expect(denied.ok).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("get_leave_balance: own for a recorder; foreign needs balances/manage", async () => {
|
||||||
|
const own = await executeTool(
|
||||||
|
"get_leave_balance",
|
||||||
|
{ year: 2098 },
|
||||||
|
{
|
||||||
|
userId: fixUserId,
|
||||||
|
roleName: "viewer",
|
||||||
|
permissions: ["attendance.record"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(own.ok).toBe(true);
|
||||||
|
expect(own.result).toMatchObject({
|
||||||
|
user_id: fixUserId,
|
||||||
|
year: 2098,
|
||||||
|
vacation_total: 25,
|
||||||
|
vacation_used: 5,
|
||||||
|
vacation_remaining: 20,
|
||||||
|
sick_used: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bare attendance.record asking about someone else → denied.
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_leave_balance",
|
||||||
|
{ user_id: fixUserId, year: 2098 },
|
||||||
|
RECORDER,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
|
||||||
|
// attendance.balances (the overview route's permission) unlocks others.
|
||||||
|
const hr = await executeTool(
|
||||||
|
"get_leave_balance",
|
||||||
|
{ user_id: fixUserId, year: 2098 },
|
||||||
|
{
|
||||||
|
userId: 999999998,
|
||||||
|
roleName: "viewer",
|
||||||
|
permissions: ["attendance.balances"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(hr.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_work_fund returns the current-month fund; foreign needs manage", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_work_fund",
|
||||||
|
{ user_id: fixUserId },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
fund_hours: number;
|
||||||
|
worked_hours: number;
|
||||||
|
business_days: number;
|
||||||
|
};
|
||||||
|
expect(r.fund_hours).toBeGreaterThan(0);
|
||||||
|
expect(r.business_days).toBeGreaterThan(0);
|
||||||
|
expect(r.worked_hours).toBe(0); // fixture attendance lives in 2098
|
||||||
|
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_work_fund",
|
||||||
|
{ user_id: fixUserId },
|
||||||
|
RECORDER,
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_project_hours_report is manage-gated and aggregates the fixture shift", async () => {
|
||||||
|
const denied = await executeTool("get_project_hours_report", {}, RECORDER);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
|
||||||
|
// The 2098 fixture shift (8.00 h, no project) lands in the labeled
|
||||||
|
// no-project bucket with per-user hours.
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_project_hours_report",
|
||||||
|
{ year: 2098 },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
const r = res.result as {
|
||||||
|
year: number;
|
||||||
|
projects: {
|
||||||
|
label: string;
|
||||||
|
hours: number;
|
||||||
|
by_user: Record<string, number>;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
expect(r.year).toBe(2098);
|
||||||
|
const bucket = r.projects.find((p) => p.label === "(bez projektu)");
|
||||||
|
expect(bucket).toBeDefined();
|
||||||
|
expect(bucket!.hours).toBe(8);
|
||||||
|
expect(bucket!.by_user[`${FIX.first} ${FIX.last}`]).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("find_supplier + list_vehicles return their compact shapes", async () => {
|
||||||
|
const sup = await executeTool(
|
||||||
|
"find_supplier",
|
||||||
|
{ search: "AiTest Dodavatel" },
|
||||||
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||||
|
);
|
||||||
|
expect(sup.ok).toBe(true);
|
||||||
|
const s = (
|
||||||
|
sup.result as { suppliers: { name: string; ico: string | null }[] }
|
||||||
|
).suppliers.find((x) => x.name === FIX_SUPPLIER);
|
||||||
|
expect(s).toMatchObject({ ico: "99999998" });
|
||||||
|
|
||||||
|
const veh = await executeTool(
|
||||||
|
"list_vehicles",
|
||||||
|
{ search: FIX.spz },
|
||||||
|
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
||||||
|
);
|
||||||
|
expect(veh.ok).toBe(true);
|
||||||
|
const v = (
|
||||||
|
veh.result as {
|
||||||
|
vehicles: { spz: string; current_km: number; trip_count: number }[];
|
||||||
|
}
|
||||||
|
).vehicles[0];
|
||||||
|
// Odometer rule: max trip end_km (1050) over initial_km; 3 fixture trips.
|
||||||
|
expect(v).toMatchObject({ spz: FIX.spz, current_km: 1050, trip_count: 3 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await executeTool("find_supplier", { search: "x" }, NOBODY)).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_document_totals sums the WHOLE filtered set per currency", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "offers", search: FIX_OFFER_NUMBER },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(
|
||||||
|
(res.result as { totals: { currency: string; amount: number }[] }).totals,
|
||||||
|
).toEqual([{ currency: "EUR", amount: 2000 }]); // excluded row not counted
|
||||||
|
|
||||||
|
// orders.view alone cannot read offer totals (per-type re-check).
|
||||||
|
const denied = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "offers" },
|
||||||
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
||||||
|
);
|
||||||
|
expect(denied.ok).toBe(false);
|
||||||
|
|
||||||
|
// Offers have no period filter — month/year must error, not be ignored.
|
||||||
|
const offersMonth = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "offers", month: 6, year: 2026 },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(offersMonth.ok).toBe(false);
|
||||||
|
|
||||||
|
// A bare month defaults the year (never an unmarked all-time sum) and
|
||||||
|
// the applied period is echoed back.
|
||||||
|
const bareMonth = await executeTool(
|
||||||
|
"get_document_totals",
|
||||||
|
{ type: "orders", month: 6 },
|
||||||
|
ADMIN,
|
||||||
|
);
|
||||||
|
expect(bareMonth.ok).toBe(true);
|
||||||
|
expect((bareMonth.result as { period: string }).period).toBe(
|
||||||
|
`6/${new Date().getFullYear()}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("get_received_invoice_detail returns the gross-amount detail", async () => {
|
||||||
|
const res = await executeTool(
|
||||||
|
"get_received_invoice_detail",
|
||||||
|
{ search: "AiTest Supplier" },
|
||||||
|
VIEWER_INVOICES,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.result).toMatchObject({
|
||||||
|
supplier: FIX_RI_SUPPLIER,
|
||||||
|
number: "AITEST-RI-1",
|
||||||
|
amount_with_vat: 1210,
|
||||||
|
vat_amount: 210,
|
||||||
|
status: "unpaid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await executeTool(
|
||||||
|
"get_received_invoice_detail",
|
||||||
|
{ search: "x" },
|
||||||
|
NOBODY,
|
||||||
|
)
|
||||||
|
).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
it("get_work_plan resolves the range-entry into per-day rows", async () => {
|
||||||
const res = await executeTool(
|
const res = await executeTool(
|
||||||
"get_work_plan",
|
"get_work_plan",
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import type Anthropic from "@anthropic-ai/sdk";
|
import type Anthropic from "@anthropic-ai/sdk";
|
||||||
import prisma from "../config/database";
|
import prisma from "../config/database";
|
||||||
import { listInvoices, getInvoiceStats, getInvoice } from "./invoices.service";
|
import { listInvoices, getInvoiceStats, getInvoice } from "./invoices.service";
|
||||||
import { listOffers, getOffer } from "./offers.service";
|
import { listOffers, getOffer, getOfferTotals } from "./offers.service";
|
||||||
import { listOrders, getOrder } from "./orders.service";
|
import { listOrders, getOrder, getOrderTotals } from "./orders.service";
|
||||||
import { listIssuedOrders, getIssuedOrder } from "./issued-orders.service";
|
import {
|
||||||
|
listIssuedOrders,
|
||||||
|
getIssuedOrder,
|
||||||
|
getIssuedOrderTotals,
|
||||||
|
} from "./issued-orders.service";
|
||||||
import { listProjects, getProject } from "./projects.service";
|
import { listProjects, getProject } from "./projects.service";
|
||||||
import { getBelowMinimumItems } from "./warehouse.service";
|
import { getBelowMinimumItems } from "./warehouse.service";
|
||||||
import { calcWorkedHours } from "./attendance.service";
|
import {
|
||||||
|
calcWorkedHours,
|
||||||
|
getBalances,
|
||||||
|
getWorkfund,
|
||||||
|
getProjectReport,
|
||||||
|
} from "./attendance.service";
|
||||||
import { resolveGrid, listPlanUsers } from "./plan.service";
|
import { resolveGrid, listPlanUsers } from "./plan.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1281,6 +1290,503 @@ const TOOLS: ToolSpec[] = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Own balance parity: getStatus exposes it to every attendance.record
|
||||||
|
// holder; the all-users overview route requires attendance.balances.
|
||||||
|
// attendance.manage also unlocks foreign balances — DELIBERATE widening:
|
||||||
|
// the manage-gated print route (getPrintData) already returns
|
||||||
|
// vacation_total/remaining for every user, so manage holders gain
|
||||||
|
// nothing new here.
|
||||||
|
permission: [
|
||||||
|
"attendance.record",
|
||||||
|
"attendance.manage",
|
||||||
|
"attendance.balances",
|
||||||
|
],
|
||||||
|
definition: {
|
||||||
|
name: "get_leave_balance",
|
||||||
|
description:
|
||||||
|
"Zůstatek dovolené a čerpání nemocenské za rok (nárok, vyčerpáno, zbývá). Bez user_id vrací zůstatek PŘIHLÁŠENÉHO uživatele; cizí zůstatky vyžadují oprávnění na zůstatky/správu docházky.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user_id: {
|
||||||
|
type: "number",
|
||||||
|
description: "ID jiného uživatele (zjistíš přes find_employee)",
|
||||||
|
},
|
||||||
|
year: { type: "number", description: "Rok (výchozí aktuální)" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input, ctx) => {
|
||||||
|
const targetUserId = num(input.user_id) ?? ctx.userId;
|
||||||
|
if (
|
||||||
|
targetUserId !== ctx.userId &&
|
||||||
|
!ctxCan(ctx, "attendance.balances") &&
|
||||||
|
!ctxCan(ctx, "attendance.manage")
|
||||||
|
) {
|
||||||
|
return { error: DENIED("zůstatky dovolené jiných uživatelů") };
|
||||||
|
}
|
||||||
|
const year = num(input.year) ?? new Date().getFullYear();
|
||||||
|
const { balances } = await getBalances(year);
|
||||||
|
const b = balances[String(targetUserId)];
|
||||||
|
if (!b) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Uživatel není v přehledu docházky (neaktivní nebo bez docházky).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { user_id: targetUserId, year, ...b };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Own month parity: getStatus exposes the fund to the user; the
|
||||||
|
// all-users workfund route requires attendance.manage.
|
||||||
|
permission: ["attendance.record", "attendance.manage"],
|
||||||
|
definition: {
|
||||||
|
name: "get_work_fund",
|
||||||
|
description:
|
||||||
|
"Fond pracovní doby za měsíc: fond hodin (i k dnešnímu dni), odpracováno, pokryto (práce + dovolená/nemoc), přesčas a chybějící hodiny. Bez user_id vrací PŘIHLÁŠENÉHO uživatele; cizí fond vyžaduje správu docházky.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user_id: {
|
||||||
|
type: "number",
|
||||||
|
description: "ID jiného uživatele (jen se správou docházky)",
|
||||||
|
},
|
||||||
|
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, ctx) => {
|
||||||
|
const targetUserId = num(input.user_id) ?? ctx.userId;
|
||||||
|
if (targetUserId !== ctx.userId && !ctxCan(ctx, "attendance.manage")) {
|
||||||
|
return { error: DENIED("fond pracovní doby jiných uživatelů") };
|
||||||
|
}
|
||||||
|
const now = new Date();
|
||||||
|
const month = num(input.month) ?? now.getMonth() + 1;
|
||||||
|
const year = num(input.year) ?? now.getFullYear();
|
||||||
|
if (!Number.isInteger(month) || month > 12) {
|
||||||
|
return { error: "Měsíc musí být celé číslo 1-12." };
|
||||||
|
}
|
||||||
|
const fund = await getWorkfund(year);
|
||||||
|
// getWorkfund's future-year early return types months as `{}` — pin
|
||||||
|
// the indexable shape.
|
||||||
|
const months: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
month_name: string;
|
||||||
|
fund: number;
|
||||||
|
fund_to_date: number;
|
||||||
|
business_days: number;
|
||||||
|
users: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
worked: number;
|
||||||
|
covered: number;
|
||||||
|
overtime: number;
|
||||||
|
missing: number;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
> = fund.months;
|
||||||
|
const m = months[String(month)];
|
||||||
|
if (!m) {
|
||||||
|
return {
|
||||||
|
error: "Pro tento měsíc zatím nejsou data (budoucí období).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const u = m.users[String(targetUserId)];
|
||||||
|
if (!u) {
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Uživatel není v přehledu docházky (neaktivní nebo bez docházky).",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user_id: targetUserId,
|
||||||
|
month,
|
||||||
|
year,
|
||||||
|
month_name: m.month_name,
|
||||||
|
business_days: m.business_days,
|
||||||
|
fund_hours: m.fund,
|
||||||
|
fund_hours_to_date: m.fund_to_date,
|
||||||
|
worked_hours: u.worked,
|
||||||
|
covered_hours: u.covered,
|
||||||
|
overtime_hours: u.overtime,
|
||||||
|
missing_hours: u.missing,
|
||||||
|
note: "Pokryto = odpracováno + dovolená/nemoc; fond k dnešnímu dni počítá jen uplynulé pracovní dny.",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Route parity: action=project_report requires attendance.manage.
|
||||||
|
permission: "attendance.manage",
|
||||||
|
definition: {
|
||||||
|
name: "get_project_hours_report",
|
||||||
|
description:
|
||||||
|
"Report odpracovaných hodin podle PROJEKTŮ za rok (z docházky). Volitelně filtr na jeden projekt (číslo nebo část názvu) a měsíc; vrací hodiny celkem, po měsících a po lidech. Volej na dotazy typu 'kolik hodin jsme strávili na projektu X'.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
year: { type: "number", description: "Rok (výchozí aktuální)" },
|
||||||
|
month: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jen měsíc 1-12 (vynech pro celý rok)",
|
||||||
|
},
|
||||||
|
project: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Číslo nebo část názvu projektu (vynech pro přehled všech)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const year = num(input.year) ?? new Date().getFullYear();
|
||||||
|
const monthFilter = num(input.month);
|
||||||
|
const projectFilter = str(input.project)?.toLowerCase();
|
||||||
|
const report = await getProjectReport(year);
|
||||||
|
|
||||||
|
type Agg = {
|
||||||
|
label: string;
|
||||||
|
project_number?: string;
|
||||||
|
project_name?: string;
|
||||||
|
hours: number;
|
||||||
|
by_month: Record<string, number>;
|
||||||
|
by_user: Record<string, number>;
|
||||||
|
};
|
||||||
|
const byProject = new Map<string, Agg>();
|
||||||
|
for (const [monthKey, m] of Object.entries(report.months)) {
|
||||||
|
if (monthFilter && Number(monthKey) !== monthFilter) continue;
|
||||||
|
for (const p of m.projects) {
|
||||||
|
const label = p.project_number || p.project_name || "(bez projektu)";
|
||||||
|
if (
|
||||||
|
projectFilter &&
|
||||||
|
!`${p.project_number ?? ""} ${p.project_name ?? ""}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(projectFilter)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const agg = byProject.get(label) ?? {
|
||||||
|
// Always-present label: the no-project bucket has undefined
|
||||||
|
// number/name, which JSON.stringify would drop entirely.
|
||||||
|
label,
|
||||||
|
project_number: p.project_number,
|
||||||
|
project_name: p.project_name,
|
||||||
|
hours: 0,
|
||||||
|
by_month: {},
|
||||||
|
by_user: {},
|
||||||
|
};
|
||||||
|
agg.hours += p.hours;
|
||||||
|
agg.by_month[monthKey] = round2(
|
||||||
|
(agg.by_month[monthKey] || 0) + p.hours,
|
||||||
|
);
|
||||||
|
for (const u of p.users) {
|
||||||
|
agg.by_user[u.user_name] = round2(
|
||||||
|
(agg.by_user[u.user_name] || 0) + u.hours,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
byProject.set(label, agg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const projects = [...byProject.values()]
|
||||||
|
.sort((a, b) => b.hours - a.hours)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((p) => ({ ...p, hours: round2(p.hours) }));
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
...(monthFilter ? { month: monthFilter } : {}),
|
||||||
|
projects_matched: byProject.size,
|
||||||
|
projects,
|
||||||
|
note: "Hodiny pocházejí z docházky (záznamy práce s projektem).",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The issued-orders supplier picker serves orders.view; the warehouse
|
||||||
|
// supplier admin serves warehouse.manage — the fields returned here are
|
||||||
|
// the same set getIssuedOrder already exposes to orders.view holders.
|
||||||
|
permission: ["warehouse.manage", "orders.view"],
|
||||||
|
definition: {
|
||||||
|
name: "find_supplier",
|
||||||
|
description:
|
||||||
|
"Najde dodavatele podle názvu, IČO nebo města (sklad + objednávky vydané). Vrací max 10 odpovídajících.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
search: {
|
||||||
|
type: "string",
|
||||||
|
description: "Název, IČO nebo město (stačí část)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["search"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input, ctx) => {
|
||||||
|
const search = str(input.search);
|
||||||
|
if (!search) return { error: "Zadej název, IČO nebo město dodavatele." };
|
||||||
|
// Route parity: the orders.view picker (GET /issued-orders/suppliers)
|
||||||
|
// filters is_active: true — only the warehouse.manage supplier admin
|
||||||
|
// sees deactivated (soft-deleted) suppliers.
|
||||||
|
const rows = await prisma.sklad_suppliers.findMany({
|
||||||
|
where: {
|
||||||
|
...(ctxCan(ctx, "warehouse.manage") ? {} : { is_active: true }),
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search } },
|
||||||
|
{ ico: { contains: search } },
|
||||||
|
{ city: { contains: search } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: [{ is_active: "desc" }, { name: "asc" }, { id: "asc" }],
|
||||||
|
take: 10,
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
city: true,
|
||||||
|
ico: true,
|
||||||
|
dic: true,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
shown: rows.length,
|
||||||
|
suppliers: rows.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
city: s.city,
|
||||||
|
ico: s.ico,
|
||||||
|
dic: s.dic,
|
||||||
|
active: s.is_active,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Mirrors GET /vehicles: any trips permission or vehicle management.
|
||||||
|
permission: [
|
||||||
|
"vehicles.manage",
|
||||||
|
"trips.record",
|
||||||
|
"trips.manage",
|
||||||
|
"trips.history",
|
||||||
|
],
|
||||||
|
definition: {
|
||||||
|
name: "list_vehicles",
|
||||||
|
description:
|
||||||
|
"Firemní vozidla: SPZ, značka/model, aktuální stav tachometru a počet jízd. Volej na dotazy typu 'kolik má najeto dodávka'.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
search: {
|
||||||
|
type: "string",
|
||||||
|
description: "Hledání podle názvu nebo SPZ (vynech pro všechna)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const search = str(input.search);
|
||||||
|
const where = search
|
||||||
|
? {
|
||||||
|
OR: [{ name: { contains: search } }, { spz: { contains: search } }],
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
// Same odometer rule as GET /vehicles: max(initial_km, max trip end_km).
|
||||||
|
const [vehicles, total, tripStats] = await Promise.all([
|
||||||
|
prisma.vehicles.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||||
|
take: 20,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
spz: true,
|
||||||
|
brand: true,
|
||||||
|
model: true,
|
||||||
|
initial_km: true,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.vehicles.count({ where }),
|
||||||
|
prisma.trips.groupBy({
|
||||||
|
by: ["vehicle_id"],
|
||||||
|
_max: { end_km: true },
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const statsMap = new Map(
|
||||||
|
tripStats.map((s) => [
|
||||||
|
s.vehicle_id,
|
||||||
|
{ maxKm: s._max.end_km ?? 0, count: s._count.id },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
total_matching: total,
|
||||||
|
shown: vehicles.length,
|
||||||
|
vehicles: vehicles.map((v) => {
|
||||||
|
const stats = statsMap.get(v.id);
|
||||||
|
return {
|
||||||
|
name: v.name,
|
||||||
|
spz: v.spz,
|
||||||
|
brand: [v.brand, v.model].filter(Boolean).join(" ") || null,
|
||||||
|
current_km: stats
|
||||||
|
? Math.max(v.initial_km, stats.maxKm)
|
||||||
|
: v.initial_km,
|
||||||
|
trip_count: stats?.count ?? 0,
|
||||||
|
active: v.is_active,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Coarse gate any-of; the handler re-checks the exact permission per
|
||||||
|
// document type (offers vs orders families).
|
||||||
|
permission: ["offers.view", "orders.view"],
|
||||||
|
definition: {
|
||||||
|
name: "get_document_totals",
|
||||||
|
description:
|
||||||
|
"Součty hodnot dokumentů za CELÝ filtr (ne jen prvních 20 z listu!) po měnách, BEZ DPH. Typy: offers (nabídky), orders (objednávky přijaté), issued_orders (objednávky vydané). VŽDY použij tento nástroj místo sčítání řádků z list_* nástrojů, když je výsledků víc než 20.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["offers", "orders", "issued_orders"],
|
||||||
|
description: "Typ dokumentů",
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
description: "Filtr stavu (vynech pro všechny)",
|
||||||
|
},
|
||||||
|
search: {
|
||||||
|
type: "string",
|
||||||
|
description: "Hledání jako v list_* nástroji",
|
||||||
|
},
|
||||||
|
month: {
|
||||||
|
type: "number",
|
||||||
|
description: "Měsíc 1-12 (jen orders/issued_orders)",
|
||||||
|
},
|
||||||
|
year: {
|
||||||
|
type: "number",
|
||||||
|
description: "Rok (jen orders/issued_orders)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["type"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input, ctx) => {
|
||||||
|
const type = str(input.type);
|
||||||
|
const month = num(input.month);
|
||||||
|
// A bare month means the current year — the where-builders apply the
|
||||||
|
// period only when BOTH are set, and a silently dropped filter would
|
||||||
|
// let all-time totals masquerade as the asked-about month.
|
||||||
|
const year =
|
||||||
|
num(input.year) ?? (month ? new Date().getFullYear() : undefined);
|
||||||
|
if (month && (!Number.isInteger(month) || month > 12)) {
|
||||||
|
return { error: "Měsíc musí být celé číslo 1-12." };
|
||||||
|
}
|
||||||
|
const filters = {
|
||||||
|
search: str(input.search),
|
||||||
|
status: str(input.status),
|
||||||
|
month,
|
||||||
|
year,
|
||||||
|
};
|
||||||
|
// Echoed so the model (and the user) always see which period the sum
|
||||||
|
// actually covers.
|
||||||
|
const period =
|
||||||
|
month && year ? `${month}/${year}` : "vše (bez filtru období)";
|
||||||
|
if (type === "offers") {
|
||||||
|
if (!ctxCan(ctx, "offers.view")) return { error: DENIED("nabídky") };
|
||||||
|
if (month || num(input.year)) {
|
||||||
|
// OfferFilterParams has no month/year — they'd be silently ignored
|
||||||
|
// and the all-time sum would pose as a monthly figure.
|
||||||
|
return {
|
||||||
|
error:
|
||||||
|
"Nabídky nelze filtrovat podle měsíce/roku — souhrn nabídek je vždy za všechny odpovídající; vynech month/year.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { totals } = await getOfferTotals(filters);
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
period: "vše",
|
||||||
|
totals,
|
||||||
|
note: "Součty bez DPH, po měnách.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === "orders") {
|
||||||
|
if (!ctxCan(ctx, "orders.view")) return { error: DENIED("objednávky") };
|
||||||
|
const { totals } = await getOrderTotals(filters);
|
||||||
|
return { type, period, totals, note: "Součty bez DPH, po měnách." };
|
||||||
|
}
|
||||||
|
if (type === "issued_orders") {
|
||||||
|
if (!ctxCan(ctx, "orders.view")) {
|
||||||
|
return { error: DENIED("objednávky vydané") };
|
||||||
|
}
|
||||||
|
const { totals } = await getIssuedOrderTotals(filters);
|
||||||
|
return { type, period, totals, note: "Součty bez DPH, po měnách." };
|
||||||
|
}
|
||||||
|
return { error: "Neznámý typ dokumentu." };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permission: "invoices.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_received_invoice_detail",
|
||||||
|
description:
|
||||||
|
"Detail jedné přijaté faktury (od dodavatele): částka VČETNĚ DPH, stav, splatnost/uhrazení, popis a interní poznámky. Hledej podle čísla faktury nebo názvu dodavatele.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
search: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo faktury nebo název dodavatele (stačí část)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["search"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const search = str(input.search);
|
||||||
|
if (!search) return { error: "Zadej číslo faktury nebo dodavatele." };
|
||||||
|
const where = {
|
||||||
|
OR: [
|
||||||
|
{ invoice_number: { contains: search } },
|
||||||
|
{ supplier_name: { contains: search } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const [inv, matched] = await Promise.all([
|
||||||
|
prisma.received_invoices.findFirst({
|
||||||
|
where,
|
||||||
|
orderBy: [{ issue_date: "desc" }, { id: "desc" }],
|
||||||
|
}),
|
||||||
|
prisma.received_invoices.count({ where }),
|
||||||
|
]);
|
||||||
|
if (!inv) {
|
||||||
|
return { error: "Přijatá faktura odpovídající hledání neexistuje." };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total_matching: matched,
|
||||||
|
supplier: inv.supplier_name,
|
||||||
|
number: inv.invoice_number,
|
||||||
|
description: inv.description,
|
||||||
|
amount_with_vat: Number(inv.amount),
|
||||||
|
vat_rate: Number(inv.vat_rate),
|
||||||
|
vat_amount: Number(inv.vat_amount),
|
||||||
|
currency: inv.currency,
|
||||||
|
issue_date: inv.issue_date,
|
||||||
|
due_date: inv.due_date,
|
||||||
|
paid_date: inv.paid_date,
|
||||||
|
status: inv.status,
|
||||||
|
attachment: inv.file_name,
|
||||||
|
internal_notes: stripHtml(inv.notes).slice(0, 300) || null,
|
||||||
|
note: "Částka je včetně DPH (gross). Zobrazena nejnovější odpovídající faktura.",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Tool definitions the caller may use — filtered by their permissions. */
|
/** Tool definitions the caller may use — filtered by their permissions. */
|
||||||
@@ -1348,4 +1854,11 @@ export const TOOL_LABELS: Record<string, string> = {
|
|||||||
get_issued_order_detail: "Detail obj. vydané",
|
get_issued_order_detail: "Detail obj. vydané",
|
||||||
get_invoice_detail: "Detail faktury",
|
get_invoice_detail: "Detail faktury",
|
||||||
get_project_detail: "Detail projektu",
|
get_project_detail: "Detail projektu",
|
||||||
|
get_leave_balance: "Zůstatek dovolené",
|
||||||
|
get_work_fund: "Fond pracovní doby",
|
||||||
|
get_project_hours_report: "Hodiny na projektech",
|
||||||
|
find_supplier: "Dodavatelé",
|
||||||
|
list_vehicles: "Vozidla",
|
||||||
|
get_document_totals: "Součty dokumentů",
|
||||||
|
get_received_invoice_detail: "Detail přijaté faktury",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user