Compare commits
5 Commits
e72467da50
...
15a17574c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a17574c3 | ||
|
|
7fd8d5d704 | ||
|
|
fe7be79b90 | ||
|
|
1eb5cb4e59 | ||
|
|
9443f20820 |
@@ -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}` |
|
||||
|
||||
@@ -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 }[] }`
|
||||
|
||||
@@ -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 }[] }`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ Created attribute value
|
||||
"id": 1,
|
||||
"attributeId": 1,
|
||||
"value": "…",
|
||||
"sort": 1
|
||||
"sort": 1,
|
||||
"articleCount": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ Updated attribute value
|
||||
"id": 1,
|
||||
"attributeId": 1,
|
||||
"value": "…",
|
||||
"sort": 1
|
||||
"sort": 1,
|
||||
"articleCount": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
53
docs/api/routes/attributevalue/_id/image.delete.md
Normal file
53
docs/api/routes/attributevalue/_id/image.delete.md
Normal 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`._
|
||||
64
docs/api/routes/attributevalue/_id/image.post.md
Normal file
64
docs/api/routes/attributevalue/_id/image.post.md
Normal 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`._
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ Created property name
|
||||
"id": 1,
|
||||
"name": "…",
|
||||
"sort": 1,
|
||||
"exclusive": true
|
||||
"exclusive": true,
|
||||
"articleCount": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -42,7 +42,8 @@ Updated property name
|
||||
"id": 1,
|
||||
"name": "…",
|
||||
"sort": 1,
|
||||
"exclusive": true
|
||||
"exclusive": true,
|
||||
"articleCount": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ const STORE_NAMESPACES = [
|
||||
'payment',
|
||||
'shipping',
|
||||
'package',
|
||||
'articlelist',
|
||||
'optionslist',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
55
src/services/companyImport/articleImageImporter.js
Normal file
55
src/services/companyImport/articleImageImporter.js
Normal 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
|
||||
190
src/services/companyImport/articleImporter.js
Normal file
190
src/services/companyImport/articleImporter.js
Normal 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
|
||||
91
src/services/companyImport/brandImporter.js
Normal file
91
src/services/companyImport/brandImporter.js
Normal 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
|
||||
170
src/services/companyImport/catalogPreprocessor.js
Normal file
170
src/services/companyImport/catalogPreprocessor.js
Normal 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
|
||||
148
src/services/companyImport/categoryImporter.js
Normal file
148
src/services/companyImport/categoryImporter.js
Normal 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
|
||||
128
src/services/companyImport/csvParser.js
Normal file
128
src/services/companyImport/csvParser.js
Normal 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
|
||||
41
src/services/companyImport/imageHasher.js
Normal file
41
src/services/companyImport/imageHasher.js
Normal 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
|
||||
165
src/services/companyImport/orderImporter.js
Normal file
165
src/services/companyImport/orderImporter.js
Normal 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
|
||||
241
src/services/companyImport/pfandImporter.js
Normal file
241
src/services/companyImport/pfandImporter.js
Normal 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
|
||||
74
src/services/companyImport/progressTracker.js
Normal file
74
src/services/companyImport/progressTracker.js
Normal 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
|
||||
53
src/services/companyImport/variantHelpers.js
Normal file
53
src/services/companyImport/variantHelpers.js
Normal 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
|
||||
130
src/services/companyImport/variantImporter.js
Normal file
130
src/services/companyImport/variantImporter.js
Normal 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
|
||||
Reference in New Issue
Block a user