Compare commits
2 Commits
v2.4.21
...
aa7b97689b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7b97689b | ||
|
|
5cd5a9e37d |
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.21",
|
||||
"version": "2.4.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "app-ts",
|
||||
"version": "2.4.21",
|
||||
"version": "2.4.22",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.102.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "2.4.21",
|
||||
"version": "2.4.22",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 () => {
|
||||
// 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",
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type Anthropic from "@anthropic-ai/sdk";
|
||||
import prisma from "../config/database";
|
||||
import { listInvoices, getInvoiceStats } from "./invoices.service";
|
||||
import { listOffers } from "./offers.service";
|
||||
import { listOrders } from "./orders.service";
|
||||
import { listIssuedOrders } from "./issued-orders.service";
|
||||
import { listProjects } from "./projects.service";
|
||||
import { listInvoices, getInvoiceStats, getInvoice } from "./invoices.service";
|
||||
import { listOffers, getOffer } from "./offers.service";
|
||||
import { listOrders, getOrder } from "./orders.service";
|
||||
import { listIssuedOrders, getIssuedOrder } from "./issued-orders.service";
|
||||
import { listProjects, getProject } from "./projects.service";
|
||||
import { getBelowMinimumItems } from "./warehouse.service";
|
||||
import { calcWorkedHours } from "./attendance.service";
|
||||
import { resolveGrid, listPlanUsers } from "./plan.service";
|
||||
@@ -86,6 +86,77 @@ const hhmm = (d: Date | null): string | null =>
|
||||
: null;
|
||||
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[] = [
|
||||
{
|
||||
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. */
|
||||
@@ -1042,4 +1343,9 @@ export const TOOL_LABELS: Record<string, string> = {
|
||||
find_employee: "Zaměstnanci",
|
||||
list_trips: "Kniha jízd",
|
||||
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