Compare commits

...

5 Commits

Author SHA1 Message Date
seb
15a17574c3 u 2026-10-11 19:46:33 +02:00
seb
7fd8d5d704 u 2026-10-11 18:55:06 +02:00
seb
fe7be79b90 u 2026-10-11 18:20:35 +02:00
seb
1eb5cb4e59 u 2026-10-11 18:11:55 +02:00
seb
9443f20820 u 2026-10-11 17:26:29 +02:00
33 changed files with 2182 additions and 1186 deletions

View File

@@ -80,6 +80,8 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route
| [`routes/attributename/_id.patch.md`](routes/attributename/_id.patch.md) | `PATCH /attributename/{id}` |
| [`routes/attributevalue/_id.delete.md`](routes/attributevalue/_id.delete.md) | `DELETE /attributevalue/{id}` |
| [`routes/attributevalue/_id.patch.md`](routes/attributevalue/_id.patch.md) | `PATCH /attributevalue/{id}` |
| [`routes/attributevalue/_id/image.delete.md`](routes/attributevalue/_id/image.delete.md) | `DELETE /attributevalue/{id}/image` |
| [`routes/attributevalue/_id/image.post.md`](routes/attributevalue/_id/image.post.md) | `POST /attributevalue/{id}/image` |
| [`routes/auth/login.md`](routes/auth/login.md) | `POST /auth/login` |
| [`routes/auth/logout.md`](routes/auth/logout.md) | `POST /auth/logout` |
| [`routes/auth/me.md`](routes/auth/me.md) | `GET /auth/me` |
@@ -250,7 +252,7 @@ Clients subscribe via [`GET /events`](routes/events.md) (`text/event-stream`). A
|--------|------------|
| `article-<sku>` | `PUT /article/{sku}/brand`, `PUT /article/{sku}/categories`, `PUT /article/{sku}/eans`, `POST /article/{sku}/image`, `DELETE /article/{sku}/image/{fullHash}`, `PUT /article/{sku}/images/order`, `PUT /article/{sku}/prices`, `POST /article/{sku}/supplier`, `PUT /article/{sku}/supplier/order`, `DELETE /article/{sku}/supplier/{id}`, `PATCH /article/{sku}/supplier/{id}`, `PUT /article/{sku}/track_stock`, `PUT /article/{sku}/weight`, `POST /stock/adjust` |
| `articles` | `POST /article`, `DELETE /article/{sku}`, `PATCH /article/{sku}`, `DELETE /category/{id}`, `POST /stock/adjust` |
| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}` |
| `attributename` | `POST /attributename`, `PUT /attributename/order`, `DELETE /attributename/{id}`, `PATCH /attributename/{id}`, `DELETE /attributevalue/{id}/image`, `POST /attributevalue/{id}/image` |
| `brand` | `POST /brand`, `DELETE /brand/{id}`, `PATCH /brand/{id}`, `DELETE /brand/{id}/image`, `POST /brand/{id}/image` |
| `category` | `POST /category`, `PUT /category/order`, `DELETE /category/{id}`, `PATCH /category/{id}`, `DELETE /category/{id}/image`, `POST /category/{id}/image` |
| `labeltemplate` | `POST /labeltemplate`, `DELETE /labeltemplate/{id}`, `PATCH /labeltemplate/{id}`, `PUT /labeltemplate/{id}` |

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview (All)
Overview of all 207 API endpoints across all modules.
Overview of all 209 API endpoints across all modules.
### Notation
@@ -10,46 +10,46 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ article: T2 }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T31, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T35[], suppliers: T12[] }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T31, brand: T28|null, categoryIds: int[], photos: T26[], eans: str[], prices: T37[], suppliers: T12[] }`
- `T3` = `{ delivery: T4 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T20[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T40 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T21[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T41 }`
- `T5` = `{ labeltemplate: T6 }`
- `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T16, thumb?: str|null }`
- `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T17, thumb?: str|null }`
- `T7` = `{ order: T11 }`
- `T8` = `{ quote: T11 }`
- `T9` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T10` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }`
- `T11` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T20[], createdAt?: str }`
- `T11` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T21[], createdAt?: str }`
- `T12` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T43|null }`
- `T13` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T14` = `{ ledger: T15[] }`
- `T15` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T16` = `{ objects: T21[] }`
- `T16` = `{ id: int, name: str, sort: int, exclusive: bool, values: T32[], articleCount?: int }`
- `T17` = `{ objects: T22[] }`
- `T18` = `{ printer: T23 }`
- `T19` = `{ entity: T27 }`
- `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T21` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T18` = `{ objects: T23[] }`
- `T19` = `{ printer: T24 }`
- `T20` = `{ entity: T27 }`
- `T21` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T22` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T23` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T24` = `{ id: int, name: str, sort: int, exclusive: bool, values: T38[] }`
- `T23` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T24` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T25` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T26` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T27` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T28` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T29` = `{ id: int, companyuser_id: int, provider: str, model: str, created_at: str, updated_at: str, state: str }`
- `T30` = `{ article: T33 }`
- `T30` = `{ article: T35 }`
- `T31` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T32` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- `T33` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T34` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T35` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T36` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T37` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T38` = `{ id: int, attributeId: int, value: str, sort: int }`
- `T39` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T40` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T41` = `{ id: int, name: str, sort: int, exclusive: bool }`
- `T32` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T33` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T34` = `{ id: int, iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- `T35` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T36` = `{ id: int, email: str, selectedCompanyId: int|null, picture?: str }`
- `T37` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T38` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T39` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T40` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T41` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T42` = `{ warehouse: T44 }`
- `T43` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T44` = `{ id: int, name: str, sort: int }`
@@ -113,7 +113,7 @@ _none_
- Returns: `201 T30`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
- Returns: `200 { article: T33, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T35, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: bool }`
@@ -182,19 +182,19 @@ _none_
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T24[] }`
- Returns: `200 { attributenames: T16[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T24 }`
- Returns: `201 { attributename: T16 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T38[] }`
- Returns: `200 { values: T32[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T38 }`
- Returns: `201 { attributevalue: T32 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -203,7 +203,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T24 }`
- Returns: `200 { attributename: T16 }`
### Section: /attributevalue
@@ -212,22 +212,28 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T38 }`
- Returns: `200 { attributevalue: T32 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T38[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T38[] } }`
### Section: /auth
#### Login with Google ID token — `POST /auth/login`
- Params: `body: { idToken: str }`
- Returns: `200 { message: str, user: T34 }`
- Returns: `200 { message: str, user: T36 }`
#### Logout — `POST /auth/logout`
_none_
- Returns: `200 { message: str }`
#### Current user — `GET /auth/me`
_none_
- Returns: `200 { user: T34, googleClientId: str }`
- Returns: `200 { user: T36, googleClientId: str }`
#### Set selected company — `PUT /auth/selected-company`
- Params: `body: { companyId: int }`
- Returns: `200 { user: T34 }`
- Returns: `200 { user: T36 }`
### Section: /brand
@@ -236,7 +242,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T36[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T38[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -245,10 +251,10 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T36[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T38[] } }`
#### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T36[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T38[] } }`
### Section: /category
@@ -299,10 +305,10 @@ _none_
#### List companies — `GET /company`
_none_
- Returns: `200 { companies: T32[] }`
- Returns: `200 { companies: T34[] }`
#### Create company — `POST /company`
- Params: `body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `201 { company: T32, user: T34 }`
- Returns: `201 { company: T34, user: T36 }`
#### Get company history — `GET /company/history`
- Params: `query: limit?: int`
- Returns: `200 { history: { id: int, date: str, text: str, companyuserId?: int|null }[], truncated: bool }`
@@ -326,7 +332,7 @@ _none_
- Returns: `204 void`
#### Update company — `PUT /company/{id}`
- Params: `id: int, body: { iso639: str, name: str, taxrate: num, reducedtaxrate: num }`
- Returns: `200 { company: T32 }`
- Returns: `200 { company: T34 }`
### Section: /customergroup
@@ -350,7 +356,7 @@ _none_
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T40 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T41 }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T25[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T3`
@@ -386,7 +392,7 @@ _none_
- Returns: `200 { entities: T27[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T19`
- Returns: `201 T20`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
@@ -404,25 +410,25 @@ _none_
- Returns: `200 { address: T13 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T37[] }`
- Returns: `200 { contacts: T39[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T37 }`
- Returns: `201 { contact: T39 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T37 }`
- Returns: `200 { contact: T39 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T19`
- Returns: `200 T20`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T19`
- Returns: `200 T20`
### Section: /events
@@ -464,7 +470,7 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T17 }`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T18 }`
- Returns: `201 T5`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
@@ -473,10 +479,10 @@ _none_
- Params: `id: int`
- Returns: `200 T5`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T17 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T18 }`
- Returns: `200 T5`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T17 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T18 }`
- Returns: `200 T5`
### Section: /ledger
@@ -600,10 +606,10 @@ _none_
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T23[] }`
- Returns: `200 { printers: T24[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T18`
- Returns: `201 T19`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -612,22 +618,22 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T18`
- Returns: `200 T19`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T18`
- Returns: `200 T19`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T18`
- Returns: `200 T19`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T41[] }`
- Returns: `200 { propertynames: T33[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T41 }`
- Returns: `201 { propertyname: T33 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -636,7 +642,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T41 }`
- Returns: `200 { propertyname: T33 }`
### Section: /public
@@ -714,16 +720,16 @@ _none_
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T39[] }`
- Returns: `200 { suppliers: T40[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T39 }`
- Returns: `201 { supplier: T40 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T39 }`
- Returns: `200 { supplier: T40 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T43|null }[] }`

View File

@@ -1,6 +1,6 @@
# Dash API Routes Overview
Overview of all 160 API endpoints across all modules.
Overview of all 162 API endpoints across all modules.
### Notation
@@ -10,42 +10,42 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body).
### Reusable types
- `T1` = `{ article: T2 }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T29, brand: T27|null, categoryIds: int[], photos: T25[], eans: str[], prices: T31[], suppliers: T11[] }`
- `T2` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T29, brand: T27|null, categoryIds: int[], photos: T25[], eans: str[], prices: T33[], suppliers: T11[] }`
- `T3` = `{ delivery: T4 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T19[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T36 }`
- `T4` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T20[], files: T9[], externalNumber?: str|null, createdAt?: str, ledger?: T37 }`
- `T5` = `{ labeltemplate: T6 }`
- `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T15, thumb?: str|null }`
- `T6` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T16, thumb?: str|null }`
- `T7` = `{ order: T10 }`
- `T8` = `{ quote: T10 }`
- `T9` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }`
- `T10` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T19[], createdAt?: str }`
- `T10` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T20[], createdAt?: str }`
- `T11` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T39|null }`
- `T12` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }`
- `T13` = `{ ledger: T14[] }`
- `T14` = `{ id: int, manifestarticle_id: int, transaction_type: str, amount: num, transaction_text: str, hash_version: int, prev_hash?: str, sku?: str|null, created_at?: str }`
- `T15` = `{ objects: T20[] }`
- `T15` = `{ id: int, name: str, sort: int, exclusive: bool, values: T30[], articleCount?: int }`
- `T16` = `{ objects: T21[] }`
- `T17` = `{ printer: T22 }`
- `T18` = `{ entity: T26 }`
- `T19` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T20` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T17` = `{ objects: T22[] }`
- `T18` = `{ printer: T23 }`
- `T19` = `{ entity: T26 }`
- `T20` = `{ id: int, sku: str|null, name: str|null, pu: num, amount: num, net: num, rebate: num, taxrate: num, comment: str|null }`
- `T21` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T22` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T23` = `{ id: int, name: str, sort: int, exclusive: bool, values: T34[] }`
- `T22` = `{ id: str, type: str, x: num, y: num, w?: num, h?: num, text?: str, font?: str, fontSize?: num, barcodeFormat?: str }`
- `T23` = `{ id: int, company_id: int, address: str, type: str, sort: int, model?: str|null, serial?: str|null, desc?: str|null }`
- `T24` = `{ amount: num, net: num, taxrate: num, sku?: str|null, name?: str|null, pu?: num, rebate?: num, comment?: str|null }`
- `T25` = `{ sort: int, fullHash: str, fullW: int, fullH: int, fullSize: int, thumbW: int, thumbH: int, thumbHash: str }`
- `T26` = `{ id: int, customergroupId: int, firstName: str|null, name: str, number: str|null, createdAt: str, updatedAt: str }`
- `T27` = `{ name: str, id?: int|null, fullHash?: str|null, thumbW?: int|null, thumbH?: int|null, thumbHash?: str|null }`
- `T28` = `{ article: T30 }`
- `T28` = `{ article: T32 }`
- `T29` = `{ si: str, basesi: str, basesifactor: num, symbol?: str|null, name?: str|null }`
- `T30` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T31` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T32` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T33` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T34` = `{ id: int, attributeId: int, value: str, sort: int }`
- `T35` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T36` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T37` = `{ id: int, name: str, sort: int, exclusive: bool }`
- `T30` = `{ id: int, attributeId: int, value: str, sort: int, articleCount?: int }`
- `T31` = `{ id: int, name: str, sort: int, exclusive: bool, articleCount?: int }`
- `T32` = `{ id: int, categoryId: int, name: str, sku: str, track_stock: bool }`
- `T33` = `{ minmultiplier: num, net: num, taxRate: num, customergroupId: int }`
- `T34` = `{ fullHash: str, thumbW: int, thumbH: int, thumbHash: str }`
- `T35` = `{ id: int, entityId: int, contactType: str, contact: str }`
- `T36` = `{ id: int, name: str, createdAt: str, updatedAt: str }`
- `T37` = `{ totalPositions: int, completelyOnLedgerPositions: int }`
- `T38` = `{ warehouse: T40 }`
- `T39` = `{ net: num, taxId: int, taxRate?: num|null }`
- `T40` = `{ id: int, name: str, sort: int }`
@@ -105,7 +105,7 @@ _none_
- Returns: `201 T28`
#### Attach an existing article as a child to a parent article — `PUT /article/{sku}/children`
- Params: `sku: str, body: { sku: str, attributeValueId?: int, value?: str, reparent?: bool }`
- Returns: `200 { article: T30, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
- Returns: `200 { article: T32, children: { id: int, sku: str, name: str, parentsort: int, attributeValueId: int, attributeValue: str }[] }`
#### Remove a child article from a parent article — `DELETE /article/{sku}/children/{childSku}`
- Params: `sku: str, childSku: str, query: deleteArticle?: bool`
- Returns: `200 { success: bool, deleted: bool }`
@@ -171,19 +171,19 @@ _none_
#### List attribute names — `GET /attributename`
_none_
- Returns: `200 { attributenames: T23[] }`
- Returns: `200 { attributenames: T15[] }`
#### Create attribute name — `POST /attributename`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { attributename: T23 }`
- Returns: `201 { attributename: T15 }`
#### Reorder attribute names — `PUT /attributename/order`
- Params: `body: { attributenameIds: int[] }`
- Returns: `200 { ok: bool }`
#### List values for an attribute — `GET /attributename/{attributeId}/value`
- Params: `attributeId: int`
- Returns: `200 { values: T34[] }`
- Returns: `200 { values: T30[] }`
#### Create value for an attribute — `POST /attributename/{attributeId}/value`
- Params: `attributeId: int, body: { value: str, sort?: int }`
- Returns: `201 { attributevalue: T34 }`
- Returns: `201 { attributevalue: T30 }`
#### Reorder values of an attribute — `PUT /attributename/{attributeId}/value/order`
- Params: `attributeId: int, body: { attributevalueIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -192,7 +192,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute name — `PATCH /attributename/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { attributename: T23 }`
- Returns: `200 { attributename: T15 }`
### Section: /attributevalue
@@ -201,7 +201,13 @@ _none_
- Returns: `200 { ok: bool }`
#### Update attribute value — `PATCH /attributevalue/{id}`
- Params: `id: int, body: { value?: str, sort?: int }`
- Returns: `200 { attributevalue: T34 }`
- Returns: `200 { attributevalue: T30 }`
#### Delete attribute value image — `DELETE /attributevalue/{id}/image`
- Params: `id: int`
- Returns: `200 { attributevalue: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T34[] } }`
#### Add or replace attribute value image — `POST /attributevalue/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { attributevalue: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T34[] } }`
### Section: /auth
@@ -219,7 +225,7 @@ _none_
- Returns: `200 { brands: { id: int, name: str, articleCount: int, pics: { fullHash: str, thumbW: int, thumbH: int, thumbHash: str, smallThumbW?: int|null, smallThumbH?: int|null, smallThumbBinary?: str|null }[], gsprpinfo?: str|null }[], total: int, offset: int, limit: int }`
#### Create brand — `POST /brand`
- Params: `body: { name: str, gsprpinfo?: str }`
- Returns: `201 { brand: { id: int, name: str, pics: T32[], gsprpinfo?: str|null } }`
- Returns: `201 { brand: { id: int, name: str, pics: T34[], gsprpinfo?: str|null } }`
#### Delete brand — `DELETE /brand/{id}`
- Params: `id: int`
- Returns: `204 void`
@@ -228,10 +234,10 @@ _none_
- Returns: `200 { brand: { id: int, name: str, touchedArticleCount: int, gsprpinfo?: str|null } }`
#### Delete brand image — `DELETE /brand/{id}/image`
- Params: `id: int`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T32[] } }`
- Returns: `200 { brand: { id: int, thumbW: int|null, thumbH: int|null, thumbHash: str|null, pics: T34[] } }`
#### Add or replace brand image — `POST /brand/{id}/image`
- Params: `id: int, image/avif`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T32[] } }`
- Returns: `201 { brand: { id: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str, pics: T34[] } }`
### Section: /category
@@ -321,7 +327,7 @@ _none_
#### List deliveries — `GET /delivery`
_none_
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T36 }[] }`
- Returns: `200 { deliveries: { id: int, number: str, type: str, supplierId: int|null, date: str, externalNumber?: str|null, createdAt?: str, ledger?: T37 }[] }`
#### Create delivery — `POST /delivery`
- Params: `body: { externalNumber: str, date: str, lines: T24[], supplierId?: int|null, files?: { mimetype: str, name: str, content: str, sort?: int, size?: int, hash?: str, created?: str, modified?: str, meta?: { [k]: str } }[] }`
- Returns: `201 T3`
@@ -345,7 +351,7 @@ _none_
- Returns: `200 { entities: T26[] }`
#### Create entity — `POST /entity`
- Params: `body: { customergroupId: int, name: str, firstName?: str|null, number?: str }`
- Returns: `201 T18`
- Returns: `201 T19`
#### Peek next customer number — `GET /entity/next-number`
_none_
- Returns: `200 { number: str }`
@@ -363,25 +369,25 @@ _none_
- Returns: `200 { address: T12 }`
#### List entity contacts — `GET /entity/{entityId}/contact`
- Params: `entityId: int`
- Returns: `200 { contacts: T33[] }`
- Returns: `200 { contacts: T35[] }`
#### Create entity contact — `POST /entity/{entityId}/contact`
- Params: `entityId: int, body: { contactType: str, contact: str }`
- Returns: `201 { contact: T33 }`
- Returns: `201 { contact: T35 }`
#### Delete entity contact — `DELETE /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int`
- Returns: `204 void`
#### Update entity contact — `PATCH /entity/{entityId}/contact/{id}`
- Params: `entityId: int, id: int, body: { contactType?: str, contact?: str }`
- Returns: `200 { contact: T33 }`
- Returns: `200 { contact: T35 }`
#### Delete entity — `DELETE /entity/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Get entity — `GET /entity/{id}`
- Params: `id: int`
- Returns: `200 T18`
- Returns: `200 T19`
#### Update entity — `PATCH /entity/{id}`
- Params: `id: int, body: { name?: str, firstName?: str|null, number?: str|null }`
- Returns: `200 T18`
- Returns: `200 T19`
### Section: /labeltemplate
@@ -389,7 +395,7 @@ _none_
_none_
- Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }`
#### Create label template — `POST /labeltemplate`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T16 }`
- Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T17 }`
- Returns: `201 T5`
#### Delete label template — `DELETE /labeltemplate/{id}`
- Params: `id: int`
@@ -398,10 +404,10 @@ _none_
- Params: `id: int`
- Returns: `200 T5`
#### Update label template (partial) — `PATCH /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T17 }`
- Returns: `200 T5`
#### Update label template — `PUT /labeltemplate/{id}`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T16 }`
- Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T17 }`
- Returns: `200 T5`
### Section: /ledger
@@ -474,10 +480,10 @@ _none_
#### List printers — `GET /printer`
_none_
- Returns: `200 { printers: T22[] }`
- Returns: `200 { printers: T23[] }`
#### Create printer — `POST /printer`
- Params: `body: { address: str, type: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `201 T17`
- Returns: `201 T18`
#### Reorder printers — `PUT /printer/order`
- Params: `body: { printerIds: int[] }`
- Returns: `200 { printerIds: int[] }`
@@ -486,22 +492,22 @@ _none_
- Returns: `204 void`
#### Get printer by ID — `GET /printer/{id}`
- Params: `id: int`
- Returns: `200 T17`
- Returns: `200 T18`
#### Update printer (partial) — `PATCH /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T17`
- Returns: `200 T18`
#### Update printer — `PUT /printer/{id}`
- Params: `id: int, body: { address?: str, type?: str, model?: str|null, serial?: str|null, sort?: int, desc?: str|null }`
- Returns: `200 T17`
- Returns: `200 T18`
### Section: /propertyname
#### List property names — `GET /propertyname`
_none_
- Returns: `200 { propertynames: T37[] }`
- Returns: `200 { propertynames: T31[] }`
#### Create property name — `POST /propertyname`
- Params: `body: { name: str, sort?: int, exclusive?: bool }`
- Returns: `201 { propertyname: T37 }`
- Returns: `201 { propertyname: T31 }`
#### Reorder property names — `PUT /propertyname/order`
- Params: `body: { propertynameIds: int[] }`
- Returns: `200 { ok: bool }`
@@ -510,7 +516,7 @@ _none_
- Returns: `200 { ok: bool }`
#### Update property name — `PATCH /propertyname/{id}`
- Params: `id: int, body: { name?: str, sort?: int, exclusive?: bool }`
- Returns: `200 { propertyname: T37 }`
- Returns: `200 { propertyname: T31 }`
### Section: /quote
@@ -558,16 +564,16 @@ _none_
#### List suppliers — `GET /supplier`
_none_
- Returns: `200 { suppliers: T35[] }`
- Returns: `200 { suppliers: T36[] }`
#### Create supplier — `POST /supplier`
- Params: `body: { name: str }`
- Returns: `201 { supplier: T35 }`
- Returns: `201 { supplier: T36 }`
#### Delete supplier — `DELETE /supplier/{id}`
- Params: `id: int`
- Returns: `204 void`
#### Update supplier — `PATCH /supplier/{id}`
- Params: `id: int, body: { name: str }`
- Returns: `200 { supplier: T35 }`
- Returns: `200 { supplier: T36 }`
#### List supplier articles by SKUs — `GET /supplier/{id}/articles`
- Params: `id: int, query: skus?: string`
- Returns: `200 { articles: { id: int, articleId: int, entityId: int, pu: num, sort: int, article: { id: int, sku: str, name?: str|null, slug?: str|null, photos?: { sort: int, fullHash: str, thumbW: int, thumbH: int, thumbHash: str }[], eans?: str[] }, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T39|null }[] }`

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
List attribute names
Lists all attribute names defined for the selected company, including nested values.
Lists all attribute names defined for the selected company, including nested values. Each attribute name includes `articleCount` (the number of parent articles configured with this attribute) and each value includes `articleCount` (the number of articles having this attribute value).
## Response `200`
@@ -18,12 +18,14 @@ List of attribute names
"name": "…",
"sort": 1,
"exclusive": true,
"articleCount": 1,
"values": [
{
"id": 1,
"attributeId": 1,
"value": "…",
"sort": 1
"sort": 1,
"articleCount": 1
}
]
}

