mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
feat(invoices): M8 AI tools — create/mark_paid/list/stats
The last open item from the plan. Missions can now draft invoices from chat context, mark customer payments, and read status for autonomous follow-up cadences. Tool catalog (packages/shared-ai/src/tools/schemas.ts) - create_invoice (propose) — clientName + lines[] + currency + due - mark_invoice_paid (propose) — by id, optional back-dated paidAt - list_invoices (auto) — with status + limit filter - get_invoice_stats (auto) — open/overdue/YTD per currency Had to widen the tool-parameter type vocabulary so create_invoice can declare lines as a typed array. Touched three places: - ToolSchema-side: the catalog's `type` string is already free-form so 'array' / 'object' just pass through - ModuleTool-side (apps/mana/apps/web/src/lib/data/tools/types.ts): added 'array' | 'object' to the union so TS doesn't narrow the executor's param signatures - function-schema translator (packages/shared-ai): mapParamType + JsonSchemaProperty both gained the two new types; the catalog-typo guard test now uses 'fruit' as its sentinel (array no longer unknown) Executor (apps/mana/apps/web/src/lib/modules/invoices/tools.ts) - coerceLines accepts either a real array or a JSON-stringified array (planners vary), skips malformed entries, converts major→minor units - create_invoice pulls the generated number back from Dexie so the success message shows "Entwurf 2026-0042 …" — the user recognises it - mark_invoice_paid normalises YYYY-MM-DD → ISO so the store's timestamp invariant (ISO throughout) stays intact - list_invoices derives overdue on read (consistent with useAllInvoices), returns major-unit amounts so the LLM reasons in user-facing numbers - get_invoice_stats returns counts + open/overdue/YTD per currency Registration: invoicesTools added to tools/init.ts. mana-ai drift guard is happy (41/41 green); webapp + shared-ai type-check 0 errors; full invoice test suite 59/59 green. Closes: docs/plans/invoices-module.md §M8. All plan milestones now DONE. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d613e1846
commit
a4bc7d2ee3
7 changed files with 427 additions and 4 deletions
|
|
@ -41,6 +41,7 @@ import { moodTools } from '$lib/modules/mood/tools';
|
|||
import { wishesTools } from '$lib/modules/wishes/tools';
|
||||
import { wetterTools } from '$lib/modules/wetter/tools';
|
||||
import { quizTools } from '$lib/modules/quiz/tools';
|
||||
import { invoicesTools } from '$lib/modules/invoices/tools';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
|
|
@ -83,5 +84,6 @@ export function initTools(): void {
|
|||
registerTools(wishesTools);
|
||||
registerTools(wetterTools);
|
||||
registerTools(quizTools);
|
||||
registerTools(invoicesTools);
|
||||
initialized = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,10 @@ export interface ModuleTool {
|
|||
|
||||
export interface ToolParameter {
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
/** JSON Schema primitive types. `array` / `object` used by tools that
|
||||
* accept structured payloads (e.g. invoice line items) — parsing
|
||||
* is the tool's responsibility inside execute(). */
|
||||
type: 'string' | 'number' | 'boolean' | 'array' | 'object';
|
||||
description: string;
|
||||
required: boolean;
|
||||
enum?: string[];
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ export { generateSCORReference, QRBillError } from './pdf/qr-bill';
|
|||
export { invoicesStore } from './stores/invoices.svelte';
|
||||
export { invoiceSettingsStore, ensureSettings } from './stores/settings.svelte';
|
||||
|
||||
export { invoicesTools } from './tools';
|
||||
|
||||
export type {
|
||||
LocalInvoice,
|
||||
LocalInvoiceLine,
|
||||
|
|
|
|||
301
apps/mana/apps/web/src/lib/modules/invoices/tools.ts
Normal file
301
apps/mana/apps/web/src/lib/modules/invoices/tools.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
/**
|
||||
* Invoice Tools — LLM-accessible operations.
|
||||
*
|
||||
* Schema definitions live in @mana/shared-ai's AI_TOOL_CATALOG (the SSOT);
|
||||
* this file provides the matching `execute` fns that call the local store.
|
||||
* Amounts arrive from the LLM in major units (CHF 150.00) and get
|
||||
* converted to minor units (15000) here.
|
||||
*/
|
||||
|
||||
import type { ModuleTool } from '$lib/data/tools/types';
|
||||
import { invoiceTable } from './collections';
|
||||
import { decryptRecords } from '$lib/data/crypto';
|
||||
import { invoicesStore } from './stores/invoices.svelte';
|
||||
import { toInvoice, computeStats, formatAmount } from './queries';
|
||||
import { CURRENCIES } from './constants';
|
||||
import type {
|
||||
LocalInvoice,
|
||||
LocalInvoiceLine,
|
||||
Invoice,
|
||||
InvoiceStatus,
|
||||
Currency,
|
||||
InvoiceClientSnapshot,
|
||||
} from './types';
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────
|
||||
|
||||
async function listDecryptedInvoices(): Promise<Invoice[]> {
|
||||
const rows = await invoiceTable.toArray();
|
||||
const visible = rows.filter((r) => !r.deletedAt);
|
||||
const decrypted = await decryptRecords<LocalInvoice>('invoices', visible);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return decrypted.map((local) => {
|
||||
const inv = toInvoice(local);
|
||||
if (inv.status === 'sent' && inv.dueDate < today) {
|
||||
return { ...inv, status: 'overdue' as InvoiceStatus };
|
||||
}
|
||||
return inv;
|
||||
});
|
||||
}
|
||||
|
||||
interface RawLine {
|
||||
title?: string;
|
||||
quantity?: number;
|
||||
unitPrice?: number;
|
||||
vatRate?: number;
|
||||
unit?: string;
|
||||
description?: string;
|
||||
discount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce whatever the LLM produced for `lines` into LocalInvoiceLine[].
|
||||
* Accepts either a real array or a JSON-stringified one (some planners
|
||||
* emit strings for array params). Rejects when no usable entries remain.
|
||||
*/
|
||||
function coerceLines(raw: unknown, currency: Currency, defaultVatRate: number): LocalInvoiceLine[] {
|
||||
let parsed: RawLine[];
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(raw) as RawLine[];
|
||||
} catch {
|
||||
throw new Error('Positionen konnten nicht geparst werden (ungültiges JSON).');
|
||||
}
|
||||
} else if (Array.isArray(raw)) {
|
||||
parsed = raw as RawLine[];
|
||||
} else {
|
||||
throw new Error('Positionen müssen als Array übergeben werden.');
|
||||
}
|
||||
|
||||
const minor = CURRENCIES[currency].minorUnit;
|
||||
const lines: LocalInvoiceLine[] = [];
|
||||
for (const p of parsed) {
|
||||
if (!p.title || typeof p.quantity !== 'number' || typeof p.unitPrice !== 'number') {
|
||||
continue; // silently skip malformed entries so a single bad line doesn't kill the invoice
|
||||
}
|
||||
lines.push({
|
||||
id: crypto.randomUUID(),
|
||||
title: p.title,
|
||||
description: p.description ?? null,
|
||||
quantity: p.quantity,
|
||||
unit: p.unit ?? null,
|
||||
unitPrice: Math.round(p.unitPrice * minor),
|
||||
vatRate: typeof p.vatRate === 'number' ? p.vatRate : defaultVatRate,
|
||||
discount: typeof p.discount === 'number' ? p.discount : null,
|
||||
});
|
||||
}
|
||||
|
||||
if (lines.length === 0) {
|
||||
throw new Error('Keine gültige Position — erwarte { title, quantity, unitPrice } je Eintrag.');
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ─── Tools ──────────────────────────────────────────────
|
||||
|
||||
export const invoicesTools: ModuleTool[] = [
|
||||
{
|
||||
name: 'create_invoice',
|
||||
module: 'invoices',
|
||||
description:
|
||||
'Erstellt eine neue Rechnung als Entwurf. Setzt Kunde (Name + optional Adresse + E-Mail), Positionen (Titel, Menge, Einzelpreis in Hauptwaehrung), Faelligkeit. Nummer wird automatisch vergeben.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'clientName',
|
||||
type: 'string',
|
||||
description: 'Name des Kunden (erforderlich)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'clientEmail',
|
||||
type: 'string',
|
||||
description: 'E-Mail-Adresse des Kunden',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'clientAddress',
|
||||
type: 'string',
|
||||
description: 'Postanschrift des Kunden (mehrzeilig, Strasse + Nr, dann PLZ Ort)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'subject',
|
||||
type: 'string',
|
||||
description: 'Kurzer Betreff (z.B. "Beratung April")',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'string',
|
||||
description: 'Waehrung (Standard: CHF)',
|
||||
required: false,
|
||||
enum: ['CHF', 'EUR', 'USD'],
|
||||
},
|
||||
{
|
||||
name: 'dueDate',
|
||||
type: 'string',
|
||||
description: 'Faelligkeitsdatum (YYYY-MM-DD). Ohne Angabe: +30 Tage ab heute.',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'lines',
|
||||
type: 'array',
|
||||
description:
|
||||
'Array von Positionen: [{ title, quantity, unitPrice (Hauptwaehrung), vatRate?, unit? }]. Mindestens eine Position.',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const clientName = String(params.clientName ?? '').trim();
|
||||
if (!clientName) {
|
||||
return { success: false, message: 'Kundenname fehlt.' };
|
||||
}
|
||||
const currency = (params.currency as Currency) ?? 'CHF';
|
||||
const snapshot: InvoiceClientSnapshot = {
|
||||
name: clientName,
|
||||
address: (params.clientAddress as string | undefined)?.trim() || undefined,
|
||||
email: (params.clientEmail as string | undefined)?.trim() || undefined,
|
||||
};
|
||||
try {
|
||||
const lines = coerceLines(params.lines, currency, 8.1);
|
||||
const id = await invoicesStore.createInvoice({
|
||||
clientId: null,
|
||||
clientSource: 'invoice-client',
|
||||
clientSnapshot: snapshot,
|
||||
currency,
|
||||
dueDate: (params.dueDate as string | undefined) || undefined,
|
||||
lines,
|
||||
subject: (params.subject as string | undefined) || null,
|
||||
});
|
||||
// Re-read to get the generated number + total for the message.
|
||||
const row = await invoiceTable.get(id);
|
||||
const gross = row?.totals?.gross ?? 0;
|
||||
return {
|
||||
success: true,
|
||||
data: { id, number: row?.number, gross, currency },
|
||||
message: `Entwurf ${row?.number ?? '(neu)'} für ${clientName} über ${formatAmount(gross, currency)} angelegt.`,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
message: e instanceof Error ? e.message : 'Erstellen fehlgeschlagen',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'mark_invoice_paid',
|
||||
module: 'invoices',
|
||||
description:
|
||||
'Markiert eine versendete oder ueberfaellige Rechnung als bezahlt. paidAt ist optional (Standard: heute, fuer rueckdatierte Eingaenge ein fruehes Datum setzen).',
|
||||
parameters: [
|
||||
{
|
||||
name: 'invoiceId',
|
||||
type: 'string',
|
||||
description: 'ID der Rechnung (aus list_invoices)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'paidAt',
|
||||
type: 'string',
|
||||
description: 'Zahlungsdatum (ISO oder YYYY-MM-DD). Standard: jetzt.',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const id = String(params.invoiceId ?? '').trim();
|
||||
if (!id) return { success: false, message: 'Rechnungs-ID fehlt.' };
|
||||
const existing = await invoiceTable.get(id);
|
||||
if (!existing) return { success: false, message: `Rechnung ${id} nicht gefunden.` };
|
||||
// Normalise YYYY-MM-DD → ISO so markPaid's timestamp is consistent with
|
||||
// the UI path (which uses new Date().toISOString()).
|
||||
let paidAt = params.paidAt as string | undefined;
|
||||
if (paidAt && /^\d{4}-\d{2}-\d{2}$/.test(paidAt)) {
|
||||
paidAt = new Date(`${paidAt}T12:00:00.000Z`).toISOString();
|
||||
}
|
||||
await invoicesStore.markPaid(id, paidAt);
|
||||
return {
|
||||
success: true,
|
||||
data: { id, number: existing.number },
|
||||
message: `Rechnung ${existing.number} als bezahlt markiert.`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'list_invoices',
|
||||
module: 'invoices',
|
||||
description:
|
||||
'Listet Rechnungen auf. Optional nach Status (draft/sent/paid/overdue/void) und Limit gefiltert. Gibt ID, Nummer, Kunde, Status, Betrag, Faelligkeit zurueck.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'status',
|
||||
type: 'string',
|
||||
description: 'Nur diesen Status zeigen',
|
||||
required: false,
|
||||
enum: ['draft', 'sent', 'paid', 'overdue', 'void'],
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
description: 'Maximale Anzahl (Standard: 20)',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const all = await listDecryptedInvoices();
|
||||
const status = params.status as InvoiceStatus | undefined;
|
||||
const filtered = status ? all.filter((i) => i.status === status) : all;
|
||||
const limit = Number(params.limit ?? 20);
|
||||
const slice = filtered.slice(0, Math.max(1, limit));
|
||||
const data = slice.map((i) => ({
|
||||
id: i.id,
|
||||
number: i.number,
|
||||
status: i.status,
|
||||
client: i.clientSnapshot.name,
|
||||
gross: i.totals.gross / CURRENCIES[i.currency].minorUnit,
|
||||
currency: i.currency,
|
||||
issueDate: i.issueDate,
|
||||
dueDate: i.dueDate,
|
||||
}));
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message: `${slice.length} Rechnung${slice.length === 1 ? '' : 'en'}${status ? ` im Status ${status}` : ''}.`,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'get_invoice_stats',
|
||||
module: 'invoices',
|
||||
description:
|
||||
'Gibt Rechnungs-Kennzahlen zurueck: offene Summe, ueberfaellige Summe, YTD fakturiert + bezahlt (pro Waehrung, in Hauptwaehrung als Gleitkomma).',
|
||||
parameters: [],
|
||||
async execute() {
|
||||
const all = await listDecryptedInvoices();
|
||||
const year = new Date().getFullYear();
|
||||
const stats = computeStats(all, year);
|
||||
// Convert minor-unit maps to major-unit maps so the LLM can reason
|
||||
// with numbers that match the user's mental model.
|
||||
const toMajor = (map: Record<Currency, number>): Record<Currency, number> => ({
|
||||
CHF: map.CHF / 100,
|
||||
EUR: map.EUR / 100,
|
||||
USD: map.USD / 100,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
counts: stats.totalByStatus,
|
||||
openByCurrency: toMajor(stats.openByCurrency),
|
||||
overdueByCurrency: toMajor(stats.overdueByCurrency),
|
||||
invoicedYtdByCurrency: toMajor(stats.invoicedYtdByCurrency),
|
||||
paidYtdByCurrency: toMajor(stats.paidYtdByCurrency),
|
||||
year,
|
||||
},
|
||||
message: `${stats.totalByStatus.sent + stats.totalByStatus.overdue} offen, ${stats.totalByStatus.overdue} überfällig.`,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -87,8 +87,10 @@ describe('toolToFunctionSchema', () => {
|
|||
description: 'Broken',
|
||||
defaultPolicy: 'auto',
|
||||
parameters: [
|
||||
// `fruit` is genuinely not a JSON Schema type — the catalog-typo
|
||||
// guard exists to catch accidental one-off strings like this.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
{ name: 'p', type: 'array' as any, description: 'p', required: true },
|
||||
{ name: 'p', type: 'fruit' as any, description: 'p', required: true },
|
||||
],
|
||||
};
|
||||
expect(() => toolToFunctionSchema(tool)).toThrow(/Unsupported parameter type/);
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@
|
|||
|
||||
import type { ToolSchema } from './schemas';
|
||||
|
||||
/** OpenAI-compatible JSON-Schema property type. */
|
||||
/** OpenAI-compatible JSON-Schema property type. `array` / `object` are
|
||||
* emitted without a nested `items` / `properties` schema — tools that
|
||||
* take structured payloads describe the expected shape inside their
|
||||
* human-readable description and parse in the executor. Tightening to
|
||||
* a full JSON-Schema tree would be strictly better but isn't required
|
||||
* by the OpenAI / Anthropic function-calling specs. */
|
||||
export interface JsonSchemaProperty {
|
||||
type: 'string' | 'number' | 'integer' | 'boolean';
|
||||
type: 'string' | 'number' | 'integer' | 'boolean' | 'array' | 'object';
|
||||
description?: string;
|
||||
enum?: readonly string[];
|
||||
}
|
||||
|
|
@ -53,6 +58,8 @@ function mapParamType(t: string): JsonSchemaProperty['type'] {
|
|||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
case 'array':
|
||||
case 'object':
|
||||
return t;
|
||||
default:
|
||||
// Unknown types in the catalog are a bug, but don't silently
|
||||
|
|
|
|||
|
|
@ -1110,6 +1110,112 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
|
|||
defaultPolicy: 'auto',
|
||||
parameters: [{ name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }],
|
||||
},
|
||||
|
||||
// ── Invoices ─────────────────────────────────────────────
|
||||
{
|
||||
name: 'create_invoice',
|
||||
module: 'invoices',
|
||||
description:
|
||||
'Erstellt eine neue Rechnung als Entwurf. Setzt Kunde (Name + optional Adresse + E-Mail), Positionen (Titel, Menge, Einzelpreis in Hauptwaehrung), Faelligkeit. Nummer wird automatisch vergeben.',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{
|
||||
name: 'clientName',
|
||||
type: 'string',
|
||||
description: 'Name des Kunden (erforderlich)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'clientEmail',
|
||||
type: 'string',
|
||||
description: 'E-Mail-Adresse des Kunden',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'clientAddress',
|
||||
type: 'string',
|
||||
description: 'Postanschrift des Kunden (mehrzeilig, Strasse + Nr, dann PLZ Ort)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'subject',
|
||||
type: 'string',
|
||||
description: 'Kurzer Betreff (z.B. "Beratung April")',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'currency',
|
||||
type: 'string',
|
||||
description: 'Waehrung (Standard: CHF)',
|
||||
required: false,
|
||||
enum: ['CHF', 'EUR', 'USD'],
|
||||
},
|
||||
{
|
||||
name: 'dueDate',
|
||||
type: 'string',
|
||||
description: 'Faelligkeitsdatum (YYYY-MM-DD). Ohne Angabe: +30 Tage ab heute.',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'lines',
|
||||
type: 'array',
|
||||
description:
|
||||
'Array von Positionen: [{ title: string, quantity: number, unitPrice: number (in Hauptwaehrung, z.B. 150.00), vatRate?: number, unit?: string }]. Mindestens eine Position.',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'mark_invoice_paid',
|
||||
module: 'invoices',
|
||||
description:
|
||||
'Markiert eine versendete oder ueberfaellige Rechnung als bezahlt. paidAt ist optional (Standard: heute, fuer rueckdatierte Eingaenge ein fruehes Datum setzen).',
|
||||
defaultPolicy: 'propose',
|
||||
parameters: [
|
||||
{
|
||||
name: 'invoiceId',
|
||||
type: 'string',
|
||||
description: 'ID der Rechnung (aus list_invoices)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'paidAt',
|
||||
type: 'string',
|
||||
description: 'Zahlungsdatum (ISO oder YYYY-MM-DD). Standard: jetzt.',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'list_invoices',
|
||||
module: 'invoices',
|
||||
description:
|
||||
'Listet Rechnungen auf. Optional nach Status (draft/sent/paid/overdue/void) und Limit gefiltert. Gibt ID, Nummer, Kunde, Status, Betrag, Faelligkeit zurueck.',
|
||||
defaultPolicy: 'auto',
|
||||
parameters: [
|
||||
{
|
||||
name: 'status',
|
||||
type: 'string',
|
||||
description: 'Nur diesen Status zeigen',
|
||||
required: false,
|
||||
enum: ['draft', 'sent', 'paid', 'overdue', 'void'],
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
description: 'Maximale Anzahl (Standard: 20)',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'get_invoice_stats',
|
||||
module: 'invoices',
|
||||
description:
|
||||
'Gibt Rechnungs-Kennzahlen zurueck: offene Summe, ueberfaellige Summe, YTD fakturiert + bezahlt (pro Waehrung, in Hauptwaehrung als Gleitkomma).',
|
||||
defaultPolicy: 'auto',
|
||||
parameters: [],
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue