This commit is contained in:
seb
2026-10-08 12:44:53 +02:00
parent 79a1b72292
commit e2e7f8d566
11 changed files with 172 additions and 21 deletions

View File

@@ -58,7 +58,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Routes
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, track_stock?: bool }`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T31`
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: int`
@@ -70,7 +70,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Params: `sku: str`
- Returns: `200 T1`
#### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: str, body: { name?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `200 T1`
#### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist`
- Params: `sku: str`

View File

@@ -53,7 +53,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Routes
#### Create article in category — `POST /article`
- Params: `body: { categoryId: int, name: str, track_stock?: bool }`
- Params: `body: { categoryId: int, name: str, desc?: str, seo?: str, track_stock?: bool }`
- Returns: `201 T29`
#### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: str, query: cascade?: bool`
@@ -62,7 +62,7 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
- Params: `sku: str`
- Returns: `200 T1`
#### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: str, body: { name?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Params: `sku: str, body: { name?: str, desc?: str, seo?: str, unitamount?: num, unitSi?: str, track_stock?: bool }`
- Returns: `200 T1`
#### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist`
- Params: `sku: str`

View File

@@ -12,6 +12,8 @@ Creates an article under an authorized category (`parentId`).
|-------|----------|------|-------------|
| `categoryId` | yes | integer | Category ID under which to create the article |
| `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) |
```json

View File

@@ -19,6 +19,8 @@ Patches article fields. At least one of `name`, `unitamount`, `unitSi` required.
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `name` | no | string | |
| `desc` | no | string | Article description text/HTML |
| `seo` | no | string | SEO text |
| `unitamount` | no | number | Must be finite and `> 0` |
| `unitSi` | no | string | Existing company unit `si` |
| `track_stock` | no | boolean | Whether to track inventory stock |

View File