View File

@@ -37,12 +37,14 @@ Created attribute name
"name": "…",
"sort": 1,
"exclusive": true,
"articleCount": 1,
"values": [
{
"id": 1,
"attributeId": 1,
"value": "…",
"sort": 1
"sort": 1,
"articleCount": 1
}
]
}

View File

@@ -4,7 +4,7 @@ Protected. Requires a valid session cookie (`credentials: 'include'`).
List values for an attribute
Lists all values for a specific attribute.
Lists all values for a specific attribute. Each value includes `articleCount` (the number of articles having this attribute value).
## Path parameters
@@ -23,7 +23,8 @@ List of attribute values
"id": 1,
"attributeId": 1,
"value": "…",
"sort": 1
"sort": 1,
"articleCount": 1
}
]
}

View File

@@ -36,7 +36,8 @@ Created attribute value
"id": 1,
"attributeId": 1,
"value": "…",
"sort": 1
"sort": 1,
"articleCount": 1
}
}
```

View File

@@ -43,12 +43,14 @@ Updated attribute name
"name": "…",
"sort": 1,
"exclusive": true,
"articleCount": 1,
"values": [
{
"id": 1,
"attributeId": 1,
"value": "…",
"sort": 1
"sort": 1,
"articleCount": 1
}
]
}

View File

@@ -36,7 +36,8 @@ Updated attribute value
"id": 1,
"attributeId": 1,
"value": "…",
"sort": 1
"sort": 1,
"articleCount": 1
}
}
```

