Compare commits

..

12 Commits

Author SHA1 Message Date
seb
9feca54a0b u 2026-10-04 22:14:03 +02:00
seb
ec060f5c62 u 2026-10-04 21:45:49 +02:00
seb
3ac713732c u 2026-10-04 21:22:02 +02:00
seb
45369d11da u 2026-10-04 21:22:00 +02:00
seb
6777f423d8 u 2026-10-04 20:47:16 +02:00
seb
d56b3e5c23 u 2026-10-04 20:47:11 +02:00
seb
ab9ce9b600 u 2026-10-04 20:38:22 +02:00
seb
e00533f9fa u 2026-10-04 19:38:16 +02:00
seb
a3926d3547 u 2026-10-04 19:12:33 +02:00
seb
a8e51724f8 u 2026-10-04 18:57:00 +02:00
seb
c5cb76a66c u 2026-10-04 16:22:25 +02:00
seb
43fa8ea105 u 2026-10-04 11:25:17 +02:00
54 changed files with 2136 additions and 18206 deletions

View File

@@ -30,17 +30,17 @@ Entry: `src/index.js` → `export default class LibDashStore`. Constructor requi
- Company scoping: no `companyId` arg. `resolveSelectedCompanyAccess(provider)` reads `google_auth.selected_company` → `{ companyId, companyuserId, rootCategoryId }`. Missing → throw `No company selected`. Exceptions taking explicit `companyId`: `setSelectedCompany`, `companyuser.add`, `company.delete`, internals.
- SKU resolution: `store/article/resolveArticleBySku.js` (`RESOLVE_ARTICLE_ACCESS_BY_SKU`) checks company + membership. Throws `Article not found` / `Parent not found` / `Not authorized`.
- Errors are plain `Error` with exact message strings; HTTP mapping happens in `dashback` route `*ErrorStatus()` functions. Keep messages stable/greppable. Conflict → `409` (e.g. `EAN already in use`, `Warehouse in use in ledger`, `Stock tracking is disabled...`).
- Manifest docs (`delivery/order/quote/adjustment`): header in `manifest`, lines in `manifestarticle`, helpers in `src/store/manifestLines.js` (`normalizeLines`, `insertLines`, `parseDate`, `shapeLine`) and `manifestDoc/helpers.js`. `insertLines` rejects father articles (`A father article cannot be booked on documents (SKU)`), resolves name/pu, ensures tax.
- Manifest docs (`delivery/order/quote/adjustment`): header in `manifest`, lines in `manifestarticle`, helpers in `src/store/manifestLines.js` (`normalizeLines`, `insertLines`, `parseDate`, `shapeLine`) and `manifestDoc/helpers.js`. `insertLines` rejects parent articles (`A parent article cannot be booked on documents (SKU)`), resolves name/pu, ensures tax.
- Numbers: `store/numbers/nextNumber.js`, types `sku, entity, delivery, adjustment`.
### Ledger + stock (important)
- `ledger` is append-only, tamper-chained: `hash_version=1`, `prev_hash = SHA-512(canonical JSON of prev row)` or `TODO_TSE_BASED_INITHASH` for genesis. See `store/ledger/add.js` (`shape`, `computePrevHash`, `INIT_HASH`).
- Stock is **derived**: `SUM(ledger.amount) GROUP BY warehouse`, joined `ledger → manifestarticle → manifest`, matched on `ma.sku = article.sku OR ma.sku IN (SELECT sku FROM articlesupplier WHERE article_id = ...)`, filtered by `m.company_id`. See `providers/*/queries/article.js` (`warehousesJson`/`stock_calc`) and `posterminal.js` (`fBestand`). No `stock` column on article.
- `store/stock/adjust.js` creates implicit manifest `type='adjustment'` (number from `adjustment` scheme) + one `manifestarticle` line per item (`amount: abs(amount)`) + one `ledger` row per item (`amount: signed`) + `articlehistory` (`Bestandskorrektur ±n (text)`). Validates: father → `A father article cannot have stock (SKU)`; Stückliste (`articlelist_id != null`) → `A Stückliste cannot have manual stock adjustments (SKU)`; **`track_stock==0` → `Stock tracking is disabled for article (SKU)`** (added as bugfix; checked before number allocation/manifest write, so batch is all-or-nothing).
- `store/stock/adjust.js` creates implicit manifest `type='adjustment'` (number from `adjustment` scheme) + one `manifestarticle` line per item (`amount: abs(amount)`) + one `ledger` row per item (`amount: signed`) + `articlehistory` (`Bestandskorrektur ±n (text)`). Validates: parent → `A parent article cannot have stock (SKU)`; Stückliste (`articlelist_id != null`) → `A Stückliste cannot have manual stock adjustments (SKU)`; **`track_stock==0` → `Stock tracking is disabled for article (SKU)`** (added as bugfix; checked before number allocation/manifest write, so batch is all-or-nothing).
- `store/ledger/add.js` enforces the same guard for **any warehouse-scoped entry** (`warehouse_id != null` + line has SKU): looks up `article` by `sku` or `articlesupplier.sku` in same company, rejects if any match has `track_stock==0`. Non-warehouse entries (e.g. `PAYMENT`, memo `NOTE`) are still allowed. Guard runs before `GET_LATEST_ROW`/`INSERT_ROW`, inside the same runner/tx.
- `POST /ledger` with `warehouse_id` is therefore also blocked for untracked articles; delivery receipts booked via ledger go through the same check.
- `article.track_stock`: `BIT/INTEGER DEFAULT 1`. Managed via `article.createUnderParent({track_stock})`, `updateBySku`, `setTrackStockBySku`, `POST /article` (`track_stock`/`trackStock`, default true). `toggleFather` sets `track_stock=0` when becoming father. POS surfaces it as `use_stock '1'/'0'` (`store/posterminal/getProductList.js`).
- `article.track_stock`: `BIT/INTEGER DEFAULT 1`. Managed via `article.createUnderParent({track_stock})`, `updateBySku`, `setTrackStockBySku`, `POST /article` (`track_stock`/`trackStock`, default true). `toggleParent` sets `track_stock=0` when becoming parent. POS surfaces it as `use_stock '1'/'0'` (`store/posterminal/getProductList.js`).
## dashback (Express API)

View File

@@ -30,8 +30,9 @@ Wenn ein Vertrag unklar oder fehlt: **nicht weiterwursteln**. Human fragen, Vors
| Repo | Pfad | Inhalt |
|------|------|--------|
| **dieses** | `docs/api/README.md` | HTTP-Routen, Auth, CORS |
| **dieses** | `docs/api/routes.md`, `docs/api/routes-all.md` | Endpoint-Übersicht |
| **dieses** | `docs/api/routes/` | eine Markdown-Datei pro Endpoint |
| **dieses** | `docs/api/openapi.json` | maschinenlesbare Spec |
| **dieses** | `/docs/` (lokal) | interaktive Swagger-UI; Spec wird zur Laufzeit aus den `@openapi`-JSDoc gebaut (`src/api/openapiSpec.js`) — es gibt bewusst **keine** `openapi.json` auf Disk |
| dashdbschema | `../dashdbschema/README.md` | Store-Übersicht, Konventionen, HTTP-Mapping |
| dashdbschema | `../dashdbschema/API.md` | Store-API-Index (TOC → `API.<modul>.md`) |
| dashdbschema | `../dashdbschema/API.<modul>.md` | pro Namespace (`entity`, `article`, …): Methoden, Signaturen, JSDoc-Beispiele |

View File

@@ -18,7 +18,7 @@ ESM (`type: module`), Express 5, Node. Depends on `libdashstore` via `file:../da
```bash
cp .env.example .env && npm install
npm start # PORT 4208 (HTTPS 8443)
npm run docs:api # regenerates docs/api/routes.md + docs/api/routes-all.md + docs/api/routes/ + openapi.json
npm run docs:api # regenerates docs/api/routes.md + docs/api/routes-all.md + docs/api/routes/ (no openapi.json on disk — /docs/ builds it live)
npm run manage-users # email allowlist
```

View File

@@ -29,7 +29,7 @@ Docs are generated from `@openapi` JSDoc on route handlers:
npm run docs:api
```
That writes `docs/api/routes.md`, `docs/api/routes-all.md`, and per-route Markdown under `docs/api/routes/`.
That writes `docs/api/routes.md`, `docs/api/routes-all.md`, and per-route Markdown under `docs/api/routes/`. There is no generated `openapi.json` on disk — the interactive Swagger UI at `/docs/` builds the spec from the same `@openapi` JSDoc at runtime (`src/api/openapiSpec.js`).
### Brands (company-scoped, `selectedCompanyId` aus der Session)

View File

@@ -45,15 +45,16 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/article/set-categories.md`](routes/article/set-categories.md) | `PUT /article/{sku}/categories` |
| [`routes/article/_sku/children.get.md`](routes/article/_sku/children.get.md) | `GET /article/{sku}/children` |
| [`routes/article/_sku/children.post.md`](routes/article/_sku/children.post.md) | `POST /article/{sku}/children` |
| [`routes/article/_sku/children.put.md`](routes/article/_sku/children.put.md) | `PUT /article/{sku}/children` |
| [`routes/article/_sku/children/_childSku.md`](routes/article/_sku/children/_childSku.md) | `DELETE /article/{sku}/children/{childSku}` |
| [`routes/article/set-eans.md`](routes/article/set-eans.md) | `PUT /article/{sku}/eans` |
| [`routes/article/_sku/father.md`](routes/article/_sku/father.md) | `POST /article/{sku}/father` |
| [`routes/article/history.md`](routes/article/history.md) | `GET /article/{sku}/history` |
| [`routes/article/add-image.md`](routes/article/add-image.md) | `POST /article/{sku}/image` |
| [`routes/article/delete-image.md`](routes/article/delete-image.md) | `DELETE /article/{sku}/image/{fullHash}` |
| [`routes/article/order-images.md`](routes/article/order-images.md) | `PUT /article/{sku}/images/order` |
| [`routes/article/_sku/optionslist.get.md`](routes/article/_sku/optionslist.get.md) | `GET /article/{sku}/optionslist` |
| [`routes/article/_sku/optionslist.put.md`](routes/article/_sku/optionslist.put.md) | `PUT /article/{sku}/optionslist` |
| [`routes/article/_sku/parent.md`](routes/article/_sku/parent.md) | `POST /article/{sku}/parent` |
| [`routes/article/set-prices.md`](routes/article/set-prices.md) | `PUT /article/{sku}/prices` |
| [`routes/article/_sku/print.md`](routes/article/_sku/print.md) | `POST /article/{sku}/print` |
| [`routes/article/_sku/property.get.md`](routes/article/_sku/property.get.md) | `GET /article/{sku}/property` |
@@ -100,6 +101,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/category/_id/property/_propertyId.md`](routes/category/_id/property/_propertyId.md) | `DELETE /category/{id}/property/{propertyId}` |
| [`routes/company/list.md`](routes/company/list.md) | `GET /company` |
| [`routes/company/create.md`](routes/company/create.md) | `POST /company` |
| [`routes/company/history.md`](routes/company/history.md) | `GET /company/history` |
| [`routes/company/addHistory.md`](routes/company/addHistory.md) | `POST /company/history` |
| [`routes/companysetting/list.md`](routes/companysetting/list.md) | `GET /company/setting` |
| [`routes/companysetting/get.md`](routes/companysetting/get.md) | `GET /company/setting/{key}` |
| [`routes/companysetting/set.md`](routes/companysetting/set.md) | `PUT /company/setting/{key}` |

File diff suppressed because it is too large Load Diff

View File

