Compare commits

..

2 Commits

Author SHA1 Message Date
seb
e083d66a75 u 2026-10-16 13:09:25 +02:00
seb
a560a78f4d u 2026-10-16 12:35:04 +02:00
12 changed files with 317 additions and 863 deletions

View File

@@ -130,7 +130,6 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/customergroup/update.md`](routes/customergroup/update.md) | `PATCH /customergroup/{id}` | | [`routes/customergroup/update.md`](routes/customergroup/update.md) | `PATCH /customergroup/{id}` |
| [`routes/delivery/list.md`](routes/delivery/list.md) | `GET /delivery` | | [`routes/delivery/list.md`](routes/delivery/list.md) | `GET /delivery` |
| [`routes/delivery/create.md`](routes/delivery/create.md) | `POST /delivery` | | [`routes/delivery/create.md`](routes/delivery/create.md) | `POST /delivery` |
| [`routes/delivery/parse.md`](routes/delivery/parse.md) | `POST /delivery/parse` |
| [`routes/delivery/delete.md`](routes/delivery/delete.md) | `DELETE /delivery/{id}` | | [`routes/delivery/delete.md`](routes/delivery/delete.md) | `DELETE /delivery/{id}` |
| [`routes/delivery/get.md`](routes/delivery/get.md) | `GET /delivery/{id}` | | [`routes/delivery/get.md`](routes/delivery/get.md) | `GET /delivery/{id}` |
| [`routes/delivery/update.md`](routes/delivery/update.md) | `PATCH /delivery/{id}` | | [`routes/delivery/update.md`](routes/delivery/update.md) | `PATCH /delivery/{id}` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All) # Dash API Routes Overview (All)
Overview of all 249 API endpoints across all modules. Overview of all 248 API endpoints across all modules.
### Notation ### Notation
@@ -399,18 +399,6 @@ _none_
#### Create delivery — `POST /delivery` #### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T35[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }` - Params: `body: { externalNumber: str, date: str, lines: T35[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T9` - Returns: `201 T9`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: str }`
- Returns: `200 SSE`
- Events:
- `status`: `{ "message": "…" }`
- `llm_call`: `{ "model": "…", "promptSize": 0, "messages": 0 }`
- `tool_call`: `{ "name": "output_manifest" }`
- `usage`: `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }`
- `progress`: `ManifestResult`
- `result`: `ManifestResult`
- `error`: `{ "message": "…" }`
#### Delete delivery — `DELETE /delivery/{id}` #### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: int` - Params: `id: int`
- Returns: `204 void` - Returns: `204 void`

View File