View File

@@ -0,0 +1,53 @@
# `DELETE /attributevalue/{id}/image`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Delete attribute value image
Removes the attribute value's image. Sets thumbW, thumbH, thumbHash back to null. Emits SSE signal `attributename`.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Attribute value id |
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `200`
Image removed
```json
{
"attributevalue": {
"id": 1,
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"pics": [
{
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
}
]
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | No company selected or invalid id — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Attribute value or image not found — `{"error":"…"}` |
---
_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._

View File

@@ -0,0 +1,64 @@
# `POST /attributevalue/{id}/image`
Protected. Requires a valid session cookie (`credentials: 'include'`).
Add or replace attribute value image
Uploads an image for the attribute value (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail.
Replaces any existing image for this attribute value. Emits SSE signal `attributename`.
## Path parameters
| Param | Required | Type | Description |
|-------|----------|------|-------------|
| `id` | yes | integer | Attribute value id |
## Body
Raw binary (`image/avif`).
Raw binary (`image/webp`).
Raw binary (`application/octet-stream`).
## Signals
On success, emits SSE signal: `attributename` (see [`GET /events`](../events.md)).
## Response `201`
Image stored and linked
```json
{
"attributevalue": {
"id": 1,
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…",
"pics": [
{
"fullHash": "…",
"thumbW": 1,
"thumbH": 1,
"thumbHash": "…"
}
]
}
}
```
## Errors
| Status | Description |
|--------|-------------|
| 400 | Missing body or invalid image — `{"error":"…"}` |
| 401 | Unauthorized — `{"error":"…"}` |
| 403 | Not authorized — `{"error":"…"}` |
| 404 | Attribute value not found — `{"error":"…"}` |
---
_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'`).
List property names
Lists all property names defined for the selected company, ordered by sort.
Lists all property names defined for the selected company, ordered by sort. Each property name includes `articleCount` (the number of articles in this company that have an articleproperty row for that property).
## Response `200`
@@ -17,7 +17,8 @@ List of property names
"id": 1,
"name": "…",
"sort": 1,
"exclusive": true
"exclusive": true,
"articleCount": 1
}
]
}

View File

@@ -36,7 +36,8 @@ Created property name
"id": 1,
"name": "…",
"sort": 1,
"exclusive": true
"exclusive": true,
"articleCount": 1
}
}
```

View File

@@ -42,7 +42,8 @@ Updated property name
"id": 1,
"name": "…",
"sort": 1,
"exclusive": true
"exclusive": true,
"articleCount": 1
}
}
```

View File

@@ -1526,6 +1526,9 @@
* type: integer
* exclusive:
* type: boolean
* articleCount:
* type: integer
* description: Number of articles in the selected company that have an articleproperty row for this property.
* ArticleProperty:
* type: object
* required: [id, propertyId, name, exclusive, value, sort]
@@ -1556,6 +1559,21 @@
* type: string
* sort:
* type: integer
* articleCount:
* type: integer
* description: Number of articles in the selected company having this attribute value.
* AttributeValuePic:
* type: object
* required: [fullHash, thumbW, thumbH, thumbHash]
* properties:
* fullHash:
* type: string
* thumbW:
* type: integer
* thumbH:
* type: integer
* thumbHash:
* type: string
* Attributename:
* type: object
* required: [id, name, sort, exclusive, values]
@@ -1568,6 +1586,9 @@
* type: integer
* exclusive:
* type: boolean
* articleCount:
* type: integer
* description: Number of parent articles in the selected company configured with this attribute name.
* values:
* type: array
* items:

View File

