feat(odin): document detail tools — line items for offers, orders, invoices, projects

Five read-only detail tools (offer, order, issued order, invoice, project)
looked up by document number (partial match). They call the same getX(id)
services the detail routes use under the same .view permissions (parity
verified route-by-route), and return compact payloads: line items capped at
50 rows with full totals, is_included_in_total respected, rich text
stripped to plain excerpts, invoice VAT broken down per the editor rule
(per-item rate + apply_vat).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-11 11:56:25 +02:00
parent 27b734f6d0
commit 5cd5a9e37d
2 changed files with 412 additions and 5 deletions

View File

@@ -176,6 +176,57 @@ beforeAll(async () => {
});
});
const FIX_OFFER_NUMBER = "2098/NA/99999";
let fixOfferId = 0;
beforeAll(async () => {
await prisma.quotations.deleteMany({
where: { quotation_number: FIX_OFFER_NUMBER },
});
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 &amp; montáž <i>na klíč</i>.</p>",
},
],
},
},
});
fixOfferId = q.id;
});
afterAll(async () => {
// Items/sections cascade with the quotation.
await prisma.quotations.deleteMany({ where: { id: fixOfferId } });
});
afterAll(async () => {
// FK-safe order: plan entries carry a Restrict FK on created_by.
const fixUserIds = [fixUserId, fixUser2Id];
@@ -410,6 +461,56 @@ 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_work_plan resolves the range-entry into per-day rows", async () => {
const res = await executeTool(
"get_work_plan",