@@ -14,7 +14,7 @@ Creates an article under an authorized category (`parentId`).
| `name` | yes | string | | | `name` | yes | string | |
| `desc` | no | string | Article description text/HTML | | `desc` | no | string | Article description text/HTML |
| `seo` | no | string | SEO text | | `seo` | no | string | SEO text |
| `track_stock` | no | boolean | Whether to track inventory stock (defaults to true) | | `track_stock` | no | boolean | Whether to track inventory stock (defaults to false) |
```json ```json
{ {

View File

@@ -1,91 +0,0 @@
# `POST /delivery/parse`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Parse document (SSE)
Accepts an invoice or delivery note as PDF or text file
(`multipart/form-data`, field `file`) and streams extraction results as
Server-Sent Events until parsing is complete.
PDFs are read via `pdftotext -layout`; text files are decoded as UTF-8.
Pages are separated by the form feed character (`\f`). If no form feed
is present, the input is treated as a single page.
The extracted pages are fed to an LLM (OpenRouter) which fills a structured
manifest of header data and line items. The OpenRouter API key is read from
the authenticated user's company setting `keys.openrouter` (or the server's
`OPENROUTER_FREE_KEY` if not configured); the endpoint
returns `400` if the key is not configured.
The stream ends after the final `result` event (or an `error` event on failure).
## Body (`multipart/form-data`)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `file` | yes | file | Document to parse (max 50 MB, application/pdf or text file) |
## SSE Events
The response is a `text/event-stream`. Events are emitted in order until the stream closes.
| Event | Description | Payload |
|-------|-------------|---------|
| `status` | Document parse confirmation and LLM status messages | `{ "message": "…" }` |
| `llm_call` | Fired before each LLM request | `{ "model": "…", "promptSize": 0, "messages": 0 }` |
| `tool_call` | Fired when the output_manifest tool is invoked | `{ "name": "output_manifest" }` |
| `usage` | Token usage after each LLM response | `{ "input": 0, "cached": 0, "output": 0, "cost": 0 }` |
| `progress` | Intermediate accumulated extraction state (basedata / notes / items) | `ManifestResult` |
| `result` | Final accumulated extraction — emitted just before the stream closes | `ManifestResult` |
| `error` | Emitted on document parse or LLM failure; stream closes immediately after | `{ "message": "…" }` |
## Response `200`
`text/event-stream` — emits `status`, `llm_call`, `tool_call`, `usage`,
`progress`, `result` (and `error` on failure). See SSE Events table below.
**ManifestResult shape** (used by `progress` and `result`):
```json
{
"basedata": {
"date": "2026-01-15",
"lieferant_id": 42,
"lieferantName": "Muster GmbH",
"manifestNr": "LS-2026-001",
"manifestType": "lieferschein"
},
"notes": ["Pos. 3: VPE-Angabe fehlte, Standardwert 1 angenommen."],
"items": {
"pagesFrom": 1,
"pagesTo": 2,
"items": [
{
"position": 1,
"count": 10,
"vpe": 1,
"unit": "Stk.",
"articleName": "Einhängesieb Stahl",
"liefArtNr": "ES-100",
"mwst": "19",
"eknetto": 450,
"page": 1
}
]
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No file uploaded, unsupported file type, or OpenRouter key not configured — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -30,7 +30,7 @@ _No content_
| 401 | Unauthorized — `{"error":"…"}` | | 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` | | 403 | Not authorized — `{"error":"…"}` |
| 404 | Warehouse not found — `{"error":"…"}` | | 404 | Warehouse not found — `{"error":"…"}` |
| 409 | Warehouse in use in ledger — `{"error":"…"}` | | 409 | Warehouse in use in ledger or articles have stock tracking enabled — `{"error":"…"}` |
--- ---

View File

@@ -29,7 +29,6 @@ const OMITTED_ROUTES = new Set([
'POST /company/export', 'POST /company/export',
'POST /category/{id}/image', 'POST /category/{id}/image',
'GET /delivery/{id}/file/{fileId}', 'GET /delivery/{id}/file/{fileId}',
'POST /delivery/parse',
'GET /events', 'GET /events',
'GET /health', 'GET /health',
'GET /image/{fullHash}', 'GET /image/{fullHash}',

View File

@@ -52,7 +52,7 @@ export function register(app, basePath = '') {
* description: SEO text * description: SEO text
* track_stock: * track_stock:
* type: boolean * type: boolean
* description: Whether to track inventory stock (defaults to true) * description: Whether to track inventory stock (defaults to false)
* example: * example:
* categoryId: 10 * categoryId: 10
* name: New article * name: New article
@@ -118,7 +118,7 @@ export function register(app, basePath = '') {
if (rawTrack !== undefined && typeof rawTrack !== 'boolean' && rawTrack !== 0 && rawTrack !== 1) { if (rawTrack !== undefined && typeof rawTrack !== 'boolean' && rawTrack !== 0 && rawTrack !== 1) {
return res.status(400).json({ error: 'Invalid track_stock' }); return res.status(400).json({ error: 'Invalid track_stock' });
} }
const track_stock = rawTrack !== undefined ? Boolean(rawTrack) : true; const track_stock = rawTrack !== undefined ? Boolean(rawTrack) : false;
const customSku = typeof req.body?.sku === 'string' && req.body.sku.trim() ? req.body.sku.trim() : undefined; const customSku = typeof req.body?.sku === 'string' && req.body.sku.trim() ? req.body.sku.trim() : undefined;
try { try {

View File

@@ -69,6 +69,7 @@ export function articleErrorStatus(error) {
if (error.message === 'Invalid attributeValueId') return 400; if (error.message === 'Invalid attributeValueId') return 400;
if (error.message === 'Invalid weight') return 400; if (error.message === 'Invalid weight') return 400;
if (error.message === 'Invalid track_stock') return 400; if (error.message === 'Invalid track_stock') return 400;
if (error.message === 'At least one warehouse is required to enable stock tracking') return 400;
if (error.message === 'Invalid categoryIds' || error.message === 'Article must have at least one category') return 400; if (error.message === 'Invalid categoryIds' || error.message === 'Article must have at least one category') return 400;
if (error.message === 'Attribute name not found') return 404; if (error.message === 'Attribute name not found') return 404;
if (error.message === 'attributeNameId is required to become a parent article') return 400; if (error.message === 'attributeNameId is required to become a parent article') return 400;

View File

@@ -1,593 +0,0 @@
'use strict';
import { execFileSync } from 'child_process';
import multer from 'multer';
import { LLMOrbiter, OpenRouterProvider, defineTool, DirectReturnError } from 'libllmorbiter';
import * as dbCompanySetting from '../../db/companysetting.js';
import * as dbSupplier from '../../db/supplier.js';
import env from '../../config/env.js';
import { resolveDefaultModel } from '../../tools/llm-key.js';
// #region Helpers
// ─── Multer — memory storage (no temp files); accepts PDF or text ───────────────
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB
fileFilter: (_req, file, cb) => {
const name = file.originalname?.toLowerCase() || '';
const isPdf = file.mimetype === 'application/pdf' || name.endsWith('.pdf');
const isText =
file.mimetype.startsWith('text/') ||
/\.(txt|text|md|csv|log|json)$/.test(name);
if (isPdf || isText) {
cb(null, true);
} else {
cb(new Error('Only PDF or text files are accepted'));
}
},
});
/** Detects whether an uploaded file is a PDF (else treated as text). */
function isPdfUpload(file) {
const name = file.originalname?.toLowerCase() || '';
return file.mimetype === 'application/pdf' || name.endsWith('.pdf');
}
// ─── SSE helpers ────────────────────────────────────────────────────────────────
function sseWrite(res, event, data) {
if (res.writableEnded || res.destroyed) return;
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
// ─── PDF → pages via pdftotext ──────────────────────────────────────────────────
function readPdfPages(buffer) {
// Write buffer to stdin by passing '-' as output and reading from stdin via
// execFileSync input option. pdftotext reads from '-' for stdin.
const raw = execFileSync('pdftotext', ['-layout', '-', '-'], {
input: buffer,
encoding: 'utf8',
maxBuffer: 50 * 1024 * 1024,
});
return raw
.split('\f')
.map((p) => p.trim())
.filter((p) => p.length > 0);
}
// ─── Text → pages split by form feed (same convention as pdftotext) ─────────────
function readTxtPages(textOrBuffer) {
const text = Buffer.isBuffer(textOrBuffer) ? textOrBuffer.toString('utf8') : String(textOrBuffer);
return text
.split('\f')
.map((p) => p.trim())
.filter((p) => p.length > 0);
}
// ─── Extraction prompt ──────────────────────────────────────────────────────────
const BASE_PROMPT = `Extract metadata and product line items from the provided invoice or delivery note.
Call the output_manifest tool with the extracted data. Do not answer with plain text once extraction is complete.
Rules:
- manifestType: "rechnung" or "lieferschein". For "lieferschein", prices and taxes must be null.
- Exclude non-product lines (shipping, handling, etc.).
- Line items: Extract count, vpe (default: 1), liefArtNr, mwst (7 or 19), and eknetto per piece(euro cents).
- Match supplier against the provided list for lieferant_id and lieferantName. For unknown suppliers, extract lieferantName and set lieferant_id to null.
- Write notes strictly in German.
- The output_manifest tool has three strictly optional components: basedata, notes, items. Only include a component when it carries genuinely new data for this batch; omit the rest.
- Include basedata ONLY when at least one of its fields is currently null/empty and you can fill it in, or when a value actually changes. If all basedata fields are already set and unchanged, omit basedata.
- Include items ONLY when this batch contains new product line items not already present. If there are no new items, omit items.
- Include notes ONLY when there is a genuinely new noteworthy detail not already in the notes list. These notes are for a human to double-check the extraction result, so record anything ambiguous, unusual, or worth verifying (e.g. packaging interpretations, excluded lines, unavailable articles).
VPE (Verpackungseinheit) Logic:
VPE represents the number of individually sellable units inside a bulk item. If not explicitly split for individual sale, default to 1.
- "20 Boxen á 10 Stück" -> VPE = 20.
- "10 Gläser á 100 Filter" -> VPE = 10 (we sell the 10 glasses individually).
- "100 Stück" -> VPE = 1 (sold as one bulk pack).
- We don't sell the individual filters or Pieces, but typical VPEs like Beutel, Glässer, Boxen, Hefte
- That means that in "Y Hefte á X Blatt" the VPE is NOT X but: Y
- a so called "Display" also means VPE. So "20er Display" means VPE = 20
- eknetto is the price of one full vpe
Unit (ME) rules:
- The unit (ME) must ALWAYS refer to the individual sellable item (e.g. Stk., Beutel, Flasche, Box, Set). It must NEVER be "VE" or "VPE".
- A packaging size like "VE100" means the bulk pack contains 100 individual items: set vpe = 100 and unit = the individual item (e.g. Stk.).
- Example: "Einhängesiebe Stahl, VE100" -> vpe = 100, unit = "Stk.", count = 1 (one bulk pack of 100 sieves).`;
function buildSystemPrompt(suppliers = []) {
const supplierList = suppliers.length > 0
? suppliers.map((s) => `${s.id} | ${s.name}`).join('\n')
: '(no suppliers registered)';
return `${BASE_PROMPT}
Suppliers (lieferant_id | lieferant_name):
${supplierList}`;
}
// ─── Shared SSE manifest extraction (POST /delivery/parse) ─────────────────────
/**
* Fetches the OpenRouter API key, opens the SSE stream, and runs the LLM
* manifest extraction over the given pages. Closes the stream when done.
*
* @param {object} opts
* @param {import('express').Request} opts.req
* @param {import('express').Response} opts.res
* @param {string[]} opts.pages Trimmed, non-empty document pages
* @param {string} opts.sourceLabel e.g. 'PDF' or 'Text'
*/
async function parseDocumentManifest({ req, res, pages, sourceLabel }) {
// ── Fetch OpenRouter API key from company settings (or fallback) ──
let apiKey;
try {
const setting = await dbCompanySetting.getByKey(req.user.id, 'keys.openrouter', { maskWriteonly: false });
apiKey = setting?.value?.trim();
} catch (err) {
console.error('delivery parse: failed to read OpenRouter key:', err);
}
if (!apiKey) {
apiKey = env.openrouterFreeKey?.trim();
if (apiKey) {
console.log('delivery parse: using fallback OPENROUTER_FREE_KEY');
}
}
if (!apiKey) {
return res.status(400).json({ error: 'OpenRouter API key not configured (companysetting: keys.openrouter)' });
}
// ── Fetch suppliers from Lieferanten group ────────────────────────
let suppliers = [];
try {
suppliers = await dbSupplier.list(req.user.id);
} catch (err) {
console.warn('delivery parse: failed to list suppliers:', err?.message || err);
}
// ── Open SSE stream ──────────────────────────────────────────────
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
sseWrite(res, 'status', { message: `${sourceLabel} parsed: ${pages.length} page(s)` });
// ── Accumulated state ────────────────────────────────────────────
const manifestBasedata = {
date: null,
lieferant_id: null,
lieferantName: null,
manifestNr: null,
manifestType: null,
};
const manifestNotes = [];
const manifestItems = { items: [], pagesFrom: null, pagesTo: null };
let currentPage = 0;
// ── Tool definition ──────────────────────────────────────────────
const outputManifestTool = defineTool({
name: 'output_manifest',
description:
'Stores extracted invoice/delivery-note data: header metadata (basedata), notes, and product line items. Each component is strictly optional — only include the ones that carry new data.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: [],
properties: {
basedata: {
anyOf: [
{
type: 'object',
additionalProperties: false,
properties: {
date: { type: ['string', 'null'], description: 'Document date (ISO 8601)' },
lieferant_id: { type: ['number', 'null'], description: 'Lieferant ID' },
lieferantName: { type: 'string', description: 'Lieferant name' },
manifestNr: { type: ['string', 'null'], description: 'Lieferscheinnummer or Rechnungsnummer' },
manifestType: {
anyOf: [
{ type: 'string', enum: ['rechnung', 'lieferschein'] },
{ type: 'null' },
],
description: 'Document type',
},
},
},
{ type: 'null' },
],
description: 'Header/metadata. Only include fields that are currently missing or changed.',
},
notes: {
anyOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
description: 'New noteworthy details (in German), for a human to double-check the result.',
},
items: {
anyOf: [
{
type: 'object',
additionalProperties: false,
properties: {
numberOfItemsToAdd: { type: 'number', description: 'Number of NEW line items being added in this call.' },
items: {
type: 'array',
description: 'Invoice/delivery product line items',
items: {
type: 'object',
additionalProperties: false,
required: ['position', 'count', 'vpe', 'unit', 'articleName', 'liefArtNr', 'mwst', 'eknetto'],
properties: {
position: { type: 'number', description: 'Position' },
count: { type: 'number', description: 'Quantity (Menge)' },
vpe: { type: 'number', description: 'Packaging unit size (VPE). Default: 1.' },
unit: { type: 'string', description: 'Unit of the individual sellable item. NEVER "VE" or "VPE".' },
articleName: { type: 'string', description: 'Article name' },
liefArtNr: { type: 'string', description: 'Supplier article number' },
mwst: {
anyOf: [{ type: 'string', enum: ['7', '19'] }, { type: 'null' }],
description: 'VAT rate; null if missing',
},
eknetto: { type: ['number', 'null'], description: 'Net price per vpe in euro cents; null if missing' },
},
},
},
},
},
{ type: 'null' },
],
description: 'New product line items.',
},
},
},
invoke: async (args) => {
const basedataKeys = args.basedata && typeof args.basedata === 'object' ? Object.keys(args.basedata) : [];
const notesCount = Array.isArray(args.notes) ? args.notes.length : 0;
const itemsCount = args.items?.items && Array.isArray(args.items.items) ? args.items.items.length : 0;
console.log(`[llmorbiter] Tool invoke output_manifest: basedata=[${basedataKeys.join(', ')}], notes=${notesCount}, items=${itemsCount}`);
// Merge basedata
if (args.basedata && typeof args.basedata === 'object') {
for (const key of ['date', 'lieferant_id', 'lieferantName', 'manifestNr', 'manifestType']) {
if (args.basedata[key] != null) manifestBasedata[key] = args.basedata[key];
}
if (args.basedata.kLieferant != null && manifestBasedata.lieferant_id == null) {
manifestBasedata.lieferant_id = args.basedata.kLieferant;
}
if (args.basedata.lieferant_name != null && manifestBasedata.lieferantName == null) {
manifestBasedata.lieferantName = args.basedata.lieferant_name;
}
}
// Merge notes
if (Array.isArray(args.notes)) {
for (const n of args.notes) {
if (n && !manifestNotes.includes(n)) manifestNotes.push(n);
}
}
// Merge items
if (args.items && typeof args.items === 'object' && Array.isArray(args.items.items)) {
const existing = new Set(manifestItems.items.map((it) => it.position));
for (const it of args.items.items) {
if (!existing.has(it.position)) {
manifestItems.items.push({ ...it, page: currentPage });
existing.add(it.position);
}
}
const itemPages = manifestItems.items.map((it) => it.page).filter((p) => p != null);
if (itemPages.length) {
manifestItems.pagesFrom = Math.min(...itemPages);
manifestItems.pagesTo = Math.max(...itemPages);
}
}
// Emit intermediate progress
sseWrite(res, 'progress', {
basedata: manifestBasedata,
notes: manifestNotes,
items: manifestItems,
});
throw new DirectReturnError({
basedata: manifestBasedata,
notes: manifestNotes,
items: manifestItems,
});
},
});
// ── LLMOrbiter setup ─────────────────────────────────────────────
const model = await resolveDefaultModel(req.user.id);
const provider = new OpenRouterProvider({ apiKey, apiUrl: env.openrouterApiUrl });
const orbiter = new LLMOrbiter(provider, {
onStatus: (status) => {
console.log(`[llmorbiter] Status: ${status}`);
sseWrite(res, 'status', { message: status });
},
onLLMCall: (call) => {
console.log(
`[llmorbiter] LLM call (iteration ${call.iteration}): model=${call.model}, promptSize=${call.promptSize} chars, messages=${call.messageCount || call.messages}`
);
sseWrite(res, 'llm_call', {
model: call.model,
promptSize: call.promptSize,
messages: call.messages,
});
},
onToolCall: (call) => {
const details = [];
if (call.args?.basedata) details.push(`basedata: [${Object.keys(call.args.basedata).join(', ')}]`);
if (call.args?.items?.items) details.push(`items: ${call.args.items.items.length}`);
if (call.args?.notes) details.push(`notes: ${call.args.notes.length}`);
const suffix = details.length ? ` (${details.join(', ')})` : '';
console.log(`[llmorbiter] Tool call: ${call.name}${suffix}`);
sseWrite(res, 'tool_call', { name: call.name });
},
onToolResult: (call) => {
console.log(`[llmorbiter] Tool result: ${call.name}`);
},
onToolError: (call) => {
console.error(`[llmorbiter] Tool error in ${call.name}:`, call.error);
},
onUsage: (usage) => {
const costStr = usage.cost != null ? `, cost=$${usage.cost.toFixed(4)}` : '';
console.log(
`[llmorbiter] Usage: input=${usage.input}, cached=${usage.cached}, output=${usage.output}${costStr}`
);
sseWrite(res, 'usage', {
input: usage.input,
cached: usage.cached,
output: usage.output,
cost: usage.cost,
});
},
});
const abortController = new AbortController();
abortController.signal.addEventListener('abort', () => {
const reason = abortController.signal.reason;
const msg = reason instanceof Error ? reason.message : String(reason || 'aborted');
console.log(`[llmorbiter] Abort signal triggered: ${msg}`);
});
const originalAbort = orbiter.abort.bind(orbiter);
orbiter.abort = (reason) => {
const reasonMsg = reason instanceof Error ? reason.message : String(reason || 'aborted');
console.log(`[llmorbiter] orbiter.abort() called (reason: ${reasonMsg})`);
return originalAbort(reason);
};
const onDisconnect = () => {
if (!res.writableEnded && !abortController.signal.aborted) {
console.log('[llmorbiter] Client connection closed, aborting request');
abortController.abort('Client connection closed');
orbiter.abort('Client connection closed');
}
};
res.on('close', onDisconnect);
// ── Process all pages in one batch ───────────────────────────────
try {
if (res.destroyed || res.writableEnded) {
console.log('[llmorbiter] Response already closed or destroyed before run, aborting');
abortController.abort('Response closed before run');
orbiter.abort('Response closed before run');
return;
}
const pageContent = pages
.map((p, idx) => `--- Page ${idx + 1} of ${pages.length} ---\n${p}`)
.join('\n\n');
const systemPrompt = buildSystemPrompt(suppliers);
const prompt = `You are processing all ${pages.length} page(s) of this document.
Current manifest_basedata (fill in what is still missing):
${JSON.stringify(manifestBasedata, null, 2)}
Current notes:
${JSON.stringify(manifestNotes, null, 2)}
Current manifest_items:
${JSON.stringify(manifestItems, null, 2)}
Page content:
${pageContent}`;
console.log(`[llmorbiter] Starting run: model=${model}, pages=${pages.length} (${sourceLabel}), suppliers=${suppliers.length}`);
currentPage = 1;
await orbiter.run({
prompt,
systemPrompt,
model,
tools: [outputManifestTool],
signal: abortController.signal,
});
console.log(`[llmorbiter] Run completed successfully. Extracted ${manifestItems.items.length} item(s), ${manifestNotes.length} note(s)`);
} catch (err) {
if (err.name === 'AbortError' || abortController.signal.aborted) {
console.log(`[llmorbiter] Run aborted: ${err.message || abortController.signal.reason?.message || 'AbortError'}`);
} else {
console.error('[llmorbiter] Run failed with error:', err);
}
if (!res.writableEnded && !res.destroyed) {
sseWrite(res, 'error', { message: 'LLM processing failed: ' + err.message });
}
} finally {
res.off('close', onDisconnect);
}
if (res.writableEnded || res.destroyed) {
console.log('[llmorbiter] Response ended or destroyed, skipping final result');
return;
}
// ── Final result ─────────────────────────────────────────────────
console.log(`[llmorbiter] Emitting final result event and closing stream`);
sseWrite(res, 'result', {
basedata: manifestBasedata,
notes: manifestNotes,
items: manifestItems,
});
res.end();
}
// #endregion
// ─── Route ───────────────────────────────────────────────────────────────────────
/**
* @param {import('express').Application} app
* @param {string} [basePath='']
*/
export function register(app, basePath = '') {
/**
* @openapi
* /delivery/parse:
* post:
* tags: [Delivery]
* summary: Parse document (SSE)
* description: |
* Accepts an invoice or delivery note as PDF or text file
* (`multipart/form-data`, field `file`) and streams extraction results as
* Server-Sent Events until parsing is complete.
*
* PDFs are read via `pdftotext -layout`; text files are decoded as UTF-8.
* Pages are separated by the form feed character (`\f`). If no form feed
* is present, the input is treated as a single page.
*
* The extracted pages are fed to an LLM (OpenRouter) which fills a structured
* manifest of header data and line items. The OpenRouter API key is read from
* the authenticated user's company setting `keys.openrouter` (or the server's
* `OPENROUTER_FREE_KEY` if not configured); the endpoint
* returns `400` if the key is not configured.
*
* The stream ends after the final `result` event (or an `error` event on failure).
* security:
* - cookieAuth: []
* x-markdown: delivery/parse.md
* x-sse-events:
* - name: status
* description: Document parse confirmation and LLM status messages
* payload: '{ "message": "…" }'
* - name: llm_call
* description: Fired before each LLM request
* payload: '{ "model": "…", "promptSize": 0, "messages": 0 }'
* - name: tool_call
* description: Fired when the output_manifest tool is invoked
* payload: '{ "name": "output_manifest" }'
* - name: usage
* description: Token usage after each LLM response
* payload: '{ "input": 0, "cached": 0, "output": 0, "cost": 0 }'
* - name: progress
* description: Intermediate accumulated extraction state (basedata / notes / items)
* payload: ManifestResult
* - name: result
* description: Final accumulated extraction — emitted just before the stream closes
* payload: ManifestResult
* - name: error
* description: Emitted on document parse or LLM failure; stream closes immediately after
* payload: '{ "message": "…" }'
* requestBody:
* required: true
* content:
* multipart/form-data:
* schema:
* type: object
* required: [file]
* properties:
* file:
* type: string
* format: binary
* description: Document to parse (max 50 MB, application/pdf or text file)
* responses:
* 200:
* description: |
* `text/event-stream` — emits `status`, `llm_call`, `tool_call`, `usage`,
* `progress`, `result` (and `error` on failure). See SSE Events table below.
*
* **ManifestResult shape** (used by `progress` and `result`):
*
* ```json
* {
* "basedata": {
* "date": "2026-01-15",
* "lieferant_id": 42,
* "lieferantName": "Muster GmbH",
* "manifestNr": "LS-2026-001",
* "manifestType": "lieferschein"
* },
* "notes": ["Pos. 3: VPE-Angabe fehlte, Standardwert 1 angenommen."],
* "items": {
* "pagesFrom": 1,
* "pagesTo": 2,
* "items": [
* {
* "position": 1,
* "count": 10,
* "vpe": 1,
* "unit": "Stk.",
* "articleName": "Einhängesieb Stahl",
* "liefArtNr": "ES-100",
* "mwst": "19",
* "eknetto": 450,
* "page": 1
* }
* ]
* }
* }
* ```
* content:
* text/event-stream:
* schema:
* type: string
* 400:
* description: No file uploaded, unsupported file type, or OpenRouter key not configured
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /delivery/parse
app.post(
`${basePath}/delivery/parse`,
upload.single('file'),
async (req, res) => {
if (!req.file?.buffer) {
return res.status(400).json({ error: 'A document file is required (field name: file)' });
}
const sourceLabel = isPdfUpload(req.file) ? 'PDF' : 'Text';
console.log(`[deliveryParse] Received file "${req.file.originalname}" (${req.file.size} bytes, type=${sourceLabel})`);
let pages;
try {
pages = sourceLabel === 'PDF' ? readPdfPages(req.file.buffer) : readTxtPages(req.file.buffer);
} catch (err) {
console.error(`[deliveryParse] Failed to parse document: ${err.message}`);
return res.status(400).json({ error: 'Failed to parse document: ' + err.message });
}
if (pages.length === 0) {
console.warn('[deliveryParse] Document content is empty');
return res.status(400).json({ error: 'Document content is empty' });
}
await parseDocumentManifest({
req,
res,
pages,
sourceLabel,
});
},
);
// #endregion
}

View File

@@ -27,6 +27,7 @@ function warehouseErrorStatus(error) {
if (error.message === 'Name required') return 400; if (error.message === 'Name required') return 400;
if (error.message === 'Warehouse already exists') return 409; if (error.message === 'Warehouse already exists') return 409;
if (error.message === 'Warehouse in use in ledger') return 409; if (error.message === 'Warehouse in use in ledger') return 409;
if (error.message === 'Cannot delete the last warehouse while articles have stock tracking enabled') return 409;
if (error.message === 'Invalid warehouse order') return 400; if (error.message === 'Invalid warehouse order') return 400;
if (error.message === 'Invalid sort') return 400; if (error.message === 'Invalid sort') return 400;
return 500; return 500;
@@ -543,7 +544,7 @@ export function register(app, basePath = '') {
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
* 409: * 409:
* description: Warehouse in use in ledger * description: Warehouse in use in ledger or articles have stock tracking enabled
* content: * content:
* application/json: * application/json:
* schema: * schema:

View File

@@ -1,14 +1,13 @@
VFS Drafts: Collaborative Quote, Order, and Delivery Editing VFS Drafts: Collaborative Quote and Order Editing
# Overview # Overview
The virtual directory `drafts/` in VFS allows the LLM agent and the frontend user to collaborate in real time on in-progress commercial documents. The virtual directory `drafts/` in VFS allows the LLM agent and the frontend user to collaborate in real time on in-progress commercial documents.
Drafts are stateful forms maintained in the frontend application and synchronized with the backend. Drafts are stateful forms maintained in the frontend application and synchronized with the backend.
There are three virtual draft files: There are two virtual draft files:
1. `drafts/quote` : Sales Quote / Angebot (Customer sales proposal) 1. `drafts/quote` : Sales Quote / Angebot (Customer sales proposal)
2. `drafts/order` : Sales Order / Auftrag & Bestellung (Customer order confirmation) 2. `drafts/order` : Sales Order / Auftrag & Bestellung (Customer order confirmation)
3. `drafts/delivery` : Goods Inbound / Wareneingang & Lieferschein (Supplier delivery receipt)
--- ---
@@ -31,7 +30,7 @@ Every draft file contains a root object with a `manifest` property conforming to
"properties": { "properties": {
"kind": { "kind": {
"type": "string", "type": "string",
"enum": ["quote", "order", "delivery"], "enum": ["quote", "order"],
"description": "Document type" "description": "Document type"
}, },
"mode": { "mode": {
@@ -50,7 +49,7 @@ Every draft file contains a root object with a `manifest` property conforming to
}, },
"partyId": { "partyId": {
"type": ["integer", "null"], "type": ["integer", "null"],
"description": "Customer entity.id for quotes/orders; supplier.id for deliveries" "description": "Customer entity.id for quotes and orders"
}, },
"number": { "number": {
"type": ["string", "null"], "type": ["string", "null"],
@@ -381,157 +380,3 @@ Used for customer order confirmations and sales orders (Aufträge & Bestellungen
} }
} }
``` ```
---
## 4. Goods Inbound Delivery Draft (`drafts/delivery`)
Used for inbound delivery receipts from suppliers (Wareneingang & Lieferscheine).
### Complete Delivery JSON Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DeliveryDraft",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": { "type": "string", "const": "delivery" },
"mode": { "type": "string", "enum": ["create", "edit"] },
"id": { "type": ["integer", "null"] },
"date": {
"type": ["string", "null"],
"format": "date",
"description": "Delivery arrival / receipt date (Lieferdatum, YYYY-MM-DD)"
},
"partyId": {
"type": ["integer", "null"],
"description": "Supplier ID (supplier.id / entity_id)"
},
"number": {
"type": ["string", "null"],
"description": "Delivery manifest number (allocated by system sequence upon creation)"
},
"externalNumber": {
"type": ["string", "null"],
"description": "Supplier delivery slip number (Lieferschein-Nr. des Lieferanten)"
},
"comment": {
"type": ["string", "null"],
"description": "Warehouse receipt remarks or gate notes"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "amount", "net", "taxrate"],
"properties": {
"pos": {
"type": "integer",
"minimum": 1,
"description": "Position line number (1, 2, 3...)"
},
"sku": {
"type": ["string", "null"],
"description": "Article SKU (internal SKU or matched supplier SKU)"
},
"name": {
"type": "string",
"description": "Delivered article name"
},
"amount": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Delivered quantity received into warehouse"
},
"pu": {
"type": "number",
"minimum": 1,
"default": 1,
"description": "Packaging unit / VPE (default 1)"
},
"net": {
"type": "number",
"minimum": 0,
"description": "Purchase cost net per unit in EUR (EK netto)"
},
"gross": {
"type": ["number", "null"],
"description": "Purchase cost gross per unit in EUR (EK brutto)"
},
"taxrate": {
"type": "number",
"minimum": 0,
"description": "Input tax percentage (e.g. 19 or 7)"
},
"rebate": {
"type": "number",
"minimum": 0,
"maximum": 100,
"default": 0,
"description": "Supplier discount percentage"
},
"unit": {
"type": ["string", "null"],
"description": "Unit of measurement"
},
"comment": {
"type": ["string", "null"],
"description": "Batch number, lot, condition, or storage bin note"
}
}
}
}
}
}
}
}
```
### Complete Delivery Example
```json
{
"manifest": {
"kind": "delivery",
"mode": "create",
"id": null,
"date": "2026-10-14",
"partyId": 12,
"number": null,
"externalNumber": "LS-SUP-99412",
"comment": "Ware unbeschädigt auf Palette 3 angeliefert.",
"lines": [
{
"pos": 1,
"sku": "42001257",
"name": "1 Pot 8L System inkl. 47L Wasserfass",
"amount": 20,
"pu": 1,
"net": 38.50,
"gross": 45.82,
"taxrate": 19,
"rebate": 0,
"unit": "Stück",
"comment": "Palette A-04"
},
{
"pos": 2,
"sku": "42000009",
"name": "Autopot Easy2Grow Erweiterungsset 2Pot",
"amount": 50,
"pu": 1,
"net": 24.00,
"gross": 28.56,
"taxrate": 19,
"rebate": 3,
"unit": "Stück",
"comment": "Karton 1 bis 5"
}
]
}
}
```

View File

@@ -0,0 +1,305 @@
Process Incoming Product and Invoices: Extraction & Delivery Drafts
# Overview
This skill guides the automated and interactive processing of inbound supplier documents (invoices / *Rechnungen* and delivery notes / *Lieferscheine*).
It covers:
1. **Document Data Extraction**: Extracting structured metadata, packaging units (VPE), units of measurement, line items, and notes from document text/images via the `output_manifest` tool.
2. **Goods Inbound Delivery Draft (`drafts/delivery`)**: Storing and maintaining the inbound receipt in the virtual file system (`drafts/delivery`) for real-time collaboration with the user in the UI.
---
# 1. Document Extraction Rules & Prompt Logic
When reading or processing an invoice or delivery note, follow these core rules:
## General Extraction Rules
- **Document Type (`manifestType`)**: `"rechnung"` or `"lieferschein"`. For `"lieferschein"`, prices (`eknetto`) and taxes (`mwst`) must be `null`.
- **Exclude Non-Product Lines**: Exclude freight, shipping, postage, pallet charges, packaging disposal fees, and handling surcharges. Only include physical inventory products.
- **Line Items**: Extract `position`, `count`, `vpe` (default: 1), `unit`, `articleName`, `liefArtNr`, `mwst` (7, 19, or null), and `eknetto` per piece in euro cents (or null).
- **Supplier Matching**: Match the supplier against known company suppliers to identify `lieferant_id` and `lieferantName`. For unknown suppliers, extract `lieferantName` as string and set `lieferant_id` to `null`.
- **Notes Strictly in German**: Record all notes, ambiguities, packaging assumptions, or excluded line details in German. These notes assist a human reviewer in double-checking results.
- **Delta / Batching Updates**: The `output_manifest` tool consists of three optional components: `basedata`, `notes`, and `items`. Only include a component when it carries genuinely new or changed data; omit unchanged components.
- Include `basedata` ONLY when at least one field is currently null/empty or value actually changes.
- Include `items` ONLY when new product line items are being added.
- Include `notes` ONLY when there is a genuinely new noteworthy detail.
## VPE (Verpackungseinheit) Logic
VPE represents the number of individually sellable units inside a bulk item. If not explicitly split for individual sale, default to 1.
- `"20 Boxen á 10 Stück"` -> `vpe = 20`.
- `"10 Gläser á 100 Filter"` -> `vpe = 10` (the 10 glasses are sold individually).
- `"100 Stück"` -> `vpe = 1` (sold as one bulk pack).
- Typical sellable VPEs are containers like *Beutel*, *Gläser*, *Boxen*, *Hefte*, not loose sub-components.
- In `"Y Hefte á X Blatt"` the VPE is NOT X, but `Y`.
- A "Display" represents a VPE: `"20er Display"` means `vpe = 20`.
- `eknetto` is the price of one full VPE.
## Unit (ME) Rules
- The unit (ME) must ALWAYS refer to the individual sellable item (e.g. `Stk.`, `Beutel`, `Flasche`, `Box`, `Set`). It must **NEVER** be `"VE"` or `"VPE"`.
- A packaging size like `"VE100"` means the bulk pack contains 100 individual items: set `vpe = 100` and `unit = "Stk."` (or the relevant individual unit).
- Example: `"Einhängesiebe Stahl, VE100"` -> `vpe = 100`, `unit = "Stk."`, `count = 1` (one bulk pack of 100 sieves).
---
# 2. Extraction Tool Specification (`output_manifest`)
The extraction engine outputs structured findings via the `output_manifest` tool call.
```json
{
"name": "output_manifest",
"description": "Stores extracted invoice/delivery-note data: header metadata (basedata), notes, and product line items. Each component is strictly optional — only include the ones that carry new data.",
"strict": false,
"parameters": {
"type": "object",
"additionalProperties": false,
"required": [],
"properties": {
"basedata": {
"anyOf": [
{
"type": "object",
"additionalProperties": false,
"properties": {
"date": { "type": ["string", "null"], "description": "Document date (ISO 8601, YYYY-MM-DD)" },
"lieferant_id": { "type": ["number", "null"], "description": "Lieferant ID if matched, else null" },
"lieferantName": { "type": "string", "description": "Supplier name" },
"manifestNr": { "type": ["string", "null"], "description": "Lieferscheinnummer or Rechnungsnummer" },
"manifestType": {
"anyOf": [
{ "type": "string", "enum": ["rechnung", "lieferschein"] },
{ "type": "null" }
],
"description": "Document type"
}
}
},
{ "type": "null" }
],
"description": "Header/metadata. Only include fields that are currently missing or changed."
},
"notes": {
"anyOf": [
{ "type": "array", "items": { "type": "string" } },
{ "type": "null" }
],
"description": "New noteworthy details (in German), for a human to double-check the result."
},
"items": {
"anyOf": [
{
"type": "object",
"additionalProperties": false,
"properties": {
"numberOfItemsToAdd": { "type": "number", "description": "Number of NEW line items being added in this call." },
"items": {
"type": "array",
"description": "Invoice/delivery product line items",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["position", "count", "vpe", "unit", "articleName", "liefArtNr", "mwst", "eknetto"],
"properties": {
"position": { "type": "number", "description": "Position number" },
"count": { "type": "number", "description": "Quantity (Menge)" },
"vpe": { "type": "number", "description": "Packaging unit size (VPE). Default: 1." },
"unit": { "type": "string", "description": "Unit of the individual sellable item. NEVER 'VE' or 'VPE'." },
"articleName": { "type": "string", "description": "Article name" },
"liefArtNr": { "type": "string", "description": "Supplier article number" },
"mwst": {
"anyOf": [{ "type": "string", "enum": ["7", "19"] }, { "type": "null" }],
"description": "VAT rate percentage; null if delivery note or missing"
},
"eknetto": { "type": ["number", "null"], "description": "Net purchase price per VPE in euro cents; null if missing" }
}
}
}
}
},
{ "type": "null" }
],
"description": "New product line items."
}
}
}
}
```
---
# 3. Goods Inbound Delivery Draft (`drafts/delivery`)
Inbound deliveries are staged and edited as drafts in the virtual file system under `drafts/delivery`.
This allows the agent and frontend user to review, correct, and finalize stock receipts and purchase costs before committing them into the inventory ledger.
## Field Mapping from Extracted Manifest to Delivery Draft
- **`date`**: Document date (`YYYY-MM-DD`).
- **`partyId`**: Supplier ID (`lieferant_id` / `entity.id`).
- **`externalNumber`**: Supplier delivery slip number or invoice number (`manifestNr`).
- **`comment`**: Human-readable notes, inspection remarks, storage location, or extraction caveats.
- **`lines`**:
- `pos`: 1-based position line number.
- `sku`: Internal article SKU or matched supplier SKU (`liefArtNr`).
- `name`: Delivered product name (`articleName`).
- `amount`: Quantity delivered (`count`).
- `pu`: Packaging unit size (`vpe`, default `1`).
- `net`: Purchase cost net per unit in EUR (`eknetto / 100`, or `0` for delivery notes without prices).
- `gross`: Purchase cost gross per unit in EUR (`net * (1 + taxrate/100)`).
- `taxrate`: Input tax rate percentage (`19`, `7`, or `0`).
- `rebate`: Supplier discount percentage (default `0`).
- `unit`: Individual sellable unit (e.g. `Stück`, `Beutel`, `Flasche`).
- `comment`: Specific batch, lot, or packaging comment.
---
## Delivery Draft JSON Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DeliveryDraft",
"type": "object",
"required": ["manifest"],
"properties": {
"manifest": {
"type": "object",
"required": ["kind", "mode", "lines"],
"properties": {
"kind": { "type": "string", "const": "delivery" },
"mode": { "type": "string", "enum": ["create", "edit"] },
"id": { "type": ["integer", "null"] },
"date": {
"type": ["string", "null"],
"format": "date",
"description": "Delivery arrival / receipt date (Lieferdatum, YYYY-MM-DD)"
},
"partyId": {
"type": ["integer", "null"],
"description": "Supplier ID (supplier.id / entity_id)"
},
"number": {
"type": ["string", "null"],
"description": "Delivery manifest number (allocated by system sequence upon creation)"
},
"externalNumber": {
"type": ["string", "null"],
"description": "Supplier delivery slip number (Lieferschein-Nr. des Lieferanten)"
},
"comment": {
"type": ["string", "null"],
"description": "Warehouse receipt remarks or gate notes"
},
"lines": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "amount", "net", "taxrate"],
"properties": {
"pos": {
"type": "integer",
"minimum": 1,
"description": "Position line number (1, 2, 3...)"
},
"sku": {
"type": ["string", "null"],
"description": "Article SKU (internal SKU or matched supplier SKU)"
},
"name": {
"type": "string",
"description": "Delivered article name"
},
"amount": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Delivered quantity received into warehouse"
},
"pu": {
"type": "number",
"minimum": 1,
"default": 1,
"description": "Packaging unit / VPE (default 1)"
},
"net": {
"type": "number",
"minimum": 0,
"description": "Purchase cost net per unit in EUR (EK netto)"
},
"gross": {
"type": ["number", "null"],
"description": "Purchase cost gross per unit in EUR (EK brutto)"
},
"taxrate": {
"type": "number",
"minimum": 0,
"description": "Input tax percentage (e.g. 19 or 7)"
},
"rebate": {
"type": "number",
"minimum": 0,
"maximum": 100,
"default": 0,
"description": "Supplier discount percentage"
},
"unit": {
"type": ["string", "null"],
"description": "Unit of measurement"
},
"comment": {
"type": ["string", "null"],
"description": "Batch number, lot, condition, or storage bin note"
}
}
}
}
}
}
}
}
```
---
## Delivery Draft Complete Example
```json
{
"manifest": {
"kind": "delivery",
"mode": "create",
"id": null,
"date": "2026-10-14",
"partyId": 12,
"number": null,
"externalNumber": "LS-SUP-99412",
"comment": "Ware unbeschädigt auf Palette 3 angeliefert.",
"lines": [
{
"pos": 1,
"sku": "42001257",
"name": "1 Pot 8L System inkl. 47L Wasserfass",
"amount": 20,
"pu": 1,
"net": 38.50,
"gross": 45.82,
"taxrate": 19,
"rebate": 0,
"unit": "Stück",
"comment": "Palette A-04"
},
{
"pos": 2,
"sku": "42000009",
"name": "Autopot Easy2Grow Erweiterungsset 2Pot",
"amount": 50,
"pu": 1,
"net": 24.00,
"gross": 28.56,
"taxrate": 19,
"rebate": 3,
"unit": "Stück",
"comment": "Karton 1 bis 5"
}
]
}
}
```