@@ -34,7 +34,7 @@ export function register(app, basePath = '') {
* get:
* tags: [Article]
* summary: List attribute names
* description: Lists all attribute names defined for the selected company, including nested values.
* description: Lists all attribute names defined for the selected company, including nested values. Each attribute name includes `articleCount` (the number of parent articles configured with this attribute) and each value includes `articleCount` (the number of articles having this attribute value).
* security:
* - cookieAuth: []
* responses:

View File

@@ -1,6 +1,9 @@
import express from 'express';
import * as db from '../../db/attributevalue.js';
import { emit } from '../sse.js';
const AVIF_BODY_LIMIT = 10 * 1024 * 1024;
function requireSelectedCompany(req) {
const companyId = req.user?.selectedCompanyId;
if (companyId == null) {
@@ -11,8 +14,10 @@ function requireSelectedCompany(req) {
function attributeValueErrorStatus(error) {
if (error.message === 'No company selected') return 400;
if (error.message === 'Unsupported image format' || error.message?.includes('unsupported image format')) return 400;
if (error.message === 'Not authorized') return 403;
if (error.message === 'Company not found' || error.message === 'Attribute name not found' || error.message === 'Attribute value not found') return 404;
if (error.message === 'Image not found') return 404;
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;
@@ -33,7 +38,7 @@ export function register(app, basePath = '') {
* get:
* tags: [Article]
* summary: List values for an attribute
* description: Lists all values for a specific attribute.
* description: Lists all values for a specific attribute. Each value includes `articleCount` (the number of articles having this attribute value).
* security:
* - cookieAuth: []
* parameters:
@@ -301,4 +306,215 @@ export function register(app, basePath = '') {
res.status(status).json({ error: message });
}
});
/**
* @openapi
* /attributevalue/{id}/image:
* post:
* tags: [Article]
* summary: Add or replace attribute value image
* description: |
* Uploads an image for the attribute value (AVIF, WebP, or octet-stream). Converted to AVIF full and thumbnail.
* Replaces any existing image for this attribute value. Emits SSE signal `attributename`.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Attribute value id
* requestBody:
* required: true
* content:
* image/avif:
* schema:
* type: string
* format: binary
* image/webp:
* schema:
* type: string
* format: binary
* application/octet-stream:
* schema:
* type: string
* format: binary
* responses:
* 201:
* description: Image stored and linked
* content:
* application/json:
* schema:
* type: object
* properties:
* attributevalue:
* type: object
* properties:
* id:
* type: integer
* fullHash:
* type: string
* thumbW:
* type: integer
* thumbH:
* type: integer
* thumbHash:
* type: string
* pics:
* type: array
* items:
* $ref: '#/components/schemas/AttributeValuePic'
* 400:
* description: Missing body or invalid image
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Attribute value not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* delete:
* tags: [Article]
* summary: Delete attribute value image
* description: Removes the attribute value's image. Sets thumbW, thumbH, thumbHash back to null. Emits SSE signal `attributename`.
* security:
* - cookieAuth: []
* x-signals: [attributename]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* description: Attribute value id
* responses:
* 200:
* description: Image removed
* content:
* application/json:
* schema:
* type: object
* properties:
* attributevalue:
* type: object
* properties:
* id:
* type: integer
* thumbW:
* type: integer
* nullable: true
* thumbH:
* type: integer
* nullable: true
* thumbHash:
* type: string
* nullable: true
* pics:
* type: array
* items:
* $ref: '#/components/schemas/AttributeValuePic'
* 400:
* description: No company selected or invalid id
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 401:
* description: Unauthorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 403:
* description: Not authorized
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
* 404:
* description: Attribute value or image not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/Error'
*/
// #region POST /attributevalue/:id/image
app.post(
`${basePath}/attributevalue/:id/image`,
express.raw({ type: ['image/avif', 'image/webp', 'application/octet-stream'], limit: AVIF_BODY_LIMIT }),
async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
const imageBuffer = Buffer.isBuffer(req.body) ? req.body : null;
if (!imageBuffer?.length) {
return res.status(400).json({ error: 'AVIF or WebP image body is required' });
}
try {
const attributevalue = await db.addImage(req.user.id, id, imageBuffer);
emit('attributename', { companyId: selected.companyId });
res.status(201).json({ attributevalue });
} catch (error) {
const status = attributeValueErrorStatus(error);
if (status === 500) console.error('Add attribute value image failed:', error);
res.status(status).json({ error: error.message });
}
}
);
// #endregion
// #region DELETE /attributevalue/:id/image
app.delete(`${basePath}/attributevalue/:id/image`, async (req, res) => {
const selected = requireSelectedCompany(req);
if (selected.error) {
return res.status(400).json({ error: selected.error });
}
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id is required' });
}
try {
const attributevalue = await db.deleteImage(req.user.id, id);
emit('attributename', { companyId: selected.companyId });
res.status(200).json({ attributevalue });
} catch (error) {
const status = attributeValueErrorStatus(error);
if (status === 500) console.error('Delete attribute value image failed:', error);
res.status(status).json({ error: error.message });
}
});
// #endregion
}

View File

@@ -38,7 +38,7 @@ export function register(app, basePath = '') {
* get:
* tags: [Article]
* summary: List property names
* description: Lists all property names defined for the selected company, ordered by sort.
* description: Lists all property names defined for the selected company, ordered by sort. Each property name includes `articleCount` (the number of articles in this company that have an articleproperty row for that property).
* security:
* - cookieAuth: []
* responses:

View File

@@ -24,3 +24,14 @@ export async function remove(googleAuthId, id) {
const store = await getStore();
return store.attributevalue.delete(googleAuthId, id);
}
export async function addImage(googleAuthId, attributeValueId, image) {
const store = await getStore();
return store.attributevalue.addImage(googleAuthId, attributeValueId, image);
}
export async function deleteImage(googleAuthId, attributeValueId) {
const store = await getStore();
return store.attributevalue.deleteImage(googleAuthId, attributeValueId);
}

View File

@@ -52,6 +52,8 @@ const STORE_NAMESPACES = [
'payment',
'shipping',
'package',
'articlelist',
'optionslist',
];
/**

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
// MARK: - Imports
// #region Imports
import fs from 'fs';
import { computeSkeleHash } from './imageHasher.js';
import { createLogger } from '../../util/logger.js';
// #endregion
const log = createLogger('import');
// MARK: - Article Image Importer
// #region Article Image Importer
/**
* Imports article images and emits their skelehashes.
*
* @param {object} params
* @param {object} params.store - Store singleton
* @param {number} params.googleAuthId
* @param {Array<object>} params.articleImageTasks
* @param {object} params.progressTracker
* @returns {Promise<{ totalImagesImported: number }>}
*/
export async function importArticleImages({
store,
googleAuthId,
articleImageTasks,
progressTracker,
}) {
let totalImagesImported = 0;
for (const task of articleImageTasks) {
try {
const imgBuf = fs.readFileSync(task.fullPath);
await store.article.addImage(googleAuthId, task.sku, imgBuf);
totalImagesImported++;
const skele = await computeSkeleHash(imgBuf);
progressTracker.emitSkeleHash(skele?.base62 || null);
} catch (err) {
log.warn(`Article image add failed for ${task.sku} (${task.relPath}):`, err.message);
}
progressTracker.counts.imagesdone++;
progressTracker.emitProgress();
}
progressTracker.flushSkeleHashes();
progressTracker.emitProgress(true);
log.info(`Article pictures complete: ${totalImagesImported}/${articleImageTasks.length}`);
return {
totalImagesImported,
};
}
// #endregion

View File

@@ -0,0 +1,190 @@
// MARK: - Imports
// #region Imports
import { cleanValue, parseGermanFloat } from './csvParser.js';
import { createLogger } from '../../util/logger.js';
// #endregion
const log = createLogger('import');
// MARK: - Article Importer
// #region Article Importer
/**
* Imports articles (insert or update), setting prices, categories, barcodes, and brands.
*
* @param {object} params
* @param {object} params.store - Store singleton
* @param {number} params.googleAuthId
* @param {Array<Record<string, string>>} params.includedArticles
* @param {Map<string, number>} params.jtlCatIdToDashCatId
* @param {number} params.hauptDashId
* @param {Map<string, number>} params.brandNameToId
* @param {object} params.progressTracker
* @returns {Promise<{ jtlArtIdToSku: Map<string, string>, articlesImported: number, articlesUpdated: number }>}
*/
export async function importArticles({
store,
googleAuthId,
includedArticles,
jtlCatIdToDashCatId,
hauptDashId,
brandNameToId,
progressTracker,
}) {
const jtlArtIdToSku = new Map();
let articlesImported = 0;
let articlesUpdated = 0;
for (const a of includedArticles) {
const jtlArtId = String(a.kArtikel);
const sku = cleanValue(a.cArtNr);
if (!sku) {
progressTracker.counts.articlesdone++;
continue;
}
jtlArtIdToSku.set(jtlArtId, sku);
let name = cleanValue(a.cName);
let desc = cleanValue(a.descriptions);
if (!name && desc) {
try {
const parsed = JSON.parse(desc);
const item = Array.isArray(parsed) ? parsed[0] : parsed;
if (item?.cName) name = String(item.cName).trim();
if (item?.cBeschreibung) desc = String(item.cBeschreibung).trim();
} catch {}
}
if (!name) name = `Artikel ${sku}`;
// Category mapping
const assignedDashCatIds = [];
const rawCatStr = String(a.categories || '').trim();
if (rawCatStr) {
let parsedIds = [];
if (rawCatStr.startsWith('[') || rawCatStr.startsWith('{')) {
try {
const raw = JSON.parse(rawCatStr);
parsedIds = (Array.isArray(raw) ? raw : [raw]).map((it) => String(it.kKategorie || it));
} catch {
parsedIds = rawCatStr.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean);
}
} else {
parsedIds = rawCatStr.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean);
}
for (const jtlCId of parsedIds) {
if (jtlCatIdToDashCatId.has(jtlCId)) {
assignedDashCatIds.push(jtlCatIdToDashCatId.get(jtlCId));
}
}
}
const primaryCatId = assignedDashCatIds[0] || hauptDashId;
const trackStock = a.cLagerAktiv === 'Y' ? 1 : 0;
const netPrice = parseGermanFloat(a.fVKNetto, 0);
const taxRate = a.kSteuerklasse === '3' ? 7 : 19;
const barcode = cleanValue(a.cBarcode);
const hersteller = cleanValue(a.Hersteller);
const brandId = hersteller ? (brandNameToId.get(hersteller.toLowerCase()) || null) : null;
let existingArticle = null;
try {
const res = await store.article.getBySku(googleAuthId, sku);
existingArticle = res.article;
} catch {
existingArticle = null;
}
try {
if (existingArticle) {
await store.article.updateBySku(googleAuthId, sku, {
name,
track_stock: trackStock,
desc: desc || undefined,
});
await store.article.setPricesBySku(googleAuthId, sku, [
{ net: netPrice, tax: taxRate },
]);
if (assignedDashCatIds.length > 0) {
await store.article.setCategoryIdsBySku(googleAuthId, sku, assignedDashCatIds);
}
if (barcode) {
try {
const currentEans = existingArticle.eans || [];
if (!currentEans.includes(barcode)) {
await store.article.setEansBySku(googleAuthId, sku, [...currentEans, barcode]);
}
} catch (eanErr) {
log.warn(`Could not set EAN for ${sku}:`, eanErr.message);
}
}
if (brandId && existingArticle.brand?.id !== brandId) {
try {
await store.article.setBrandIdBySku(googleAuthId, sku, brandId);
} catch (bErr) {
log.warn(`Could not set brand for ${sku}:`, bErr.message);
}
}
articlesUpdated++;
} else {
await store.article.createUnderParent({
googleAuthId,
parentId: primaryCatId,
name,
sku,
track_stock: trackStock,
desc: desc || undefined,
});
await store.article.setPricesBySku(googleAuthId, sku, [
{ net: netPrice, tax: taxRate },
]);
if (assignedDashCatIds.length > 1) {
await store.article.setCategoryIdsBySku(googleAuthId, sku, assignedDashCatIds);
}
if (barcode) {
try {
await store.article.setEansBySku(googleAuthId, sku, [barcode]);
} catch (eanErr) {
log.warn(`Could not set EAN for ${sku}:`, eanErr.message);
}
}
if (brandId) {
try {
await store.article.setBrandIdBySku(googleAuthId, sku, brandId);
} catch (bErr) {
log.warn(`Could not set brand for ${sku}:`, bErr.message);
}
}
articlesImported++;
}
} catch (err) {
log.warn(`Failed to save article ${sku} ("${name}"):`, err.message);
}
progressTracker.counts.articlesdone++;
progressTracker.emitProgress();
}
progressTracker.emitProgress(true);
log.info(`Articles complete: New: ${articlesImported}, Updated: ${articlesUpdated}`);
return {
jtlArtIdToSku,
articlesImported,
articlesUpdated,
};
}
// #endregion

