feat(odin): HR, suppliers, vehicles, document-totals and received-invoice tools

Seven read-only tools: leave balance (own/attendance.balances parity), work
fund + overtime (getWorkfund), project hours report (attendance.manage),
supplier lookup (inactive only for warehouse.manage — picker-route parity),
vehicles with the route's odometer rule, whole-set document totals per
currency (fixes the sum-of-20-list-rows trap; bare month defaults the year
and the applied period is echoed — adversarial review caught the silent
all-time-as-monthly figure), and received-invoice detail with match count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-11 12:27:34 +02:00
parent aa7b97689b
commit 738f852168
2 changed files with 746 additions and 4 deletions

View File

@@ -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,
@@ -177,12 +187,40 @@ 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.";
let fixOfferId = 0;
let fixSupplierId = 0;
let fixRiId = 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;
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,
@@ -225,6 +263,8 @@ beforeAll(async () => {
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 } });
});
afterAll(async () => {
@@ -511,6 +551,195 @@ describe("ai-tools — employees, attendance detail, trips, work plan", () => {
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 () => {
const res = await executeTool(
"get_work_plan",