@@ -1,192 +1,559 @@
# Dash API Routes Overview (All)
Overview of all 178 API endpoints across all modules.
Overview of all 181 API endpoints across all modules.
### Parameter Notation
- **Required parameters** have no `?` (e.g. `sku:string`, `categoryId`, `query: si:string`, `body: { name }`).
- **Optional parameters** are marked with `?` (e.g. `query: categoryId?:integer`, `track_stock?`, `body?: ...`).
- **Every value carries its type** — `string`, `integer`, `number`, `boolean`, `object`, arrays as `type[]`.
- **Required parameters** have no `?` (e.g. `sku: string`, `id: integer`, `query: si: string`, `body: { name: string }`).
- **Optional parameters** are marked with `?` (e.g. `query: limit?: integer`, `body: { sort?: integer }`, `body?: { … }`).
- **Path parameters** match `{param}` in the route URL.
- **Query parameters** are prefixed with `query: ...`.
- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if optional).
- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if the whole body is optional).
- **Response objects** use the same notation, so `201 { article: { … } }` means a JSON object with an `article` property.
| Method | Route | Params | Return Type | Summary |
|:---|:---|:---|:---|:---|
| `POST` | `/article` | `body: { categoryId, name, track_stock? }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Create article in category |
| `GET` | `/article/export.zip` | `query: categoryId?:integer` | `200 zip` | Export articles as ZIP |
| `DELETE` | `/article/{sku}` | `sku:string, query: cascade?:boolean` | `204 void` | Delete article by SKU |
| `GET` | `/article/{sku}` | `sku:string` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Get article by SKU |
| `PATCH` | `/article/{sku}` | `sku:string, body: { name?, unitamount?, unitSi?, track_stock? }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Update article by SKU |
| `GET` | `/article/{sku}/articlelist` | `sku:string` | `200 { items: { id, articleId, sku, name, count, sort }[] }` | Get Stückliste (bundle items) for an article |
| `PUT` | `/article/{sku}/articlelist` | `sku:string, body: { items: { articleId?, sku?: string, count?, sort? }[] }` | `200 { items: object[] }` | Set Stückliste (bundle items) for an article |
| `GET` | `/article/{sku}/attributevalue` | `sku:string` | `200 { attributevalues: { id, articleId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | List article attribute values |
| `PUT` | `/article/{sku}/attributevalue` | `sku:string, body: { attributevalues: any[] }` | `200 { attributevalues: { id, articleId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set article attribute values |
| `PUT` | `/article/{sku}/brand` | `sku:string, body: { brandId: integer }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set or clear article brand by SKU |
| `PUT` | `/article/{sku}/categories` | `sku:string, body: { categoryIds: integer[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article categories by SKU |
| `GET` | `/article/{sku}/children` | `sku:string` | `200 { children: { id, sku, name, fathersort, attributeValueId, attributeValue }[] }` | List child articles of a father article |
| `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name?, sku?: string }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a father article |
| `DELETE` | `/article/{sku}/children/{childSku}` | `sku:string, childSku:string, query: deleteArticle?:boolean` | `200 { success, deleted }` | Remove a child article from a father article |
| `PUT` | `/article/{sku}/eans` | `sku:string, body: { eans: string[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article EANs by SKU |
| `POST` | `/article/{sku}/father` | `sku:string, body: { isFather, attributeNameId? }` | `200 { article: { id, categoryId, name, sku, track_stock } }` | Toggle father mode for an article |
| `GET` | `/article/{sku}/history` | `sku:string, query: limit?:integer` | `200 { history: { id, date, text }[], truncated }` | Get article history by SKU |
| `POST` | `/article/{sku}/image` | `sku:string, image/avif` | `201 { image: { picId, sort, full, thumb, hash, thumbHash } }` | Add article image |
| `DELETE` | `/article/{sku}/image/{fullHash}` | `sku:string, fullHash:string` | `204 void` | Delete article image |
| `PUT` | `/article/{sku}/images/order` | `sku:string, body: { fullHashes: string[] }` | `200 { fullHashes: string[] }` | Reorder article images |
| `GET` | `/article/{sku}/optionslist` | `sku:string` | `200 { options: { id, articleId, sku, name, count, sort, mandated }[] }` | Get options list for an article |
| `PUT` | `/article/{sku}/optionslist` | `sku:string, body: { options: { articleId?, sku?: string, count?, sort?, mandated? }[] }` | `200 { options: object[] }` | Set options list for an article |
| `PUT` | `/article/{sku}/prices` | `sku:string, body: { prices: { price, tax, minmultiplier? }[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace POS prices by SKU |
| `POST` | `/article/{sku}/print` | `sku:string, body: { templateId, printerId, quantity }` | `200` | Print article label |
| `GET` | `/article/{sku}/property` | `sku:string` | `200 { properties: { id, articleId, propertyId, name, exclusive, value, sort }[] }` | List article properties |
| `PUT` | `/article/{sku}/property` | `sku:string, body: { properties: { propertyId, value, sort? }[] }` | `200 { properties: { id, articleId, propertyId, name, exclusive, value, sort }[] }` | Bulk set article properties |
| `DELETE` | `/article/{sku}/property/{propertyId}` | `sku:string, propertyId:integer` | `200` | Delete article property |
| `GET` | `/article/{sku}/supplier` | `sku:string` | `200 { suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] }` | List article suppliers |
| `POST` | `/article/{sku}/supplier` | `sku:string, body: { entityId, sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `201 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Assign supplier to article |
| `PUT` | `/article/{sku}/supplier/order` | `sku:string, body: { ids: integer[] }` | `200 { ids: integer[] }` | Reorder article suppliers |
| `DELETE` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer` | `204 void` | Remove supplier assignment from article |
| `PATCH` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer, body: { sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `200 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Update article supplier assignment |
| `PUT` | `/article/{sku}/track_stock` | `sku:string, body: { track_stock: boolean }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article track_stock flag by SKU |
| `PUT` | `/article/{sku}/weight` | `sku:string, body: { weight: number }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article weight by SKU |
| `GET` | `/articleIndex` | `query: windowSize:integer` | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[], truncated }` | List article search index |
| `GET` | `/attributename` | — | `200 { attributenames: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] }[] }` | List attribute names |
| `POST` | `/attributename` | `body: { name, sort?, exclusive? }` | `201 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Create attribute name |
| `PUT` | `/attributename/order` | `body: { attributenameIds: integer[] }` | `200` | Reorder attribute names |
| `GET` | `/attributename/{attributeId}/value` | `attributeId:integer` | `200 { values: { id, attributeId, value, sort }[] }` | List values for an attribute |
| `POST` | `/attributename/{attributeId}/value` | `attributeId:integer, body: { value, sort? }` | `201 { attributevalue: { id, attributeId, value, sort } }` | Create value for an attribute |
| `PUT` | `/attributename/{attributeId}/value/order` | `attributeId:integer, body: { attributevalueIds: integer[] }` | `200` | Reorder values of an attribute |
| `DELETE` | `/attributename/{id}` | `id:integer` | `200` | Delete attribute name |
| `PATCH` | `/attributename/{id}` | `id:integer, body: { name?, sort?, exclusive? }` | `200 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Update attribute name |
| `DELETE` | `/attributevalue/{id}` | `id:integer` | `200` | Delete attribute value |
| `PATCH` | `/attributevalue/{id}` | `id:integer, body: { value?, sort? }` | `200 { attributevalue: { id, attributeId, value, sort } }` | Update attribute value |
| `POST` | `/auth/login` | `body: { idToken: string }` | `200 { message, user }` | Login with Google ID token |
| `POST` | `/auth/logout` | — | `200 { message: string }` | Logout |
| `GET` | `/auth/me` | — | `200 { user, googleClientId }` | Current user |
| `PUT` | `/auth/selected-company` | `body: { companyId: integer }` | `200 { user: { id, email, selectedCompanyId, picture } }` | Set selected company |
| `GET` | `/brand` | — | `200 { brands: { id, name, gsprpinfo, pics: { id, sort, fullHash, thumbW, thumbH, thumbHash }[] }[] }` | List brands |
| `PUT` | `/brand` | `body: { name, gsprpinfo? }` | `200 { brand: { id, name, gsprpinfo, pics: { id, sort, fullHash, thumbW, thumbH, thumbHash }[] } }` | Upsert brand |
| `DELETE` | `/brand/{id}` | `id:integer` | `204 void` | Delete brand |
| `POST` | `/category` | `body: { parentId, name }` | `201 { category: { id, parentId, sort, name } }` | Create category |
| `GET` | `/category/export.zip` | — | `200 zip` | Export categories as ZIP |
| `PUT` | `/category/order` | `body: { parentId, categoryIds: integer[] }` | `200 { categoryIds: integer[] }` | Reorder sibling categories |
| `GET` | `/category/tree` | — | `200 { languageId, tree: object[], articles: object[] }` | Category tree |
| `DELETE` | `/category/{id}` | `id:integer` | `204 void` | Delete category |
| `PATCH` | `/category/{id}` | `id:integer, body: { name?, parentId? }` | `200 { category: { id, name, slug, parentId, sort } }` | Update category (rename or move) |
| `GET` | `/category/{id}/articles` | `id:integer` | `200 { articles: { id, sku, name, slug, net, taxRate, track_stock, optionsCount, articleListCount, childCount, fullHash, thumbW, thumbH, thumbHash, brand }[] }` | Articles in category |
| `GET` | `/category/{id}/attributevalue` | `id:integer` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | List category attribute values |
| `PUT` | `/category/{id}/attributevalue` | `id:integer, body: { attributevalues: any[] }` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set category attribute values |
| `DELETE` | `/category/{id}/image` | `id:integer` | `200 { category: { id, thumbW, thumbH, thumbHash } }` | Delete category image |
| `POST` | `/category/{id}/image` | `id:integer, image/avif` | `201 { category: { id, thumbW, thumbH, thumbHash } }` | Add category image |
| `GET` | `/category/{id}/property` | `id:integer` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | List category properties |
| `PUT` | `/category/{id}/property` | `id:integer, body: { properties: { propertyId, value, sort? }[] }` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | Bulk set category properties |
| `DELETE` | `/category/{id}/property/{propertyId}` | `id:integer, propertyId:integer` | `200` | Delete category property |
| `GET` | `/company` | — | `200 { companies: { id, iso639, name, taxrate, reducedtaxrate }[] }` | List companies |
| `POST` | `/company` | `body: { iso639, name, taxrate, reducedtaxrate }` | `201 { company, user }` | Create company |
| `GET` | `/company/setting` | — | `200 { settings: { key, value }[] }` | List company settings |
| `GET` | `/company/setting/{key}` | `key:string` | `200 { setting: { key, value } }` | Get company setting by key |
| `PUT` | `/company/setting/{key}` | `key:string, body: { value, frontend? }` | `200 { setting: { key, value } }` | Upsert company setting |
| `DELETE` | `/company/{id}` | `id:integer` | `204 void` | Delete company |
| `PUT` | `/company/{id}` | `id:integer, body: { iso639, name, taxrate, reducedtaxrate }` | `200 { company: { id, iso639, name, taxrate, reducedtaxrate } }` | Update company |
| `GET` | `/customergroup` | — | `200 { customergroups: { id, name, sort, editable }[] }` | List customer groups |
| `POST` | `/customergroup` | `body: { name: string }` | `201 { customergroup: { id, name, sort } }` | Create customer group |
| `PUT` | `/customergroup/order` | `body: { customergroupIds: integer[] }` | `200 { customergroupIds: integer[] }` | Reorder customer groups |
| `DELETE` | `/customergroup/{id}` | `id:integer` | `204 void` | Delete customer group |
| `PATCH` | `/customergroup/{id}` | `id:integer, body: { name: string }` | `200 { customergroup: { id, name, sort } }` | Update customer group |
| `GET` | `/delivery` | — | `200 { deliveries: { id, number, type, supplierId, externalNumber, date, createdAt, ledger }[] }` | List deliveries |
| `POST` | `/delivery` | `body: { externalNumber, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], supplierId?, files?: { mimetype, name, content, sort?, size?, hash?, created?, modified?, meta? }[] }` | `201 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Create delivery |
| `POST` | `/delivery/parse` | `form: { file: string }` | `200 SSE` | Parse document (SSE) |
| `DELETE` | `/delivery/{id}` | `id:integer` | `204 void` | Delete delivery |
| `GET` | `/delivery/{id}` | `id:integer` | `200 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Get delivery |
| `PATCH` | `/delivery/{id}` | `id:integer, body: { externalNumber?, date?, supplierId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], files?: { mimetype, name, content, sort?, size?, hash?, created?, modified?, meta? }[] }` | `200 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Update delivery |
| `GET` | `/delivery/{id}/file/{fileId}` | `id:integer, fileId:integer, query: download?:boolean` | `200 string` | Get delivery attachment file content |
| `GET` | `/entity` | `query: customergroupId?:integer, role?:string` | `200 { entities: { id, customergroupId, firstName, name, number, createdAt, updatedAt }[] }` | List entities |
| `POST` | `/entity` | `body: { customergroupId, name, firstName?, number? }` | `201 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Create entity |
| `GET` | `/entity/next-number` | — | `200 { number: string }` | Peek next customer number |
| `GET` | `/entity/{entityId}/address` | `entityId:integer` | `200 { addresses: { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType }[] }` | List entity addresses |
| `POST` | `/entity/{entityId}/address` | `entityId:integer, body: { addressType, firstName?, name?, street?, streetNumber?, zip?, city?, county? }` | `201 { address: { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType } }` | Create entity address |
| `DELETE` | `/entity/{entityId}/address/{id}` | `entityId:integer, id:integer` | `204 void` | Delete entity address |
| `PATCH` | `/entity/{entityId}/address/{id}` | `entityId:integer, id:integer, body: { addressType?, firstName?, name?, street?, streetNumber?, zip?, city?, county? }` | `200 { address: { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType } }` | Update entity address |
| `GET` | `/entity/{entityId}/contact` | `entityId:integer` | `200 { contacts: { id, entityId, contactType, contact }[] }` | List entity contacts |
| `POST` | `/entity/{entityId}/contact` | `entityId:integer, body: { contactType, contact }` | `201 { contact: { id, entityId, contactType, contact } }` | Create entity contact |
| `DELETE` | `/entity/{entityId}/contact/{id}` | `entityId:integer, id:integer` | `204 void` | Delete entity contact |
| `PATCH` | `/entity/{entityId}/contact/{id}` | `entityId:integer, id:integer, body: { contactType?, contact? }` | `200 { contact: { id, entityId, contactType, contact } }` | Update entity contact |
| `DELETE` | `/entity/{id}` | `id:integer` | `204 void` | Delete entity |
| `GET` | `/entity/{id}` | `id:integer` | `200 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Get entity |
| `PATCH` | `/entity/{id}` | `id:integer, body: { name?, firstName?, number? }` | `200 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Update entity |
| `GET` | `/events` | — | `200 SSE` | Subscribe to update signals (SSE) |
| `GET` | `/health` | — | `200 { ok: boolean }` | Database health check |
| `GET` | `/image/{fullHash}` | `fullHash:string` | `200 avif` | Full image by hash |
| `GET` | `/labeltemplate` | — | `200 { labeltemplates: { id, name, width, height, dpi, type, content, thumb }[] }` | List label templates |
| `POST` | `/labeltemplate` | `body: { name, width, height, dpi, type, content }` | `201 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Create label template |
| `DELETE` | `/labeltemplate/{id}` | `id:integer` | `204 void` | Delete label template |
| `GET` | `/labeltemplate/{id}` | `id:integer` | `200 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Get label template by ID |
| `PATCH` | `/labeltemplate/{id}` | `id:integer, body: { name?, width?, height?, dpi?, type?, content? }` | `200 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Update label template (partial) |
| `PUT` | `/labeltemplate/{id}` | `id:integer, body: { name?, width?, height?, dpi?, type?, content? }` | `200 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Update label template |
| `GET` | `/ledger` | `query: manifestarticle_id?:integer, sku?:string, transaction_type?:string` | `200 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Query ledger by manifest article ID or SKU |
| `POST` | `/ledger` | `body: { manifestarticle_id, transaction_type, amount, transaction_text }` | `201 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at } }` | Add ledger entry |
| `GET` | `/ledger/manifestarticle/{manifestarticleId}` | `manifestarticleId:integer, query: transaction_type?:string` | `200 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Query ledger by manifest article ID |
| `GET` | `/ledger/sku/{sku}` | `sku:string, query: transaction_type?:string` | `200 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Query ledger by article SKU |
| `GET` | `/modelconversation` | — | `200 { conversations: { id, companyuser_id, provider, model, created_at, updated_at, state }[] }` | List conversations |
| `POST` | `/modelconversation` | `body: { provider, model }` | `201 { conversation: { id, companyuser_id, provider, model, created_at, updated_at, state } }` | Create conversation |
| `DELETE` | `/modelconversation/{id}` | `id:integer` | `204 void` | Delete conversation |
| `GET` | `/modelconversation/{id}` | `id:integer` | `200 { conversation: { id, companyuser_id, provider, model, created_at, updated_at, state } }` | Get conversation |
| `GET` | `/modelconversation/{id}/message` | `id:integer` | `200 { messages: { id, conversation_id, created_at, role, message }[] }` | List conversation messages |
| `POST` | `/modelconversation/{id}/message` | `id:integer, body: { message?, role?, tool_call_id?, content?, answer? }` | `201 { message: { id, conversation_id, created_at, role, message } }` | Post a user message |
| `GET` | `/modelconversation/{id}/stream` | `id:integer` | `200 SSE` | Stream conversation events (SSE) |
| `GET` | `/number` | — | `200 { numbers: { id, type, prefix, padding, next }[] }` | List number sequences |
| `PATCH` | `/number/{id}` | `id:integer, body: { prefix?, padding?, next? }` | `200 { number: { id, type, prefix, padding, next } }` | Update number sequence |
| `GET` | `/order` | — | `200 { orders: { id, number, type, entityId, date, createdAt }[] }` | List orders |
| `POST` | `/order` | `body: { number, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], entityId? }` | `201 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Create order |
| `DELETE` | `/order/{id}` | `id:integer` | `204 void` | Delete order |
| `GET` | `/order/{id}` | `id:integer` | `200 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Get order |
| `PATCH` | `/order/{id}` | `id:integer, body: { number?, date?, entityId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[] }` | `200 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Update order |
| `GET` | `/posterminal` | — | `200 { posterminals: { id, name, pairingpin, categoryIds: integer[] }[] }` | List POS terminals |
| `PUT` | `/posterminal` | `body: { name: string }` | `200 { posterminal: { id, name, pairingpin, categoryIds: integer[] } }` | Create POS terminal |
| `DELETE` | `/posterminal/{id}` | `id:integer` | `204 void` | Delete POS terminal |
| `DELETE` | `/posterminal/{id}/all` | `id:integer` | `200 { id, categoryIds: integer[] }` | Disable all categories on terminal |
| `PUT` | `/posterminal/{id}/all` | `id:integer` | `200 { id, categoryIds: integer[] }` | Enable all categories on terminal |
| `DELETE` | `/posterminal/{id}/category/{categoryId}` | `id:integer, categoryId:integer` | `200 { id, categoryIds: integer[] }` | Disable category on terminal |
| `PUT` | `/posterminal/{id}/category/{categoryId}` | `id:integer, categoryId:integer` | `200 { id, categoryIds: integer[] }` | Enable category on terminal |
| `GET` | `/printer` | — | `200 { printers: { id, company_id, address, type, model, serial, sort, desc }[] }` | List printers |
| `POST` | `/printer` | `body: { address, type, model?, serial?, sort?, desc? }` | `201 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Create printer |
| `PUT` | `/printer/order` | `body: { printerIds: integer[] }` | `200 { printerIds: integer[] }` | Reorder printers |
| `DELETE` | `/printer/{id}` | `id:integer` | `204 void` | Delete printer |
| `GET` | `/printer/{id}` | `id:integer` | `200 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Get printer by ID |
| `PATCH` | `/printer/{id}` | `id:integer, body: { address?, type?, model?, serial?, sort?, desc? }` | `200 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Update printer (partial) |
| `PUT` | `/printer/{id}` | `id:integer, body: { address?, type?, model?, serial?, sort?, desc? }` | `200 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Update printer |
| `GET` | `/propertyname` | — | `200 { propertynames: { id, name, sort, exclusive }[] }` | List property names |
| `POST` | `/propertyname` | `body: { name, sort?, exclusive? }` | `201 { propertyname: { id, name, sort, exclusive } }` | Create property name |
| `PUT` | `/propertyname/order` | `body: { propertynameIds: integer[] }` | `200` | Reorder property names |
| `DELETE` | `/propertyname/{id}` | `id:integer` | `200` | Delete property name |
| `PATCH` | `/propertyname/{id}` | `id:integer, body: { name?, sort?, exclusive? }` | `200 { propertyname: { id, name, sort, exclusive } }` | Update property name |
| `GET` | `/public/{companyId}/articles` | `companyId:integer, query: categoryId?:integer, search?:string, limit?:integer, offset?:integer` | `200 { articles: { id, sku, name, slug, net, taxRate, gross, trackStock, brand, fullHash, thumbW, thumbH, thumbHash }[] }` | List public articles |
| `GET` | `/public/{companyId}/articles/{sku}` | `companyId:integer, sku:string` | `200 { article: { id, sku, name, slug, description, unitamount, weight, trackStock, unit, brand, price, prices: object[], categories: object[], photos: object[], eans: string[], isFather, fatherSku, variants: object[] } }` | Get public article detail |
| `GET` | `/public/{companyId}/categories` | `companyId:integer` | `200 { categories: { id, parentId, sort, name, slug, fullHash, thumbW, thumbH, thumbHash }[] }` | List public categories |
| `GET` | `/quote` | — | `200 { quotes: { id, number, type, entityId, date, createdAt }[] }` | List quotes |
| `POST` | `/quote` | `body: { number, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], entityId? }` | `201 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Create quote |
| `DELETE` | `/quote/{id}` | `id:integer` | `204 void` | Delete quote |
| `GET` | `/quote/{id}` | `id:integer` | `200 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Get quote |
| `PATCH` | `/quote/{id}` | `id:integer, body: { number?, date?, entityId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[] }` | `200 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Update quote |
| `POST` | `/serper/image` | `body: { url: string }` | `200 avif` | Crawl image from URL, crop white/transparency borders, and return AVIF |
| `POST` | `/serper/images` | `body: { q, gl?, hl?, num?, preferSource? }` | `200 SSE` | Search images via Serper.dev and stream WebP thumbnails (SSE) |
| `POST` | `/stock/adjust` | `body: { sku?: string, warehouseId?, amount?, text?, lines?: { sku: string, amount, warehouseId?, text? }[] }` | `201 { manifest, lines: object[], ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Adjust stock manually |
| `GET` | `/supplier` | — | `200 { suppliers: { id, name, createdAt, updatedAt }[] }` | List suppliers |
| `POST` | `/supplier` | `body: { name: string }` | `201 { supplier: { id, name, createdAt, updatedAt } }` | Create supplier |
| `DELETE` | `/supplier/{id}` | `id:integer` | `204 void` | Delete supplier |
| `PATCH` | `/supplier/{id}` | `id:integer, body: { name: string }` | `200 { supplier: { id, name, createdAt, updatedAt } }` | Update supplier |
| `GET` | `/supplier/{id}/articles` | `id:integer, query: skus?` | `200 { articles: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price, article }[] }` | List supplier articles by SKUs |
| `POST` | `/supplier/{id}/articles` | `id:integer, body?: { skus?: string[] }` | `200 { articles: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price, article }[] }` | Query supplier articles by SKUs (bulk) |
| `GET` | `/thumb/{fullHash}` | `fullHash:string` | `200 avif` | Thumb image by hash |
| `DELETE` | `/unit` | `query: si:string` | `204 void` | Delete unit |
| `GET` | `/unit` | — | `200 { units: { si, basesi, basesifactor, symbol, name }[] }` | List units |
| `PUT` | `/unit` | `body: { si, basesi, basesifactor, symbol, name }` | `200 { unit: { si, basesi, basesifactor, symbol, name } }` | Upsert unit |
| `GET` | `/v1/category` | `query: lastChangedCategory?:string, limit?:integer` | `200 object[]` | JTL-POS category synchronization |
| `GET` | `/v1/client` | `query: authCode:string, name?:string` | `200 { authCode, authToken, certificateFingerprint, certificateSerialNumber, mandantId, mandantName, mandantDatabase, serverFingerprint, name, serverTimestamp }` | POS discovery and pairing |
| `GET` | `/v1/init` | `query: lastChangedProduct?:integer, lastChangedCategory?:integer, lastChangedCustomer?:integer, lastChangedCustomerGroup?:integer, lastChangedCompositeProduct?:integer, lastChangedDeletedEntity?:integer` | `200 { version, product_count, category_count, customer_count, customerGroup_count, compositeProduct_count, configurationGroup_count, configurationItem_count, deletedEntity_count, max_orderId_count }` | POS sync initialization counts |
| `GET` | `/v1/product` | `query: lastChangedProduct?:string, limit?:integer` | `200 object[]` | JTL-POS product catalog synchronization |
| `GET` | `/vfs/{*path}` | `path:string, query: download?:boolean` | `200 string` | Get a virtual filesystem file |
| `GET` | `/warehouse` | — | `200 { warehouses: { id, name, sort }[] }` | List warehouses |
| `POST` | `/warehouse` | `body: { name, sort? }` | `201 { warehouse: { id, name, sort } }` | Create warehouse |
| `PUT` | `/warehouse/order` | `body: { warehouseIds: integer[] }` | `200 { warehouseIds: integer[] }` | Reorder warehouses |
| `DELETE` | `/warehouse/{id}` | `id:integer` | `204 void` | Delete warehouse |
| `GET` | `/warehouse/{id}` | `id:integer` | `200 { warehouse: { id, name, sort } }` | Get warehouse by ID |
| `PATCH` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse (partial) |
| `PUT` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse |
### Routes
#### Create article in category — `POST /article`
- Params: `body: { categoryId: integer, name: string, track_stock?: boolean }`
- Returns: `201 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }`
#### Export articles as ZIP — `GET /article/export.zip`
- Params: `query: categoryId?: integer`
- Returns: `200 zip`
#### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: string, query: cascade?: boolean`
- Returns: `204 void`
#### Get article by SKU — `GET /article/{sku}`
- Params: `sku: string`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: string, body: { name?: string, unitamount?: number, unitSi?: string, track_stock?: boolean }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist`
- Params: `sku: string`
- Returns: `200 { items: { id: integer, articleId: integer, sku: string, name: string, count: integer, sort: integer }[] }`
#### Set Stückliste (bundle items) for an article — `PUT /article/{sku}/articlelist`
- Params: `sku: string, body: { items: { articleId?: integer, sku?: string, count?: integer, sort?: integer }[] }`
- Returns: `200 { items: object[] }`
#### List article attribute values — `GET /article/{sku}/attributevalue`
- Params: `sku: string`
- Returns: `200 { attributevalues: { id: integer, articleId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }`
#### Bulk set article attribute values — `PUT /article/{sku}/attributevalue`
- Params: `sku: string, body: integer[]`
- Returns: `200 { attributevalues: { id: integer, articleId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }`
#### Set or clear article brand by SKU — `PUT /article/{sku}/brand`
- Params: `sku: string, body: { brandId: integer }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Replace article categories by SKU — `PUT /article/{sku}/categories`
- Params: `sku: string, body: { categoryIds: integer[] }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### List child articles of a parent article — `GET /article/{sku}/children`
- Params: `sku: string`
- Returns: `200 { children: { id: integer, sku: string, name: string, parentsort: integer, attributeValueId: integer, attributeValue: string }[] }`
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
- Params: `sku: string, body: { attributeValueId?: integer, value?: string, name?: string }`
- Returns: `201 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: string, body: { sku: string, attributeValueId?: integer, value?: string, reparent?: boolean }`
- Returns: `200 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean }, children: object[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: string, childSku: string, query: deleteArticle?: boolean`
- Returns: `200 { success: boolean, deleted: boolean }`
#### Replace article EANs by SKU — `PUT /article/{sku}/eans`
- Params: `sku: string, body: { eans: string[] }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Get article history by SKU — `GET /article/{sku}/history`
- Params: `sku: string, query: limit?: integer`
- Returns: `200 { history: { id: integer, date: string, text: string }[], truncated: boolean }`
#### Add article image — `POST /article/{sku}/image`
- Params: `sku: string, image/avif`
- Returns: `201 { image: { picId: integer, sort: integer, full: { w: integer, h: integer, size: integer }, thumb: { w: integer, h: integer }, hash: string, thumbHash: string } }`
#### Delete article image — `DELETE /article/{sku}/image/{fullHash}`
- Params: `sku: string, fullHash: string`
- Returns: `204 void`
#### Reorder article images — `PUT /article/{sku}/images/order`
- Params: `sku: string, body: { fullHashes: string[] }`
- Returns: `200 { fullHashes: string[] }`
#### Get options list for an article — `GET /article/{sku}/optionslist`
- Params: `sku: string`
- Returns: `200 { options: { id: integer, articleId: integer, sku: string, name: string, count: integer, sort: integer, mandated: boolean }[] }`
#### Set options list for an article — `PUT /article/{sku}/optionslist`
- Params: `sku: string, body: { options: { articleId?: integer, sku?: string, count?: integer, sort?: integer, mandated?: boolean }[] }`
- Returns: `200 { options: object[] }`
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: string, body: { isParent: boolean, attributeNameId?: integer }`
- Returns: `200 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }`
#### Replace POS prices by SKU — `PUT /article/{sku}/prices`
- Params: `sku: string, body: { prices: { price: number, tax: number, minmultiplier?: number }[] }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Print article label — `POST /article/{sku}/print`
- Params: `sku: string, body: { templateId: integer, printerId: integer, quantity: integer }`
- Returns: `200`
#### List article properties — `GET /article/{sku}/property`
- Params: `sku: string`
- Returns: `200 { properties: { id: integer, articleId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }`
#### Bulk set article properties — `PUT /article/{sku}/property`
- Params: `sku: string, body: { properties: { propertyId: integer, value: string, sort?: integer }[] }`
- Returns: `200 { properties: { id: integer, articleId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }`
#### Delete article property — `DELETE /article/{sku}/property/{propertyId}`
- Params: `sku: string, propertyId: integer`
- Returns: `200`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: string`
- Returns: `200 { suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: string, body: { entityId: integer, sku?: string, pu?: number, stock?: number, sort?: integer, netPrice?: number, taxId?: integer, taxRate?: number }`
- Returns: `201 { supplier: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } } }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: string, body: { ids: integer[] }`
- Returns: `200 { ids: integer[] }`
#### Remove supplier assignment from article — `DELETE /article/{sku}/supplier/{id}`
- Params: `sku: string, id: integer`
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: string, id: integer, body: { sku?: string, pu?: number, stock?: number, sort?: integer, netPrice?: number, taxId?: integer, taxRate?: number }`
- Returns: `200 { supplier: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } } }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: string, body: { track_stock: boolean }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Set article weight by SKU — `PUT /article/{sku}/weight`
- Params: `sku: string, body: { weight: number }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### List article search index — `GET /articleIndex`
- Params: `query: windowSize: integer`
- Returns: `200 { articles: { id: integer, sku: string, name: string, eans: string[], categoryIds: integer[] }[], truncated: boolean }`
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] }[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: string, sort?: integer, exclusive?: boolean }`
- Returns: `201 { attributename: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] } }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: integer[] }`
- Returns: `200`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: integer`
- Returns: `200 { values: { id: integer, attributeId: integer, value: string, sort: integer }[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: integer, body: { value: string, sort?: integer }`
- Returns: `201 { attributevalue: { id: integer, attributeId: integer, value: string, sort: integer } }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: integer, body: { attributevalueIds: integer[] }`
- Returns: `200`
#### Delete attribute name — `DELETE /attributename/{id}`
- Params: `id: integer`
- Returns: `200`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: integer, body: { name?: string, sort?: integer, exclusive?: boolean }`
- Returns: `200 { attributename: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] } }`
#### Delete attribute value — `DELETE /attributevalue/{id}`
- Params: `id: integer`
- Returns: `200`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: integer, body: { value?: string, sort?: integer }`
- Returns: `200 { attributevalue: { id: integer, attributeId: integer, value: string, sort: integer } }`
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: string }`
- Returns: `200 { message: string, user: { id: integer, email: string, selectedCompanyId: integer, picture: string } }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: string }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: { id: integer, email: string, selectedCompanyId: integer, picture: string }, googleClientId: string }`
#### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: integer }`
- Returns: `200 { user: { id: integer, email: string, selectedCompanyId: integer, picture: string } }`
#### List brands — `GET /brand`
_none_
- Returns: `200 { brands: { id: integer, name: string, gsprpinfo: string, pics: { id: integer, sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] }[] }`
#### Upsert brand — `PUT /brand`
- Params: `body: { name: string, gsprpinfo?: string }`
- Returns: `200 { brand: { id: integer, name: string, gsprpinfo: string, pics: { id: integer, sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Create category — `POST /category`
- Params: `body: { parentId: integer, name: string }`
- Returns: `201 { category: { id: integer, parentId: integer, sort: integer, name: string } }`
#### Export categories as ZIP — `GET /category/export.zip`
_none_
- Returns: `200 zip`
#### Reorder sibling categories — `PUT /category/order`
- Params: `body: { parentId: integer, categoryIds: integer[] }`
- Returns: `200 { categoryIds: integer[] }`
#### Category tree — `GET /category/tree`
_none_
- Returns: `200 { languageId: integer, tree: object[], articles: object[] }`
#### Delete category — `DELETE /category/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Update category (rename or move) — `PATCH /category/{id}`
- Params: `id: integer, body: { name?: string, parentId?: integer }`
- Returns: `200 { category: { id: integer, name: string, slug: string, parentId: integer, sort: integer } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: integer`
- Returns: `200 { articles: { id: integer, sku: string, name: string, slug: string, net: number, taxRate: number, track_stock: boolean, optionsCount: integer, articleListCount: integer, childCount: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string, brand: object }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: integer`
- Returns: `200 { attributevalues: { id: integer, categoryId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }`
#### Bulk set category attribute values — `PUT /category/{id}/attributevalue`
- Params: `id: integer, body: integer[]`
- Returns: `200 { attributevalues: { id: integer, categoryId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }`
#### Delete category image — `DELETE /category/{id}/image`
- Params: `id: integer`
- Returns: `200 { category: { id: integer, thumbW: integer, thumbH: integer, thumbHash: string } }`
#### Add category image — `POST /category/{id}/image`
- Params: `id: integer, image/avif`
- Returns: `201 { category: { id: integer, thumbW: integer, thumbH: integer, thumbHash: string } }`
#### List category properties — `GET /category/{id}/property`
- Params: `id: integer`
- Returns: `200 { properties: { id: integer, categoryId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }`
#### Bulk set category properties — `PUT /category/{id}/property`
- Params: `id: integer, body: { properties: { propertyId: integer, value: string, sort?: integer }[] }`
- Returns: `200 { properties: { id: integer, categoryId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }`
#### Delete category property — `DELETE /category/{id}/property/{propertyId}`
- Params: `id: integer, propertyId: integer`
- Returns: `200`
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number }[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: string, name: string, taxrate: number, reducedtaxrate: number }`
- Returns: `201 { company: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number }, user: { id: integer, email: string, selectedCompanyId: integer, picture: string } }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: integer`
- Returns: `200 { history: { id: integer, companyuserId: integer, date: string, text: string }[], truncated: boolean }`
#### Add company history entry — `POST /company/history`
- Params: `body: { text: string }`
- Returns: `201 { history: { id: integer, companyuserId: integer, date: string, text: string } }`
#### List company settings — `GET /company/setting`
_none_
- Returns: `200 { settings: { key: string, value: string }[] }`
#### Get company setting by key — `GET /company/setting/{key}`
- Params: `key: string`
- Returns: `200 { setting: { key: string, value: string } }`
#### Upsert company setting — `PUT /company/setting/{key}`
- Params: `key: string, body: { value: string, frontend?: boolean }`
- Returns: `200 { setting: { key: string, value: string } }`
#### Delete company — `DELETE /company/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Update company — `PUT /company/{id}`
- Params: `id: integer, body: { iso639: string, name: string, taxrate: number, reducedtaxrate: number }`
- Returns: `200 { company: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number } }`
#### List customer groups — `GET /customergroup`
_none_
- Returns: `200 { customergroups: { id: integer, name: string, sort: integer, editable: boolean }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: string }`
- Returns: `201 { customergroup: { id: integer, name: string, sort: integer } }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: integer[] }`
- Returns: `200 { customergroupIds: integer[] }`
#### Delete customer group — `DELETE /customergroup/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: integer, body: { name: string }`
- Returns: `200 { customergroup: { id: integer, name: string, sort: integer } }`
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer } }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], supplierId?: integer, files?: { mimetype: string, name: string, content: string, sort?: integer, size?: integer, hash?: string, created?: string, modified?: string, meta?: object }[] }`
- Returns: `201 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }`
#### Parse document (SSE) — `POST /delivery/parse`
- Params: `form: { file: string }`
- Returns: `200 SSE`
#### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get delivery — `GET /delivery/{id}`
- Params: `id: integer`
- Returns: `200 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }`
#### Update delivery — `PATCH /delivery/{id}`
- Params: `id: integer, body: { externalNumber?: string, date?: string, supplierId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], files?: { mimetype: string, name: string, content: string, sort?: integer, size?: integer, hash?: string, created?: string, modified?: string, meta?: object }[] }`
- Returns: `200 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: integer, fileId: integer, query: download?: boolean`
- Returns: `200 string`
#### List entities — `GET /entity`
- Params: `query: customergroupId?: integer, role?: string`
- Returns: `200 { entities: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string }[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: integer, name: string, firstName?: string, number?: string }`
- Returns: `201 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: string }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: integer`
- Returns: `200 { addresses: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string }[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: integer, body: { addressType: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string }`
- Returns: `201 { address: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string } }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: integer, id: integer`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: integer, id: integer, body: { addressType?: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string }`
- Returns: `200 { address: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string } }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: integer`
- Returns: `200 { contacts: { id: integer, entityId: integer, contactType: string, contact: string }[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: integer, body: { contactType: string, contact: string }`
- Returns: `201 { contact: { id: integer, entityId: integer, contactType: string, contact: string } }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: integer, id: integer`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: integer, id: integer, body: { contactType?: string, contact?: string }`
- Returns: `200 { contact: { id: integer, entityId: integer, contactType: string, contact: string } }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: integer`
- Returns: `200 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: integer, body: { name?: string, firstName?: string, number?: string }`
- Returns: `200 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }`
#### Subscribe to update signals (SSE) — `GET /events`
_none_
- Returns: `200 SSE`
#### Database health check — `GET /health`
_none_
- Returns: `200 { ok: boolean }`
#### Full image by hash — `GET /image/{fullHash}`
- Params: `fullHash: string`
- Returns: `200 avif`
#### List label templates — `GET /labeltemplate`
_none_
- Returns: `200 { labeltemplates: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: string, width: number, height: number, dpi: integer, type: string, content: object }`
- Returns: `201 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: integer`
- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: integer, body: { name?: string, width?: number, height?: number, dpi?: integer, type?: string, content?: object }`
- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: integer, body: { name?: string, width?: number, height?: number, dpi?: integer, type?: string, content?: object }`
- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }`
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: integer, sku?: string, transaction_type?: string`
- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string }`
- Returns: `201 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string } }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: integer, query: transaction_type?: string`
- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: string, query: transaction_type?: string`
- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }`
#### List conversations — `GET /modelconversation`
_none_
- Returns: `200 { conversations: { id: integer, companyuser_id: integer, provider: string, model: string, created_at: string, updated_at: string, state: string }[] }`
#### Create conversation — `POST /modelconversation`
- Params: `body: { provider: string, model: string }`
- Returns: `201 { conversation: { id: integer, companyuser_id: integer, provider: string, model: string, created_at: string, updated_at: string, state: string } }`
#### Delete conversation — `DELETE /modelconversation/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get conversation — `GET /modelconversation/{id}`
- Params: `id: integer`
- Returns: `200 { conversation: { id: integer, companyuser_id: integer, provider: string, model: string, created_at: string, updated_at: string, state: string } }`
#### List conversation messages — `GET /modelconversation/{id}/message`
- Params: `id: integer`
- Returns: `200 { messages: { id: integer, conversation_id: integer, created_at: string, role: string, message: string }[] }`
#### Post a user message — `POST /modelconversation/{id}/message`
- Params: `id: integer, body: { message?: string, role?: string, tool_call_id?: string, content?: any, answer?: any }`
- Returns: `201 { message: { id: integer, conversation_id: integer, created_at: string, role: string, message: string } }`
#### Stream conversation events (SSE) — `GET /modelconversation/{id}/stream`
- Params: `id: integer`
- Returns: `200 SSE`
#### List number sequences — `GET /number`
_none_
- Returns: `200 { numbers: { id: integer, type: string, prefix: string, padding: integer, next: integer }[] }`
#### Update number sequence — `PATCH /number/{id}`
- Params: `id: integer, body: { prefix?: string, padding?: integer, next?: integer }`
- Returns: `200 { number: { id: integer, type: string, prefix: string, padding: integer, next: integer } }`
#### List orders — `GET /order`
_none_
- Returns: `200 { orders: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string }[] }`
#### Create order — `POST /order`
- Params: `body: { number: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], entityId?: integer }`
- Returns: `201 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Delete order — `DELETE /order/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get order — `GET /order/{id}`
- Params: `id: integer`
- Returns: `200 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Update order — `PATCH /order/{id}`
- Params: `id: integer, body: { number?: string, date?: string, entityId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[] }`
- Returns: `200 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### List POS terminals — `GET /posterminal`
_none_
- Returns: `200 { posterminals: { id: integer, name: string, pairingpin: string, categoryIds: integer[] }[] }`
#### Create POS terminal — `PUT /posterminal`
- Params: `body: { name: string }`
- Returns: `200 { posterminal: { id: integer, name: string, pairingpin: string, categoryIds: integer[] } }`
#### Delete POS terminal — `DELETE /posterminal/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Disable all categories on terminal — `DELETE /posterminal/{id}/all`
- Params: `id: integer`
- Returns: `200 { id: integer, categoryIds: integer[] }`
#### Enable all categories on terminal — `PUT /posterminal/{id}/all`
- Params: `id: integer`
- Returns: `200 { id: integer, categoryIds: integer[] }`
#### Disable category on terminal — `DELETE /posterminal/{id}/category/{categoryId}`
- Params: `id: integer, categoryId: integer`
- Returns: `200 { id: integer, categoryIds: integer[] }`
#### Enable category on terminal — `PUT /posterminal/{id}/category/{categoryId}`
- Params: `id: integer, categoryId: integer`
- Returns: `200 { id: integer, categoryIds: integer[] }`
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string }[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: string, type: string, model?: string, serial?: string, sort?: integer, desc?: string }`
- Returns: `201 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: integer[] }`
- Returns: `200 { printerIds: integer[] }`
#### Delete printer — `DELETE /printer/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: integer`
- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: integer, body: { address?: string, type?: string, model?: string, serial?: string, sort?: integer, desc?: string }`
- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }`
#### Update printer — `PUT /printer/{id}`
- Params: `id: integer, body: { address?: string, type?: string, model?: string, serial?: string, sort?: integer, desc?: string }`
- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }`
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: { id: integer, name: string, sort: integer, exclusive: boolean }[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: string, sort?: integer, exclusive?: boolean }`
- Returns: `201 { propertyname: { id: integer, name: string, sort: integer, exclusive: boolean } }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: integer[] }`
- Returns: `200`
#### Delete property name — `DELETE /propertyname/{id}`
- Params: `id: integer`
- Returns: `200`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: integer, body: { name?: string, sort?: integer, exclusive?: boolean }`
- Returns: `200 { propertyname: { id: integer, name: string, sort: integer, exclusive: boolean } }`
#### List public articles — `GET /public/{companyId}/articles`
- Params: `companyId: integer, query: categoryId?: integer, search?: string, limit?: integer, offset?: integer`
- Returns: `200 { articles: { id: integer, sku: string, name: string, slug: string, net: number, taxRate: number, gross: number, trackStock: boolean, brand: { id: integer, name: string }, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] }`
#### Get public article detail — `GET /public/{companyId}/articles/{sku}`
- Params: `companyId: integer, sku: string`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, description: string, unitamount: number, weight: number, trackStock: boolean, unit: object, brand: object, price: object, prices: object[], categories: object[], photos: object[], eans: string[], isParent: boolean, parentSku: string, variants: object[] } }`
#### List public categories — `GET /public/{companyId}/categories`
- Params: `companyId: integer`
- Returns: `200 { categories: { id: integer, parentId: integer, sort: integer, name: string, slug: string, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] }`
#### List quotes — `GET /quote`
_none_
- Returns: `200 { quotes: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string }[] }`
#### Create quote — `POST /quote`
- Params: `body: { number: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], entityId?: integer }`
- Returns: `201 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: integer`
- Returns: `200 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: integer, body: { number?: string, date?: string, entityId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[] }`
- Returns: `200 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Crawl image from URL, crop white/transparency borders, and return AVIF — `POST /serper/image`
- Params: `body: { url: string }`
- Returns: `200 avif`
#### Search images via Serper.dev and stream WebP thumbnails (SSE) — `POST /serper/images`
- Params: `body: { q: string, gl?: string, hl?: string, num?: integer, preferSource?: boolean }`
- Returns: `200 SSE`
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: string, warehouseId?: integer, amount?: number, text?: string, lines?: { sku: string, amount: number, warehouseId?: integer, text?: string }[] }`
- Returns: `201 { manifest: { id: integer, number: string, type: string, date: string, createdAt: string }, lines: object[], ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }`
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: { id: integer, name: string, createdAt: string, updatedAt: string }[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: string }`
- Returns: `201 { supplier: { id: integer, name: string, createdAt: string, updatedAt: string } }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: integer, body: { name: string }`
- Returns: `200 { supplier: { id: integer, name: string, createdAt: string, updatedAt: string } }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: integer, query: skus?: string`
- Returns: `200 { articles: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number }, article: { id: integer, sku: string, name: string, slug: string, photos: { sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[] } }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: integer, body?: { skus?: string[] }`
- Returns: `200 { articles: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number }, article: { id: integer, sku: string, name: string, slug: string, photos: { sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[] } }[] }`
#### Thumb image by hash — `GET /thumb/{fullHash}`
- Params: `fullHash: string`
- Returns: `200 avif`
#### Delete unit — `DELETE /unit`
- Params: `query: si: string`
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: { si: string, basesi: string, basesifactor: number, symbol: string, name: string }[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: string, basesi: string, basesifactor: number, symbol: string, name: string }`
- Returns: `200 { unit: { si: string, basesi: string, basesifactor: number, symbol: string, name: string } }`
#### JTL-POS category synchronization — `GET /v1/category`
- Params: `query: lastChangedCategory?: string, limit?: integer`
- Returns: `200 object[]`
#### POS discovery and pairing — `GET /v1/client`
- Params: `query: authCode: string, name?: string`
- Returns: `200 { authCode: string, authToken: string, certificateFingerprint: string, certificateSerialNumber: string, mandantId: string, mandantName: string, mandantDatabase: string, serverFingerprint: string, name: string, serverTimestamp: string }`
#### POS sync initialization counts — `GET /v1/init`
- Params: `query: lastChangedProduct?: integer, lastChangedCategory?: integer, lastChangedCustomer?: integer, lastChangedCustomerGroup?: integer, lastChangedCompositeProduct?: integer, lastChangedDeletedEntity?: integer`
- Returns: `200 { version: string, product_count: string, category_count: string, customer_count: string, customerGroup_count: string, compositeProduct_count: string, configurationGroup_count: string, configurationItem_count: string, deletedEntity_count: string, max_orderId_count: string }`
#### JTL-POS product catalog synchronization — `GET /v1/product`
- Params: `query: lastChangedProduct?: string, limit?: integer`
- Returns: `200 object[]`
#### Get a virtual filesystem file — `GET /vfs/{*path}`
- Params: `path: string, query: download?: boolean`
- Returns: `200 string`
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: { id: integer, name: string, sort: integer }[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: string, sort?: integer }`
- Returns: `201 { warehouse: { id: integer, name: string, sort: integer } }`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: integer[] }`
- Returns: `200 { warehouseIds: integer[] }`
#### Delete warehouse — `DELETE /warehouse/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: integer`
- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: integer, body: { name?: string, sort?: integer }`
- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: integer, body: { name?: string, sort?: integer }`
- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }`