View File

@@ -0,0 +1,91 @@
// MARK: - Imports
// #region Imports
import fs from 'fs';
import { computeSkeleHash } from './imageHasher.js';
import { createLogger } from '../../util/logger.js';
// #endregion
const log = createLogger('import');
// MARK: - Brand Importer
// #region Brand Importer
/**
* Imports brand manufacturers and attaches brand logos.
*
* @param {object} params
* @param {object} params.store - Store singleton
* @param {number} params.googleAuthId
* @param {Map<string, Array<{ relPath: string, fullPath: string }>>} params.brandMap
* @param {Array<object>} params.brandImageTasks
* @param {object} params.progressTracker
* @returns {Promise<{ brandNameToId: Map<string, number>, createdBrandCount: number, brandPicCount: number }>}
*/
export async function importBrands({
store,
googleAuthId,
brandMap,
brandImageTasks,
progressTracker,
}) {
const brandNameToId = new Map();
let createdBrandCount = 0;
let brandPicCount = 0;
const { brands: existingBrandRows } = await store.brand.list(googleAuthId, { offset: 0, limit: 100 });
const existingBrandNames = new Set(
existingBrandRows.map((b) => (b.name || '').trim().toLowerCase()).filter(Boolean)
);
const brandTaskMap = new Map();
for (const task of brandImageTasks) {
brandTaskMap.set(task.brandName.toLowerCase(), task);
}
for (const [brandName] of brandMap.entries()) {
const lowerName = brandName.toLowerCase();
const isNew = !existingBrandNames.has(lowerName);
try {
const brand = await store.brand.upsert(googleAuthId, { name: brandName });
brandNameToId.set(lowerName, brand.id);
if (isNew) {
createdBrandCount++;
existingBrandNames.add(lowerName);
}
const imgTask = brandTaskMap.get(lowerName);
if (imgTask) {
if (!brand.pics || brand.pics.length === 0) {
try {
const imgBuf = fs.readFileSync(imgTask.fullPath);
await store.brand.addImage(googleAuthId, brand.id, imgBuf);
brandPicCount++;
const skele = await computeSkeleHash(imgBuf);
progressTracker.emitSkeleHash(skele?.base62 || null);
} catch (err) {
log.warn(`Brand image error for "${brandName}":`, err.message);
}
}
progressTracker.counts.imagesdone++;
}
} catch (err) {
log.warn(`Failed to upsert brand "${brandName}":`, err.message);
}
progressTracker.emitProgress();
}
progressTracker.flushSkeleHashes();
progressTracker.emitProgress(true);
log.info(`Brands resolved: ${brandNameToId.size}, created: ${createdBrandCount}, images: ${brandPicCount}`);
return {
brandNameToId,
createdBrandCount,
brandPicCount,
};
}
// #endregion

View File

@@ -0,0 +1,170 @@
// MARK: - Imports
// #region Imports
import fs from 'fs';
import path from 'path';
import { cleanValue, parseCsvRecords } from './csvParser.js';
// #endregion
// MARK: - Category & Article Filtering
// #region Category & Article Filtering
/**
* Filters out trash/papierkorb categories and their descendants,
* as well as articles that exclusively belong to excluded categories.
*
* @param {Array<Record<string, string>>} catRecords
* @param {Array<Record<string, string>>} artRecords
* @returns {{ includedCategories: Array<Record<string, string>>, includedArticles: Array<Record<string, string>>, excludedJtlIds: Set<string> }}
*/
export function filterCatalogRecords(catRecords, artRecords) {
const excludedJtlIds = new Set(['72']);
for (const c of catRecords) {
let name = (c.cName || '').trim();
if (!name) {
try {
const tr = JSON.parse(c.translations || '[]');
name = tr[0]?.cName || '';
} catch {}
}
if (name.toLowerCase().includes('papierkorb') || name.toLowerCase().includes('trash')) {
excludedJtlIds.add(String(c.kKategorie));
}
}
let addedExcluded = true;
while (addedExcluded) {
addedExcluded = false;
for (const c of catRecords) {
const id = String(c.kKategorie);
const parent = String(c.kOberKategorie);
if (!excludedJtlIds.has(id) && excludedJtlIds.has(parent)) {
excludedJtlIds.add(id);
addedExcluded = true;
}
}
}
const includedCategories = catRecords.filter((c) => !excludedJtlIds.has(String(c.kKategorie)));
const includedArticles = artRecords.filter((a) => {
const catStr = String(a.categories || '').trim();
if (!catStr) return true;
let catIds = [];
if (catStr.startsWith('[') || catStr.startsWith('{')) {
try {
const raw = JSON.parse(catStr);
catIds = (Array.isArray(raw) ? raw : [raw]).map((it) => String(it.kKategorie || it));
} catch {
catIds = catStr.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean);
}
} else {
catIds = catStr.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean);
}
if (catIds.length > 0 && catIds.every((cid) => excludedJtlIds.has(cid))) {
return false;
}
return true;
});
return { includedCategories, includedArticles, excludedJtlIds };
}
// #endregion
// MARK: - Task Collection & Manifests
// #region Task Collection & Manifests
/**
* Scans directories for existing category, brand, and article images,
* and loads receipts / manifest records if available.
*
* @param {object} params
* @param {string} params.importDir
* @param {Array<Record<string, string>>} params.includedCategories
* @param {Array<Record<string, string>>} params.includedArticles
* @returns {object} Discovered image tasks, brand map, and manifest records
*/
export function prepareImportTasks({ importDir, includedCategories, includedArticles }) {
// Category images
const categoryImageTasks = [];
for (const c of includedCategories) {
const picRel = (c.pictureFiles || '').trim();
if (picRel) {
for (const rel of picRel.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean)) {
const full = path.resolve(importDir, rel);
if (fs.existsSync(full)) {
categoryImageTasks.push({ catJtlId: String(c.kKategorie), relPath: rel, fullPath: full });
}
}
}
}
// Brand images (unique manufacturers, 1 primary logo)
const brandMap = new Map(); // brandName -> array of { relPath, fullPath }
for (const a of includedArticles) {
const brand = cleanValue(a.Hersteller);
if (!brand) continue;
if (!brandMap.has(brand)) {
brandMap.set(brand, []);
const rawPics = String(a.manufacturerPictureFiles || '').trim();
if (rawPics) {
for (const rel of rawPics.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean)) {
const full = path.resolve(importDir, rel);
if (fs.existsSync(full)) {
brandMap.get(brand).push({ relPath: rel, fullPath: full });
}
}
}
}
}
const brandImageTasks = [];
for (const [brandName, pics] of brandMap.entries()) {
if (pics.length > 0) {
brandImageTasks.push({ brandName, ...pics[0] });
}
}
// Article images
const articleImageTasks = [];
for (const a of includedArticles) {
const sku = cleanValue(a.cArtNr);
if (!sku) continue;
const rawPics = String(a.pictureFiles || '').trim();
if (rawPics) {
for (const rel of rawPics.split(/[\r\n,;]+/).map((s) => s.trim()).filter(Boolean)) {
const full = path.resolve(importDir, rel);
if (fs.existsSync(full)) {
articleImageTasks.push({ sku, relPath: rel, fullPath: full });
}
}
}
}
const totalImagesCount = categoryImageTasks.length + brandImageTasks.length + articleImageTasks.length;
// Receipts / Manifests
const manCsvPath = path.join(importDir, 'manifests.csv');
const itemsCsvPath = path.join(importDir, 'manifestitems.csv');
let manRecords = [];
let itemRecords = [];
if (fs.existsSync(manCsvPath) && fs.existsSync(itemsCsvPath)) {
manRecords = parseCsvRecords(fs.readFileSync(manCsvPath, 'utf8'));
itemRecords = parseCsvRecords(fs.readFileSync(itemsCsvPath, 'utf8'));
}
const totalReceiptsCount = manRecords.length;
return {
categoryImageTasks,
brandMap,
brandImageTasks,
articleImageTasks,
totalImagesCount,
manRecords,
itemRecords,
totalReceiptsCount,
};
}
// #endregion

