|
|
|
|
@@ -112,6 +112,16 @@ beforeAll(async () => {
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
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({
|
|
|
|
|
data: {
|
|
|
|
|
user_id: fixUserId,
|
|
|
|
|
@@ -176,6 +186,107 @@ beforeAll(async () => {
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const FIX_OFFER_NUMBER = "2098/NA/99999";
|
|
|
|
|
const FIX_SUPPLIER = "AiTest Dodavatel s.r.o.";
|
|
|
|
|
const FIX_RI_SUPPLIER = "AiTest Supplier s.r.o.";
|
|
|
|
|
const FIX_CUSTOMER = "AiTest Zákazník s.r.o.";
|
|
|
|
|
let fixOfferId = 0;
|
|
|
|
|
let fixSupplierId = 0;
|
|
|
|
|
let fixRiId = 0;
|
|
|
|
|
let fixCustomerId = 0;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
await prisma.quotations.deleteMany({
|
|
|
|
|
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;
|
|
|
|
|
// Customer + two projects for the per-customer aggregation tool.
|
|
|
|
|
await prisma.projects.deleteMany({
|
|
|
|
|
where: { name: { startsWith: "AiTest Projekt" } },
|
|
|
|
|
});
|
|
|
|
|
await prisma.customers.deleteMany({ where: { name: FIX_CUSTOMER } });
|
|
|
|
|
const cust = await prisma.customers.create({
|
|
|
|
|
data: { name: FIX_CUSTOMER },
|
|
|
|
|
});
|
|
|
|
|
fixCustomerId = cust.id;
|
|
|
|
|
await prisma.projects.createMany({
|
|
|
|
|
data: [
|
|
|
|
|
{ name: "AiTest Projekt 1", customer_id: fixCustomerId },
|
|
|
|
|
{ name: "AiTest Projekt 2", customer_id: fixCustomerId },
|
|
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
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({
|
|
|
|
|
data: {
|
|
|
|
|
quotation_number: FIX_OFFER_NUMBER,
|
|
|
|
|
status: "active",
|
|
|
|
|
currency: "EUR",
|
|
|
|
|
quotation_items: {
|
|
|
|
|
create: [
|
|
|
|
|
{
|
|
|
|
|
position: 1,
|
|
|
|
|
description: "Rozvaděč RVO-1",
|
|
|
|
|
item_description: "<p>Hlavní <b>rozvaděč</b> včetně montáže</p>",
|
|
|
|
|
quantity: 2,
|
|
|
|
|
unit: "ks",
|
|
|
|
|
unit_price: 1000,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
position: 2,
|
|
|
|
|
description: "Doprava (informativní)",
|
|
|
|
|
quantity: 1,
|
|
|
|
|
unit: "ks",
|
|
|
|
|
unit_price: 500,
|
|
|
|
|
is_included_in_total: false,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
scope_sections: {
|
|
|
|
|
create: [
|
|
|
|
|
{
|
|
|
|
|
position: 1,
|
|
|
|
|
title_cz: "Rozsah projektu",
|
|
|
|
|
content: "<p>Dodávka & montáž <i>na klíč</i>.</p>",
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
fixOfferId = q.id;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
// Items/sections cascade with the quotation.
|
|
|
|
|
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
|
|
|
|
|
await prisma.sklad_suppliers.deleteMany({ where: { id: fixSupplierId } });
|
|
|
|
|
await prisma.received_invoices.deleteMany({ where: { id: fixRiId } });
|
|
|
|
|
// Projects before the customer (Restrict FK on projects.customer_id).
|
|
|
|
|
await prisma.projects.deleteMany({ where: { customer_id: fixCustomerId } });
|
|
|
|
|
await prisma.customers.deleteMany({ where: { id: fixCustomerId } });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
|
|
|
|
const fixUserIds = [fixUserId, fixUser2Id];
|
|
|
|
|
@@ -410,6 +521,320 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
|
|
|
|
|
expect(o.total_km).toBe(52); // foreign 100km trip excluded from totals too
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("get_offer_detail returns line items, totals and stripped sections", async () => {
|
|
|
|
|
const res = await executeTool(
|
|
|
|
|
"get_offer_detail",
|
|
|
|
|
{ number: "99999" }, // partial number lookup
|
|
|
|
|
ADMIN,
|
|
|
|
|
);
|
|
|
|
|
expect(res.ok).toBe(true);
|
|
|
|
|
const r = res.result as {
|
|
|
|
|
number: string;
|
|
|
|
|
currency: string;
|
|
|
|
|
item_count: number;
|
|
|
|
|
items_total: number;
|
|
|
|
|
items: {
|
|
|
|
|
name: string;
|
|
|
|
|
detail?: string;
|
|
|
|
|
line_total: number;
|
|
|
|
|
excluded_from_total?: boolean;
|
|
|
|
|
}[];
|
|
|
|
|
sections: { title: string | null; text: string }[];
|
|
|
|
|
};
|
|
|
|
|
expect(r.number).toBe(FIX_OFFER_NUMBER);
|
|
|
|
|
expect(r.currency).toBe("EUR");
|
|
|
|
|
expect(r.item_count).toBe(2);
|
|
|
|
|
expect(r.items[0]).toMatchObject({
|
|
|
|
|
name: "Rozvaděč RVO-1",
|
|
|
|
|
detail: "Hlavní rozvaděč včetně montáže", // HTML stripped
|
|
|
|
|
line_total: 2000,
|
|
|
|
|
});
|
|
|
|
|
expect(r.items[1].excluded_from_total).toBe(true);
|
|
|
|
|
expect(r.items_total).toBe(2000); // excluded row not counted
|
|
|
|
|
expect(r.sections[0]).toMatchObject({ title: "Rozsah projektu" });
|
|
|
|
|
expect(r.sections[0].text).toContain("Dodávka & montáž na klíč");
|
|
|
|
|
|
|
|
|
|
// Unknown number → explicit Czech error, flagged not-ok.
|
|
|
|
|
const miss = await executeTool(
|
|
|
|
|
"get_offer_detail",
|
|
|
|
|
{ number: "NEEXISTUJE-123" },
|
|
|
|
|
ADMIN,
|
|
|
|
|
);
|
|
|
|
|
expect(miss.ok).toBe(false);
|
|
|
|
|
|
|
|
|
|
// No offers.view → denied.
|
|
|
|
|
const denied = await executeTool(
|
|
|
|
|
"get_offer_detail",
|
|
|
|
|
{ number: "99999" },
|
|
|
|
|
NOBODY,
|
|
|
|
|
);
|
|
|
|
|
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" });
|
|
|
|
|
|
|
|
|
|
// IČO typed with spaces finds the space-less stored value.
|
|
|
|
|
const spacedIco = await executeTool(
|
|
|
|
|
"find_supplier",
|
|
|
|
|
{ search: "999 99 998" },
|
|
|
|
|
{ userId: 999999998, roleName: "viewer", permissions: ["orders.view"] },
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(spacedIco.result as { suppliers: { name: string }[] }).suppliers.some(
|
|
|
|
|
(x) => x.name === FIX_SUPPLIER,
|
|
|
|
|
),
|
|
|
|
|
).toBe(true);
|
|
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
|
|
|
|
|
// SPZ spacing is normalized: "AITEST 999" finds the stored "AITEST999",
|
|
|
|
|
// in list_vehicles AND in the list_trips vehicle filter.
|
|
|
|
|
const spaced = await executeTool(
|
|
|
|
|
"list_vehicles",
|
|
|
|
|
{ search: "aitest 999" },
|
|
|
|
|
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(spaced.result as { vehicles: { spz: string }[] }).vehicles[0]?.spz,
|
|
|
|
|
).toBe(FIX.spz);
|
|
|
|
|
const tripsByPlate = await executeTool(
|
|
|
|
|
"list_trips",
|
|
|
|
|
{ vehicle: "AITEST 999", date_from: "2098-06-15", date_to: "2098-06-16" },
|
|
|
|
|
{ userId: fixUserId, roleName: "viewer", permissions: ["trips.history"] },
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(tripsByPlate.result as { total_matching: number }).total_matching,
|
|
|
|
|
).toBe(2);
|
|
|
|
|
|
|
|
|
|
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_top_customers aggregates per customer across the whole table", async () => {
|
|
|
|
|
const res = await executeTool(
|
|
|
|
|
"get_top_customers",
|
|
|
|
|
{ type: "projects" },
|
|
|
|
|
ADMIN,
|
|
|
|
|
);
|
|
|
|
|
expect(res.ok).toBe(true);
|
|
|
|
|
const r = res.result as {
|
|
|
|
|
customers_with_records: number;
|
|
|
|
|
top: { customer: string; count: number }[];
|
|
|
|
|
};
|
|
|
|
|
const mine = r.top.find((t) => t.customer === FIX_CUSTOMER);
|
|
|
|
|
expect(mine).toBeDefined();
|
|
|
|
|
expect(mine!.count).toBe(2);
|
|
|
|
|
expect(r.customers_with_records).toBeGreaterThanOrEqual(1);
|
|
|
|
|
|
|
|
|
|
// invoices.view alone cannot aggregate projects (per-type re-check).
|
|
|
|
|
const denied = await executeTool(
|
|
|
|
|
"get_top_customers",
|
|
|
|
|
{ type: "projects" },
|
|
|
|
|
VIEWER_INVOICES,
|
|
|
|
|
);
|
|
|
|
|
expect(denied.ok).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("find_employee and find_supplier report total_matching", async () => {
|
|
|
|
|
const emp = await executeTool(
|
|
|
|
|
"find_employee",
|
|
|
|
|
{ search: FIX.last },
|
|
|
|
|
RECORDER,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(emp.result as { total_matching: number }).total_matching,
|
|
|
|
|
).toBeGreaterThanOrEqual(1);
|
|
|
|
|
const sup = await executeTool(
|
|
|
|
|
"find_supplier",
|
|
|
|
|
{ search: FIX_SUPPLIER },
|
|
|
|
|
ADMIN,
|
|
|
|
|
);
|
|
|
|
|
expect(
|
|
|
|
|
(sup.result as { total_matching: number }).total_matching,
|
|
|
|
|
).toBeGreaterThanOrEqual(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 () => {
|
|
|
|
|
const res = await executeTool(
|
|
|
|
|
"get_work_plan",
|
|
|
|
|
|