From a560a78f4dba3bb0226083d975bac8b486a3ccd1 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 16 Oct 2026 12:35:04 +0200 Subject: [PATCH] u --- docs/api/README.md | 1 - docs/api/routes-all.md | 14 +- docs/api/routes/article/create.md | 2 +- docs/api/routes/delivery/parse.md | 91 ----- docs/api/routes/warehouse/delete.md | 2 +- scripts/generateApiDocs.js | 1 - src/api/routes/article/crud.js | 4 +- src/api/routes/article/shared.js | 1 + src/api/routes/deliveryParse.js | 593 ---------------------------- src/api/routes/warehouse.js | 3 +- 10 files changed, 8 insertions(+), 704 deletions(-) delete mode 100644 docs/api/routes/delivery/parse.md delete mode 100644 src/api/routes/deliveryParse.js diff --git a/docs/api/README.md b/docs/api/README.md index 12ef3fc..dba2617 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -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}` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index 47a84d9..7269b2e 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -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` diff --git a/docs/api/routes/article/create.md b/docs/api/routes/article/create.md index f2d2fc5..c9e4cb4 100644 --- a/docs/api/routes/article/create.md +++ b/docs/api/routes/article/create.md @@ -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 { diff --git a/docs/api/routes/delivery/parse.md b/docs/api/routes/delivery/parse.md deleted file mode 100644 index 0b08503..0000000 --- a/docs/api/routes/delivery/parse.md +++ /dev/null @@ -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`._ diff --git a/docs/api/routes/warehouse/delete.md b/docs/api/routes/warehouse/delete.md index c773ae2..27b803c 100644 --- a/docs/api/routes/warehouse/delete.md +++ b/docs/api/routes/warehouse/delete.md @@ -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":"…"}` | --- diff --git a/scripts/generateApiDocs.js b/scripts/generateApiDocs.js index 86b9339..6b28d49 100644 --- a/scripts/generateApiDocs.js +++ b/scripts/generateApiDocs.js @@ -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}', diff --git a/src/api/routes/article/crud.js b/src/api/routes/article/crud.js index d414ef4..c4cd664 100644 --- a/src/api/routes/article/crud.js +++ b/src/api/routes/article/crud.js @@ -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 { diff --git a/src/api/routes/article/shared.js b/src/api/routes/article/shared.js index 5f98580..29df5fe 100644 --- a/src/api/routes/article/shared.js +++ b/src/api/routes/article/shared.js @@ -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; diff --git a/src/api/routes/deliveryParse.js b/src/api/routes/deliveryParse.js deleted file mode 100644 index cc396ed..0000000 --- a/src/api/routes/deliveryParse.js +++ /dev/null @@ -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 -} diff --git a/src/api/routes/warehouse.js b/src/api/routes/warehouse.js index 7214594..8877086 100644 --- a/src/api/routes/warehouse.js +++ b/src/api/routes/warehouse.js @@ -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: