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:
@@ -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 & 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 () => {
|
afterAll(async () => {
|
||||||
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
// FK-safe order: plan entries carry a Restrict FK on created_by.
|
||||||
const fixUserIds = [fixUserId, fixUser2Id];
|
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
|
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 () => {
|
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,10 +1,10 @@
|
|||||||
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 } from "./invoices.service";
|
import { listInvoices, getInvoiceStats, getInvoice } from "./invoices.service";
|
||||||
import { listOffers } from "./offers.service";
|
import { listOffers, getOffer } from "./offers.service";
|
||||||
import { listOrders } from "./orders.service";
|
import { listOrders, getOrder } from "./orders.service";
|
||||||
import { listIssuedOrders } from "./issued-orders.service";
|
import { listIssuedOrders, getIssuedOrder } from "./issued-orders.service";
|
||||||
import { listProjects } 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 } from "./attendance.service";
|
||||||
import { resolveGrid, listPlanUsers } from "./plan.service";
|
import { resolveGrid, listPlanUsers } from "./plan.service";
|
||||||
@@ -86,6 +86,77 @@ const hhmm = (d: Date | null): string | null =>
|
|||||||
: null;
|
: null;
|
||||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||||
|
|
||||||
|
/** Quill/HTML rich text → compact plain text for model context. */
|
||||||
|
const stripHtml = (html: string | null | undefined): string =>
|
||||||
|
(html ?? "")
|
||||||
|
.replace(/<[^>]+>/g, " ")
|
||||||
|
.replace(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/gi, ">")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
interface DocItemRow {
|
||||||
|
description: string | null;
|
||||||
|
item_description: string | null;
|
||||||
|
quantity: unknown;
|
||||||
|
unit: string | null;
|
||||||
|
unit_price: unknown;
|
||||||
|
is_included_in_total?: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared line-item mapping for the document detail tools. Shows up to 50
|
||||||
|
* rows; the items_total still covers ALL included rows (same rule as the
|
||||||
|
* editors: is_included_in_total === false rows don't count).
|
||||||
|
*/
|
||||||
|
const mapDocItems = (rows: DocItemRow[]) => {
|
||||||
|
let total = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.is_included_in_total !== false) {
|
||||||
|
total += Number(r.quantity ?? 0) * Number(r.unit_price ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
item_count: rows.length,
|
||||||
|
items_shown: Math.min(rows.length, 50),
|
||||||
|
items: rows.slice(0, 50).map((r) => {
|
||||||
|
const qty = Number(r.quantity ?? 0);
|
||||||
|
const price = Number(r.unit_price ?? 0);
|
||||||
|
const detail = stripHtml(r.item_description);
|
||||||
|
return {
|
||||||
|
name: r.description,
|
||||||
|
...(detail ? { detail: detail.slice(0, 300) } : {}),
|
||||||
|
quantity: qty,
|
||||||
|
unit: r.unit,
|
||||||
|
unit_price: price,
|
||||||
|
line_total: round2(qty * price),
|
||||||
|
...(r.is_included_in_total === false
|
||||||
|
? { excluded_from_total: true }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
items_total: round2(total),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DocSectionRow {
|
||||||
|
title: string | null;
|
||||||
|
title_cz: string | null;
|
||||||
|
content: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rich-text sections → short plain-text excerpts (max 10 × 500 chars). */
|
||||||
|
const mapDocSections = (rows: DocSectionRow[]) =>
|
||||||
|
rows
|
||||||
|
.map((s) => ({
|
||||||
|
title: s.title_cz || s.title || null,
|
||||||
|
text: stripHtml(s.content).slice(0, 500),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.title || s.text)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
const TOOLS: ToolSpec[] = [
|
const TOOLS: ToolSpec[] = [
|
||||||
{
|
{
|
||||||
permission: "invoices.view",
|
permission: "invoices.view",
|
||||||
@@ -980,6 +1051,236 @@ const TOOLS: ToolSpec[] = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
permission: "offers.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_offer_detail",
|
||||||
|
description:
|
||||||
|
"Detail jedné nabídky včetně POLOŽEK (rozpis řádků s množstvím, jednotkovými cenami a součtem) a textových sekcí. Volej, když se uživatel ptá na obsah/položky konkrétní nabídky. Číslo nabídky zjistíš z list_offers nebo od uživatele; koncepty bez čísla detail nemají. Ceny BEZ DPH.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo nabídky, stačí část (např. 10035)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo nabídky." };
|
||||||
|
const found = await prisma.quotations.findFirst({
|
||||||
|
where: { quotation_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) return { error: "Nabídka s tímto číslem neexistuje." };
|
||||||
|
const o = await getOffer(found.id);
|
||||||
|
if (!o) return { error: "Nabídka nenalezena." };
|
||||||
|
return {
|
||||||
|
number: o.quotation_number,
|
||||||
|
status: o.status,
|
||||||
|
customer: o.customer_name,
|
||||||
|
project_code: o.project_code,
|
||||||
|
valid_until: o.valid_until,
|
||||||
|
currency: o.currency,
|
||||||
|
order_number: o.order?.order_number ?? null,
|
||||||
|
note: "Ceny jsou bez DPH (nabídka není daňový doklad).",
|
||||||
|
...mapDocItems(o.items),
|
||||||
|
sections: mapDocSections(o.sections),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permission: "orders.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_order_detail",
|
||||||
|
description:
|
||||||
|
"Detail jedné přijaté objednávky (od zákazníka) včetně POLOŽEK a textových sekcí, s vazbami na nabídku, projekt a fakturu. Číslo objednávky zjistíš z list_orders. Ceny BEZ DPH.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo objednávky, stačí část",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo objednávky." };
|
||||||
|
const found = await prisma.orders.findFirst({
|
||||||
|
where: { order_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) return { error: "Objednávka s tímto číslem neexistuje." };
|
||||||
|
const o = await getOrder(found.id);
|
||||||
|
if (!o) return { error: "Objednávka nenalezena." };
|
||||||
|
return {
|
||||||
|
number: o.order_number,
|
||||||
|
customer_order_number: o.customer_order_number,
|
||||||
|
status: o.status,
|
||||||
|
customer: o.customer_name,
|
||||||
|
currency: o.currency,
|
||||||
|
quotation_number: o.quotation_number,
|
||||||
|
project_number: o.project?.project_number ?? null,
|
||||||
|
invoice_number: o.invoice_number,
|
||||||
|
note: "Ceny jsou bez DPH.",
|
||||||
|
...mapDocItems(o.items),
|
||||||
|
sections: mapDocSections(o.sections),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permission: "orders.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_issued_order_detail",
|
||||||
|
description:
|
||||||
|
"Detail jedné vydané objednávky (nákup u dodavatele) včetně POLOŽEK a obsahu. Číslo zjistíš z list_issued_orders. Ceny BEZ DPH.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo vydané objednávky, stačí část",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo vydané objednávky." };
|
||||||
|
const found = await prisma.issued_orders.findFirst({
|
||||||
|
where: { po_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) {
|
||||||
|
return { error: "Vydaná objednávka s tímto číslem neexistuje." };
|
||||||
|
}
|
||||||
|
const o = await getIssuedOrder(found.id);
|
||||||
|
if (!o) return { error: "Vydaná objednávka nenalezena." };
|
||||||
|
return {
|
||||||
|
number: o.po_number,
|
||||||
|
status: o.status,
|
||||||
|
supplier: o.supplier_name,
|
||||||
|
order_date: o.order_date,
|
||||||
|
currency: o.currency,
|
||||||
|
note: "Ceny jsou bez DPH.",
|
||||||
|
...mapDocItems(o.items),
|
||||||
|
sections: mapDocSections(o.sections),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permission: "invoices.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_invoice_detail",
|
||||||
|
description:
|
||||||
|
"Detail jedné vydané faktury včetně POLOŽEK a rozpisu DPH (základ, DPH, celkem s DPH). Číslo faktury zjistíš z list_invoices.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo faktury, stačí část",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo faktury." };
|
||||||
|
const found = await prisma.invoices.findFirst({
|
||||||
|
where: { invoice_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) return { error: "Faktura s tímto číslem neexistuje." };
|
||||||
|
const inv = await getInvoice(found.id);
|
||||||
|
if (!inv) return { error: "Faktura nenalezena." };
|
||||||
|
// Same VAT rule as the invoice editor: per-item vat_rate, applied only
|
||||||
|
// when the header apply_vat switch is on.
|
||||||
|
let net = 0;
|
||||||
|
let vat = 0;
|
||||||
|
for (const it of inv.items) {
|
||||||
|
const line = Number(it.quantity ?? 0) * Number(it.unit_price ?? 0);
|
||||||
|
net += line;
|
||||||
|
if (inv.apply_vat !== false) {
|
||||||
|
vat += line * (Number(it.vat_rate ?? 0) / 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
number: inv.invoice_number,
|
||||||
|
status: inv.status,
|
||||||
|
customer: inv.customer_name,
|
||||||
|
order_number: inv.order_number,
|
||||||
|
issue_date: inv.issue_date,
|
||||||
|
due_date: inv.due_date,
|
||||||
|
currency: inv.currency,
|
||||||
|
net_total: round2(net),
|
||||||
|
vat_total: round2(vat),
|
||||||
|
total_with_vat: round2(net + vat),
|
||||||
|
...mapDocItems(inv.items),
|
||||||
|
sections: mapDocSections(inv.sections),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
permission: "projects.view",
|
||||||
|
definition: {
|
||||||
|
name: "get_project_detail",
|
||||||
|
description:
|
||||||
|
"Detail jednoho projektu: stav, zákazník, odpovědná osoba, termíny, vazby na nabídku/objednávku a poslední poznámky. Číslo projektu zjistíš z list_projects.",
|
||||||
|
input_schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
number: {
|
||||||
|
type: "string",
|
||||||
|
description: "Číslo projektu, stačí část",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
handler: async (input) => {
|
||||||
|
const number = str(input.number);
|
||||||
|
if (!number) return { error: "Zadej číslo projektu." };
|
||||||
|
const found = await prisma.projects.findFirst({
|
||||||
|
where: { project_number: { contains: number } },
|
||||||
|
orderBy: { id: "desc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!found) return { error: "Projekt s tímto číslem neexistuje." };
|
||||||
|
const p = await getProject(found.id);
|
||||||
|
if (!p) return { error: "Projekt nenalezen." };
|
||||||
|
return {
|
||||||
|
number: p.project_number,
|
||||||
|
name: p.name,
|
||||||
|
status: p.status,
|
||||||
|
customer: p.customer_name,
|
||||||
|
responsible: p.responsible_user_name,
|
||||||
|
start_date: p.start_date,
|
||||||
|
end_date: p.end_date,
|
||||||
|
quotation_number: p.quotation_number,
|
||||||
|
order_number: p.order_number,
|
||||||
|
order_status: p.order_status,
|
||||||
|
description: stripHtml(p.notes).slice(0, 500) || null,
|
||||||
|
recent_notes: p.project_notes.slice(0, 5).map((n) => ({
|
||||||
|
author: n.user_name,
|
||||||
|
date: n.created_at,
|
||||||
|
text: stripHtml(n.content).slice(0, 300),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Tool definitions the caller may use — filtered by their permissions. */
|
/** Tool definitions the caller may use — filtered by their permissions. */
|
||||||
@@ -1042,4 +1343,9 @@ export const TOOL_LABELS: Record<string, string> = {
|
|||||||
find_employee: "Zaměstnanci",
|
find_employee: "Zaměstnanci",
|
||||||
list_trips: "Kniha jízd",
|
list_trips: "Kniha jízd",
|
||||||
get_work_plan: "Plán práce",
|
get_work_plan: "Plán práce",
|
||||||
|
get_offer_detail: "Detail nabídky",
|
||||||
|
get_order_detail: "Detail objednávky",
|
||||||
|
get_issued_order_detail: "Detail obj. vydané",
|
||||||
|
get_invoice_detail: "Detail faktury",
|
||||||
|
get_project_detail: "Detail projektu",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user