View File

@@ -1,159 +1,454 @@
# Dash API Routes Overview
Overview of all 142 API endpoints across all modules.
Overview of all 145 API endpoints across all modules.
### Parameter Notation
- **Required parameters** have no `?` (e.g. `sku:string`, `categoryId`, `query: si:string`, `body: { name }`).
- **Optional parameters** are marked with `?` (e.g. `query: categoryId?:integer`, `track_stock?`, `body?: ...`).
- **Every value carries its type** — `string`, `integer`, `number`, `boolean`, `object`, arrays as `type[]`.
- **Required parameters** have no `?` (e.g. `sku: string`, `id: integer`, `query: si: string`, `body: { name: string }`).
- **Optional parameters** are marked with `?` (e.g. `query: limit?: integer`, `body: { sort?: integer }`, `body?: { … }`).
- **Path parameters** match `{param}` in the route URL.
- **Query parameters** are prefixed with `query: ...`.
- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if optional).
- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if the whole body is optional).
- **Response objects** use the same notation, so `201 { article: { … } }` means a JSON object with an `article` property.
| Method | Route | Params | Return Type | Summary |
|:---|:---|:---|:---|:---|
| `POST` | `/article` | `body: { categoryId, name, track_stock? }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Create article in category |
| `DELETE` | `/article/{sku}` | `sku:string, query: cascade?:boolean` | `204 void` | Delete article by SKU |
| `GET` | `/article/{sku}` | `sku:string` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Get article by SKU |
| `PATCH` | `/article/{sku}` | `sku:string, body: { name?, unitamount?, unitSi?, track_stock? }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Update article by SKU |
| `GET` | `/article/{sku}/articlelist` | `sku:string` | `200 { items: { id, articleId, sku, name, count, sort }[] }` | Get Stückliste (bundle items) for an article |
| `PUT` | `/article/{sku}/articlelist` | `sku:string, body: { items: { articleId?, sku?: string, count?, sort? }[] }` | `200 { items: object[] }` | Set Stückliste (bundle items) for an article |
| `GET` | `/article/{sku}/attributevalue` | `sku:string` | `200 { attributevalues: { id, articleId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | List article attribute values |
| `PUT` | `/article/{sku}/attributevalue` | `sku:string, body: { attributevalues: any[] }` | `200 { attributevalues: { id, articleId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set article attribute values |
| `PUT` | `/article/{sku}/brand` | `sku:string, body: { brandId: integer }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set or clear article brand by SKU |
| `PUT` | `/article/{sku}/categories` | `sku:string, body: { categoryIds: integer[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article categories by SKU |
| `GET` | `/article/{sku}/children` | `sku:string` | `200 { children: { id, sku, name, fathersort, attributeValueId, attributeValue }[] }` | List child articles of a father article |
| `POST` | `/article/{sku}/children` | `sku:string, body: { attributeValueId?, value?, name?, sku?: string }` | `201 { article: { id, categoryId, name, sku, track_stock } }` | Add a child article to a father article |
| `DELETE` | `/article/{sku}/children/{childSku}` | `sku:string, childSku:string, query: deleteArticle?:boolean` | `200 { success, deleted }` | Remove a child article from a father article |
| `PUT` | `/article/{sku}/eans` | `sku:string, body: { eans: string[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace article EANs by SKU |
| `POST` | `/article/{sku}/father` | `sku:string, body: { isFather, attributeNameId? }` | `200 { article: { id, categoryId, name, sku, track_stock } }` | Toggle father mode for an article |
| `GET` | `/article/{sku}/history` | `sku:string, query: limit?:integer` | `200 { history: { id, date, text }[], truncated }` | Get article history by SKU |
| `DELETE` | `/article/{sku}/image/{fullHash}` | `sku:string, fullHash:string` | `204 void` | Delete article image |
| `PUT` | `/article/{sku}/images/order` | `sku:string, body: { fullHashes: string[] }` | `200 { fullHashes: string[] }` | Reorder article images |
| `GET` | `/article/{sku}/optionslist` | `sku:string` | `200 { options: { id, articleId, sku, name, count, sort, mandated }[] }` | Get options list for an article |
| `PUT` | `/article/{sku}/optionslist` | `sku:string, body: { options: { articleId?, sku?: string, count?, sort?, mandated? }[] }` | `200 { options: object[] }` | Set options list for an article |
| `PUT` | `/article/{sku}/prices` | `sku:string, body: { prices: { price, tax, minmultiplier? }[] }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Replace POS prices by SKU |
| `POST` | `/article/{sku}/print` | `sku:string, body: { templateId, printerId, quantity }` | `200` | Print article label |
| `GET` | `/article/{sku}/property` | `sku:string` | `200 { properties: { id, articleId, propertyId, name, exclusive, value, sort }[] }` | List article properties |
| `PUT` | `/article/{sku}/property` | `sku:string, body: { properties: { propertyId, value, sort? }[] }` | `200 { properties: { id, articleId, propertyId, name, exclusive, value, sort }[] }` | Bulk set article properties |
| `DELETE` | `/article/{sku}/property/{propertyId}` | `sku:string, propertyId:integer` | `200` | Delete article property |
| `GET` | `/article/{sku}/supplier` | `sku:string` | `200 { suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] }` | List article suppliers |
| `POST` | `/article/{sku}/supplier` | `sku:string, body: { entityId, sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `201 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Assign supplier to article |
| `PUT` | `/article/{sku}/supplier/order` | `sku:string, body: { ids: integer[] }` | `200 { ids: integer[] }` | Reorder article suppliers |
| `DELETE` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer` | `204 void` | Remove supplier assignment from article |
| `PATCH` | `/article/{sku}/supplier/{id}` | `sku:string, id:integer, body: { sku?: string, pu?, stock?, sort?, netPrice?, taxId?, taxRate? }` | `200 { supplier: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price } }` | Update article supplier assignment |
| `PUT` | `/article/{sku}/track_stock` | `sku:string, body: { track_stock: boolean }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article track_stock flag by SKU |
| `PUT` | `/article/{sku}/weight` | `sku:string, body: { weight: number }` | `200 { article: { id, sku, name, slug, track_stock, unitamount, weight, unit, brand, categoryIds: integer[], photos: { sort, fullHash, fullW, fullH, fullSize, thumbW, thumbH, thumbHash }[], eans: string[], prices: { minmultiplier, net, taxRate, customergroupId }[], suppliers: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price }[] } }` | Set article weight by SKU |
| `GET` | `/articleIndex` | `query: windowSize:integer` | `200 { articles: { id, sku, name, eans: string[], categoryIds: integer[] }[], truncated }` | List article search index |
| `GET` | `/attributename` | — | `200 { attributenames: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] }[] }` | List attribute names |
| `POST` | `/attributename` | `body: { name, sort?, exclusive? }` | `201 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Create attribute name |
| `PUT` | `/attributename/order` | `body: { attributenameIds: integer[] }` | `200` | Reorder attribute names |
| `GET` | `/attributename/{attributeId}/value` | `attributeId:integer` | `200 { values: { id, attributeId, value, sort }[] }` | List values for an attribute |
| `POST` | `/attributename/{attributeId}/value` | `attributeId:integer, body: { value, sort? }` | `201 { attributevalue: { id, attributeId, value, sort } }` | Create value for an attribute |
| `PUT` | `/attributename/{attributeId}/value/order` | `attributeId:integer, body: { attributevalueIds: integer[] }` | `200` | Reorder values of an attribute |
| `DELETE` | `/attributename/{id}` | `id:integer` | `200` | Delete attribute name |
| `PATCH` | `/attributename/{id}` | `id:integer, body: { name?, sort?, exclusive? }` | `200 { attributename: { id, name, sort, exclusive, values: { id, attributeId, value, sort }[] } }` | Update attribute name |
| `DELETE` | `/attributevalue/{id}` | `id:integer` | `200` | Delete attribute value |
| `PATCH` | `/attributevalue/{id}` | `id:integer, body: { value?, sort? }` | `200 { attributevalue: { id, attributeId, value, sort } }` | Update attribute value |
| `POST` | `/auth/login` | `body: { idToken: string }` | `200 { message, user }` | Login with Google ID token |
| `POST` | `/auth/logout` | — | `200 { message: string }` | Logout |
| `GET` | `/brand` | — | `200 { brands: { id, name, gsprpinfo, pics: { id, sort, fullHash, thumbW, thumbH, thumbHash }[] }[] }` | List brands |
| `PUT` | `/brand` | `body: { name, gsprpinfo? }` | `200 { brand: { id, name, gsprpinfo, pics: { id, sort, fullHash, thumbW, thumbH, thumbHash }[] } }` | Upsert brand |
| `DELETE` | `/brand/{id}` | `id:integer` | `204 void` | Delete brand |
| `POST` | `/category` | `body: { parentId, name }` | `201 { category: { id, parentId, sort, name } }` | Create category |
| `PUT` | `/category/order` | `body: { parentId, categoryIds: integer[] }` | `200 { categoryIds: integer[] }` | Reorder sibling categories |
| `GET` | `/category/tree` | — | `200 { languageId, tree: object[], articles: object[] }` | Category tree |
| `DELETE` | `/category/{id}` | `id:integer` | `204 void` | Delete category |
| `PATCH` | `/category/{id}` | `id:integer, body: { name?, parentId? }` | `200 { category: { id, name, slug, parentId, sort } }` | Update category (rename or move) |
| `GET` | `/category/{id}/articles` | `id:integer` | `200 { articles: { id, sku, name, slug, net, taxRate, track_stock, optionsCount, articleListCount, childCount, fullHash, thumbW, thumbH, thumbHash, brand }[] }` | Articles in category |
| `GET` | `/category/{id}/attributevalue` | `id:integer` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | List category attribute values |
| `PUT` | `/category/{id}/attributevalue` | `id:integer, body: { attributevalues: any[] }` | `200 { attributevalues: { id, categoryId, attributeId, attributeName, exclusive, attributeValueId, value, sort }[] }` | Bulk set category attribute values |
| `DELETE` | `/category/{id}/image` | `id:integer` | `200 { category: { id, thumbW, thumbH, thumbHash } }` | Delete category image |
| `GET` | `/category/{id}/property` | `id:integer` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | List category properties |
| `PUT` | `/category/{id}/property` | `id:integer, body: { properties: { propertyId, value, sort? }[] }` | `200 { properties: { id, categoryId, propertyId, name, exclusive, value, sort }[] }` | Bulk set category properties |
| `DELETE` | `/category/{id}/property/{propertyId}` | `id:integer, propertyId:integer` | `200` | Delete category property |
| `GET` | `/company` | — | `200 { companies: { id, iso639, name, taxrate, reducedtaxrate }[] }` | List companies |
| `GET` | `/company/setting` | — | `200 { settings: { key, value }[] }` | List company settings |
| `GET` | `/company/setting/{key}` | `key:string` | `200 { setting: { key, value } }` | Get company setting by key |
| `PUT` | `/company/setting/{key}` | `key:string, body: { value, frontend? }` | `200 { setting: { key, value } }` | Upsert company setting |
| `PUT` | `/company/{id}` | `id:integer, body: { iso639, name, taxrate, reducedtaxrate }` | `200 { company: { id, iso639, name, taxrate, reducedtaxrate } }` | Update company |
| `GET` | `/customergroup` | — | `200 { customergroups: { id, name, sort, editable }[] }` | List customer groups |
| `POST` | `/customergroup` | `body: { name: string }` | `201 { customergroup: { id, name, sort } }` | Create customer group |
| `PUT` | `/customergroup/order` | `body: { customergroupIds: integer[] }` | `200 { customergroupIds: integer[] }` | Reorder customer groups |
| `DELETE` | `/customergroup/{id}` | `id:integer` | `204 void` | Delete customer group |
| `PATCH` | `/customergroup/{id}` | `id:integer, body: { name: string }` | `200 { customergroup: { id, name, sort } }` | Update customer group |
| `GET` | `/delivery` | — | `200 { deliveries: { id, number, type, supplierId, externalNumber, date, createdAt, ledger }[] }` | List deliveries |
| `POST` | `/delivery` | `body: { externalNumber, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], supplierId?, files?: { mimetype, name, content, sort?, size?, hash?, created?, modified?, meta? }[] }` | `201 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Create delivery |
| `DELETE` | `/delivery/{id}` | `id:integer` | `204 void` | Delete delivery |
| `GET` | `/delivery/{id}` | `id:integer` | `200 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Get delivery |
| `PATCH` | `/delivery/{id}` | `id:integer, body: { externalNumber?, date?, supplierId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], files?: { mimetype, name, content, sort?, size?, hash?, created?, modified?, meta? }[] }` | `200 { delivery: { id, number, type, supplierId, externalNumber, date, createdAt, ledger, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[], files: { id, manifestId, fileId, sort, hash, mimetype, name, size, created, modified, thumbavif, thumbx, thumby, meta }[] } }` | Update delivery |
| `GET` | `/delivery/{id}/file/{fileId}` | `id:integer, fileId:integer, query: download?:boolean` | `200 string` | Get delivery attachment file content |
| `GET` | `/entity` | `query: customergroupId?:integer, role?:string` | `200 { entities: { id, customergroupId, firstName, name, number, createdAt, updatedAt }[] }` | List entities |
| `POST` | `/entity` | `body: { customergroupId, name, firstName?, number? }` | `201 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Create entity |
| `GET` | `/entity/next-number` | — | `200 { number: string }` | Peek next customer number |
| `GET` | `/entity/{entityId}/address` | `entityId:integer` | `200 { addresses: { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType }[] }` | List entity addresses |
| `POST` | `/entity/{entityId}/address` | `entityId:integer, body: { addressType, firstName?, name?, street?, streetNumber?, zip?, city?, county? }` | `201 { address: { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType } }` | Create entity address |
| `DELETE` | `/entity/{entityId}/address/{id}` | `entityId:integer, id:integer` | `204 void` | Delete entity address |
| `PATCH` | `/entity/{entityId}/address/{id}` | `entityId:integer, id:integer, body: { addressType?, firstName?, name?, street?, streetNumber?, zip?, city?, county? }` | `200 { address: { id, entityId, firstName, name, street, streetNumber, zip, city, county, addressType } }` | Update entity address |
| `GET` | `/entity/{entityId}/contact` | `entityId:integer` | `200 { contacts: { id, entityId, contactType, contact }[] }` | List entity contacts |
| `POST` | `/entity/{entityId}/contact` | `entityId:integer, body: { contactType, contact }` | `201 { contact: { id, entityId, contactType, contact } }` | Create entity contact |
| `DELETE` | `/entity/{entityId}/contact/{id}` | `entityId:integer, id:integer` | `204 void` | Delete entity contact |
| `PATCH` | `/entity/{entityId}/contact/{id}` | `entityId:integer, id:integer, body: { contactType?, contact? }` | `200 { contact: { id, entityId, contactType, contact } }` | Update entity contact |
| `DELETE` | `/entity/{id}` | `id:integer` | `204 void` | Delete entity |
| `GET` | `/entity/{id}` | `id:integer` | `200 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Get entity |
| `PATCH` | `/entity/{id}` | `id:integer, body: { name?, firstName?, number? }` | `200 { entity: { id, customergroupId, firstName, name, number, createdAt, updatedAt } }` | Update entity |
| `GET` | `/labeltemplate` | — | `200 { labeltemplates: { id, name, width, height, dpi, type, content, thumb }[] }` | List label templates |
| `POST` | `/labeltemplate` | `body: { name, width, height, dpi, type, content }` | `201 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Create label template |
| `DELETE` | `/labeltemplate/{id}` | `id:integer` | `204 void` | Delete label template |
| `GET` | `/labeltemplate/{id}` | `id:integer` | `200 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Get label template by ID |
| `PATCH` | `/labeltemplate/{id}` | `id:integer, body: { name?, width?, height?, dpi?, type?, content? }` | `200 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Update label template (partial) |
| `PUT` | `/labeltemplate/{id}` | `id:integer, body: { name?, width?, height?, dpi?, type?, content? }` | `200 { labeltemplate: { id, name, width, height, dpi, type, content, thumb } }` | Update label template |
| `GET` | `/ledger` | `query: manifestarticle_id?:integer, sku?:string, transaction_type?:string` | `200 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Query ledger by manifest article ID or SKU |
| `POST` | `/ledger` | `body: { manifestarticle_id, transaction_type, amount, transaction_text }` | `201 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at } }` | Add ledger entry |
| `GET` | `/ledger/manifestarticle/{manifestarticleId}` | `manifestarticleId:integer, query: transaction_type?:string` | `200 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Query ledger by manifest article ID |
| `GET` | `/ledger/sku/{sku}` | `sku:string, query: transaction_type?:string` | `200 { ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Query ledger by article SKU |
| `GET` | `/number` | — | `200 { numbers: { id, type, prefix, padding, next }[] }` | List number sequences |
| `PATCH` | `/number/{id}` | `id:integer, body: { prefix?, padding?, next? }` | `200 { number: { id, type, prefix, padding, next } }` | Update number sequence |
| `GET` | `/order` | — | `200 { orders: { id, number, type, entityId, date, createdAt }[] }` | List orders |
| `POST` | `/order` | `body: { number, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], entityId? }` | `201 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Create order |
| `DELETE` | `/order/{id}` | `id:integer` | `204 void` | Delete order |
| `GET` | `/order/{id}` | `id:integer` | `200 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Get order |
| `PATCH` | `/order/{id}` | `id:integer, body: { number?, date?, entityId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[] }` | `200 { order: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Update order |
| `GET` | `/printer` | — | `200 { printers: { id, company_id, address, type, model, serial, sort, desc }[] }` | List printers |
| `POST` | `/printer` | `body: { address, type, model?, serial?, sort?, desc? }` | `201 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Create printer |
| `PUT` | `/printer/order` | `body: { printerIds: integer[] }` | `200 { printerIds: integer[] }` | Reorder printers |
| `DELETE` | `/printer/{id}` | `id:integer` | `204 void` | Delete printer |
| `GET` | `/printer/{id}` | `id:integer` | `200 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Get printer by ID |
| `PATCH` | `/printer/{id}` | `id:integer, body: { address?, type?, model?, serial?, sort?, desc? }` | `200 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Update printer (partial) |
| `PUT` | `/printer/{id}` | `id:integer, body: { address?, type?, model?, serial?, sort?, desc? }` | `200 { printer: { id, company_id, address, type, model, serial, sort, desc } }` | Update printer |
| `GET` | `/propertyname` | — | `200 { propertynames: { id, name, sort, exclusive }[] }` | List property names |
| `POST` | `/propertyname` | `body: { name, sort?, exclusive? }` | `201 { propertyname: { id, name, sort, exclusive } }` | Create property name |
| `PUT` | `/propertyname/order` | `body: { propertynameIds: integer[] }` | `200` | Reorder property names |
| `DELETE` | `/propertyname/{id}` | `id:integer` | `200` | Delete property name |
| `PATCH` | `/propertyname/{id}` | `id:integer, body: { name?, sort?, exclusive? }` | `200 { propertyname: { id, name, sort, exclusive } }` | Update property name |
| `GET` | `/quote` | — | `200 { quotes: { id, number, type, entityId, date, createdAt }[] }` | List quotes |
| `POST` | `/quote` | `body: { number, date, lines: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[], entityId? }` | `201 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Create quote |
| `DELETE` | `/quote/{id}` | `id:integer` | `204 void` | Delete quote |
| `GET` | `/quote/{id}` | `id:integer` | `200 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Get quote |
| `PATCH` | `/quote/{id}` | `id:integer, body: { number?, date?, entityId?, lines?: { amount, net, taxrate, sku?: string, name?, pu?, rebate?, comment? }[] }` | `200 { quote: { id, number, type, entityId, date, createdAt, lines: { id, sku, name, pu, amount, net, rebate, taxrate, comment }[] } }` | Update quote |
| `POST` | `/stock/adjust` | `body: { sku?: string, warehouseId?, amount?, text?, lines?: { sku: string, amount, warehouseId?, text? }[] }` | `201 { manifest, lines: object[], ledger: { id, manifestarticle_id, transaction_type, amount, transaction_text, hash_version, prev_hash, sku, created_at }[] }` | Adjust stock manually |
| `GET` | `/supplier` | — | `200 { suppliers: { id, name, createdAt, updatedAt }[] }` | List suppliers |
| `POST` | `/supplier` | `body: { name: string }` | `201 { supplier: { id, name, createdAt, updatedAt } }` | Create supplier |
| `DELETE` | `/supplier/{id}` | `id:integer` | `204 void` | Delete supplier |
| `PATCH` | `/supplier/{id}` | `id:integer, body: { name: string }` | `200 { supplier: { id, name, createdAt, updatedAt } }` | Update supplier |
| `GET` | `/supplier/{id}/articles` | `id:integer, query: skus?` | `200 { articles: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price, article }[] }` | List supplier articles by SKUs |
| `POST` | `/supplier/{id}/articles` | `id:integer, body?: { skus?: string[] }` | `200 { articles: { id, articleId, entityId, supplierName, sku, pu, stock, sort, priceId, price, article }[] }` | Query supplier articles by SKUs (bulk) |
| `DELETE` | `/unit` | `query: si:string` | `204 void` | Delete unit |
| `GET` | `/unit` | — | `200 { units: { si, basesi, basesifactor, symbol, name }[] }` | List units |
| `PUT` | `/unit` | `body: { si, basesi, basesifactor, symbol, name }` | `200 { unit: { si, basesi, basesifactor, symbol, name } }` | Upsert unit |
| `GET` | `/vfs/{*path}` | `path:string, query: download?:boolean` | `200 string` | Get a virtual filesystem file |
| `GET` | `/warehouse` | — | `200 { warehouses: { id, name, sort }[] }` | List warehouses |
| `POST` | `/warehouse` | `body: { name, sort? }` | `201 { warehouse: { id, name, sort } }` | Create warehouse |
| `PUT` | `/warehouse/order` | `body: { warehouseIds: integer[] }` | `200 { warehouseIds: integer[] }` | Reorder warehouses |
| `DELETE` | `/warehouse/{id}` | `id:integer` | `204 void` | Delete warehouse |
| `GET` | `/warehouse/{id}` | `id:integer` | `200 { warehouse: { id, name, sort } }` | Get warehouse by ID |
| `PATCH` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse (partial) |
| `PUT` | `/warehouse/{id}` | `id:integer, body: { name?, sort? }` | `200 { warehouse: { id, name, sort } }` | Update warehouse |
### Routes
#### Create article in category — `POST /article`
- Params: `body: { categoryId: integer, name: string, track_stock?: boolean }`
- Returns: `201 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }`
#### Delete article by SKU — `DELETE /article/{sku}`
- Params: `sku: string, query: cascade?: boolean`
- Returns: `204 void`
#### Get article by SKU — `GET /article/{sku}`
- Params: `sku: string`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Update article by SKU — `PATCH /article/{sku}`
- Params: `sku: string, body: { name?: string, unitamount?: number, unitSi?: string, track_stock?: boolean }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Get Stückliste (bundle items) for an article — `GET /article/{sku}/articlelist`
- Params: `sku: string`
- Returns: `200 { items: { id: integer, articleId: integer, sku: string, name: string, count: integer, sort: integer }[] }`
#### Set Stückliste (bundle items) for an article — `PUT /article/{sku}/articlelist`
- Params: `sku: string, body: { items: { articleId?: integer, sku?: string, count?: integer, sort?: integer }[] }`
- Returns: `200 { items: object[] }`
#### List article attribute values — `GET /article/{sku}/attributevalue`
- Params: `sku: string`
- Returns: `200 { attributevalues: { id: integer, articleId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }`
#### Bulk set article attribute values — `PUT /article/{sku}/attributevalue`
- Params: `sku: string, body: integer[]`
- Returns: `200 { attributevalues: { id: integer, articleId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }`
#### Set or clear article brand by SKU — `PUT /article/{sku}/brand`
- Params: `sku: string, body: { brandId: integer }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Replace article categories by SKU — `PUT /article/{sku}/categories`
- Params: `sku: string, body: { categoryIds: integer[] }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### List child articles of a parent article — `GET /article/{sku}/children`
- Params: `sku: string`
- Returns: `200 { children: { id: integer, sku: string, name: string, parentsort: integer, attributeValueId: integer, attributeValue: string }[] }`
#### Create and attach a new child article to a parent article — `POST /article/{sku}/children`
- Params: `sku: string, body: { attributeValueId?: integer, value?: string, name?: string }`
- Returns: `201 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: string, body: { sku: string, attributeValueId?: integer, value?: string, reparent?: boolean }`
- Returns: `200 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean }, children: object[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: string, childSku: string, query: deleteArticle?: boolean`
- Returns: `200 { success: boolean, deleted: boolean }`
#### Replace article EANs by SKU — `PUT /article/{sku}/eans`
- Params: `sku: string, body: { eans: string[] }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Get article history by SKU — `GET /article/{sku}/history`
- Params: `sku: string, query: limit?: integer`
- Returns: `200 { history: { id: integer, date: string, text: string }[], truncated: boolean }`
#### Delete article image — `DELETE /article/{sku}/image/{fullHash}`
- Params: `sku: string, fullHash: string`
- Returns: `204 void`
#### Reorder article images — `PUT /article/{sku}/images/order`
- Params: `sku: string, body: { fullHashes: string[] }`
- Returns: `200 { fullHashes: string[] }`
#### Get options list for an article — `GET /article/{sku}/optionslist`
- Params: `sku: string`
- Returns: `200 { options: { id: integer, articleId: integer, sku: string, name: string, count: integer, sort: integer, mandated: boolean }[] }`
#### Set options list for an article — `PUT /article/{sku}/optionslist`
- Params: `sku: string, body: { options: { articleId?: integer, sku?: string, count?: integer, sort?: integer, mandated?: boolean }[] }`
- Returns: `200 { options: object[] }`
#### Toggle parent mode for an article — `POST /article/{sku}/parent`
- Params: `sku: string, body: { isParent: boolean, attributeNameId?: integer }`
- Returns: `200 { article: { id: integer, categoryId: integer, name: string, sku: string, track_stock: boolean } }`
#### Replace POS prices by SKU — `PUT /article/{sku}/prices`
- Params: `sku: string, body: { prices: { price: number, tax: number, minmultiplier?: number }[] }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Print article label — `POST /article/{sku}/print`
- Params: `sku: string, body: { templateId: integer, printerId: integer, quantity: integer }`
- Returns: `200`
#### List article properties — `GET /article/{sku}/property`
- Params: `sku: string`
- Returns: `200 { properties: { id: integer, articleId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }`
#### Bulk set article properties — `PUT /article/{sku}/property`
- Params: `sku: string, body: { properties: { propertyId: integer, value: string, sort?: integer }[] }`
- Returns: `200 { properties: { id: integer, articleId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }`
#### Delete article property — `DELETE /article/{sku}/property/{propertyId}`
- Params: `sku: string, propertyId: integer`
- Returns: `200`
#### List article suppliers — `GET /article/{sku}/supplier`
- Params: `sku: string`
- Returns: `200 { suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] }`
#### Assign supplier to article — `POST /article/{sku}/supplier`
- Params: `sku: string, body: { entityId: integer, sku?: string, pu?: number, stock?: number, sort?: integer, netPrice?: number, taxId?: integer, taxRate?: number }`
- Returns: `201 { supplier: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } } }`
#### Reorder article suppliers — `PUT /article/{sku}/supplier/order`
- Params: `sku: string, body: { ids: integer[] }`
- Returns: `200 { ids: integer[] }`
#### Remove supplier assignment from article — `DELETE /article/{sku}/supplier/{id}`
- Params: `sku: string, id: integer`
- Returns: `204 void`
#### Update article supplier assignment — `PATCH /article/{sku}/supplier/{id}`
- Params: `sku: string, id: integer, body: { sku?: string, pu?: number, stock?: number, sort?: integer, netPrice?: number, taxId?: integer, taxRate?: number }`
- Returns: `200 { supplier: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } } }`
#### Set article track_stock flag by SKU — `PUT /article/{sku}/track_stock`
- Params: `sku: string, body: { track_stock: boolean }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### Set article weight by SKU — `PUT /article/{sku}/weight`
- Params: `sku: string, body: { weight: number }`
- Returns: `200 { article: { id: integer, sku: string, name: string, slug: string, track_stock: boolean, unitamount: number, weight: number, unit: object, brand: object, categoryIds: integer[], photos: { sort: integer, fullHash: string, fullW: integer, fullH: integer, fullSize: integer, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[], prices: { minmultiplier: number, net: number, taxRate: number, customergroupId: integer }[], suppliers: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number } }[] } }`
#### List article search index — `GET /articleIndex`
- Params: `query: windowSize: integer`
- Returns: `200 { articles: { id: integer, sku: string, name: string, eans: string[], categoryIds: integer[] }[], truncated: boolean }`
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] }[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: string, sort?: integer, exclusive?: boolean }`
- Returns: `201 { attributename: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] } }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: integer[] }`
- Returns: `200`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: integer`
- Returns: `200 { values: { id: integer, attributeId: integer, value: string, sort: integer }[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: integer, body: { value: string, sort?: integer }`
- Returns: `201 { attributevalue: { id: integer, attributeId: integer, value: string, sort: integer } }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: integer, body: { attributevalueIds: integer[] }`
- Returns: `200`
#### Delete attribute name — `DELETE /attributename/{id}`
- Params: `id: integer`
- Returns: `200`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: integer, body: { name?: string, sort?: integer, exclusive?: boolean }`
- Returns: `200 { attributename: { id: integer, name: string, sort: integer, exclusive: boolean, values: { id: integer, attributeId: integer, value: string, sort: integer }[] } }`
#### Delete attribute value — `DELETE /attributevalue/{id}`
- Params: `id: integer`
- Returns: `200`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: integer, body: { value?: string, sort?: integer }`
- Returns: `200 { attributevalue: { id: integer, attributeId: integer, value: string, sort: integer } }`
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: string }`
- Returns: `200 { message: string, user: { id: integer, email: string, selectedCompanyId: integer, picture: string } }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: string }`
#### List brands — `GET /brand`
_none_
- Returns: `200 { brands: { id: integer, name: string, gsprpinfo: string, pics: { id: integer, sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] }[] }`
#### Upsert brand — `PUT /brand`
- Params: `body: { name: string, gsprpinfo?: string }`
- Returns: `200 { brand: { id: integer, name: string, gsprpinfo: string, pics: { id: integer, sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[] } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Create category — `POST /category`
- Params: `body: { parentId: integer, name: string }`
- Returns: `201 { category: { id: integer, parentId: integer, sort: integer, name: string } }`
#### Reorder sibling categories — `PUT /category/order`
- Params: `body: { parentId: integer, categoryIds: integer[] }`
- Returns: `200 { categoryIds: integer[] }`
#### Category tree — `GET /category/tree`
_none_
- Returns: `200 { languageId: integer, tree: object[], articles: object[] }`
#### Delete category — `DELETE /category/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Update category (rename or move) — `PATCH /category/{id}`
- Params: `id: integer, body: { name?: string, parentId?: integer }`
- Returns: `200 { category: { id: integer, name: string, slug: string, parentId: integer, sort: integer } }`
#### Articles in category — `GET /category/{id}/articles`
- Params: `id: integer`
- Returns: `200 { articles: { id: integer, sku: string, name: string, slug: string, net: number, taxRate: number, track_stock: boolean, optionsCount: integer, articleListCount: integer, childCount: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string, brand: object }[] }`
#### List category attribute values — `GET /category/{id}/attributevalue`
- Params: `id: integer`
- Returns: `200 { attributevalues: { id: integer, categoryId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }`
#### Bulk set category attribute values — `PUT /category/{id}/attributevalue`
- Params: `id: integer, body: integer[]`
- Returns: `200 { attributevalues: { id: integer, categoryId: integer, attributeId: integer, attributeName: string, exclusive: boolean, attributeValueId: integer, value: string, sort: integer }[] }`
#### Delete category image — `DELETE /category/{id}/image`
- Params: `id: integer`
- Returns: `200 { category: { id: integer, thumbW: integer, thumbH: integer, thumbHash: string } }`
#### List category properties — `GET /category/{id}/property`
- Params: `id: integer`
- Returns: `200 { properties: { id: integer, categoryId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }`
#### Bulk set category properties — `PUT /category/{id}/property`
- Params: `id: integer, body: { properties: { propertyId: integer, value: string, sort?: integer }[] }`
- Returns: `200 { properties: { id: integer, categoryId: integer, propertyId: integer, name: string, exclusive: boolean, value: string, sort: integer }[] }`
#### Delete category property — `DELETE /category/{id}/property/{propertyId}`
- Params: `id: integer, propertyId: integer`
- Returns: `200`
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number }[] }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: integer`
- Returns: `200 { history: { id: integer, companyuserId: integer, date: string, text: string }[], truncated: boolean }`
#### Add company history entry — `POST /company/history`
- Params: `body: { text: string }`
- Returns: `201 { history: { id: integer, companyuserId: integer, date: string, text: string } }`
#### List company settings — `GET /company/setting`
_none_
- Returns: `200 { settings: { key: string, value: string }[] }`
#### Get company setting by key — `GET /company/setting/{key}`
- Params: `key: string`
- Returns: `200 { setting: { key: string, value: string } }`
#### Upsert company setting — `PUT /company/setting/{key}`
- Params: `key: string, body: { value: string, frontend?: boolean }`
- Returns: `200 { setting: { key: string, value: string } }`
#### Update company — `PUT /company/{id}`
- Params: `id: integer, body: { iso639: string, name: string, taxrate: number, reducedtaxrate: number }`
- Returns: `200 { company: { id: integer, iso639: string, name: string, taxrate: number, reducedtaxrate: number } }`
#### List customer groups — `GET /customergroup`
_none_
- Returns: `200 { customergroups: { id: integer, name: string, sort: integer, editable: boolean }[] }`
#### Create customer group — `POST /customergroup`
- Params: `body: { name: string }`
- Returns: `201 { customergroup: { id: integer, name: string, sort: integer } }`
#### Reorder customer groups — `PUT /customergroup/order`
- Params: `body: { customergroupIds: integer[] }`
- Returns: `200 { customergroupIds: integer[] }`
#### Delete customer group — `DELETE /customergroup/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Update customer group — `PATCH /customergroup/{id}`
- Params: `id: integer, body: { name: string }`
- Returns: `200 { customergroup: { id: integer, name: string, sort: integer } }`
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer } }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], supplierId?: integer, files?: { mimetype: string, name: string, content: string, sort?: integer, size?: integer, hash?: string, created?: string, modified?: string, meta?: object }[] }`
- Returns: `201 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }`
#### Delete delivery — `DELETE /delivery/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get delivery — `GET /delivery/{id}`
- Params: `id: integer`
- Returns: `200 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }`
#### Update delivery — `PATCH /delivery/{id}`
- Params: `id: integer, body: { externalNumber?: string, date?: string, supplierId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], files?: { mimetype: string, name: string, content: string, sort?: integer, size?: integer, hash?: string, created?: string, modified?: string, meta?: object }[] }`
- Returns: `200 { delivery: { id: integer, number: string, type: string, supplierId: integer, externalNumber: string, date: string, createdAt: string, ledger: { totalPositions: integer, completelyOnLedgerPositions: integer }, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[], files: { id: integer, manifestId: integer, fileId: integer, sort: integer, hash: string, mimetype: string, name: string, size: integer, created: string, modified: string, thumbavif: string, thumbx: integer, thumby: integer, meta: object }[] } }`
#### Get delivery attachment file content — `GET /delivery/{id}/file/{fileId}`
- Params: `id: integer, fileId: integer, query: download?: boolean`
- Returns: `200 string`
#### List entities — `GET /entity`
- Params: `query: customergroupId?: integer, role?: string`
- Returns: `200 { entities: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string }[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: integer, name: string, firstName?: string, number?: string }`
- Returns: `201 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: string }`
#### List entity addresses — `GET /entity/{entityId}/address`
- Params: `entityId: integer`
- Returns: `200 { addresses: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string }[] }`
#### Create entity address — `POST /entity/{entityId}/address`
- Params: `entityId: integer, body: { addressType: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string }`
- Returns: `201 { address: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string } }`
#### Delete entity address — `DELETE /entity/{entityId}/address/{id}`
- Params: `entityId: integer, id: integer`
- Returns: `204 void`
#### Update entity address — `PATCH /entity/{entityId}/address/{id}`
- Params: `entityId: integer, id: integer, body: { addressType?: string, firstName?: string, name?: string, street?: string, streetNumber?: string, zip?: string, city?: string, county?: string }`
- Returns: `200 { address: { id: integer, entityId: integer, firstName: string, name: string, street: string, streetNumber: string, zip: string, city: string, county: string, addressType: string } }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: integer`
- Returns: `200 { contacts: { id: integer, entityId: integer, contactType: string, contact: string }[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: integer, body: { contactType: string, contact: string }`
- Returns: `201 { contact: { id: integer, entityId: integer, contactType: string, contact: string } }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: integer, id: integer`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: integer, id: integer, body: { contactType?: string, contact?: string }`
- Returns: `200 { contact: { id: integer, entityId: integer, contactType: string, contact: string } }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: integer`
- Returns: `200 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: integer, body: { name?: string, firstName?: string, number?: string }`
- Returns: `200 { entity: { id: integer, customergroupId: integer, firstName: string, name: string, number: string, createdAt: string, updatedAt: string } }`
#### List label templates — `GET /labeltemplate`
_none_
- Returns: `200 { labeltemplates: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: string, width: number, height: number, dpi: integer, type: string, content: object }`
- Returns: `201 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get label template by ID — `GET /labeltemplate/{id}`
- Params: `id: integer`
- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: integer, body: { name?: string, width?: number, height?: number, dpi?: integer, type?: string, content?: object }`
- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: integer, body: { name?: string, width?: number, height?: number, dpi?: integer, type?: string, content?: object }`
- Returns: `200 { labeltemplate: { id: integer, name: string, width: number, height: number, dpi: integer, type: string, content: object, thumb: string } }`
#### Query ledger by manifest article ID or SKU — `GET /ledger`
- Params: `query: manifestarticle_id?: integer, sku?: string, transaction_type?: string`
- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }`
#### Add ledger entry — `POST /ledger`
- Params: `body: { manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string }`
- Returns: `201 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string } }`
#### Query ledger by manifest article ID — `GET /ledger/manifestarticle/{manifestarticleId}`
- Params: `manifestarticleId: integer, query: transaction_type?: string`
- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }`
#### Query ledger by article SKU — `GET /ledger/sku/{sku}`
- Params: `sku: string, query: transaction_type?: string`
- Returns: `200 { ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }`
#### List number sequences — `GET /number`
_none_
- Returns: `200 { numbers: { id: integer, type: string, prefix: string, padding: integer, next: integer }[] }`
#### Update number sequence — `PATCH /number/{id}`
- Params: `id: integer, body: { prefix?: string, padding?: integer, next?: integer }`
- Returns: `200 { number: { id: integer, type: string, prefix: string, padding: integer, next: integer } }`
#### List orders — `GET /order`
_none_
- Returns: `200 { orders: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string }[] }`
#### Create order — `POST /order`
- Params: `body: { number: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], entityId?: integer }`
- Returns: `201 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Delete order — `DELETE /order/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get order — `GET /order/{id}`
- Params: `id: integer`
- Returns: `200 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Update order — `PATCH /order/{id}`
- Params: `id: integer, body: { number?: string, date?: string, entityId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[] }`
- Returns: `200 { order: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string }[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: string, type: string, model?: string, serial?: string, sort?: integer, desc?: string }`
- Returns: `201 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: integer[] }`
- Returns: `200 { printerIds: integer[] }`
#### Delete printer — `DELETE /printer/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: integer`
- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: integer, body: { address?: string, type?: string, model?: string, serial?: string, sort?: integer, desc?: string }`
- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }`
#### Update printer — `PUT /printer/{id}`
- Params: `id: integer, body: { address?: string, type?: string, model?: string, serial?: string, sort?: integer, desc?: string }`
- Returns: `200 { printer: { id: integer, company_id: integer, address: string, type: string, model: string, serial: string, sort: integer, desc: string } }`
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: { id: integer, name: string, sort: integer, exclusive: boolean }[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: string, sort?: integer, exclusive?: boolean }`
- Returns: `201 { propertyname: { id: integer, name: string, sort: integer, exclusive: boolean } }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: integer[] }`
- Returns: `200`
#### Delete property name — `DELETE /propertyname/{id}`
- Params: `id: integer`
- Returns: `200`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: integer, body: { name?: string, sort?: integer, exclusive?: boolean }`
- Returns: `200 { propertyname: { id: integer, name: string, sort: integer, exclusive: boolean } }`
#### List quotes — `GET /quote`
_none_
- Returns: `200 { quotes: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string }[] }`
#### Create quote — `POST /quote`
- Params: `body: { number: string, date: string, lines: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[], entityId?: integer }`
- Returns: `201 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Delete quote — `DELETE /quote/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get quote — `GET /quote/{id}`
- Params: `id: integer`
- Returns: `200 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Update quote — `PATCH /quote/{id}`
- Params: `id: integer, body: { number?: string, date?: string, entityId?: integer, lines?: { amount: number, net: number, taxrate: number, sku?: string, name?: string, pu?: number, rebate?: number, comment?: string }[] }`
- Returns: `200 { quote: { id: integer, number: string, type: string, entityId: integer, date: string, createdAt: string, lines: { id: integer, sku: string, name: string, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string }[] } }`
#### Adjust stock manually — `POST /stock/adjust`
- Params: `body: { sku?: string, warehouseId?: integer, amount?: number, text?: string, lines?: { sku: string, amount: number, warehouseId?: integer, text?: string }[] }`
- Returns: `201 { manifest: { id: integer, number: string, type: string, date: string, createdAt: string }, lines: object[], ledger: { id: integer, manifestarticle_id: integer, transaction_type: string, amount: number, transaction_text: string, hash_version: integer, prev_hash: string, sku: string, created_at: string }[] }`
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: { id: integer, name: string, createdAt: string, updatedAt: string }[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: string }`
- Returns: `201 { supplier: { id: integer, name: string, createdAt: string, updatedAt: string } }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: integer, body: { name: string }`
- Returns: `200 { supplier: { id: integer, name: string, createdAt: string, updatedAt: string } }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: integer, query: skus?: string`
- Returns: `200 { articles: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number }, article: { id: integer, sku: string, name: string, slug: string, photos: { sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[] } }[] }`
#### Query supplier articles by SKUs (bulk) — `POST /supplier/{id}/articles`
- Params: `id: integer, body?: { skus?: string[] }`
- Returns: `200 { articles: { id: integer, articleId: integer, entityId: integer, supplierName: string, sku: string, pu: number, stock: number, sort: integer, priceId: integer, price: { net: number, taxId: integer, taxRate: number }, article: { id: integer, sku: string, name: string, slug: string, photos: { sort: integer, fullHash: string, thumbW: integer, thumbH: integer, thumbHash: string }[], eans: string[] } }[] }`
#### Delete unit — `DELETE /unit`
- Params: `query: si: string`
- Returns: `204 void`
#### List units — `GET /unit`
_none_
- Returns: `200 { units: { si: string, basesi: string, basesifactor: number, symbol: string, name: string }[] }`
#### Upsert unit — `PUT /unit`
- Params: `body: { si: string, basesi: string, basesifactor: number, symbol: string, name: string }`
- Returns: `200 { unit: { si: string, basesi: string, basesifactor: number, symbol: string, name: string } }`
#### Get a virtual filesystem file — `GET /vfs/{*path}`
- Params: `path: string, query: download?: boolean`
- Returns: `200 string`
#### List warehouses — `GET /warehouse`
_none_
- Returns: `200 { warehouses: { id: integer, name: string, sort: integer }[] }`
#### Create warehouse — `POST /warehouse`
- Params: `body: { name: string, sort?: integer }`
- Returns: `201 { warehouse: { id: integer, name: string, sort: integer } }`
#### Reorder warehouses — `PUT /warehouse/order`
- Params: `body: { warehouseIds: integer[] }`
- Returns: `200 { warehouseIds: integer[] }`
#### Delete warehouse — `DELETE /warehouse/{id}`
- Params: `id: integer`
- Returns: `204 void`
#### Get warehouse by ID — `GET /warehouse/{id}`
- Params: `id: integer`
- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }`
#### Update warehouse (partial) — `PATCH /warehouse/{id}`
- Params: `id: integer, body: { name?: string, sort?: integer }`
- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }`
#### Update warehouse — `PUT /warehouse/{id}`
- Params: `id: integer, body: { name?: string, sort?: integer }`
- Returns: `200 { warehouse: { id: integer, name: string, sort: integer } }`
### Binary & streaming routes (reachable only via `fetch()` in javascript_execute)
These routes are NOT in the table above because their responses are binary or long-lived streams, which the `dashback_request` tool cannot return usefully. They ARE reachable from inside `javascript_execute` with `fetch("/route")`, where you can decode the bytes, write them to the VFS and embed them in your reply:

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Set Stückliste (bundle items) for an article
Configures the article as a Stückliste covering a list of count * article. A father article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste.
Configures the article as a Stückliste covering a list of count * article. A parent article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste.
## Path parameters
@@ -51,7 +51,7 @@ Updated Stückliste items
| Status | Description |
|--------|-------------|
| 400 | Article is a father, has options, or contains itself |
| 400 | Article is a parent, has options, or contains itself |
| 404 | Article or component article not found |
---

View File

@@ -14,16 +14,10 @@ Replaces all attribute values for an article. Enforces exclusive constraint (max
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `attributevalues` | yes | any[] | Array of attribute value IDs (numbers) or objects with id or attributeValueId |
```json
{
"attributevalues": [
"…"
]
}
[
166
]
```
## Response `200`

View File

@@ -2,15 +2,15 @@
Protected. Requires a valid session cookie (`credentials: 'include'`).
List child articles of a father article
List child articles of a parent article
Returns variants/children linked to this father article.
Returns variants/children linked to this parent article.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | Father article SKU |
| `sku` | yes | string | Parent article SKU |
## Response `200`
@@ -23,7 +23,7 @@ List of children
"id": 1,
"sku": "…",
"name": "…",
"fathersort": 1,
"parentsort": 1,
"attributeValueId": 1,
"attributeValue": "…"
}

View File

@@ -2,31 +2,29 @@
Protected. Requires a valid session cookie (`credentials: 'include'`).
Add a child article to a father article
Create and attach a new child article to a parent article
Automatically generates SKU as fatherSku-nextFreeIndex (or accepts custom sku) and assigns the variant attribute.
Automatically generates SKU as parentSku-nextFreeIndex and name from parent name + value. Rejects a caller-supplied sku.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | Father article SKU |
| `sku` | yes | string | Parent article SKU |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `attributeValueId` | no | integer | ID of existing attribute value for the father's attribute |
| `value` | no | string | String value to find or create under the father's attribute |
| `attributeValueId` | no | integer | ID of existing attribute value for the parent's attribute |
| `value` | no | string | String value to find or create under the parent's attribute |
| `name` | no | string | Optional custom variant name |
| `sku` | no | string | Optional custom SKU for the child article |
```json
{
"attributeValueId": 1,
"value": "…",
"name": "…",
"sku": "…"
"name": "…"
}
```
@@ -50,7 +48,7 @@ Created child article
| Status | Description |
|--------|-------------|
| 400 | Father not configured as father, missing value, duplicate variant, or child SKU already exists |
| 400 | Parent not configured as parent, missing value, duplicate variant, or sku was provided in body |
---

View File

@@ -0,0 +1,62 @@
# `PUT /article/{sku}/children`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Attach an existing article as a child to a parent article
Attaches an existing article to this parent article. Idempotent if already a child. Returns 409 if the article already belongs to another parent.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | Parent article SKU |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | SKU of the existing article to attach as child |
| `attributeValueId` | no | integer | ID of existing attribute value for the parent's attribute |
| `value` | no | string | String value to find or create under the parent's attribute |
| `reparent` | no | boolean | Whether to allow re-parenting if child already belongs to another parent |
```json
{
"sku": "…",
"attributeValueId": 1,
"value": "…",
"reparent": true
}
```
## Response `200`
Attached child article and list of children
```json
{
"article": {
"id": 1,
"categoryId": 1,
"name": "…",
"sku": "…",
"track_stock": true
},
"children": [
{}
]
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Missing sku or attribute value, or invalid input |
| 404 | Parent or child article not found |
| 409 | Article already belongs to another parent, or variant value already exists |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -2,22 +2,22 @@
Protected. Requires a valid session cookie (`credentials: 'include'`).
Remove a child article from a father article
Remove a child article from a parent article
Unlinks the child from the father (or deletes if deleteArticle query/body is true).
Unlinks the child from the parent (or deletes if deleteArticle query/body is true).
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | Father article SKU |
| `sku` | yes | string | Parent article SKU |
| `childSku` | yes | string | Child article SKU |
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `deleteArticle` | no | boolean | If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a father) instead of just unlinking |
| `deleteArticle` | no | boolean | If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a parent) instead of just unlinking |
## Response `200`