@@ -167,6 +167,12 @@ export function register(app, basePath = '') {
* description: Category ID under which to create the article
* name:
* type: string
* desc:
* type: string
* description: Article description text/HTML
* seo:
* type: string
* description: SEO text
* track_stock:
* type: boolean
* description: Whether to track inventory stock (defaults to true)
@@ -215,7 +221,7 @@ export function register(app, basePath = '') {
* $ref: '#/components/schemas/Error'
*/
app.post(`${basePath}/article`, async (req, res) => {
const rawParent = req.body?.categoryId ?? req.body?.parentId;
const rawParent = req.body?.categoryId;
if (rawParent === undefined || rawParent === null) {
return res.status(400).json({ error: 'categoryId is required' });
}
@@ -229,7 +235,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: 'name is required' });
}
const rawTrack = req.body?.track_stock ?? req.body?.trackStock;
const rawTrack = req.body?.track_stock;
if (rawTrack !== undefined && typeof rawTrack !== 'boolean' && rawTrack !== 0 && rawTrack !== 1) {
return res.status(400).json({ error: 'Invalid track_stock' });
}
@@ -237,12 +243,15 @@ export function register(app, basePath = '') {
const customSku = typeof req.body?.sku === 'string' && req.body.sku.trim() ? req.body.sku.trim() : undefined;
try {
const desc = req.body?.desc;
const created = await db.createUnderParent({
googleAuthId: req.user.id,
parentId,
name,
track_stock,
sku: customSku,
desc: desc !== undefined ? desc : undefined,
seo: req.body?.seo !== undefined ? req.body.seo : undefined,
});
emitArticlesSignal(req);
const article = {
@@ -438,6 +447,12 @@ export function register(app, basePath = '') {
* properties:
* name:
* type: string
* desc:
* type: string
* description: Article description text/HTML
* seo:
* type: string
* description: SEO text
* unitamount:
* type: number
* description: Must be finite and `> 0`
@@ -672,12 +687,23 @@ export function register(app, basePath = '') {
}
if (Object.prototype.hasOwnProperty.call(body, 'track_stock')) {
patch.track_stock = body.track_stock;
} else if (Object.prototype.hasOwnProperty.call(body, 'trackStock')) {
patch.track_stock = body.trackStock;
}
if (Object.prototype.hasOwnProperty.call(body, 'desc')) {
patch.desc = body.desc;
}
if (Object.prototype.hasOwnProperty.call(body, 'seo')) {
patch.seo = body.seo;
}
if (!('name' in patch) && !('unitamount' in patch) && !('unitSi' in patch) && !('track_stock' in patch)) {
return res.status(400).json({ error: 'name, unitamount, unitSi, or track_stock is required' });
if (
!('name' in patch) &&
!('unitamount' in patch) &&
!('unitSi' in patch) &&
!('track_stock' in patch) &&
!('desc' in patch) &&
!('seo' in patch)
) {
return res.status(400).json({ error: 'name, unitamount, unitSi, track_stock, desc, or seo is required' });
}
try {
@@ -1443,7 +1469,7 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: skuParsed.error });
}
const raw = req.body?.track_stock ?? req.body?.trackStock;
const raw = req.body?.track_stock;
if (raw === undefined || (typeof raw !== 'boolean' && raw !== 0 && raw !== 1)) {
return res.status(400).json({ error: 'Invalid track_stock' });
}
@@ -1783,7 +1809,7 @@ export function register(app, basePath = '') {
if (typeof isParent !== 'boolean') {
return res.status(400).json({ error: 'isParent boolean is required' });
}
const attributeNameId = req.body?.attributeNameId ?? req.body?.parentAttributeNameId;
const attributeNameId = req.body?.attributeNameId;
try {
const result = await db.toggleParent(req.user.id, parsed.sku, {

View File

@@ -1,8 +1,8 @@
import { getStore } from './store.js';
export async function createUnderParent({ googleAuthId, parentId, name, track_stock, sku }) {
export async function createUnderParent(args) {
const store = await getStore();
return store.article.createUnderParent({ googleAuthId, parentId, name, track_stock, sku });
return store.article.createUnderParent(args);
}
export async function getBySku(googleAuthId, sku) {

View File

@@ -70,10 +70,17 @@ function wrapStoreWithTiming(rawStore) {
storeLog.debug(`${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)}`);
return result;
} catch (err) {
storeLog.error(
`${namespace}.${method} ${chalk.red('FAILED')} ` +
`${chalk.gray(`(${Date.now() - start}ms)`)} ${chalk.red(err.message)}`
);
const isNotFound = /not found/i.test(err?.message);
if (isNotFound) {
storeLog.debug(
`${namespace}.${method} ${chalk.gray(`(${Date.now() - start}ms)`)} ${chalk.gray(err.message)}`
);
} else {
storeLog.error(
`${namespace}.${method} ${chalk.red('FAILED')} ` +
`${chalk.gray(`(${Date.now() - start}ms)`)} ${chalk.red(err.message)}`
);
}
throw err;
}
};

View File

@@ -343,9 +343,17 @@ export async function executeCompanyImport({
jtlArtIdToSku.set(jtlArtId, sku);
let name = '';
let desc = '';
let seo = '';
try {
const desc = JSON.parse(a.descriptions || '[]');
name = desc[0]?.cName || '';
const descriptions = JSON.parse(a.descriptions || '[]');
const descItem = Array.isArray(descriptions) ? descriptions[0] : descriptions;
if (descItem) {
name = String(descItem.cName || '').trim();
desc = String(descItem.cBeschreibung || '').trim();
seo = String(descItem.cKurzBeschreibung || '').trim();
}
} catch {}
if (!name) name = `Artikel ${sku}`;
@@ -388,6 +396,8 @@ export async function executeCompanyImport({
await store.article.updateBySku(googleAuthId, sku, {
name: name.trim(),
track_stock: trackStock,
desc: desc || undefined,
seo: seo || undefined,
});
// Update prices
@@ -432,6 +442,8 @@ export async function executeCompanyImport({
name: name.trim(),
sku,
track_stock: trackStock,
desc: desc || undefined,
seo: seo || undefined,
});
// Set price

View File

@@ -28,6 +28,7 @@ import { createPdfReadTool } from '../tools/pdf-read.js';
import { createSearchArticlesTool } from '../tools/search-articles.js';
import { createEanChecksumTool } from '../tools/ean-checksum.js';
import { createNetGrossCalcTool } from '../tools/net-gross-calc.js';
import { createSetUserRouteTool } from '../tools/set-user-route.js';
import { resolveOpenRouterKey, resolveDefaultModel, resolveModelReasoning, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js';
const KEEP_ALIVE_MS = 20_000;
@@ -132,6 +133,7 @@ async function buildSystemPrompt(vfsRoot, googleAuthId) {
'16. edit_image: Generate or edit images using the configured company image model (modelslugimage). Supports attachments (low/high quality), size tiers (1K/2K/4K), and aspect ratios (1:1/4:3/3:4). Writes output to VFS (always AVIF format).',
'17. ean_checksum: Validate EAN/GTIN/UPC barcode check digits or calculate missing check digit for barcodes (EAN-13, EAN-8, UPC-A, GTIN-14).',
'18. net_gross_calc: Convert between net and gross prices with tax/VAT rate (default 19%). Provides 2-decimal display values and high-precision net prices for API article creation.',
'19. set-user-route: Navigate the user to a frontend route (e.g. when the user says "show me the article", "open quote 1001", or asks to view a page/entity).',
'',
'VIRTUAL FILESYSTEM:',
'- Persistent per-company VFS. Paths are root-relative forward-slash (e.g. "labels/sheet1.png").',
@@ -157,7 +159,37 @@ async function buildSystemPrompt(vfsRoot, googleAuthId) {
'- GROSS vs NET PRICES: User prices are almost always GROSS (incl. VAT); API requires NET. Use net_gross_calc to compute high-precision (4–6 decimals) NET prices for API requests: `net = gross / (1 + taxRate / 100)`.',
'- DISPLAYING GENERATED IMAGES: Inline markdown images supported. Embed written VFS image paths: `![descriptive alt](artifacts/example.png)`. Other files (PDF, JSON, CSV) do not render inline; provide download links.',
'- LABEL TEMPLATES (/labeltemplate): Must use Dashback JSON schema `{ "objects": [ ... ] }`. Do NOT use ZPL.',
'- USER NAVIGATION: When the user says "show me the article", "open this order", or asks to navigate to a screen/entity, call set-user-route with the appropriate path from the Route Construction Matrix.',
'- Always Format output with markdown and emoji.',
'',
'USER PERSPECTIVE ROUTE CONSTRUCTION MATRIX:',
'| Destination | Path Pattern | Parameters / Construction | Example |',
'|:---|:---|:---|:---|',
'| **Article Search / Index** | `/artikel` | Query params: `?target={query}&filter={filter}&offset={n}` | `/artikel?target=kabel` |',
'| **Article Detail** | `/artikel/:sku` | Encode SKU: `/artikel/${encodeURIComponent(sku)}` | `/artikel/ART-10042` |',
'| **Warehouse / Stock** | `/artikel/lager` | Static | `/artikel/lager` |',
'| **Brands** | `/artikel/marken` | Static | `/artikel/marken` |',
'| **Label Templates** | `/artikel/vorlagen` | Static | `/artikel/vorlagen` |',
'| **Attributes & Features** | `/artikel/merkmale` | Static | `/artikel/merkmale` |',
'| **Category Tree Root** | `/kategorien` | Static | `/kategorien` |',
'| **Category Subtree** | `/kategorien/*` | Join encoded slugs: `/kategorien/${slug1}/${slug2}` | `/kategorien/elektronik/kabel` |',
'| **Delivery List** | `/lieferung` | Static | `/lieferung` |',
'| **New Delivery Draft** | `/lieferung/neu` | Static (receipt & draft line editor) | `/lieferung/neu` |',
'| **Delivery Detail** | `/lieferung/:deliveryId` | Delivery document ID: `/lieferung/${deliveryId}` | `/lieferung/42` |',
'| **Suppliers Directory** | `/lieferung/lieferanten` | Static | `/lieferung/lieferanten` |',
'| **Quote List (Angebote)** | `/auftrag` | Static | `/auftrag` |',
'| **New Quote** | `/auftrag/neu` | Static draft form | `/auftrag/neu` |',
'| **Quote Detail** | `/auftrag/:quoteId` | Quote document ID: `/auftrag/${quoteId}` | `/auftrag/1001` |',
'| **Order List (Bestellungen)**| `/auftrag/bestellung` | Static | `/auftrag/bestellung` |',
'| **New Order** | `/auftrag/bestellung/neu` | Static draft form | `/auftrag/bestellung/neu` |',
'| **Order Detail** | `/auftrag/bestellung/:orderId` | Order document ID: `/auftrag/bestellung/${orderId}` | `/auftrag/bestellung/2005` |',
'| **Customer List** | `/kunde` | Static | `/kunde` |',
'| **New Customer** | `/kunde/neu` | Static form | `/kunde/neu` |',
'| **Customer Detail** | `/kunde/:entityId` | Customer entity ID: `/kunde/${entityId}` | `/kunde/312` |',
'| **Customer Groups** | `/kunde/gruppen` | Static (pricing & customer tiers) | `/kunde/gruppen` |',
'| **Article History** | `/infos` or `/infos/articlehistory` | Static | `/infos/articlehistory` |',
'| **Global History** | `/infos/globalhistory` | Static audit log | `/infos/globalhistory` |',
'| **Statistics** | `/infos/statistics` | Static KPIs & charts | `/infos/statistics` |',
...(routesDoc ? [
'',
'DASHBACK API ROUTES:',
@@ -874,6 +906,11 @@ export class ConversationManager {
createSearchArticlesTool({ googleAuthId, companyId: this.companyId }),
createEanChecksumTool(),
createNetGrossCalcTool(),
createSetUserRouteTool({
onRoute: (route) => {
this.broadcast('route', { route });
},
}),
],
});

View File

@@ -0,0 +1,46 @@
import { defineTool } from 'libllmorbiter';
/**
* Creates the set-user-route tool.
* Sends the user to a specific frontend route (e.g. when the user says "show me the article",
* "open quote 1001", "go to orders", etc.).
*
* @param {{ onRoute?: (route: string) => void }} [options]
*/
export function createSetUserRouteTool({ onRoute } = {}) {
return defineTool({
name: 'set-user-route',
description:
'Navigate the user to a frontend route. Use this when the user says "show me the article" or asks to see, open, or view a specific page or entity (e.g. an article detail page, orders, quotes, deliveries, or customer). Refer to the User Perspective Route Construction Matrix for valid route patterns.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['route'],
properties: {
route: {
type: 'string',
description: 'Target frontend route path according to the Route Construction Matrix (e.g. "/artikel/ART-10042", "/kategorien/elektronik/kabel", "/auftrag/1001").',
},
},
},
invoke: async ({ route }) => {
const trimmedRoute = typeof route === 'string' ? route.trim() : '';
if (!trimmedRoute) {
throw new Error('route is required');
}
if (typeof onRoute === 'function') {
try {
onRoute(trimmedRoute);
} catch (err) {
console.error('set-user-route onRoute callback error:', err);
}
}
return {
success: true,
route: trimmedRoute,
message: `Navigated user to ${trimmedRoute}`,
};
},
});
}

21
todo.md
View File

@@ -9,4 +9,23 @@ Mein Aufräumskript löste eine Fehlermeldung aus GET /articleIndex( windowSize
~25 Artikel mit den Bezeichnungen ZZ-*(ART-000478 … ART-000503)
Attributname ZZ-StressAttr(ID 14) + seine Werte, Eigenschaftsname ZZ-StressPropund zwei Kategorien mit den Namen ZZ-STRESS(IDs 76, 77)
attributename/orderDer Aufruf von Note war [1, 14]erfolgreich, daher wurde auch die Sortierposition von Farbe geändert – möglicherweise ist eine manuelle Wiederherstellung erforderlich. Geben Sie mir Bescheid, und ich führe den Teardown erneut auf 1000 Seiten durch.
attributename/orderDer Aufruf von Note war [1, 14]erfolgreich, daher wurde auch die Sortierposition von Farbe geändert – möglicherweise ist eine manuelle Wiederherstellung erforderlich. Geben Sie mir Bescheid, und ich führe den Teardown erneut auf 1000 Seiten durch.
# ERP Ledger Architecture: TSE Genesis Seed
## Core Idea
We use a KassenSichV **`SonstigerVorgang`** transaction as the absolute cryptographic "Genesis Block" for our ERP ledger. Instead of using artificial software zero-hashes, the ledger's initial trust state is anchored in a legally binding, physical hardware/cloud TSE signature.
## Mapping Overview
| ERP Ledger Element | TSE Implementation | Purpose |
| :--- | :--- | :--- |
| **Genesis Entry** | `processType: "SonstigerVorgang"` | Legally logs system initialization for tax auditors. |
| **Seed Metadata** | `processData: "System-Event: Ledger-Init..."` | Explains the entry purpose transparently. |
| **`prev_hash` Anchor** | Returned TSE `signature` / `tx_id` | Becomes the cryptographic baseline for Transaction #2. |
## Quick Validation Loop
```text
[TSE Signed Genesis] -> [Tx 2 (prev_hash = Genesis Signature)] -> [Tx 3] -> ...
```