View File

@@ -0,0 +1,148 @@
// MARK: - Imports
// #region Imports
import fs from 'fs';
import { cleanValue } from './csvParser.js';
import { computeSkeleHash } from './imageHasher.js';
import { createLogger } from '../../util/logger.js';
// #endregion
const log = createLogger('import');
// MARK: - Category Importer
// #region Category Importer
/**
* Imports category tree and attaches category images.
*
* @param {object} params
* @param {object} params.store - Store singleton
* @param {number} params.googleAuthId
* @param {Array<Record<string, string>>} params.includedCategories
* @param {Array<object>} params.categoryImageTasks
* @param {object} params.progressTracker
* @returns {Promise<{ jtlCatIdToDashCatId: Map<string, number>, hauptDashId: number, createdCatCount: number, catPicCount: number }>}
*/
export async function importCategories({
store,
googleAuthId,
includedCategories,
categoryImageTasks,
progressTracker,
}) {
const treeResult = await store.category.getTreeForCompany(googleAuthId);
const existingTree = treeResult.tree || [];
const rootCategory = existingTree.find((c) => c.parentId == null) || existingTree[0];
if (!rootCategory) {
throw new Error('Company root category not found');
}
const rootCategoryId = rootCategory.id;
const existingMap = new Map();
for (const c of existingTree) {
const key = `${c.parentId ?? 'root'}:${String(c.name || '').trim().toLowerCase()}`;
existingMap.set(key, c.id);
}
const jtlCatIdToDashCatId = new Map();
jtlCatIdToDashCatId.set('0', rootCategoryId);
// Group category images by JTL category ID
const catImagesByJtlId = new Map();
for (const task of categoryImageTasks) {
if (!catImagesByJtlId.has(task.catJtlId)) {
catImagesByJtlId.set(task.catJtlId, []);
}
catImagesByJtlId.get(task.catJtlId).push(task);
}
const pendingCats = [...includedCategories];
let createdCatCount = 0;
let catPicCount = 0;
let safety = 0;
while (pendingCats.length > 0 && safety < 10) {
safety++;
const remaining = [];
for (const c of pendingCats) {
const jtlId = String(c.kKategorie);
const jtlParent = String(c.kOberKategorie || '0');
let name = cleanValue(c.cName);
if (!name) {
try {
const tr = JSON.parse(c.translations || '[]');
name = tr[0]?.cName || '';
} catch {}
}
if (!name) name = `Kategorie ${jtlId}`;
let dashParentId;
if (jtlParent === '0' || !jtlParent) {
dashParentId = rootCategoryId;
} else if (jtlCatIdToDashCatId.has(jtlParent)) {
dashParentId = jtlCatIdToDashCatId.get(jtlParent);
} else {
remaining.push(c);
continue;
}
const key = `${dashParentId}:${name.toLowerCase()}`;
let dashCatId = existingMap.get(key);
if (!dashCatId) {
try {
const created = await store.category.createUnderParent({
googleAuthId,
parentId: dashParentId,
name,
});
dashCatId = created.id;
existingMap.set(key, dashCatId);
createdCatCount++;
} catch (err) {
log.warn(`Failed to create category "${name}":`, err.message);
}
}
if (dashCatId) {
jtlCatIdToDashCatId.set(jtlId, dashCatId);
// Add category images
const imgs = catImagesByJtlId.get(jtlId) || [];
for (const imgTask of imgs) {
try {
const imgBuf = fs.readFileSync(imgTask.fullPath);
await store.category.addImage(googleAuthId, dashCatId, imgBuf);
catPicCount++;
const skele = await computeSkeleHash(imgBuf);
progressTracker.emitSkeleHash(skele?.base62 || null);
} catch (err) {
log.warn(`Category image error (${imgTask.relPath}):`, err.message);
}
progressTracker.counts.imagesdone++;
}
}
progressTracker.counts.categoriesdone++;
progressTracker.emitProgress();
}
pendingCats.length = 0;
pendingCats.push(...remaining);
}
progressTracker.flushSkeleHashes();
progressTracker.emitProgress(true);
log.info(`Categories resolved: ${jtlCatIdToDashCatId.size}/${includedCategories.length}, created: ${createdCatCount}, images: ${catPicCount}`);
const hauptDashId = jtlCatIdToDashCatId.get('1') || rootCategoryId;
return {
jtlCatIdToDashCatId,
hauptDashId,
createdCatCount,
catPicCount,
};
}
// #endregion

View File

@@ -0,0 +1,128 @@
// MARK: - CSV Parsing & Value Sanitization
// #region CSV Parsing & Value Sanitization
/**
* Cleans formula prefixes (="..."), quotes, and whitespace from CSV values.
*
* @param {string|any} str
* @returns {string}
*/
export function cleanValue(str) {
if (str == null) return '';
let val = String(str).trim();
if (val.startsWith('=')) {
val = val.slice(1).trim();
}
if (val.startsWith('"') && val.endsWith('"')) {
val = val.slice(1, -1).trim();
}
return val;
}
/**
* Parses German decimal format numbers (e.g. "8,36" -> 8.36).
*
* @param {string|number} str
* @param {number} [fallback=0]
* @returns {number}
*/
export function parseGermanFloat(str, fallback = 0) {
const clean = cleanValue(str).replace(',', '.');
const num = parseFloat(clean);
return Number.isNaN(num) ? fallback : num;
}
/**
* Parses CSV text into an array of objects keyed by header.
* Supports auto-detecting semicolon (;) vs comma (,) delimiters,
* quoted fields with embedded newlines/quotes, and formula prefixes.
*
* @param {string} text - Raw CSV text
* @returns {Array<Record<string, string>>}
*/
export function parseCsvRecords(text) {
const clean = text.replace(/^\uFEFF/, '');
const firstLine = clean.split(/[\r\n]/)[0] || '';
const semiCount = (firstLine.match(/;/g) || []).length;
const commaCount = (firstLine.match(/,/g) || []).length;
const delimiter = semiCount >= commaCount ? ';' : ',';
const rows = [];
let field = '';
let row = [];
let inQuotes = false;
let i = 0;
const len = clean.length;
while (i < len) {
const c = clean[i];
if (inQuotes) {
if (c === '"') {
if (i + 1 < len && clean[i + 1] === '"') {
field += '"';
i += 2;
continue;
} else {
inQuotes = false;
i++;
continue;
}
} else {
field += c;
i++;
continue;
}
} else {
if (c === '"') {
inQuotes = true;
i++;
continue;
} else if (c === delimiter) {
row.push(cleanValue(field));
field = '';
i++;
continue;
} else if (c === '\r') {
if (i + 1 < len && clean[i + 1] === '\n') {
i++;
}
row.push(cleanValue(field));
rows.push(row);
field = '';
row = [];
i++;
continue;
} else if (c === '\n') {
row.push(cleanValue(field));
rows.push(row);
field = '';
row = [];
i++;
continue;
} else {
field += c;
i++;
continue;
}
}
}
if (field !== '' || row.length > 0) {
row.push(cleanValue(field));
rows.push(row);
}
if (rows.length === 0) return [];
const header = rows[0].map((h) => cleanValue(h));
const records = [];
for (let r = 1; r < rows.length; r++) {
const curr = rows[r];
if (curr.length === 1 && curr[0] === '') continue;
const obj = {};
for (let col = 0; col < header.length; col++) {
obj[header[col]] = curr[col] ?? '';
}
records.push(obj);
}
return records;
}
// #endregion

View File

@@ -0,0 +1,41 @@
// MARK: - Imports
// #region Imports
import sharp from 'sharp';
import { encodeRGBA } from 'skelehash-wasm';
import { createLogger } from '../../util/logger.js';
// #endregion
const log = createLogger('import');
// MARK: - SkeleHash Computation
// #region SkeleHash Computation
/**
* Computes SkeleHash base62 hash for an image buffer.
*
* @param {Buffer} buffer
* @returns {Promise<{base62: string, width: number, height: number, hashLength: number}|null>}
*/
export async function computeSkeleHash(buffer) {
try {
const { data, info } = await sharp(buffer)
.rotate()
.resize({ width: 200, height: 200, fit: 'inside' })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
const result = await encodeRGBA(info.width, info.height, data);
return {
base62: result.base62,
width: info.width,
height: info.height,
hashLength: result.hash?.length ?? 0,
};
} catch (err) {
log.warn('Failed to compute skelehash for image:', err.message);
return null;
}
}
// #endregion

View File