View File

@@ -1,54 +0,0 @@
# `POST /article/{sku}/father`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Toggle father mode for an article
Enables or disables father article status. Enabling requires specifying an attributeNameId; disabling requires the article to have zero children.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | Article SKU |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `isFather` | yes | boolean | True to make article a father, false to revert to regular article |
| `attributeNameId` | no | integer | Required when isFather is true. The attributename id for variants. |
```json
{
"isFather": true,
"attributeNameId": 1
}
```
## Response `200`
Updated article
```json
{
"article": {
"id": 1,
"categoryId": 1,
"name": "…",
"sku": "…",
"track_stock": true
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid input or article still has children |
| 404 | Article or attribute name not found |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Set options list for an article
Configures options for an article. Some options can be mandated (come with the article in any case). A father article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options.
Configures options for an article. Some options can be mandated (come with the article in any case). A parent article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options.
## Path parameters
@@ -53,7 +53,7 @@ Updated options list
| Status | Description |
|--------|-------------|
| 400 | Article is a father, a Stückliste, or contains itself |
| 400 | Article is a parent, a Stückliste, or contains itself |
| 404 | Article or option article not found |
---

View File

@@ -0,0 +1,67 @@
# `POST /article/{sku}/parent`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Toggle parent mode for an article
Enables or disables parent article status. Enabling requires specifying an
attributeNameId; disabling requires the article to have zero children.
`attributeNameId` is **conditionally required**: it is optional in the schema
because it is only required when `isParent` is `true`. Omitting it (or passing
`0`/a negative value) together with `isParent: true` returns `400`
`attributeNameId is required to become a parent article`. It must not be sent
when `isParent` is `false`.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `sku` | yes | string | Article SKU |
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `isParent` | yes | boolean | True to make article a parent, false to revert to regular article |
| `attributeNameId` | no | integer | **Conditionally required when `isParent` is `true`** (required iff isParent=true). The attributename id used for the article's variants. Omitting it while enabling parent mode returns 400. Must be omitted when `isParent` is `false`. |
```json
{
"isParent": true,
"attributeNameId": 1
}
```
## Response `200`
Updated article
```json
{
"article": {
"id": 1,
"categoryId": 1,
"name": "…",
"sku": "…",
"track_stock": true
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid input — `attributeNameId` missing while `isParent` is true, article still
has children (disabling), or the attribute is already used by an ancestor parent.
|
| 404 | Article or attribute name not found |
| 409 | Article state conflicts with becoming a variant parent — it still has stock,
assigned suppliers, a Stückliste, or options. Clear these first.
|
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -112,7 +112,7 @@ Updated article detail
| Status | Description |
|--------|-------------|
| 400 | Invalid prices, POS customer group required, or father article cannot have prices — `{"error":"…"}` |
| 400 | Invalid prices, POS customer group required, or parent article cannot have prices — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article not found — `{"error":"…"}` |

View File

@@ -16,6 +16,17 @@ Deletes an attribute name and cascades to attribute values and article assignmen
Deleted successfully
## Errors
| Status | Description |
|--------|-------------|
| 400 | Bad request or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Forbidden — `{"error":"…"}` |
| 404 | Attribute name not found — `{"error":"…"}` |
| 409 | Attribute name in use — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -16,6 +16,17 @@ Deletes an attribute value and cascades to article assignments.
Deleted successfully
## Errors
| Status | Description |
|--------|-------------|
| 400 | Bad request or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Forbidden — `{"error":"…"}` |
| 404 | Attribute value not found — `{"error":"…"}` |
| 409 | Attribute value in use — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -14,16 +14,10 @@ Replaces all attribute values for a category. Enforces exclusive constraint (max
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `attributevalues` | yes | any[] | Array of attribute value IDs (numbers) or objects with id or attributeValueId |
```json
{
"attributevalues": [
"…"
]
}
[
166
]
```
## Response `200`

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
Articles in category
Returns articles in a category the user is authorized for (omits child articles of a father article). Each article includes `childCount` (the number of its child articles).
Returns articles in a category the user is authorized for (omits child articles of a parent article). Each article includes `childCount` (the number of its child articles).
## Path parameters

View File

@@ -0,0 +1,48 @@
# `POST /company/history`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Add company history entry
Appends an entry to `companyhistory` for the selected company.
## Body (JSON)
| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `text` | yes | string | History entry text or note |
```json
{
"text": "…"
}
```
## Response `201`
History entry created
```json
{
"history": {
"id": 1,
"companyuserId": 1,
"date": "2026-08-10T00:00:00.000Z",
"text": "…"
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Text required or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Forbidden — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,45 @@
# `GET /company/history`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Get company history
Returns `companyhistory` rows for the selected company, newest first (`date` DESC, `id` DESC).
## Query parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `limit` | no | integer | Maximum number of entries to return (newest first). Omit for all entries. |
## Response `200`
Company history (newest first)
```json
{
"history": [
{
"id": 1,
"companyuserId": 1,
"date": "2026-08-10T00:00:00.000Z",
"text": "…"
}
],
"truncated": true
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Invalid limit or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Forbidden — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -16,6 +16,17 @@ Deletes a property name and cascades to article properties.
Deleted successfully
## Errors
| Status | Description |
|--------|-------------|
| 400 | Bad request or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Forbidden — `{"error":"…"}` |
| 404 | Property name not found — `{"error":"…"}` |
| 409 | Property name in use — `{"error":"…"}` |
| 500 | Server error — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -43,8 +43,8 @@ Article detail
"eans": [
"…"
],
"isFather": true,
"fatherSku": "…",
"isParent": true,
"parentSku": "…",
"variants": [
{}
]

View File

@@ -77,7 +77,7 @@ Stock adjusted successfully
| Status | Description |
|--------|-------------|
| 400 | Missing fields, invalid amount, father article cannot have stock, or no company selected — `{"error":"…"}` |
| 400 | Missing fields, invalid amount, parent article cannot have stock, or no company selected — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Article or warehouse not found — `{"error":"…"}` |

108
package-lock.json generated
View File

@@ -14,10 +14,10 @@
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
"crypto": "^1.0.1",
"dotenv": "^18.0.4",
"dotenv": "^18.0.5",
"express": "^5.2.1",
"google-auth-library": "^11.1.0",
"ink": "^7.1.1",
"ink": "^8.0.0",
"ink-text-input": "^6.0.0",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.2",
@@ -41,9 +41,9 @@
"license": "ISC",
"dependencies": {
"@clack/prompts": "^1.8.1",
"@hpcc-js/wasm-graphviz": "^1.29.1",
"@hpcc-js/wasm-graphviz": "^1.29.2",
"chalk": "^6.0.1",
"dotenv": "^18.0.4",
"dotenv": "^18.0.5",
"pdfconvert-wasm": "^0.3.0",
"sharp": "^0.35.5",
"thumbhash": "^0.1.1"
@@ -66,7 +66,7 @@
"version": "0.1.0",
"license": "0BSD",
"dependencies": {
"dotenv": "^18.0.4"
"dotenv": "^18.0.5"
},
"engines": {
"node": ">=18.0.0"
@@ -2372,43 +2372,41 @@
"license": "ISC"
},
"node_modules/ink": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz",
"integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/ink/-/ink-8.0.0.tgz",
"integrity": "sha512-M2aFwZOqmkFxCwedy6xrP4E+DGoK57iahAFnfTqNyOyJ54O7DZzu027z/NI8EGy44Sw9O9BcbTF4QEUS2GHAtg==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.3.0",
"@alcalzone/ansi-tokenize": "^0.3.1",
"ansi-escapes": "^7.3.0",
"ansi-styles": "^6.2.3",
"ansi-styles": "^7.0.0",
"auto-bind": "^5.0.1",
"chalk": "^5.6.2",
"chalk": "^6.0.1",
"cli-boxes": "^4.0.1",
"cli-cursor": "^4.0.0",
"cli-truncate": "^6.0.0",
"code-excerpt": "^4.0.0",
"es-toolkit": "^1.45.1",
"es-toolkit": "^1.52.0",
"indent-string": "^5.0.0",
"is-in-ci": "^2.0.0",
"patch-console": "^2.0.0",
"react-reconciler": "^0.33.0",
"scheduler": "^0.27.0",
"quick-lru": "^7.3.0",
"react-reconciler": "^0.34.0",
"signal-exit": "^3.0.7",
"slice-ansi": "^9.0.0",
"stack-utils": "^2.0.6",
"string-width": "^8.2.0",
"string-width": "^8.3.0",
"terminal-size": "^4.0.1",
"type-fest": "^5.5.0",
"type-fest": "^5.10.0",
"widest-line": "^6.0.0",
"wrap-ansi": "^10.0.0",
"ws": "^8.20.0",
"wrap-ansi": "^10.0.2",
"yoga-layout": "~3.2.1"
},
"engines": {
"node": ">=22"
},
"peerDependencies": {
"@types/react": ">=19.2.0",
"react": ">=19.2.0",
"@types/react": ">=19.3.0",
"react": ">=19.3.0",
"react-devtools-core": ">=6.1.2"
},
"peerDependenciesMeta": {
@@ -2461,16 +2459,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ink/node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"node_modules/ink/node_modules/ansi-styles": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-7.0.0.tgz",
"integrity": "sha512-kKvt3m4uwzqL0wlkPd09CmljPJGOZZ4D0fP65sqFSvPkMRKhNi+74MgIJ5QxE6SxqB4t4KyUFGg8+n5zjo6hew==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
"node": ">=22"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ipaddr.js": {
@@ -3324,6 +3322,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-lru": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-7.3.0.tgz",
"integrity": "sha512-k9lSsjl36EJdK7I06v7APZCbyGT2vMTsYSRX1Q2nbYmnkBqgUhRkAuzH08Ciotteu/PLJmIF2+tti7o3C/ts2g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
@@ -3403,26 +3413,19 @@
"react": "^19.3.0"
}
},
"node_modules/react-dom/node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT",
"peer": true
},
"node_modules/react-reconciler": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
"version": "0.34.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.34.0.tgz",
"integrity": "sha512-eVibvB1fc9j5Qf1KRkiM3GzRPBdjg2P6G6QV2f3P6Eb3OGMxRBhdg2rrUwjbV5JrQaJj8nOa8uajmmJA5tJWjQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
"scheduler": "^0.28.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.2.0"
"react": "^19.3.0"
}
},
"node_modules/readable-stream": {
@@ -3536,9 +3539,9 @@
"license": "MIT"
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/selfsigned": {
@@ -4216,27 +4219,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",

View File

@@ -19,10 +19,10 @@
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
"crypto": "^1.0.1",
"dotenv": "^18.0.4",
"dotenv": "^18.0.5",
"express": "^5.2.1",
"google-auth-library": "^11.1.0",
"ink": "^7.1.1",
"ink": "^8.0.0",
"ink-text-input": "^6.0.0",
"jsonwebtoken": "^9.0.3",
"jszip": "^3.10.2",

Submodule quixpos2jtl deleted from a0a2e8d87f

View File

@@ -1,5 +1,7 @@
/**
* Code-first API docs: scan `@openapi` JSDoc in routes → OpenAPI JSON + Markdown under docs/api/.
* Code-first API docs: scan `@openapi` JSDoc in routes → Markdown under docs/api/.
* The OpenAPI JSON is NOT written to disk; the interactive docs at /docs/ build it from
* source at runtime via getOpenApiSpec() (src/api/openapiSpec.js).
*
* Usage: npm run docs:api
*/
@@ -505,85 +507,65 @@ function renderReadme(operations) {
return lines.join('\n');
}
function summarizeSchemaCompact(schema, components = spec.components, depth = 0) {
if (!schema || depth > 4) return '';
const resolved = resolveSchema(schema, components);
if (!resolved) return schemaRefName(schema) || '';
if (resolved.type === 'array') {
const item = summarizeSchemaCompact(resolved.items, components, depth + 1) || 'any';
return `${item}[]`;
}
if (resolved.type === 'object' || resolved.properties) {
const props = Object.keys(resolved.properties || {});
if (props.length === 0) return schemaRefName(schema) || 'object';
if (props.length === 1) {
const k = props[0];
const v = summarizeSchemaCompact(resolved.properties[k], components, depth + 1);
return v ? `{ ${k}: ${v} }` : `{ ${k} }`;
}
const inner = props.map((k) => {
const sub = resolved.properties[k];
if (sub && sub.type === 'array') {
const subItem = summarizeSchemaCompact(sub.items, components, depth + 1) || 'any';
return `${k}: ${subItem}[]`;
}
return k;
});
return `{ ${inner.join(', ')} }`;
}
if (resolved.type) return resolved.type;
return schemaRefName(schema) || '';
}
function summarizeParamSchema(schema, components) {
/**
* Compact one-line schema summary. Every leaf carries its explicit type
* (`string`, `integer`, `number`, `boolean`, `object`), arrays render as `T[]`,
* and objects render as `{ key: type, … }` at every nesting level.
*
* @param {object} [schema]
* @param {object} [components]
* @param {number} [depth]
* @param {boolean} [markOptional] suffix non-required props with `?` (request bodies)
* @returns {string}
*/
function summarizeSchema(schema, components = spec.components, depth = 0, markOptional = false) {
if (!schema) return '';
if (depth > 6) return 'object';
const resolved = resolveSchema(schema, components);
if (!resolved) return schemaRefName(schema) || '';
if (resolved.$ref) return schemaRefName(resolved) || '';
if (resolved.type === 'array') {
const item = summarizeParamSchema(resolved.items, components) || 'any';
const item = summarizeSchema(resolved.items, components, depth + 1, markOptional) || 'any';
return `${item}[]`;
}
if (resolved.type === 'object' || resolved.properties) {
const required = new Set(resolved.required || []);
const props = Object.keys(resolved.properties || {});
if (props.length === 0) {
return schemaRefName(schema) || 'object';
let entries = Object.entries(resolved.properties || {});
if (entries.length === 0) return 'object';
// Required props first, then optional ones (stable within each group).
if (markOptional) {
entries = entries
.map(([name], i) => ({ name, i }))
.sort((a, b) => (required.has(a.name) ? 0 : 1) - (required.has(b.name) ? 0 : 1) || a.i - b.i)
.map(({ name }) => [name, resolved.properties[name]]);
}
// Prioritize required props before optional props
const sortedProps = [...props].sort((a, b) => {
const reqA = required.has(a) ? 0 : 1;
const reqB = required.has(b) ? 0 : 1;
return reqA - reqB;
});
const formattedProps = sortedProps.map((k) => {
const isOpt = !required.has(k);
const propSchema = resolved.properties[k];
const optSuffix = isOpt ? '?' : '';
if (propSchema?.type === 'array') {
const subItem = summarizeParamSchema(propSchema.items, components) || 'any';
return `${k}${optSuffix}: ${subItem}[]`;
}
if (propSchema?.type === 'object' && propSchema.properties) {
const sub = summarizeParamSchema(propSchema, components);
return `${k}${optSuffix}: ${sub}`;
}
if ((k === 'sku' || sortedProps.length === 1) && propSchema?.type) {
return `${k}${optSuffix}: ${propSchema.type}`;
}
return `${k}${optSuffix}`;
const parts = entries.map(([name, prop]) => {
const opt = markOptional && !required.has(name) ? '?' : '';
const type = summarizeSchema(prop, components, depth + 1, markOptional) || 'any';
return `${name}${opt}: ${type}`;
});
return `{ ${formattedProps.join(', ')} }`;
return `{ ${parts.join(', ')} }`;
}
if (resolved.type) return resolved.type;
return schemaRefName(schema) || '';
}
/**
* Always-visible type for a path/query parameter, so declarations are uniform.
* Falls back to `string` only when the parameter schema omits a type entirely.
* @param {object} param
* @returns {string}
*/
function paramType(param) {
return summarizeSchema(param?.schema, spec.components) || 'string';
}
function summarizeOperationParams(operation) {
const parts = [];
const params = operation.parameters || [];
@@ -592,13 +574,12 @@ function summarizeOperationParams(operation) {
for (const p of params) {
if (p.in === 'header' || p.in === 'cookie') continue;
const type = p.schema?.type ? `:${p.schema.type}` : '';
const isOpt = !p.required;
const optSuffix = isOpt ? '?' : '';
if (p.in === 'path') {
pathParams.push(`${p.name}${type}`);
pathParams.push(`${p.name}: ${paramType(p)}`);
} else if (p.in === 'query') {
queryParams.push(`${p.name}${optSuffix}${type}`);
queryParams.push(`${p.name}${optSuffix}: ${paramType(p)}`);
}
}
@@ -615,11 +596,11 @@ function summarizeOperationParams(operation) {
const content = operation.requestBody.content;
if (content['application/json']?.schema) {
const s = content['application/json'].schema;
const sub = summarizeParamSchema(s, spec.components);
const sub = summarizeSchema(s, spec.components, 0, true);
parts.push(`${bodyPrefix}: ${sub || 'json'}`);
} else if (content['multipart/form-data']) {
const formSchema = content['multipart/form-data'].schema;
const formSub = summarizeParamSchema(formSchema, spec.components);
const formSub = summarizeSchema(formSchema, spec.components, 0, true);
parts.push(`form: ${formSub || 'multipart'}`);
} else {
const ct = Object.keys(content)[0];
@@ -662,7 +643,7 @@ function summarizeOperationReturns(operation) {
continue;
}
const s = resp.content[ct]?.schema;
const sub = summarizeSchemaCompact(s);
const sub = summarizeSchema(s);
if (sub) {
parts.push(`${code} ${sub}`);
} else {
@@ -680,20 +661,23 @@ function renderRoutesOverview(operations, title = 'Dash API Routes Overview') {
lines.push('');
lines.push('### Parameter Notation');
lines.push('');
lines.push('- **Required parameters** have no `?` (e.g. `sku:string`, `categoryId`, `query: si:string`, `body: { name }`).');
lines.push('- **Optional parameters** are marked with `?` (e.g. `query: categoryId?:integer`, `track_stock?`, `body?: ...`).');
lines.push('- **Every value carries its type** — `string`, `integer`, `number`, `boolean`, `object`, arrays as `type[]`.');
lines.push('- **Required parameters** have no `?` (e.g. `sku: string`, `id: integer`, `query: si: string`, `body: { name: string }`).');
lines.push('- **Optional parameters** are marked with `?` (e.g. `query: limit?: integer`, `body: { sort?: integer }`, `body?: { … }`).');
lines.push('- **Path parameters** match `{param}` in the route URL.');
lines.push('- **Query parameters** are prefixed with `query: ...`.');
lines.push('- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if optional).');
lines.push('- **Request bodies** are prefixed with `body: ...` (or `body?: ...` if the whole body is optional).');
lines.push('- **Response objects** use the same notation, so `201 { article: { … } }` means a JSON object with an `article` property.');
lines.push('');
lines.push('### Routes');
lines.push('');
lines.push('| Method | Route | Params | Return Type | Summary |');
lines.push('|:---|:---|:---|:---|:---|');
for (const op of operations) {
const method = op.method.toUpperCase();
const summary = (op.summary || '').replace(/[\r\n|]+/g, ' ').trim();
const paramsStr = op.params === '—' ? '—' : `\`${op.params.replace(/\|/g, '\\|')}\``;
const returnsStr = op.returns === '—' ? '—' : `\`${op.returns.replace(/\|/g, '\\|')}\``;
lines.push(`| \`${method}\` | \`${op.path}\` | ${paramsStr} | ${returnsStr} | ${summary} |`);
const summary = (op.summary || '').replace(/[\r\n]+/g, ' ').trim();
const heading = summary ? `${summary} — \`${method} ${op.path}\`` : `\`${method} ${op.path}\``;
lines.push(`#### ${heading}`);
lines.push(op.params === '—' ? '_none_' : `- Params: \`${op.params}\``);
lines.push(op.returns === '—' ? '_no response body_' : `- Returns: \`${op.returns}\``);
}
lines.push('');
return lines.join('\n');
@@ -786,6 +770,6 @@ fs.writeFileSync(
);
fs.writeFileSync(path.join(docsDir, 'routes-all.md'), renderRoutesOverview(operations, 'Dash API Routes Overview (All)'));
fs.writeFileSync(path.join(docsDir, 'README.md'), renderReadme(operations));
fs.writeFileSync(path.join(docsDir, 'openapi.json'), JSON.stringify(spec, null, 2));
console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md + ${operations.length} in routes-all.md + openapi.json → docs/api/`);
console.log(`Wrote ${operations.length} route docs + ${overviewOperations.length} in routes.md + ${operations.length} in routes-all.md → docs/api/`);
console.log('No openapi.json written — the interactive docs at /docs/ build it from source at runtime.');

View File

@@ -0,0 +1,92 @@
-- Rename article "father" columns to "parent" (SQL Server / MSSQL).
--
-- One-off manual migration (no migration code is kept in the repo).
-- sqlcmd -S <server> -d <db> -E -i scripts/migrate-parent-rename.mssql.sql
--
-- Guarded and safe to re-run. Safe to retry after a partial run.
--
-- NOTE ON BATCHING: sp_rename is deferred and its effect is not visible to
-- subsequent statements in the SAME batch. Each rename therefore runs in its
-- own batch (GO), and constraint/index recreation happens after a GO so the
-- new column names resolve.
SET XACT_ABORT ON;
GO
BEGIN TRANSACTION;
GO
-- Drop the old filtered unique index (references fathersort).
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_article_fatherarticle_fathersort')
DROP INDEX UQ_article_fatherarticle_fathersort ON article;
GO
-- Drop the default constraints bound to the old column names.
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_article_is_father')
ALTER TABLE article DROP CONSTRAINT DF_article_is_father;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_article_fathersort')
ALTER TABLE article DROP CONSTRAINT DF_article_fathersort;
GO
-- Drop foreign keys bound to the old columns.
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_article_fatherarticle')
ALTER TABLE article DROP CONSTRAINT FK_article_fatherarticle;
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_article_fatherattributename')
ALTER TABLE article DROP CONSTRAINT FK_article_fatherattributename;
GO
-- Rename the columns (each sp_rename must be its own batch).
IF COL_LENGTH('article', 'fatherarticle_id') IS NOT NULL
AND COL_LENGTH('article', 'parentarticle_id') IS NULL
EXEC sp_rename 'article.fatherarticle_id', 'parentarticle_id', 'COLUMN';
GO
IF COL_LENGTH('article', 'fatherattributename_id') IS NOT NULL
AND COL_LENGTH('article', 'parentattributename_id') IS NULL
EXEC sp_rename 'article.fatherattributename_id', 'parentattributename_id', 'COLUMN';
GO
IF COL_LENGTH('article', 'fathersort') IS NOT NULL
AND COL_LENGTH('article', 'parentsort') IS NULL
EXEC sp_rename 'article.fathersort', 'parentsort', 'COLUMN';
GO
IF COL_LENGTH('article', 'is_father') IS NOT NULL
AND COL_LENGTH('article', 'is_parent') IS NULL
EXEC sp_rename 'article.is_father', 'is_parent', 'COLUMN';
GO
COMMIT TRANSACTION;
GO
-- Recreate the constraints under their new names (after the rename batches).
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_article_parentarticle')
ALTER TABLE article ADD CONSTRAINT FK_article_parentarticle
FOREIGN KEY (parentarticle_id) REFERENCES article(id) ON DELETE NO ACTION;
GO
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_article_parentattributename')
ALTER TABLE article ADD CONSTRAINT FK_article_parentattributename
FOREIGN KEY (parentattributename_id) REFERENCES attributename(id) ON DELETE NO ACTION;
GO
IF NOT EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_article_is_parent')
ALTER TABLE article ADD CONSTRAINT DF_article_is_parent DEFAULT (0) FOR is_parent;
GO
IF NOT EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_article_parentsort')
ALTER TABLE article ADD CONSTRAINT DF_article_parentsort DEFAULT (0) FOR parentsort;
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_article_parentarticle_parentsort')
CREATE UNIQUE INDEX UQ_article_parentarticle_parentsort
ON article (parentarticle_id, parentsort)
WHERE parentarticle_id IS NOT NULL;
GO
-- Verify
SELECT 'COL:' + c.name AS result
FROM sys.columns c
WHERE c.object_id = OBJECT_ID('article')
AND (c.name LIKE '%father%' OR c.name LIKE '%parent%');
GO

View File

@@ -0,0 +1,14 @@
-- Rename article "father" columns to "parent".
--
-- One-off manual migration (no migration code is kept in the repo).
-- sqlite3 data/dash.db < scripts/migrate-parent-rename.sqlite.sql
--
-- Safe to re-run: every step is guarded by a column-existence check.
ALTER TABLE article RENAME COLUMN fatherarticle_id TO parentarticle_id;
ALTER TABLE article RENAME COLUMN fatherattributename_id TO parentattributename_id;
ALTER TABLE article RENAME COLUMN fathersort TO parentsort;
ALTER TABLE article RENAME COLUMN is_father TO is_parent;
-- Verify
PRAGMA table_info(article);

View File

@@ -217,6 +217,20 @@
* format: date-time
* text:
* type: string
* CompanyHistoryEntry:
* type: object
* required: [id, date, text]
* properties:
* id:
* type: integer
* companyuserId:
* type: integer
* nullable: true
* date:
* type: string
* format: date-time
* text:
* type: string
* ArticleSupplierPrice:
* type: object
* required: [net, taxId]

View File

@@ -79,19 +79,31 @@ function articleErrorStatus(error) {
if (error.message === 'Invalid track_stock') return 400;
if (error.message === 'Invalid categoryIds') return 400;
if (error.message === 'Attribute name not found') return 404;
if (error.message === 'attributeNameId is required to become a father article') return 400;
if (error.message?.includes('already used by an ancestor father')) return 400;
if (error.message === 'attributeNameId is required to become a parent article') return 400;
if (error.message?.includes('already used by an ancestor parent')) return 400;
// Variant-parent preconditions: a parent article may not carry stock,
// suppliers, a Stückliste or options. These are user-correctable 409s,
// not server faults — without these the route returns a bare 500.
if (error.message === 'An article with existing stock cannot become a parent article') return 409;
if (error.message === 'An article with assigned suppliers cannot become a parent article') return 409;
if (error.message === 'A Stückliste article cannot become a parent') return 409;
if (error.message === 'An article with options cannot become a parent') return 409;
if (error.message?.includes('cannot have attribute values for its variant attribute')) return 400;
if (error.message?.includes('A father article cannot have') || error.message?.includes('father article cannot have')) return 400;
if (error.message === 'Cannot disable father mode on an article that still has children') return 400;
if (error.message === 'Article is not a father article') return 400;
if (error.message?.includes('A parent article cannot have') || error.message?.includes('parent article cannot have')) return 400;
if (error.message === 'Cannot disable parent mode on an article that still has children') return 400;
if (error.message === 'Article is not a parent article') return 400;
if (error.message === 'Attribute value is required for child article' || error.message === 'Attribute value is required') return 400;
if (error.message === 'Attribute value not found or does not match father attribute') return 400;
if (error.message === 'Child article does not belong to this father') return 400;
if (error.message === 'Attribute value not found or does not match parent attribute') return 400;
if (error.message === 'Child article does not belong to this parent') return 400;
if (error.message === 'Article already belongs to another parent') return 409;
if (error.message?.includes('sku is not allowed when creating a child article')) return 400;
if (error.message === 'sku is required' || error.message?.includes('sku is required')) return 400;
if (error.message === 'Article is already connected to a parent article') return 400;
if (error.message === 'An article cannot be a child of itself') return 400;
if (error.message === 'Variant with this attribute value already exists') return 400;
if (error.message?.startsWith('Child article') && error.message?.includes('already exists')) return 400;
if (error.message?.toLowerCase().includes('duplicate variant')) return 400;
if (error.message === 'Father article not found') return 404;
if (error.message === 'Parent article not found') return 404;
if (
error.message?.includes('Stückliste') ||
error.message?.toLowerCase().includes('option') ||
@@ -761,7 +773,7 @@ export function register(app, basePath = '') {
* article:
* $ref: '#/components/schemas/ArticleDetail'
* 400:
* description: Invalid prices, POS customer group required, or father article cannot have prices
* description: Invalid prices, POS customer group required, or parent article cannot have prices
* content:
* application/json:
* schema:
@@ -1798,10 +1810,18 @@ export function register(app, basePath = '') {
/**
* @openapi
* /article/{sku}/father:
* /article/{sku}/parent:
* post:
* summary: Toggle father mode for an article
* description: Enables or disables father article status. Enabling requires specifying an attributeNameId; disabling requires the article to have zero children.
* summary: Toggle parent mode for an article
* description: |
* Enables or disables parent article status. Enabling requires specifying an
* attributeNameId; disabling requires the article to have zero children.
*
* `attributeNameId` is **conditionally required**: it is optional in the schema
* because it is only required when `isParent` is `true`. Omitting it (or passing
* `0`/a negative value) together with `isParent: true` returns `400`
* `attributeNameId is required to become a parent article`. It must not be sent
* when `isParent` is `false`.
* tags:
* - Article
* security:
@@ -1819,14 +1839,17 @@ export function register(app, basePath = '') {
* application/json:
* schema:
* type: object
* required: [isFather]
* required: [isParent]
* properties:
* isFather:
* isParent:
* type: boolean
* description: True to make article a father, false to revert to regular article
* description: True to make article a parent, false to revert to regular article
* attributeNameId:
* type: integer
* description: Required when isFather is true. The attributename id for variants.
* description: |
* **Conditionally required when `isParent` is `true`** (required iff isParent=true).
* The attributename id used for the article's variants. Omitting it while
* enabling parent mode returns 400. Must be omitted when `isParent` is `false`.
* responses:
* 200:
* description: Updated article
@@ -1838,23 +1861,29 @@ export function register(app, basePath = '') {
* article:
* $ref: '#/components/schemas/Article'
* 400:
* description: Invalid input or article still has children
* description: |
* Invalid input — `attributeNameId` missing while `isParent` is true, article still
* has children (disabling), or the attribute is already used by an ancestor parent.
* 409:
* description: |
* Article state conflicts with becoming a variant parent — it still has stock,
* assigned suppliers, a Stückliste, or options. Clear these first.
* 404:
* description: Article or attribute name not found
*/
app.post(`${basePath}/article/:sku/father`, async (req, res) => {
app.post(`${basePath}/article/:sku/parent`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const isFather = req.body?.isFather;
if (typeof isFather !== 'boolean') {
return res.status(400).json({ error: 'isFather boolean is required' });
const isParent = req.body?.isParent;
if (typeof isParent !== 'boolean') {
return res.status(400).json({ error: 'isParent boolean is required' });
}
const attributeNameId = req.body?.attributeNameId ?? req.body?.fatherAttributeNameId;
const attributeNameId = req.body?.attributeNameId ?? req.body?.parentAttributeNameId;
try {
const result = await db.toggleFather(req.user.id, parsed.sku, {
isFather,
const result = await db.toggleParent(req.user.id, parsed.sku, {
isParent,
attributeNameId,
});
emitArticleSignal(req, parsed.sku);
@@ -1862,7 +1891,7 @@ export function register(app, basePath = '') {
res.status(200).json(result);
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Toggle father failed:', error);
if (status === 500) console.error('Toggle parent failed:', error);
res.status(status).json({ error: error.message });
}
});
@@ -1871,8 +1900,8 @@ export function register(app, basePath = '') {
* @openapi
* /article/{sku}/children:
* get:
* summary: List child articles of a father article
* description: Returns variants/children linked to this father article.
* summary: List child articles of a parent article
* description: Returns variants/children linked to this parent article.
* tags:
* - Article
* security:
@@ -1883,7 +1912,7 @@ export function register(app, basePath = '') {
* required: true
* schema:
* type: string
* description: Father article SKU
* description: Parent article SKU
* responses:
* 200:
* description: List of children
@@ -1903,7 +1932,7 @@ export function register(app, basePath = '') {
* type: string
* name:
* type: string
* fathersort:
* parentsort:
* type: integer
* attributeValueId:
* type: integer
@@ -1930,8 +1959,8 @@ export function register(app, basePath = '') {
* @openapi
* /article/{sku}/children:
* post:
* summary: Add a child article to a father article
* description: Automatically generates SKU as fatherSku-nextFreeIndex (or accepts custom sku) and assigns the variant attribute.
* summary: Create and attach a new child article to a parent article
* description: Automatically generates SKU as parentSku-nextFreeIndex and name from parent name + value. Rejects a caller-supplied sku.
* tags:
* - Article
* security:
@@ -1942,7 +1971,7 @@ export function register(app, basePath = '') {
* required: true
* schema:
* type: string
* description: Father article SKU
* description: Parent article SKU
* requestBody:
* required: true
* content:
@@ -1952,16 +1981,13 @@ export function register(app, basePath = '') {
* properties:
* attributeValueId:
* type: integer
* description: ID of existing attribute value for the father's attribute
* description: ID of existing attribute value for the parent's attribute
* value:
* type: string
* description: String value to find or create under the father's attribute
* description: String value to find or create under the parent's attribute
* name:
* type: string
* description: Optional custom variant name
* sku:
* type: string
* description: Optional custom SKU for the child article
* responses:
* 201:
* description: Created child article
@@ -1973,12 +1999,73 @@ export function register(app, basePath = '') {
* article:
* $ref: '#/components/schemas/Article'
* 400:
* description: Father not configured as father, missing value, duplicate variant, or child SKU already exists
* description: Parent not configured as parent, missing value, duplicate variant, or sku was provided in body
* put:
* summary: Attach an existing article as a child to a parent article
* description: Attaches an existing article to this parent article. Idempotent if already a child. Returns 409 if the article already belongs to another parent.
* tags:
* - Article
* security:
* - cookieAuth: []
* parameters:
* - in: path
* name: sku
* required: true
* schema:
* type: string
* description: Parent article SKU
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - sku
* properties:
* sku:
* type: string
* description: SKU of the existing article to attach as child
* attributeValueId:
* type: integer
* description: ID of existing attribute value for the parent's attribute
* value:
* type: string
* description: String value to find or create under the parent's attribute
* reparent:
* type: boolean
* description: Whether to allow re-parenting if child already belongs to another parent
* responses:
* 200:
* description: Attached child article and list of children
* content:
* application/json:
* schema:
* type: object
* properties:
* article:
* $ref: '#/components/schemas/Article'
* children:
* type: array
* items:
* type: object
* 400:
* description: Missing sku or attribute value, or invalid input
* 404:
* description: Parent or child article not found
* 409:
* description: Article already belongs to another parent, or variant value already exists
*/
app.post(`${basePath}/article/:sku/children`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
if (req.body?.sku || req.body?.childSku) {
return res.status(400).json({
error: 'sku is not allowed when creating a child article; omit sku to auto-generate or use PUT to attach an existing article',
});
}
try {
const result = await db.addChild(req.user.id, parsed.sku, req.body || {});
emitArticleSignal(req, parsed.sku);
@@ -1994,12 +2081,36 @@ export function register(app, basePath = '') {
}
});
app.put(`${basePath}/article/:sku/children`, async (req, res) => {
const parsed = parseSku(req.params.sku);
if (parsed.error) return res.status(400).json({ error: parsed.error });
const childSku = typeof req.body?.sku === 'string' && req.body.sku.trim()
? req.body.sku.trim()
: (typeof req.body?.childSku === 'string' && req.body.childSku.trim() ? req.body.childSku.trim() : '');
if (!childSku) {
return res.status(400).json({ error: 'sku is required' });
}
try {
const result = await db.attachChild(req.user.id, parsed.sku, req.body || {});
emitArticleSignal(req, parsed.sku);
emitArticleSignal(req, childSku);
emitArticlesSignal(req);
res.status(200).json(result);
} catch (error) {
const status = articleErrorStatus(error);
if (status === 500) console.error('Attach child failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /article/{sku}/children/{childSku}:
* delete:
* summary: Remove a child article from a father article
* description: Unlinks the child from the father (or deletes if deleteArticle query/body is true).
* summary: Remove a child article from a parent article
* description: Unlinks the child from the parent (or deletes if deleteArticle query/body is true).
* tags:
* - Article
* security:
@@ -2010,7 +2121,7 @@ export function register(app, basePath = '') {
* required: true
* schema:
* type: string
* description: Father article SKU
* description: Parent article SKU
* - in: path
* name: childSku
* required: true
@@ -2021,7 +2132,7 @@ export function register(app, basePath = '') {
* name: deleteArticle
* schema:
* type: boolean
* description: If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a father) instead of just unlinking
* description: If true, permanently deletes the child article (and cascades its entire descendant subtree if it is a parent) instead of just unlinking
* responses:
* 200:
* description: Child removed
@@ -2123,7 +2234,7 @@ export function register(app, basePath = '') {
* /article/{sku}/articlelist:
* put:
* summary: Set Stückliste (bundle items) for an article
* description: Configures the article as a Stückliste covering a list of count * article. A father article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste.
* description: Configures the article as a Stückliste covering a list of count * article. A parent article cannot be a Stückliste. Can be combined with options. Pass an empty array to remove the Stückliste.
* tags:
* - Article
* security:
@@ -2170,7 +2281,7 @@ export function register(app, basePath = '') {
* items:
* type: object
* 400:
* description: Article is a father, has options, or contains itself
* description: Article is a parent, has options, or contains itself
* 404:
* description: Article or component article not found
*/
@@ -2261,7 +2372,7 @@ export function register(app, basePath = '') {
* /article/{sku}/optionslist:
* put:
* summary: Set options list for an article
* description: Configures options for an article. Some options can be mandated (come with the article in any case). A father article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options.
* description: Configures options for an article. Some options can be mandated (come with the article in any case). A parent article cannot have options. Can be combined with a Stückliste. Pass an empty array to remove options.
* tags:
* - Article
* security:
@@ -2311,7 +2422,7 @@ export function register(app, basePath = '') {
* items:
* type: object
* 400:
* description: Article is a father, a Stückliste, or contains itself
* description: Article is a parent, a Stückliste, or contains itself
* 404:
* description: Article or option article not found
*/

View File

@@ -27,7 +27,7 @@ function articleAttributeValueErrorStatus(error) {
if (error.message === 'Company not found' || error.message === 'Article not found') return 404;
if (error.message?.startsWith('Attribute value not found')) return 404;
if (error.message?.includes('exclusive')) return 400;
if (error.message?.includes('father article') || error.message?.includes('Child article must have')) return 400;
if (error.message?.includes('parent article') || error.message?.includes('Child article must have')) return 400;
if (error.message === 'Invalid attribute values array' || error.message === 'Invalid attributeValueId' || error.message === 'Duplicate attributeValueId') return 400;
return 500;
}
@@ -84,26 +84,11 @@ export function register(app, basePath = '') {
* content:
* application/json:
* schema:
* type: object
* required: [attributevalues]
* properties:
* attributevalues:
* type: array
* description: Array of attribute value IDs (numbers) or objects with id or attributeValueId
* items:
* oneOf:
* - type: integer
* description: Attribute value ID
* - type: object
* properties:
* id:
* type: integer
* description: Attribute value ID
* attributeValueId:
* type: integer
* description: Attribute value ID
* sort:
* type: integer
* type: array
* description: Array of attribute value IDs. Array order defines sort.
* items:
* type: integer
* example: [166]
* responses:
* 200:
* description: Updated article attribute values
@@ -147,13 +132,10 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: skuParsed.error });
}
const valuesInput = req.body?.attributevalues ?? req.body?.attributeValues ?? req.body?.attribute_values ?? req.body?.values ?? req.body;
if (!Array.isArray(valuesInput)) {
return res.status(400).json({ error: 'attributevalues array required' });
}
if (!Array.isArray(req.body)) return res.status(400).json({ error: 'attributevalues array required' });
try {
const attributevalues = await db.setByArticleSku(req.user.id, skuParsed.sku, valuesInput);
const attributevalues = await db.setByArticleSku(req.user.id, skuParsed.sku, req.body);
emitArticleSignal(req, skuParsed.sku);
res.json({ attributevalues });
} catch (error) {

View File

@@ -26,6 +26,7 @@ function articlePropertyErrorStatus(error) {
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found' || error.message === 'Article not found') return 404;
if (error.message?.startsWith('Property not found')) return 404;
if (error.message?.startsWith('Exclusive property cannot be overwritten')) return 409;
if (error.message === 'Invalid properties array' || error.message === 'Invalid propertyId' || error.message === 'Duplicate propertyId') return 400;
return 500;
}

View File

@@ -17,6 +17,8 @@ function attributeErrorStatus(error) {
if (error.message === 'Name required') return 400;
if (error.message === 'Invalid attribute name order') return 400;
if (error.message === 'Invalid id') return 400;
if (error.message === 'Attribute name already exists') return 409;
if (error.message === 'Attribute name in use' || error.message?.includes('FOREIGN KEY constraint')) return 409;
return 500;
}
@@ -200,6 +202,42 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Deleted successfully
* 400:
* description: Bad request or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Forbidden
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Attribute name not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Attribute name in use
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.patch(`${basePath}/attributename/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
@@ -213,7 +251,9 @@ export function register(app, basePath = '') {
emit('attributename', { companyId: selected.companyId });
res.json({ attributename });
} catch (error) {
res.status(attributeErrorStatus(error)).json({ error: error.message });
const status = attributeErrorStatus(error);
if (status === 500) console.error('Update attribute name failed:', error);
res.status(status).json({ error: error.message });
}
});
@@ -229,7 +269,12 @@ export function register(app, basePath = '') {
emit('attributename', { companyId: selected.companyId });
res.json({ ok: true });
} catch (error) {
res.status(attributeErrorStatus(error)).json({ error: error.message });
const status = attributeErrorStatus(error);
const message = (error.message === 'Attribute name in use' || error.message?.includes('FOREIGN KEY constraint'))
? 'Attribute name in use'
: error.message;
if (status === 500) console.error('Delete attribute name failed:', error);
res.status(status).json({ error: message });
}
});
}

View File

@@ -16,6 +16,8 @@ function attributeValueErrorStatus(error) {
if (error.message === 'Value required') return 400;
if (error.message === 'Invalid attribute value order') return 400;
if (error.message === 'Invalid id' || error.message === 'Invalid attributeId') return 400;
if (error.message === 'Attribute value already exists') return 409;
if (error.message === 'Attribute value in use' || error.message?.includes('FOREIGN KEY constraint')) return 409;
return 500;
}
@@ -216,6 +218,42 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Deleted successfully
* 400:
* description: Bad request or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Forbidden
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Attribute value not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Attribute value in use
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.patch(`${basePath}/attributevalue/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
@@ -229,7 +267,9 @@ export function register(app, basePath = '') {
emit('attributename', { companyId: selected.companyId });
res.json({ attributevalue });
} catch (error) {
res.status(attributeValueErrorStatus(error)).json({ error: error.message });
const status = attributeValueErrorStatus(error);
if (status === 500) console.error('Update attribute value failed:', error);
res.status(status).json({ error: error.message });
}
});
@@ -245,7 +285,12 @@ export function register(app, basePath = '') {
emit('attributename', { companyId: selected.companyId });
res.json({ ok: true });
} catch (error) {
res.status(attributeValueErrorStatus(error)).json({ error: error.message });
const status = attributeValueErrorStatus(error);
const message = (error.message === 'Attribute value in use' || error.message?.includes('FOREIGN KEY constraint'))
? 'Attribute value in use'
: error.message;
if (status === 500) console.error('Delete attribute value failed:', error);
res.status(status).json({ error: message });
}
});
}

View File

@@ -304,7 +304,7 @@ export function register(app, basePath = '') {
* get:
* tags: [Category]
* summary: Articles in category
* description: Returns articles in a category the user is authorized for (omits child articles of a father article). Each article includes `childCount` (the number of its child articles).
* description: Returns articles in a category the user is authorized for (omits child articles of a parent article). Each article includes `childCount` (the number of its child articles).
* security:
* - cookieAuth: []
* x-markdown: category/articles.md
@@ -396,10 +396,11 @@ export function register(app, basePath = '') {
try {
const allArticles = await db.getCategoryArticles(req.user.id, parsed.id);
const articles = allArticles
.filter((a) => a.fatherArticleId == null)
.filter((a) => a.parentArticleId == null)
.map((a) => ({
...a,
childCount: Number(a.childCount ?? 0),
isParent: Boolean(a.isParent ?? a.is_parent),
}));
res.status(200).json({ articles });
} catch (error) {

View File

@@ -85,26 +85,11 @@ export function register(app, basePath = '') {
* content:
* application/json:
* schema:
* type: object
* required: [attributevalues]
* properties:
* attributevalues:
* type: array
* description: Array of attribute value IDs (numbers) or objects with id or attributeValueId
* items:
* oneOf:
* - type: integer
* description: Attribute value ID
* - type: object
* properties:
* id:
* type: integer
* description: Attribute value ID
* attributeValueId:
* type: integer
* description: Attribute value ID
* sort:
* type: integer
* type: array
* description: Array of attribute value IDs. Array order defines sort.
* items:
* type: integer
* example: [166]
* responses:
* 200:
* description: Updated category attribute values
@@ -148,13 +133,10 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: idParsed.error });
}
const valuesInput = req.body?.attributevalues ?? req.body?.attributeValues ?? req.body?.attribute_values ?? req.body?.values ?? req.body;
if (!Array.isArray(valuesInput)) {
return res.status(400).json({ error: 'attributevalues array required' });
}
if (!Array.isArray(req.body)) return res.status(400).json({ error: 'attributevalues array required' });
try {
const attributevalues = await db.setByCategoryId(req.user.id, idParsed.id, valuesInput);
const attributevalues = await db.setByCategoryId(req.user.id, idParsed.id, req.body);
emitCategorySignal(req);
res.json({ attributevalues });
} catch (error) {

View File

@@ -32,10 +32,21 @@ function parseCompanyId(id) {
return { companyId };
}
function requireSelectedCompany(req) {
const id = req.user?.selectedCompanyId;
if (id == null) {
const err = new Error('No company selected');
err.statusCode = 400;
throw err;
}
return id;
}
function companyErrorStatus(error) {
if (error.message === 'Company not found') return 404;
if (error.message === 'Company name not available') return 409;
if (error.message === 'Invalid tax rate') return 400;
if (error.message === 'Invalid tax rate' || error.message === 'Text required' || error.message === 'No company selected') return 400;
if (error.message === 'Not authorized') return 403;
return 500;
}
@@ -167,6 +178,170 @@ export function register(app, basePath = '') {
}
});
/**
* @openapi
* /company/history:
* get:
* tags: [Company]
* summary: Get company history
* description: |
* Returns `companyhistory` rows for the selected company, newest first (`date` DESC, `id` DESC).
* security:
* - cookieAuth: []
* x-markdown: company/history.md
* parameters:
* - in: query
* name: limit
* required: false
* schema:
* type: integer
* minimum: 1
* maximum: 1000
* description: Maximum number of entries to return (newest first). Omit for all entries.
* responses:
* 200:
* description: Company history (newest first)
* content:
* application/json:
* schema:
* type: object
* required: [history, truncated]
* properties:
* history:
* type: array
* items:
* $ref: '#/components/schemas/CompanyHistoryEntry'
* truncated:
* type: boolean
* description: True when a limit was applied and the result set was exactly that size (more entries may exist).
* 400:
* description: Invalid limit or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Forbidden
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* post:
* tags: [Company]
* summary: Add company history entry
* description: |
* Appends an entry to `companyhistory` for the selected company.
* security:
* - cookieAuth: []
* x-markdown: company/addHistory.md
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [text]
* properties:
* text:
* type: string
* description: History entry text or note
* responses:
* 201:
* description: History entry created
* content:
* application/json:
* schema:
* type: object
* required: [history]
* properties:
* history:
* $ref: '#/components/schemas/CompanyHistoryEntry'
* 400:
* description: Text required or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Forbidden
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.get(`${basePath}/company/history`, async (req, res) => {
try {
requireSelectedCompany(req);
} catch (error) {
return res.status(error.statusCode || 400).json({ error: error.message });
}
const rawLimit = req.query.limit;
let limit = null;
if (rawLimit !== undefined) {
limit = parseInt(rawLimit, 10);
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) {
return res.status(400).json({ error: 'limit must be an integer between 1 and 1000' });
}
}
try {
const history = await db.getCompanyHistory(req.user.id, limit != null ? { limit } : undefined);
const truncated = limit != null && history.length === limit;
res.status(200).json({ history, truncated });
} catch (error) {
const status = companyErrorStatus(error);
if (status === 500) console.error('Get company history failed:', error);
res.status(status).json({ error: error.message });
}
});
app.post(`${basePath}/company/history`, async (req, res) => {
try {
requireSelectedCompany(req);
} catch (error) {
return res.status(error.statusCode || 400).json({ error: error.message });
}
const text = typeof req.body?.text === 'string' ? req.body.text.trim() : '';
if (!text) {
return res.status(400).json({ error: 'text is required' });
}
try {
const entry = await db.addCompanyHistory(req.user.id, { text });
res.status(201).json({ history: entry });
} catch (error) {
const status = companyErrorStatus(error);
if (status === 500) console.error('Add company history failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @openapi
* /company/{id}:

View File

@@ -171,7 +171,7 @@ function deliveryErrorStatus(error) {
if (error.message === 'Invalid file') return 400;
if (error.message === 'Invalid JSON in "data" field') return 400;
if (error.message === 'Only PDF or text files are accepted as attachments') return 400;
if (error.message?.includes('father article') || error.message?.includes('A father article')) return 400;
if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400;
if (
error.message === 'Delivery in use in ledger' ||
error.message === 'Delivery lines already booked on ledger' ||

View File

@@ -18,7 +18,7 @@ function orderErrorStatus(error) {
if (error.message === 'Invalid entityId') return 400;
if (error.message === 'Invalid order') return 400;
if (error.message === 'Invalid lines') return 400;
if (error.message?.includes('father article') || error.message?.includes('A father article')) return 400;
if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400;
return 500;
}

View File

@@ -17,6 +17,8 @@ function propertyErrorStatus(error) {
if (error.message === 'Name required') return 400;
if (error.message === 'Invalid property name order') return 400;
if (error.message === 'Invalid id') return 400;
if (error.message === 'Property name already exists') return 409;
if (error.message === 'Property name in use' || error.message?.startsWith('Cannot set property exclusive') || error.message?.includes('FOREIGN KEY constraint')) return 409;
return 500;
}
@@ -214,6 +216,42 @@ export function register(app, basePath = '') {
* responses:
* 200:
* description: Deleted successfully
* 400:
* description: Bad request or no company selected
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Forbidden
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Property name not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 409:
* description: Property name in use
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 500:
* description: Server error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
app.patch(`${basePath}/propertyname/:id`, async (req, res) => {
const selected = requireSelectedCompany(req);
@@ -227,7 +265,9 @@ export function register(app, basePath = '') {
emit('propertyname', { companyId: selected.companyId });
res.json({ propertyname });
} catch (error) {
res.status(propertyErrorStatus(error)).json({ error: error.message });
const status = propertyErrorStatus(error);
if (status === 500) console.error('Update property name failed:', error);
res.status(status).json({ error: error.message });
}
});
@@ -243,7 +283,12 @@ export function register(app, basePath = '') {
emit('propertyname', { companyId: selected.companyId });
res.json({ ok: true });
} catch (error) {
res.status(propertyErrorStatus(error)).json({ error: error.message });
const status = propertyErrorStatus(error);
const message = (error.message === 'Property name in use' || error.message?.includes('FOREIGN KEY constraint'))
? 'Property name in use'
: error.message;
if (status === 500) console.error('Delete property name failed:', error);
res.status(status).json({ error: message });
}
});
}

View File

@@ -318,9 +318,9 @@ export function register(app, basePath = '') {
* type: array
* items:
* type: string
* isFather:
* isParent:
* type: boolean
* fatherSku:
* parentSku:
* type: string
* nullable: true
* variants:

View File

@@ -18,7 +18,7 @@ function quoteErrorStatus(error) {
if (error.message === 'Invalid entityId') return 400;
if (error.message === 'Invalid quote') return 400;
if (error.message === 'Invalid lines') return 400;
if (error.message?.includes('father article') || error.message?.includes('A father article')) return 400;
if (error.message?.includes('parent article') || error.message?.includes('A parent article')) return 400;
return 500;
}

View File

@@ -20,7 +20,7 @@ function stockErrorStatus(error) {
if (error.message?.includes('Amount must be')) return 400;
if (error.message?.includes('Invalid amount')) return 400;
if (error.message === 'No items to adjust') return 400;
if (error.message?.includes('cannot have stock') || error.message?.includes('A father article cannot have') || error.message?.includes('father article') || error.message?.includes('Stückliste')) return 400;
if (error.message?.includes('cannot have stock') || error.message?.includes('A parent article cannot have') || error.message?.includes('parent article') || error.message?.includes('Stückliste')) return 400;
if (error.message?.includes('Stock tracking is disabled')) return 409;
return 500;
}
@@ -86,7 +86,7 @@ export function register(app, basePath = '') {
* schema:
* $ref: '#/components/schemas/StockAdjustmentResult'
* 400:
* description: Missing fields, invalid amount, father article cannot have stock, or no company selected
* description: Missing fields, invalid amount, parent article cannot have stock, or no company selected
* content:
* application/json:
* schema:

View File

@@ -80,9 +80,9 @@ export async function listExport(googleAuthId, categoryId) {
return store.article.listExport(googleAuthId, categoryId);
}
export async function toggleFather(googleAuthId, sku, options) {
export async function toggleParent(googleAuthId, sku, options) {
const store = await getStore();
return store.article.toggleFather(googleAuthId, sku, options);
return store.article.toggleParent(googleAuthId, sku, options);
}
export async function addChild(googleAuthId, sku, options) {
@@ -90,6 +90,13 @@ export async function addChild(googleAuthId, sku, options) {
return store.article.addChild(googleAuthId, sku, options);
}
export async function attachChild(googleAuthId, sku, options) {
const store = await getStore();
return store.article.attachChild
? store.article.attachChild(googleAuthId, sku, options)
: store.article.addChild(googleAuthId, sku, { ...options, attach: true });
}
export async function removeChild(googleAuthId, sku, childSku, options) {
const store = await getStore();
return store.article.removeChild(googleAuthId, sku, childSku, options);

View File

@@ -24,3 +24,13 @@ export async function deleteCompany(companyId) {
const store = await getStore();
await store.company.delete(companyId);
}
export async function getCompanyHistory(googleAuthId, opts) {
const store = await getStore();
return store.company.getHistory(googleAuthId, opts);
}
export async function addCompanyHistory(googleAuthId, data) {
const store = await getStore();
return store.company.addHistory(googleAuthId, data);
}

View File

@@ -138,7 +138,7 @@ export async function listArticles(companyId, { categoryId, limit = 50, offset =
const whereClauses = [
'a.company_id = @companyId',
'a.fatherarticle_id IS NULL',
'a.parentarticle_id IS NULL',
];
if (categoryId != null) {
whereClauses.push('ca.category_id = @categoryId');
@@ -280,9 +280,9 @@ export async function getArticleBySku(companyId, sku) {
brand_pic.thumbw AS brandThumbW,
brand_pic.thumbh AS brandThumbH,
brand_pic.thumbhash AS brandThumbHash,
a.is_father AS isFather,
a.fatherarticle_id AS fatherArticleId,
father.sku AS fatherSku
a.is_parent AS isParent,
a.parentarticle_id AS parentArticleId,
parent.sku AS parentSku
FROM article a
LEFT JOIN articletext at
ON at.article_id = a.id AND at.language_id = @languageId
@@ -297,7 +297,7 @@ export async function getArticleBySku(companyId, sku) {
ORDER BY bp.sort, bp.id
LIMIT 1
)
LEFT JOIN article father ON father.id = a.fatherarticle_id
LEFT JOIN article parent ON parent.id = a.parentarticle_id
WHERE a.sku = @sku AND a.company_id = @companyId`
: `SELECT
a.id,
@@ -319,9 +319,9 @@ export async function getArticleBySku(companyId, sku) {
brand_pic.thumbw AS brandThumbW,
brand_pic.thumbh AS brandThumbH,
brand_pic.thumbhash AS brandThumbHash,
a.is_father AS isFather,
a.fatherarticle_id AS fatherArticleId,
father.sku AS fatherSku
a.is_parent AS isParent,
a.parentarticle_id AS parentArticleId,
parent.sku AS parentSku
FROM article a
LEFT JOIN articletext at
ON at.article_id = a.id AND at.language_id = @languageId
@@ -336,7 +336,7 @@ export async function getArticleBySku(companyId, sku) {
ORDER BY bp.sort ASC, bp.id ASC
) first_brand_pic
LEFT JOIN pic brand_pic ON brand_pic.id = first_brand_pic.pic_id
LEFT JOIN article father ON father.id = a.fatherarticle_id
LEFT JOIN article parent ON parent.id = a.parentarticle_id
WHERE a.sku = @sku AND a.company_id = @companyId`;
const result = await provider.query(sql, {
@@ -396,12 +396,12 @@ export async function getArticleBySku(companyId, sku) {
ORDER BY pr.minmultiplier ASC`,
{ articleId, posCustomerGroupId: company.posCustomerGroupId }
),
row.isFather
row.isParent
? provider.query(
`SELECT
child.id,
child.sku,
child.fathersort,
child.parentsort,
child.track_stock AS trackStock,
child.unitamount,
child.weight,
@@ -413,8 +413,8 @@ export async function getArticleBySku(companyId, sku) {
LEFT JOIN articletext at_child ON at_child.article_id = child.id AND at_child.language_id = @languageId
LEFT JOIN articleattributevalue aav ON aav.article_id = child.id
LEFT JOIN attributevalue av ON av.id = aav.attributevalue_id
WHERE child.fatherarticle_id = @articleId
ORDER BY child.fathersort ASC, child.id ASC`,
WHERE child.parentarticle_id = @articleId
ORDER BY child.parentsort ASC, child.id ASC`,
{ articleId, languageId: company.languageId }
)
: Promise.resolve({ rows: [] }),
@@ -480,12 +480,12 @@ export async function getArticleBySku(companyId, sku) {
thumbHash: p.thumbHash,
})),
eans: eansRes.rows.map((e) => e.ean),
isFather: Boolean(row.isFather),
fatherSku: row.fatherSku ?? null,
isParent: Boolean(row.isParent),
parentSku: row.parentSku ?? null,
variants: variantsRes.rows.map((v) => ({
id: v.id,
sku: v.sku,
sort: v.fathersort,
sort: v.parentsort,
name: v.name ?? '',
slug: v.slug ?? '',
attributeValueId: v.attributeValueId ?? null,

View File

@@ -11,6 +11,7 @@ import { createDashbackRequestTool } from '../tools/dashback-request.js';
import { createAskUserTool } from '../tools/ask-user.js';
import { createJavascriptExecuteTool } from '../tools/javascript.js';
import { createResetVfsTool } from '../tools/reset-vfs.js';
import { createApiSuggestionsOrIssuesTool } from '../tools/apisuggestionsorissues.js';
const KEEP_ALIVE_MS = 20_000;
@@ -38,6 +39,7 @@ function buildSystemPrompt() {
'2. ask_user: Ask clarifying questions or request decisions from the user if their intent is ambiguous or requires choices. When you call ask_user, execution pauses immediately and waits for the user to provide their response.',
'3. javascript_execute: Run JavaScript snippets in a sandbox to compute, aggregate, transform, or analyze data. Assign module.exports to return a value. With allow_network: true, snippets can directly call Dashback API routes using fetch() (e.g. await fetch("/article/2")) and search images using search_for_images(query). A sandboxed virtual filesystem (fs / vfs) is available to store and retrieve artifacts across runs.',
'4. reset_vfs: Delete all files, images, and artifacts stored in the virtual filesystem (VFS) to free up storage or clean up.',
'5. apisuggestionsorissues: Report missing API endpoints, bugs, or issues to be triaged. Automatically writes an entry to the company history table for review.',
'',
'OPERATING GUIDELINES:',
'- Refer to the DASHBACK API ROUTES documentation below to find appropriate endpoints, parameters, and HTTP methods.',
@@ -404,6 +406,7 @@ export class ConversationManager {
createAskUserTool(),
createJavascriptExecuteTool({ googleAuthId, companyId: this.companyId }),
createResetVfsTool({ googleAuthId, companyId: this.companyId }),
createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }),
],
});

View File

@@ -0,0 +1,61 @@
import { defineTool } from 'libllmorbiter';
import * as db from '../db/company.js';
/**
* Creates the apisuggestionsorissues tool allowing the agent to report
* missing API endpoints, bugs, or general issues into the company history table.
* These reports are then visible to triage agents or administrators.
*
* @param {{ googleAuthId: number, companyId?: number }} ctx
*/
export function createApiSuggestionsOrIssuesTool({ googleAuthId, companyId }) {
return defineTool({
name: 'apisuggestionsorissues',
description:
'Report a missing API endpoint, feature request, bug, or general issue. ' +
'The report is recorded in the company history table for review by the triage agent. ' +
'Provide a clear description of the missing functionality or issue.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['text'],
properties: {
text: {
type: 'string',
description: 'Detailed description of the missing API, bug, or issue.',
},
type: {
type: 'string',
enum: ['missing_api', 'bug', 'suggestion', 'issue', 'other'],
description: 'Category/type of the report.',
},
title: {
type: 'string',
description: 'Optional short summary or title.',
},
},
},
invoke: async ({ text, type, title }) => {
const trimmedText = typeof text === 'string' ? text.trim() : '';
if (!trimmedText) {
throw new Error('text is required');
}
const prefixParts = [];
if (type) prefixParts.push(`[${type.toUpperCase()}]`);
if (title) prefixParts.push(title.trim());
const formatted = prefixParts.length > 0
? `${prefixParts.join(' ')}\n${trimmedText}`
: trimmedText;
const entry = await db.addCompanyHistory(googleAuthId, { text: formatted });
return {
success: true,
message: 'Recorded in company history for triage agent review',
entry,
};
},
});
}

18
todo.md Normal file
View File

@@ -0,0 +1,18 @@
Fehler gefunden: Verletzungen der Eindeutigkeit werden als HTTP-500-Fehler weitergegeben
Konsistent und reproduzierbar an vier Endpunkten – ein doppelter Name/Wert trifft auf eine eindeutige Datenbankbeschränkung und wird als Fehler 500 {"error":"Internal server error"}anstelle eines 4xx-Fehlers angezeigt:
POST /attributename/{id}/valuemit einem bestehenden Wert → 500
POST /attributenamemit bestehendem Namen → 500
POST /propertynamemit bereits existierendem Namen ( Notiz) → 500
Nichts wird gespeichert (verifiziert), daher ist es zwar kosmetisch, aber fehleranfällig – dennoch ist es die einzige Klasse, die interne Informationen preisgibt und die Fehlertoleranz erhöht. Bemerkenswerterweise POST /categoryfunktioniert es auch mit einem doppelten Namen (201), das Verhalten ist also auch zwischen den Modulen inkonsistent .
Beobachtungen, keine Fehler
PUT /artificlelist/ optionslistDoppelte Einträge ablehnen statt zusammenführen – vertretbar, aber nicht dokumentiert.
Lange Werte werden von der Datenbank stillschweigend abgeschnitten, anstatt validiert zu werden (5000 Zeichen → 201 mit einer abgeschnittenen Zeile).
POST /article/{sku}/childrenDie Dokumentation beschreibt ein optionales Element, skudas von der API jedoch immer abgelehnt wird sku is not allowed.... Der angegebene Pfad ist nicht funktionsfähig – um einen bestehenden Artikel einzubinden, ist eine direkte Verknüpfung erforderlich POST /article/{sku}/parent, die in der Dokumentation nicht beschrieben wird. Eine Korrektur der Dokumentation oder eine zusätzliche Route wäre wünschenswert.
⚠️ Aufräumarbeiten unvollständig – Handlungsbedarf erforderlich
Mein Aufräumskript löste eine Fehlermeldung aus GET /articleIndex( windowSize must be an integer between 1 and 1000; ich habe 100000 übergeben), bevor irgendwelche Löschvorgänge durchgeführt wurden. Es wurde nichts entfernt. Zurück blieb:
~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.