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/delivery/list.md`](routes/delivery/list.md) | `GET /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/get.md`](routes/delivery/get.md) | `GET /delivery/{id}` |
| [`routes/delivery/update.md`](routes/delivery/update.md) | `PATCH /delivery/{id}` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 249 API endpoints across all modules.
Overview of all 248 API endpoints across all modules.
### Notation
@@ -399,18 +399,6 @@ _none_
#### 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 } }[] }`
- 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}`
- Params: `id: int`
- Returns: `204 void`

View File

@@ -14,7 +14,7 @@ Creates an article under an authorized category (`parentId`).
| `name` | yes | string | |
| `desc` | no | string | Article description text/HTML |
| `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
{

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":"…"}` |
| 403 | Not authorized — `{"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 /category/{id}/image',
'GET /delivery/{id}/file/{fileId}',
'POST /delivery/parse',
'GET /events',
'GET /health',
'GET /image/{fullHash}',

View File

@@ -52,7 +52,7 @@ export function register(app, basePath = '') {
* description: SEO text
* track_stock:
* type: boolean
* description: Whether to track inventory stock (defaults to true)
* description: Whether to track inventory stock (defaults to false)
* example:
* categoryId: 10
* name: New article
@@ -118,7 +118,7 @@ export function register(app, basePath = '') {
if (rawTrack !== undefined && typeof rawTrack !== 'boolean' && rawTrack !== 0 && rawTrack !== 1) {
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;
try {

View File

@@ -69,6 +69,7 @@ export function articleErrorStatus(error) {
if (error.message === 'Invalid attributeValueId') return 400;
if (error.message === 'Invalid weight') 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 === 'Attribute name not found') return 404;
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 === 'Warehouse already exists') 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 sort') return 400;
return 500;
@@ -543,7 +544,7 @@ export function register(app, basePath = '') {
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Warehouse in use in ledger
* description: Warehouse in use in ledger or articles have stock tracking enabled
* content:
* application/json:
* schema:

View File

@@ -1,14 +1,13 @@
VFS Drafts: Collaborative Quote, Order, and Delivery Editing
VFS Drafts: Collaborative Quote and Order Editing
# 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.
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)
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": {
"kind": {
"type": "string",
"enum": ["quote", "order", "delivery"],
"enum": ["quote", "order"],
"description": "Document type"
},
"mode": {
@@ -50,7 +49,7 @@ Every draft file contains a root object with a `manifest` property conforming to
},
"partyId": {
"type": ["integer", "null"],
"description": "Customer entity.id for quotes/orders; supplier.id for deliveries"
"description": "Customer entity.id for quotes and orders"
},
"number": {
"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"
}
]
}
}
```