@@ -0,0 +1,165 @@
// MARK: - Imports
// #region Imports
import { cleanValue, parseGermanFloat } from './csvParser.js';
import { createLogger } from '../../util/logger.js';
// #endregion
const log = createLogger('import');
// MARK: - Order / Manifest Importer
// #region Order / Manifest Importer
/**
* Imports order manifests and line items, resolving customer entities.
*
* @param {object} params
* @param {object} params.store - Store singleton
* @param {number} params.googleAuthId
* @param {Array<Record<string, string>>} params.manRecords
* @param {Array<Record<string, string>>} params.itemRecords
* @param {object} params.progressTracker
* @returns {Promise<{ ordersImported: number, ordersSkipped: number }>}
*/
export async function importOrders({
store,
googleAuthId,
manRecords,
itemRecords,
progressTracker,
}) {
let ordersImported = 0;
let ordersSkipped = 0;
if (manRecords.length === 0 || itemRecords.length === 0) {
return { ordersImported, ordersSkipped };
}
const itemsByAuftrag = new Map();
for (const it of itemRecords) {
const kAuftrag = String(it.kAuftrag || '').trim();
if (!kAuftrag) continue;
if (!itemsByAuftrag.has(kAuftrag)) {
itemsByAuftrag.set(kAuftrag, []);
}
itemsByAuftrag.get(kAuftrag).push(it);
}
const existingOrdersResult = await store.order.list(googleAuthId);
const existingOrderList = Array.isArray(existingOrdersResult)
? existingOrdersResult
: (existingOrdersResult?.orders || []);
const existingOrderNumbers = new Set(
existingOrderList.map((o) => (o.number || '').trim()).filter(Boolean)
);
let defaultCustomerGroupId = null;
try {
const groups = await store.customergroup.list(googleAuthId);
defaultCustomerGroupId = groups[0]?.id || null;
} catch {}
const entityMap = new Map();
try {
const entities = await store.entity.list(googleAuthId);
for (const ent of entities) {
if (ent.name) {
entityMap.set(ent.name.trim().toLowerCase(), ent.id);
}
}
} catch {}
const resolveEntityId = async (name, firstName) => {
const cleanName = (name || '').trim();
if (!cleanName || !defaultCustomerGroupId) return null;
const key = cleanName.toLowerCase();
if (entityMap.has(key)) return entityMap.get(key);
try {
const ent = await store.entity.create(googleAuthId, {
customergroupId: defaultCustomerGroupId,
name: cleanName,
firstName: (firstName || '').trim() || undefined,
});
entityMap.set(key, ent.id);
return ent.id;
} catch (err) {
log.warn(`Could not create entity "${cleanName}":`, err.message);
return null;
}
};
for (const m of manRecords) {
const orderNumber = cleanValue(m.cAuftragsNr);
if (!orderNumber || existingOrderNumbers.has(orderNumber)) {
ordersSkipped++;
progressTracker.counts.receiptsdone++;
progressTracker.emitProgress();
continue;
}
const rawItems = itemsByAuftrag.get(String(m.kAuftrag || '').trim()) || [];
if (rawItems.length === 0) {
ordersSkipped++;
progressTracker.counts.receiptsdone++;
progressTracker.emitProgress();
continue;
}
const lines = rawItems.map((it) => {
const sku = cleanValue(it.cArtNr) || null;
const rawName = cleanValue(it.cName || it.cNameStandard);
const name = rawName || (sku ? `Artikel ${sku}` : 'Artikel');
const amount = parseGermanFloat(it.fAnzahl, 1);
const net = parseGermanFloat(it.fVKNetto, 0);
const taxrate = parseGermanFloat(it.fMwSt, 19);
const rebate = parseGermanFloat(it.fRabatt, 0);
const comment = cleanValue(it.cHinweis) || null;
return {
sku,
name,
amount,
net,
taxrate,
rebate,
comment,
};
});
const custName = cleanValue(m.cName) || 'Laufkunde';
const custFirstName = cleanValue(m.cVorname);
const entityId = await resolveEntityId(custName, custFirstName);
const dateStr = cleanValue(m.dErstellt);
const parsedDate = dateStr ? new Date(dateStr) : new Date();
const validDate = !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : new Date().toISOString();
try {
await store.order.create(googleAuthId, {
number: orderNumber,
externalNumber: cleanValue(m.cExterneAuftragsnummer) || null,
date: validDate,
createdAt: validDate,
entityId: entityId || undefined,
lines,
});
existingOrderNumbers.add(orderNumber);
ordersImported++;
} catch (err) {
log.warn(`Failed to create order ${orderNumber}:`, err.message);
}
progressTracker.counts.receiptsdone++;
progressTracker.emitProgress();
}
progressTracker.emitProgress(true);
log.info(`Orders complete: ${ordersImported} created, ${ordersSkipped} skipped`);
return {
ordersImported,
ordersSkipped,
};
}
// #endregion

View File

@@ -0,0 +1,241 @@
// MARK: - Imports
// #region Imports
import { cleanValue } from './csvParser.js';
import { createLogger } from '../../util/logger.js';
// #endregion
const log = createLogger('import');
const PFAND_REGEX = /pfand\s*:\s*([0-9]+(?:[.,][0-9]+)?)/i;
// MARK: - Helpers
// #region Helpers
/**
* Parses the Pfand deposit value from an attributes string.
* E.g. "pfand:0.08" -> 0.08, "pfand:0,25" -> 0.25.
*
* @param {string} attrStr
* @returns {number|null}
*/
export function extractPfandValue(attrStr) {
if (!attrStr) return null;
const match = String(attrStr).match(PFAND_REGEX);
if (!match) return null;
const num = parseFloat(match[1].replace(',', '.'));
return Number.isFinite(num) && num > 0 ? num : null;
}
/**
* Formats standard Pfand SKU: e.g. PFAND-0.08, PFAND-0.15, PFAND-0.25.
*
* @param {number} val
* @returns {string}
*/
export function formatPfandSku(val) {
return `PFAND-${Number(val).toFixed(2)}`;
}
/**
* Formats standard Pfand article name: e.g. "Pfand 0,08 €", "Pfand 0,15 €", "Pfand 0,25 €".
*
* @param {number} val
* @returns {string}
*/
export function formatPfandName(val) {
return `Pfand ${Number(val).toFixed(2).replace('.', ',')} €`;
}
// #endregion
// MARK: - Pfand Importer
// #region Pfand Importer
/**
* Imports Pfand deposit articles and links them as mandatory options to beverages/articles.
*
* @param {object} params
* @param {object} params.store - Store singleton
* @param {number} params.googleAuthId
* @param {Array<Record<string, string>>} params.includedArticles
* @param {number} params.hauptDashId
* @param {Set<string>} [params.parentSkus] - Set of SKUs that are parent articles (which cannot have options)
* @returns {Promise<{ pfandArticlesCreated: number, pfandArticlesUpdated: number, pfandOptionsLinked: number }>}
*/
export async function importPfand({
store,
googleAuthId,
includedArticles,
hauptDashId,
parentSkus = new Set(),
}) {
// 1. Identify parent articles and map their Pfand values so child variants can inherit
const parentPfandMap = new Map();
for (const a of includedArticles) {
const sku = cleanValue(a.cArtNr);
const isParent = a.nIstVater === '1' || (sku && parentSkus.has(sku));
if (isParent) {
const val = extractPfandValue(a.attributes);
if (val != null) {
parentPfandMap.set(String(a.kArtikel), val);
}
}
}
// 2. Discover target articles (purchasable standalone or variant items) and their Pfand values
const articleTargets = [];
for (const a of includedArticles) {
const sku = cleanValue(a.cArtNr);
if (!sku) continue;
const isParent = a.nIstVater === '1' || parentSkus.has(sku);
if (isParent) {
// Parent articles cannot have options in Dash
continue;
}
let pfandVal = extractPfandValue(a.attributes);
if (pfandVal == null && a.kVaterArtikel && a.kVaterArtikel !== '0') {
if (parentPfandMap.has(String(a.kVaterArtikel))) {
pfandVal = parentPfandMap.get(String(a.kVaterArtikel));
}
}
if (pfandVal != null) {
articleTargets.push({ sku, pfandVal });
}
}
if (articleTargets.length === 0) {
log.info('No pfand attributes detected among articles.');
return {
pfandArticlesCreated: 0,
pfandArticlesUpdated: 0,
pfandOptionsLinked: 0,
};
}
// 3. Find unique Pfand values
const uniqueValues = Array.from(new Set(articleTargets.map((t) => t.pfandVal))).sort((a, b) => a - b);
log.info(`Discovered ${articleTargets.length} articles with Pfand across ${uniqueValues.length} unique values: ${uniqueValues.join(', ')}`);
// 4. Resolve or create "Pfand" subcategory under "Haupt"
let pfandCatId = null;
try {
const treeResult = await store.category.getTreeForCompany(googleAuthId);
const existingTree = treeResult.tree || [];
const existingPfandCat = existingTree.find(
(c) => c.parentId === hauptDashId && String(c.name || '').trim().toLowerCase() === 'pfand'
);
if (existingPfandCat) {
pfandCatId = existingPfandCat.id;
} else {
const created = await store.category.createUnderParent({
googleAuthId,
parentId: hauptDashId,
name: 'Pfand',
});
pfandCatId = created.id;
log.info(`Created "Pfand" category under Haupt (id: ${pfandCatId})`);
}
} catch (err) {
log.warn('Could not resolve or create Pfand category under Haupt, falling back to Haupt:', err.message);
pfandCatId = hauptDashId;
}
// 5. Create or update Pfand articles
let pfandArticlesCreated = 0;
let pfandArticlesUpdated = 0;
for (const val of uniqueValues) {
const pfandSku = formatPfandSku(val);
const pfandName = formatPfandName(val);
let existingArticle = null;
try {
const res = await store.article.getBySku(googleAuthId, pfandSku);
existingArticle = res.article;
} catch {
existingArticle = null;
}
try {
if (existingArticle) {
await store.article.updateBySku(googleAuthId, pfandSku, {
name: pfandName,
track_stock: 0,
});
await store.article.setPricesBySku(googleAuthId, pfandSku, [
{ gross: val, tax: 19 },
]);
pfandArticlesUpdated++;
log.info(`Updated existing Pfand article: ${pfandSku} ("${pfandName}")`);
} else {
await store.article.createUnderParent({
googleAuthId,
parentId: pfandCatId,
name: pfandName,
sku: pfandSku,
track_stock: 0,
});
await store.article.setPricesBySku(googleAuthId, pfandSku, [
{ gross: val, tax: 19 },
]);
pfandArticlesCreated++;
log.info(`Created new Pfand article: ${pfandSku} ("${pfandName}")`);
}
} catch (err) {
log.warn(`Failed to create or update Pfand article ${pfandSku}:`, err.message);
}
}
// 6. Link each target article to its Pfand article as a mandatory option
let pfandOptionsLinked = 0;
for (const { sku, pfandVal } of articleTargets) {
const pfandSku = formatPfandSku(pfandVal);
let existingOptions = [];
try {
existingOptions = (await store.article.getOptionslist(googleAuthId, sku)) || [];
} catch {
existingOptions = [];
}
// Filter out any existing PFAND options to prevent duplicates, keep any other options
const otherOptions = existingOptions.filter((o) => !o.sku?.startsWith('PFAND-'));
const newOptions = [
...otherOptions.map((o, idx) => ({
sku: o.sku,
articleId: o.articleId,
count: o.count || 1,
sort: idx,
mandated: Boolean(o.mandated),
})),
{
sku: pfandSku,
count: 1,
sort: otherOptions.length,
mandated: true,
},
];
try {
await store.article.setOptionslist(googleAuthId, sku, newOptions);
pfandOptionsLinked++;
} catch (err) {
log.warn(`Failed to link Pfand option ${pfandSku} to article ${sku}:`, err.message);
}
}
log.info(`Pfand import complete: ${pfandArticlesCreated} created, ${pfandArticlesUpdated} updated, ${pfandOptionsLinked} linked as mandatory options.`);
return {
pfandArticlesCreated,
pfandArticlesUpdated,
pfandOptionsLinked,
};
}
// #endregion

View File

@@ -0,0 +1,74 @@
// MARK: - Progress & Image Stream Tracking
// #region Progress & Image Stream Tracking
/**
* Creates an event tracking controller for throttled progress updates and batched skelehashes.
*
* @param {object} params
* @param {object} params.initialCounts - Initial totals and completed counts
* @param {function} [params.onProgress] - Throttled callback for progress counts
* @param {function} [params.onImages] - Batched callback for emitted skelehash base62 strings
* @param {number} [params.progressThrottleMs=200]
* @param {number} [params.imageBatchMs=50]
* @returns {object} Tracker with mutateable counts and emission methods
*/
export function createProgressTracker({
initialCounts,
onProgress = () => {},
onImages = () => {},
progressThrottleMs = 200,
imageBatchMs = 50,
}) {
const counts = {
categoriestotal: 0,
categoriesdone: 0,
articlestotal: 0,
articlesdone: 0,
imagestotal: 0,
imagesdone: 0,
receiptstotal: 0,
receiptsdone: 0,
...initialCounts,
};
let lastProgressEmit = 0;
const emitProgress = (force = false) => {
const now = Date.now();
if (force || now - lastProgressEmit >= progressThrottleMs) {
lastProgressEmit = now;
onProgress({ ...counts });
}
};
let skeleBuffer = [];
let lastSkeleEmit = Date.now();
const emitSkeleHash = (base62, force = false) => {
if (base62) skeleBuffer.push(base62);
const now = Date.now();
if (skeleBuffer.length > 0 && (force || now - lastSkeleEmit >= imageBatchMs)) {
const batch = skeleBuffer;
skeleBuffer = [];
lastSkeleEmit = now;
onImages(batch);
}
};
const flushSkeleHashes = () => {
if (skeleBuffer.length > 0) {
const batch = skeleBuffer;
skeleBuffer = [];
lastSkeleEmit = Date.now();
onImages(batch);
}
};
return {
counts,
emitProgress,
emitSkeleHash,
flushSkeleHashes,
};
}
// #endregion

View File

@@ -0,0 +1,53 @@
// MARK: - Variant Attribute & Value Helpers
// #region Variant Attribute & Value Helpers
/**
* Normalizes common variant attribute names and typos.
*
* @param {string} name
* @returns {string}
*/
export function normalizeAttributeName(name) {
const clean = (name || '').trim();
if (!clean) return 'Variante';
const lower = clean.toLowerCase();
if (lower === 'menge' || lower === 'mange' || lower === 'meneg') return 'Menge';
if (lower === 'farbe') return 'Farbe';
if (lower === 'größe' || lower === 'grosse') return 'Größe';
if (lower === 'sorte') return 'Sorte';
if (lower === 'geschmack') return 'Geschmack';
if (lower === 'typ') return 'Typ';
if (lower === 'stück' || lower === 'stueck') return 'Stück';
if (lower === 'ausführung' || lower === 'ausfuehrung') return 'Ausführung';
if (lower === 'motiv') return 'Motiv';
return clean.charAt(0).toUpperCase() + clean.slice(1);
}
/**
* Fallback variant value extraction if child has no explicit attribute value.
*
* @param {string} parentName
* @param {string} childName
* @param {string} childSku
* @param {string} parentSku
* @returns {string}
*/
export function extractVariantValue(parentName, childName, childSku, parentSku) {
let pName = (parentName || '').replace(/[©®™]/g, '').trim();
let cName = (childName || '').replace(/[©®™]/g, '').trim();
if (pName && cName) {
if (cName.toLowerCase().startsWith(pName.toLowerCase())) {
let val = cName.slice(pName.length).trim().replace(/^[-–—:/,\s]+/, '').trim();
if (val) return val;
}
}
if (parentSku && childSku && childSku.startsWith(parentSku)) {
let suffix = childSku.slice(parentSku.length).trim().replace(/^[-_]+/, '').trim();
if (suffix) return suffix;
}
if (cName && cName !== pName) return cName;
return childSku || 'Standard';
}
// #endregion

View File

@@ -0,0 +1,130 @@
// MARK: - Imports
// #region Imports
import { cleanValue } from './csvParser.js';
import { normalizeAttributeName } from './variantHelpers.js';
import { createLogger } from '../../util/logger.js';
// #endregion
const log = createLogger('import');
// MARK: - Variant Importer
// #region Variant Importer
/**
* Resolves attribute definitions and links variant child articles to parents.
*
* @param {object} params
* @param {object} params.store - Store singleton
* @param {number} params.googleAuthId
* @param {Array<Record<string, string>>} params.includedArticles
* @param {Map<string, string>} params.jtlArtIdToSku
* @returns {Promise<{ variantsLinked: number, parentsCount: number }>}
*/
export async function importVariants({
store,
googleAuthId,
includedArticles,
jtlArtIdToSku,
}) {
const attributeMap = new Map();
try {
const attrList = await store.attributename.list(googleAuthId);
for (const attr of attrList) {
if (attr.name) {
attributeMap.set(attr.name.trim().toLowerCase(), attr.id);
}
}
} catch (err) {
log.warn('Could not list attributes:', err.message);
}
const resolveAttributeId = async (rawName) => {
const normalized = normalizeAttributeName(rawName);
const key = normalized.toLowerCase();
if (attributeMap.has(key)) {
return attributeMap.get(key);
}
try {
const created = await store.attributename.create(googleAuthId, {
name: normalized,
exclusive: true,
});
attributeMap.set(key, created.id);
return created.id;
} catch (err) {
log.warn(`Could not create attribute "${normalized}":`, err.message);
return null;
}
};
// Group children by parent SKU
const parentChildrenMap = new Map();
for (const child of includedArticles) {
const parentJtlId = String(child.kVaterArtikel || '').trim();
if (!parentJtlId || parentJtlId === '0') continue;
const parentSku = jtlArtIdToSku.get(parentJtlId);
const childSku = cleanValue(child.cArtNr);
if (!parentSku || !childSku || parentSku === childSku) continue;
if (!parentChildrenMap.has(parentSku)) {
const parentRow = includedArticles.find((a) => String(a.kArtikel) === parentJtlId) || null;
parentChildrenMap.set(parentSku, { parentRow, children: [] });
}
parentChildrenMap.get(parentSku).children.push(child);
}
let variantsLinked = 0;
for (const [parentSku, { parentRow, children }] of parentChildrenMap.entries()) {
const rawAttrName = parentRow ? cleanValue(parentRow.kEigenschaftKombi) : '';
const targetAttrId = await resolveAttributeId(rawAttrName || 'Variante');
if (targetAttrId) {
try {
await store.article.toggleParent(googleAuthId, parentSku, {
isParent: true,
attributeNameId: targetAttrId,
});
} catch (err) {
log.warn(`Could not toggle parent for ${parentSku}:`, err.message);
}
}
const usedValues = new Set();
for (const child of children) {
const childSku = cleanValue(child.cArtNr);
let val = cleanValue(child.kEigenschaftKombi) || 'Standard';
if (usedValues.has(val.toLowerCase())) {
val = `${val} (${childSku})`;
}
usedValues.add(val.toLowerCase());
try {
if (targetAttrId) {
await store.article.attachChild(googleAuthId, parentSku, {
sku: childSku,
value: val,
reparent: true,
});
variantsLinked++;
} else {
await store.article.setParentBySku(googleAuthId, childSku, parentSku);
variantsLinked++;
}
} catch (err) {
log.warn(`Failed to attach child ${childSku} to parent ${parentSku}:`, err.message);
}
}
}
log.info(`Linked ${variantsLinked} variants across ${parentChildrenMap.size} parents`);
return {
variantsLinked,
parentsCount: parentChildrenMap.size,
parentSkus: new Set(parentChildrenMap.keys()),
};
}
// #endregion