Compare commits

...

8 Commits

Author SHA1 Message Date
seb
4718cb5726 u 2026-10-04 21:22:05 +02:00
seb
0268583ae3 u 2026-10-04 21:22:04 +02:00
seb
c80bfee8e8 u 2026-10-04 19:38:12 +02:00
seb
067ddc088c u 2026-10-04 19:12:30 +02:00
seb
2158ac475b u 2026-10-04 18:56:55 +02:00
seb
9a1c19cf60 u 2026-10-04 16:22:23 +02:00
seb
54398d90f2 u 2026-10-04 11:25:14 +02:00
seb
89fe6e6451 u 2026-10-04 09:33:27 +02:00
71 changed files with 3091 additions and 2166 deletions

View File

@@ -6,18 +6,19 @@ This document is auto-generated from the JSDoc comments in `src/store` by runnin
*Source: `src/store/article/addChild.js`*
Adds a child article to a father article.
Adds a child article to a parent article (creates and attaches with auto-generated SKU).
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `fatherSku` | `string` | The father article SKU. |
| `parentSku` | `string` | The parent article SKU. |
| `options` | `object` | |
| `[options.attributeValueId]` | `number` | Existing attribute value ID for father's attribute. |
| `[options.value]` | `string` | String value to find or create under father's attribute. |
| `[options.name]` | `string` | Custom name for child article (defaults to father name + value). |
| `[options.attach=false]` | `boolean` | If true, attaches an existing article instead of creating. |
| `[options.attributeValueId]` | `number` | Existing attribute value ID for parent's attribute. |
| `[options.value]` | `string` | String value to find or create under parent's attribute. |
| `[options.name]` | `string` | Custom name for child article (defaults to parent name + value). |
| `[options.unitamount]` | `number` | Optional unitamount override. |
| `[options.weight]` | `number` | Optional weight override. |
| `[options.track_stock]` | `boolean|number` | Optional track_stock override. |
@@ -53,6 +54,33 @@ const result = await store.article.addImage(42, 'SKU-1', imageBuffer);
// => { picId, sort, full: { w, h, size }, thumb: { w, h }, fullHash, thumbHash }
```
## `article.attachChild`
*Source: `src/store/article/attachChild.js`*
Attaches an existing article as a child to a parent article.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `parentSku` | `string` | The parent article SKU. |
| `options` | `object` | |
| `options.sku` | `string` | The child article SKU to attach. |
| `[options.childSku]` | `string` | Alias for options.sku. |
| `[options.attributeValueId]` | `number` | Existing attribute value ID for parent's attribute. |
| `[options.value]` | `string` | String value to find or create under parent's attribute. |
| `[options.reparent=false]` | `boolean` | Whether to allow re-parenting if child belongs to another parent. |
| `[options.unitamount]` | `number` | Optional unitamount override. |
| `[options.weight]` | `number` | Optional weight override. |
| `[options.track_stock]` | `boolean|number` | Optional track_stock override. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{article: object, children: Array<object>}>`
## `article.createUnderParent`
*Source: `src/store/article/createUnderParent.js`*
@@ -156,7 +184,7 @@ Returns the full detail of an article by its SKU, including unit, brand, categor
**Returns**
`Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, track_stock: boolean, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>` — The full article detail.
`Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, track_stock: boolean, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null}}>` — The full article detail.
**Throws**
@@ -203,7 +231,7 @@ const recent = await store.article.getHistoryBySku(42, 'SKU-1', { limit: 5 });
*Source: `src/store/article/importRows.js`*
Imports article rows, creating articles by their SKU and returning per-row import, skip and error outcomes. The import runs in **two passes** so a `fatherSku` that references a row appearing later in the file still resolves. A row is **skipped** when its SKU already exists in the company (upsert mode is not applied), and an EAN is skipped when already used by another row.
Imports article rows, creating articles by their SKU and returning per-row import, skip and error outcomes. The import runs in **two passes** so a `parentSku` that references a row appearing later in the file still resolves. A row is **skipped** when its SKU already exists in the company (upsert mode is not applied), and an EAN is skipped when already used by another row.
**Parameters**
@@ -214,7 +242,7 @@ Imports article rows, creating articles by their SKU and returning per-row impor
| `input.rows` | `Array<object>` | The article rows to import. |
| `input.rows[].sku` | `string` | The article SKU. |
| `input.rows[].name` | `string` | The article name. |
| `[input.rows[].vk]` | `string` | `'V'` (father/compound) or `'K'` (variant) marker. |
| `[input.rows[].vk]` | `string` | `'V'` (parent/compound) or `'K'` (variant) marker. |
| `[input.rows[].parentId]` | `number|string` | The id of the parent category. |
| `[input.rows[].parentCategory]` | `string` | The category breadcrumb path, e.g. `'Flowers | CBN'`. |
| `[input.rows[].brand]` | `string` | The brand name to upsert. |
@@ -222,7 +250,7 @@ Imports article rows, creating articles by their SKU and returning per-row impor
| `[input.rows[].tax]` | `number|string` | The tax rate in percent (e.g. `19`). |
| `[input.rows[].eans]` | `Array<string>|string` | The EANs to set. |
| `[input.rows[].track_stock=true]` | `boolean|number` | Whether inventory stock is tracked. |
| `[input.rows[].fatherSku]` | `string` | The father article SKU for a V/K compound link. |
| `[input.rows[].parentSku]` | `string` | The parent article SKU for a V/K compound link. |
| `[input.rows[].deposit]` | `number|string` | The deposit (Pfand) value; currently ignored. |
| `[tx]` | `object` | Optional transaction. |
@@ -236,7 +264,7 @@ Imports article rows, creating articles by their SKU and returning per-row impor
const result = await store.article.importRows({
googleAuthId: 42,
rows: [
{ sku: '42001190-1', name: '420Strainz 1g', vk: 'K', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '11,00', tax: 19, eans: ['4262557690455'], fatherSku: '42001190' },
{ sku: '42001190-1', name: '420Strainz 1g', vk: 'K', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '11,00', tax: 19, eans: ['4262557690455'], parentSku: '42001190' },
{ sku: '42001190', name: '420Strainz', vk: 'V', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '0,00', tax: 19 },
],
});
@@ -247,19 +275,19 @@ const result = await store.article.importRows({
*Source: `src/store/article/listChildren.js`*
Lists all children of a father article.
Lists all children of a parent article.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `fatherSku` | `string` | The father article SKU. |
| `parentSku` | `string` | The parent article SKU. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, sku: string, fathersort: number, trackStock: boolean, unitamount: number, weight: number, name: string|null, slug: string|null, attributeValueId: number|null, attributeValue: string|null}>>`
`Promise<Array<{id: number, sku: string, parentsort: number, trackStock: boolean, unitamount: number, weight: number, name: string|null, slug: string|null, attributeValueId: number|null, attributeValue: string|null}>>`
## `article.listExport`
@@ -349,14 +377,14 @@ const result = await store.article.orderImages(42, 'SKU-1', { fullHashes: ['a',
*Source: `src/store/article/removeChild.js`*
Removes a child article from a father article, either by unlinking or deleting it.
Removes a child article from a parent article, either by unlinking or deleting it.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `fatherSku` | `string` | The father article SKU. |
| `parentSku` | `string` | The parent article SKU. |
| `childSku` | `string` | The child article SKU. |
| `[options]` | `object` | |
| `[options.deleteArticle=false]` | `boolean` | Whether to delete the child article entity entirely. |
@@ -365,7 +393,7 @@ Removes a child article from a father article, either by unlinking or deleting i
**Returns**
`Promise<{success: boolean, fatherSku: string, childSku: string, deleted: boolean}>`
`Promise<{success: boolean, parentSku: string, childSku: string, deleted: boolean}>`
## `article.resolveUniqueSlug`
@@ -478,11 +506,11 @@ const article = await store.article.setEansBySku(42, 'SKU-1', ['4001234567890'])
// => { id, sku, name, ... }
```
## `article.setFatherBySku`
## `article.setParentBySku`
*Source: `src/store/article/setFatherBySku.js`*
*Source: `src/store/article/setParentBySku.js`*
Links an article to a father article (V/K compound) by their SKUs.
Links an article to a parent article (V/K compound) by their SKUs.
**Parameters**
@@ -490,7 +518,7 @@ Links an article to a father article (V/K compound) by their SKUs.
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The child article SKU. |
| `fatherSku` | `string|null` | The father article SKU, or `null` to unlink. |
| `parentSku` | `string|null` | The parent article SKU, or `null` to unlink. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
@@ -499,13 +527,13 @@ Links an article to a father article (V/K compound) by their SKUs.
**Throws**
`Error` — If the article or father is not found, or the link is invalid.
`Error` — If the article or parent is not found, or the link is invalid.
**Example**
```js
const article = await store.article.setFatherBySku(42, '42001190-1', '42001190');
// => { id, sku, name, fatherSku: '42001190', ... }
const article = await store.article.setParentBySku(42, '42001190-1', '42001190');
// => { id, sku, name, parentSku: '42001190', ... }
```
## `article.setPricesBySku`
@@ -598,11 +626,11 @@ const article = await store.article.setWeightBySku(42, 'SKU-1', 1.5);
// => { id, sku, name, ... }
```
## `article.toggleFather`
## `article.toggleParent`
*Source: `src/store/article/toggleFather.js`*
*Source: `src/store/article/toggleParent.js`*
Toggles an article into or out of father mode.
Toggles an article into or out of parent mode.
**Parameters**
@@ -611,9 +639,9 @@ Toggles an article into or out of father mode.
| `googleAuthId` | `number` | The id of the Google auth user. |
| `sku` | `string` | The article SKU. |
| `options` | `object` | |
| `options.isFather` | `boolean` | Whether the article should be a father article. |
| `[options.attributeNameId]` | `number` | The attribute name id required when enabling father mode. |
| `[options.fatherAttributeNameId]` | `number` | Alias for attributeNameId. |
| `options.isParent` | `boolean` | Whether the article should be a parent article. |
| `[options.attributeNameId]` | `number` | The attribute name id required when enabling parent mode. |
| `[options.parentAttributeNameId]` | `number` | Alias for attributeNameId. |
| `[tx]` | `object` | Optional transaction. |
**Returns**

View File

@@ -43,7 +43,7 @@ Lists the properties of an article by its SKU.
*Source: `src/store/articleproperty/setByArticleSku.js`*
Replaces all properties for an article by its SKU. Enforces exclusive flag on property names.
Replaces all properties for an article by its SKU. Enforces exclusive flag on property names. An exclusive property that already has a value is never overwritten: a different value throws `Exclusive property cannot be overwritten: <name>`.
**Parameters**

View File

@@ -23,6 +23,10 @@ Creates an attribute name for the user's selected company.
`Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>`
**Throws**
`Error` — If the name is missing or already exists for the company.
## `attributename.delete`
*Source: `src/store/attributename/delete.js`*
@@ -41,6 +45,10 @@ Deletes an attribute name for the user's selected company.
`Promise<void>`
**Throws**
`Error` — If attribute name not found or in use.
## `attributename.list`
*Source: `src/store/attributename/list.js`*
@@ -98,3 +106,7 @@ Updates an attribute name for the user's selected company.
**Returns**
`Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>`
**Throws**
`Error` — If the attribute name is unknown, the name is missing, or the name is already taken.

View File

@@ -23,6 +23,10 @@ Creates an attribute value under a given attribute.
`Promise<{id: number, attributeId: number, value: string, sort: number}>`
**Throws**
`Error` — If the value is missing, the attribute is unknown, or the value already exists for the attribute.
## `attributevalue.delete`
*Source: `src/store/attributevalue/delete.js`*
@@ -41,6 +45,10 @@ Deletes an attribute value.
`Promise<void>`
**Throws**
`Error` — If attribute value not found or in use.
## `attributevalue.listByAttributeId`
*Source: `src/store/attributevalue/listByAttributeId.js`*
@@ -99,3 +107,7 @@ Updates an attribute value.
**Returns**
`Promise<{id: number, attributeId: number, value: string, sort: number}>`
**Throws**
`Error` — If the attribute value is unknown, the value is missing, or the value is already taken.

View File

@@ -156,7 +156,7 @@ Returns the articles belonging to a single category.
**Returns**
`Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>` — The articles in the category.
`Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>` — The articles in the category.
**Example**

View File

@@ -2,6 +2,36 @@
This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`.
## `company.addHistory`
*Source: `src/store/company/addHistory.js`*
Adds an entry to the company history for the user's selected company.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `input` | `object|string` | The history input object or text string. |
| `[input.text]` | `string` | The history entry text. |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<{id: number, companyuserId: number|null, date: string, text: string}>` — The inserted entry.
**Throws**
`Error` — If text is missing or user is not authorized.
**Example**
```js
const entry = await store.company.addHistory(42, { text: 'API suggestion: add barcode scanner endpoint' });
// => { id, companyuserId, date, text }
```
## `company.create`
*Source: `src/store/company/create.js`*
@@ -112,6 +142,35 @@ const company = await store.company.getByName('Acme');
// => { id, name, ... }
```
## `company.getHistory`
*Source: `src/store/company/getHistory.js`*
Returns the company history for the user's selected company, newest first.
**Parameters**
| Name | Type | Description |
| --- | --- | --- |
| `googleAuthId` | `number` | The id of the Google auth user. |
| `[opts]` | `object` | Optional options. |
| `[opts.limit]` | `number` | Maximum number of entries to return (newest first). |
| `[tx]` | `object` | Optional transaction. |
**Returns**
`Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>` — The company history entries.
**Example**
```js
const history = await store.company.getHistory(42);
// => [{ id, companyuserId, date, text }]
const recent = await store.company.getHistory(42, { limit: 5 });
// => up to 5 most recent entries
```
## `company.list`
*Source: `src/store/company/list.js`*

20
API.md
View File

@@ -8,7 +8,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
> **Convention**: `PK` primary key · `?` nullable · `->tbl` foreign key · `UQ` unique · `s(N)` string · `txt` text · `i` int · `bi` bigint · `f` float · `d` decimal · `b` bit · `dt` datetime · `bin` binary · `=val` default
- **article**: `id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, track_stock:b=1, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_father:b=0, fatherarticle_id?->article, fatherattributename_id?->attributename, fathersort:i=0, created_at:dt, updated_at:dt, UQ(fatherarticle_id, fathersort)`
- **article**: `id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, track_stock:b=1, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_parent:b=0, parentarticle_id?->article, parentattributename_id?->attributename, parentsort:i=0, created_at:dt, updated_at:dt, UQ(parentarticle_id, parentsort)`
- **articleattributevalue**: `id PK, article_id->article, attributevalue_id->attributevalue, sort:i=0, UQ(article_id, attributevalue_id)`
- **articleean**: `id PK, article_id->article, ean:s(50) UQ`
- **articlehistory**: `id PK, article_id->article, companyuser_id?->companyuser, date:dt, text:txt`
@@ -27,6 +27,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- **categorypic**: `id PK, category_id->category, pic_id->pic, sort:i=0, UQ(category_id, pic_id), UQ(category_id, sort)`
- **categorytext**: `id PK, category_id->category, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(category_id, language_id)`
- **company**: `id PK, name:s(255) UQ, root_category_id?->category, default_language_id?->language, taxrate?->tax, reducedtaxrate?->tax, taxfreerate?->tax, poscustomergroup_id->customergroup`
- **companyhistory**: `id PK, company_id->company, companyuser_id?->companyuser, date:dt, text:txt`
- **companysetting**: `id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)`
- **companyuser**: `id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)`
- **customergroup**: `id PK, name:s(255), sort:i=0, company_id?->company`
@@ -65,10 +66,12 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `delete(id: number, tx?: object): Promise<void>`
- `getByEmail(email: string, tx?: object): Promise<Array<number>>`
- [company](API.company.md)
- `addHistory(googleAuthId: number, input: {text?: string}, tx?: object): Promise<{id: number, companyuserId: number|null, date: string, text: string}>`
- `create(input: {iso639: string, name: string, taxrate: number, reducedtaxrate: number, googleAuthId: number}, tx?: object): Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number, posCustomerGroupId: number}>`
- `delete(companyId: number, tx?: object): Promise<void>`
- `getById(id: number, tx?: object): Promise<object|undefined>`
- `getByName(name: string, tx?: object): Promise<object|undefined>`
- `getHistory(googleAuthId: number, opts?: {limit?: number}, tx?: object): Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>`
- `list(googleAuthId: number, tx?: object): Promise<Array<object>>`
- `update(id: number, input: {iso639: string, name: string, taxrate: number, reducedtaxrate: number}, tx?: object): Promise<{id: number, rootCategoryId: number, languageId: number, taxrateId: number, reducedtaxrateId: number, taxfreerateId: number}>`
- [category](API.category.md)
@@ -77,34 +80,35 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- `delete(input: {googleAuthId: number, id: number}, tx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, categoryId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>`
- `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise<number>`
- `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>`
- `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>`
- `getTreeForCompany(googleAuthId: number, tx?: object): Promise<{languageId: number, tree: Array<object>, articles: Array<object>}>`
- `move(input: {googleAuthId: number, id: number, parentId: number}, tx?: object): Promise<{id: number, parentId: number, sort: number}>`
- `order(input: {googleAuthId: number, parentId: number, categoryIds: Array<number>}, tx?: object): Promise<{categoryIds: Array<number>}>`
- `rename(input: {googleAuthId: number, id: number, name: string}, tx?: object): Promise<{id: number, name: string, slug: string}>`
- [article](API.article.md)
- `addChild(googleAuthId: number, fatherSku: string, options: {attributeValueId?: number, value?: string, name?: string, unitamount?: number, weight?: number, track_stock?: boolean|number}, tx?: object): Promise<object>`
- `addChild(googleAuthId: number, parentSku: string, options: {attach?: boolean, attributeValueId?: number, value?: string, name?: string, unitamount?: number, weight?: number, track_stock?: boolean|number}, tx?: object): Promise<object>`
- `addImage(googleAuthId: number, sku: string, image: Buffer, tx?: object): Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, fullHash: string, thumbHash: string}>`
- `attachChild(googleAuthId: number, parentSku: string, options: {sku: string, childSku?: string, attributeValueId?: number, value?: string, reparent?: boolean, unitamount?: number, weight?: number, track_stock?: boolean|number}, tx?: object): Promise<{article: object, children: Array<object>}>`
- `createUnderParent(input: {googleAuthId: number, parentId: number, name: string, track_stock?: boolean|number}, tx?: object): Promise<{id: number, parentId: number, name: string, sku: string, track_stock: boolean}>`
- `delete(googleAuthId: number, sku: string, optionsOrTx?: object|*, maybeTx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, sku: string, fullHash: string, tx?: object): Promise<void>`
- `getBySku(googleAuthId: number, sku: string, tx?: object): Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, track_stock: boolean, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>`
- `getBySku(googleAuthId: number, sku: string, tx?: object): Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, track_stock: boolean, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null}}>`
- `getHistoryBySku(googleAuthId: number, sku: string, opts?: {limit?: number}, tx?: object): Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>`
- `importRows(input: {googleAuthId: number, rows: Array<object>}, tx?: object): Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>`
- `listChildren(googleAuthId: number, fatherSku: string, tx?: object): Promise<Array<{id: number, sku: string, fathersort: number, trackStock: boolean, unitamount: number, weight: number, name: string|null, slug: string|null, attributeValueId: number|null, attributeValue: string|null}>>`
- `listChildren(googleAuthId: number, parentSku: string, tx?: object): Promise<Array<{id: number, sku: string, parentsort: number, trackStock: boolean, unitamount: number, weight: number, name: string|null, slug: string|null, attributeValueId: number|null, attributeValue: string|null}>>`
- `listExport(googleAuthId: number, categoryId?: number|null, tx?: object): Promise<Array<object>>`
- `listSearchIndex(googleAuthId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, eans: Array<string>, categoryIds: Array<number>}>>`
- `orderImages(googleAuthId: number, sku: string, input: {fullHashes: Array<string>}, tx?: object): Promise<{fullHashes: Array<string>}>`
- `removeChild(googleAuthId: number, fatherSku: string, childSku: string, options?: {deleteArticle?: boolean, cascade?: boolean}, tx?: object): Promise<{success: boolean, fatherSku: string, childSku: string, deleted: boolean}>`
- `removeChild(googleAuthId: number, parentSku: string, childSku: string, options?: {deleteArticle?: boolean, cascade?: boolean}, tx?: object): Promise<{success: boolean, parentSku: string, childSku: string, deleted: boolean}>`
- `resolveUniqueSlug(runner: object, params: {companyId: number, languageId: number, name: string, excludeArticleId?: number}): Promise<string>`
- `setBrandIdBySku(googleAuthId: number, sku: string, brandId: number|null, tx?: object): Promise<object>`
- `setCategoryIdsBySku(googleAuthId: number, sku: string, categoryIds: Array<number>, tx?: object): Promise<object>`
- `setEansBySku(googleAuthId: number, sku: string, eans: Array<string>, tx?: object): Promise<object>`
- `setFatherBySku(googleAuthId: number, sku: string, fatherSku: string|null, tx?: object): Promise<object>`
- `setParentBySku(googleAuthId: number, sku: string, parentSku: string|null, tx?: object): Promise<object>`
- `setPricesBySku(googleAuthId: number, sku: string, prices: Array<{price: number, tax: number, minmultiplier?: number}>, tx?: object): Promise<object>`
- `setTrackStockBySku(googleAuthId: number, sku: string, trackStock: boolean|number, tx?: object): Promise<object>`
- `setWeightBySku(googleAuthId: number, sku: string, weight: number, tx?: object): Promise<object>`
- `toggleFather(googleAuthId: number, sku: string, options: {isFather: boolean, attributeNameId?: number, fatherAttributeNameId?: number}, tx?: object): Promise<object>`
- `toggleParent(googleAuthId: number, sku: string, options: {isParent: boolean, attributeNameId?: number, parentAttributeNameId?: number}, tx?: object): Promise<object>`
- `updateBySku(googleAuthId: number, sku: string, patch: {name?: string, unitSi?: string, unitamount?: number, track_stock?: boolean|number}, tx?: object): Promise<object>`
- [lang](API.lang.md)
- `create(iso639: string, tx?: object): Promise<number>`

View File

@@ -23,6 +23,10 @@ Creates a property name for the user's selected company.
`Promise<{id: number, name: string, sort: number, exclusive: boolean}>`
**Throws**
`Error` — If the name is missing or already exists for the company.
## `propertyname.delete`
*Source: `src/store/propertyname/delete.js`*
@@ -41,6 +45,10 @@ Deletes a property name for the user's selected company.
`Promise<void>`
**Throws**
`Error` — If property name not found or in use.
## `propertyname.list`
*Source: `src/store/propertyname/list.js`*
@@ -98,3 +106,7 @@ Updates a property name for the user's selected company.
**Returns**
`Promise<{id: number, name: string, sort: number, exclusive: boolean}>`
**Throws**
`Error` — If the property name is unknown, the name is missing, or the name is already taken.

View File

@@ -28,7 +28,7 @@
| `article.setBrandIdBySku` | 18 | 15.50 | 24.98 | 0.57 | 0.81 | 27.21x |
| `article.setCategoryIdsBySku` | 18 | 15.08 | 33.45 | 0.74 | 1.01 | 20.24x |
| `article.setEansBySku` | 18 | 11.86 | 19.23 | 0.56 | 0.85 | 21.15x |
| `article.setFatherBySku` | 13 | 14.79 | 25.32 | 0.66 | 0.92 | 22.52x |
| `article.setParentBySku` | 13 | 14.79 | 25.32 | 0.66 | 0.92 | 22.52x |
| `article.setPricesBySku` | 18 | 14.16 | 29.51 | 0.69 | 1.12 | 20.48x |
| `article.setWeightBySku` | 18 | 14.18 | 36.45 | 0.52 | 0.68 | 27.02x |
| `article.updateBySku` | 18 | 18.71 | 84.26 | 0.83 | 1.65 | 22.52x |
@@ -153,7 +153,7 @@
| `article.setBrandIdBySku` | 18 | 15.50 | 9.85 | 24.98 | 14.23 | 24.98 | 0 |
| `article.setCategoryIdsBySku` | 18 | 15.08 | 9.51 | 33.45 | 13.12 | 33.45 | 0 |
| `article.setEansBySku` | 18 | 11.86 | 8.23 | 19.23 | 11.67 | 19.23 | 0 |
| `article.setFatherBySku` | 13 | 14.79 | 8.86 | 25.32 | 14.06 | 25.32 | 0 |
| `article.setParentBySku` | 13 | 14.79 | 8.86 | 25.32 | 14.06 | 25.32 | 0 |
| `article.setPricesBySku` | 18 | 14.16 | 8.82 | 29.51 | 12.62 | 29.51 | 0 |
| `article.setWeightBySku` | 18 | 14.18 | 9.15 | 36.45 | 12.90 | 36.45 | 0 |
| `article.updateBySku` | 18 | 18.71 | 8.88 | 84.26 | 13.70 | 84.26 | 0 |
@@ -278,7 +278,7 @@
| `article.setBrandIdBySku` | 18 | 0.57 | 0.53 | 0.81 | 0.55 | 0.81 | 0 |
| `article.setCategoryIdsBySku` | 18 | 0.74 | 0.69 | 1.01 | 0.73 | 1.01 | 0 |
| `article.setEansBySku` | 18 | 0.56 | 0.51 | 0.85 | 0.54 | 0.85 | 0 |
| `article.setFatherBySku` | 13 | 0.66 | 0.60 | 0.92 | 0.63 | 0.92 | 0 |
| `article.setParentBySku` | 13 | 0.66 | 0.60 | 0.92 | 0.63 | 0.92 | 0 |
| `article.setPricesBySku` | 18 | 0.69 | 0.63 | 1.12 | 0.65 | 1.12 | 0 |
| `article.setWeightBySku` | 18 | 0.52 | 0.49 | 0.68 | 0.51 | 0.68 | 0 |
| `article.updateBySku` | 18 | 0.83 | 0.73 | 1.65 | 0.78 | 1.65 | 0 |

View File

@@ -25,7 +25,7 @@ Nicht-Ziele (Phase 1): inkrementelle Backups, Replikation, Live-Sync, Verschlüs
| Zeitstempel: MSSQL `DATETIME2`, SQLite `TEXT` ISO-8601 (`%Y-%m-%dT%H:%M:%fZ`, ms-Präzision). | Schemas | Kanonisches Format: ISO-8601 UTC, `Z`, **genau 3 Nachkommastellen**. |
| Die **Ledger-Hashkette** (`prev_hash`) ist SHA-512 über `JSON.stringify({id, manifestarticle_id, warehouse_id, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at})`. Sie hängt von `id` und `created_at` (ms-genau) ab. | `src/store/ledger/add.js` | IDs und `created_at` müssen 1:1 erhalten bleiben. Import in aufsteigender `id`-Reihenfolge, Kette wird nach dem Import verifiziert. |
| MSSQL-Ledger ist `APPEND_ONLY`; `purge` hinterlässt `MSSQL_DroppedLedgerTable_*`. | `mssql/schema.js`, README | Import in MSSQL nur in eine **frisch initialisierte** Ledger-Tabelle. Kein Merge in vorhandene Ledger-Daten. |
| Zirkuläre FKs: `company.poscustomergroup_id → customergroup` und `customergroup.company_id → company`. Selbstreferenzen: `category.parent_id`, `article.fatherarticle_id`. Außerdem `company.root_category_id → category`. | Schemas | Die Importreihenfolge ist nicht rein topologisch; die FK-Prüfung muss zeitweise aufgeschoben werden (dialektspezifisch). |
| Zirkuläre FKs: `company.poscustomergroup_id → customergroup` und `customergroup.company_id → company`. Selbstreferenzen: `category.parent_id`, `article.parentarticle_id`. Außerdem `company.root_category_id → category`. | Schemas | Die Importreihenfolge ist nicht rein topologisch; die FK-Prüfung muss zeitweise aufgeschoben werden (dialektspezifisch). |
| SQLite hat `entity.company_id` (denormalisiert, per Trigger gepflegt). MSSQL löst das über die View `v_entity_company_number`. | `sqlite3/schema.js`, `mssql/schema.js` | `entity.company_id` ist eine **abgeleitete Spalte**: nicht exportieren, das Ziel setzt sie selbst. |
| SQLite-`initialize` seedet `tax` (0, 7, 19) und `meta.version`. | `sqlite3/schema.js` | Import-Modus „replace“ muss Seed-Zeilen entfernen oder überschreiben, sonst PK-/UQ-Konflikte. |
| Collation: SQLite-Spalten teils `COLLATE NOCASE`, MSSQL-Default ist case-insensitive. Nicht jede SQLite-UQ ist `NOCASE` (z. B. `attributename(company_id, name)`). | Schemas | SQLite → MSSQL kann an einer UQ scheitern (`Foo` vs. `foo`). Pre-Flight-Check (§8). |
@@ -113,7 +113,7 @@ Abgeleitet aus den Schemata (Eltern vor Kindern). Die Registry speichert sie exp
8. `manifest`, `manifestarticle`, `file`, `manifestattachment`, `warehouse`, `ledger`
9. `numbers`, `companyuser`, `articlehistory`, `entityhistory`, `syncpos`, `companysetting`, `posterminal`, `posterminalcategory`, `printer`, `labeltemplate`
Selbstreferenzen (`category.parent_id`, `article.fatherarticle_id`): Der Export sortiert nach `id`. Beim Import wird die FK-Prüfung zurückgestellt (§6.3), ein Zwei-Pass-Update ist nicht nötig.
Selbstreferenzen (`category.parent_id`, `article.parentarticle_id`): Der Export sortiert nach `id`. Beim Import wird die FK-Prüfung zurückgestellt (§6.3), ein Zwei-Pass-Update ist nicht nötig.
## 5. Öffentliche API (`store.transfer`)

View File

@@ -501,7 +501,7 @@ Company-scoped POS terminals. Auth via selected company (`google_auth.selected_c
| `order` | `({ googleAuthId, parentId, categoryIds })` | `{ categoryIds }` |
| `delete` | `({ googleAuthId, id })` | `void` — `Category has children` if child categories remain; FK `categoryarticle` also blocks deletion |
| `getTreeForCompany` | `(googleAuthId)` | `{ languageId, tree, articles }` — tree rows carry `name` + `slug` |
| `getCategoryArticles` | `(googleAuthId, categoryId)` | `{ id, sku, name, slug, net, taxRate, optionsCount, articleListCount, childCount, fatherArticleId, fatherSku, fullHash, thumbW, thumbH, thumbHash }[]` |
| `getCategoryArticles` | `(googleAuthId, categoryId)` | `{ id, sku, name, slug, net, taxRate, optionsCount, articleListCount, childCount, parentArticleId, parentSku, fullHash, thumbW, thumbH, thumbHash }[]` |
| `addImage` | `(googleAuthId, categoryId, imageBuffer)` | `{ id, thumbW, thumbH, thumbHash }` |
| `deleteImage` | `(googleAuthId, categoryId)` | `{ id, thumbW: null, thumbH: null, thumbHash: null }` |
@@ -643,8 +643,8 @@ type ArticleDetail = {
optionsCount: number, // 0 when no optionslist
articleListCount: number, // 0 when no articlelist
childCount: number, // 0 when no children
fatherArticleId: number | null, // null when root article
fatherSku: string | null, // null when root article
parentArticleId: number | null, // null when root article
parentSku: string | null, // null when root article
}
type PriceEntry = {
@@ -681,7 +681,7 @@ Auth via the article's `categoryarticle` category + `resolveCategoryAccess`. Err
`orderImages` sets `articlepic.sort` to `0..n-1` in the given order. `fullHashes` must list every image of the article exactly once. Errors: `Article not found`, `Invalid image order`, `Not authorized`.
`delete` removes the article when it has no child articles (`fatherarticle_id` FK is `ON DELETE NO ACTION`). Cascades: `articlehistory`, `articleean`, `categoryarticle`, `price`, `articlesupplier`, `articletext`, `articlelistarticle`, `optionslistarticle`, `articlepic`; deletes linked `pic` rows and owned `articlelist` / `optionslist`. Errors: `Article not found`, `Article has children`, `Not authorized`.
`delete` removes the article when it has no child articles (`parentarticle_id` FK is `ON DELETE NO ACTION`). Cascades: `articlehistory`, `articleean`, `categoryarticle`, `price`, `articlesupplier`, `articletext`, `articlelistarticle`, `optionslistarticle`, `articlepic`; deletes linked `pic` rows and owned `articlelist` / `optionslist`. Errors: `Article not found`, `Article has children`, `Not authorized`.
---

View File

@@ -319,7 +319,7 @@ function render() {
}
const SCHEMA_TABLES = [
['article', 'id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, track_stock:b=1, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_father:b=0, fatherarticle_id?->article, fatherattributename_id?->attributename, fathersort:i=0, created_at:dt, updated_at:dt, UQ(fatherarticle_id, fathersort)'],
['article', 'id PK, sku?:s(100) UQ, unit_id->unit, unitamount:f, weight:f, track_stock:b=1, brand_id?->brand, articlelist_id?->articlelist, optionslist_id?->optionslist, is_parent:b=0, parentarticle_id?->article, parentattributename_id?->attributename, parentsort:i=0, created_at:dt, updated_at:dt, UQ(parentarticle_id, parentsort)'],
['articleattributevalue', 'id PK, article_id->article, attributevalue_id->attributevalue, sort:i=0, UQ(article_id, attributevalue_id)'],
['articleean', 'id PK, article_id->article, ean:s(50) UQ'],
['articlehistory', 'id PK, article_id->article, companyuser_id?->companyuser, date:dt, text:txt'],
@@ -338,6 +338,7 @@ const SCHEMA_TABLES = [
['categorypic', 'id PK, category_id->category, pic_id->pic, sort:i=0, UQ(category_id, pic_id), UQ(category_id, sort)'],
['categorytext', 'id PK, category_id->category, language_id->language, name:s(255), slug:s(255), seo?:txt, desc?:txt, UQ(category_id, language_id)'],
['company', 'id PK, name:s(255) UQ, root_category_id?->category, default_language_id?->language, taxrate?->tax, reducedtaxrate?->tax, taxfreerate?->tax, poscustomergroup_id->customergroup'],
['companyhistory', 'id PK, company_id->company, companyuser_id?->companyuser, date:dt, text:txt'],
['companysetting', 'id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)'],
['companyuser', 'id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)'],
['customergroup', 'id PK, name:s(255), sort:i=0, company_id?->company'],

View File

@@ -330,6 +330,7 @@ const CLUSTERS = [
'companyuser',
'syncpos',
'company',
'companyhistory',
'companysetting',
'customergroup',
'entity',

View File

@@ -25,9 +25,9 @@
},
"dependencies": {
"@clack/prompts": "^1.8.1",
"@hpcc-js/wasm-graphviz": "^1.29.1",
"@hpcc-js/wasm-graphviz": "^1.29.2",
"chalk": "^6.0.1",
"dotenv": "^18.0.4",
"dotenv": "^18.0.5",
"pdfconvert-wasm": "^0.3.0",
"sharp": "^0.35.5",
"thumbhash": "^0.1.1"

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 294 KiB

View File

@@ -52,7 +52,7 @@ export async function fillDatabase(store, options = {}) {
secondaryCompany: null,
categories: [],
articles: [],
fatherArticles: [],
parentArticles: [],
childArticles: [],
customergroups: [],
entities: [],
@@ -326,7 +326,7 @@ export async function fillDatabase(store, options = {}) {
const numArticles = counts.articles;
for (let i = 1; i <= numArticles; i++) {
const targetCat = ctx.categories[i % ctx.categories.length];
const isFather = i % 4 === 1;
const isParent = i % 4 === 1;
const art = await store.article.createUnderParent({
googleAuthId: ctx.googleAuthId,
parentId: targetCat.id,
@@ -365,12 +365,12 @@ export async function fillDatabase(store, options = {}) {
ctx.categories[(i + 1) % ctx.categories.length].id,
]);
// V/K Father-Child hierarchy
if (isFather) {
ctx.fatherArticles.push(art);
} else if (ctx.fatherArticles.length > 0) {
const parentFather = ctx.fatherArticles[ctx.fatherArticles.length - 1];
await store.article.setFatherBySku(ctx.googleAuthId, sku, parentFather.sku);
// V/K Parent-Child hierarchy
if (isParent) {
ctx.parentArticles.push(art);
} else if (ctx.parentArticles.length > 0) {
const parentParent = ctx.parentArticles[ctx.parentArticles.length - 1];
await store.article.setParentBySku(ctx.googleAuthId, sku, parentParent.sku);
ctx.childArticles.push(art);
}
@@ -426,7 +426,7 @@ export async function fillDatabase(store, options = {}) {
brand: 'BenchBrand Pro',
net: '45,50',
tax: 19,
fatherSku: `IMP-${Date.now()}-1`,
parentSku: `IMP-${Date.now()}-1`,
},
];
await store.article.importRows({

View File

@@ -6,7 +6,7 @@ import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing Father History & AttributeValue Shapes ---');
console.log('--- Testing Parent History & AttributeValue Shapes ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-hist-attr-'));
const dbFile = join(tempDir, 'test.db');
@@ -31,11 +31,11 @@ async function run() {
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
console.log('1. Testing setFatherBySku history logging...');
const father = await store.article.createUnderParent({
console.log('1. Testing setParentBySku history logging...');
const parent = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Father Shirt',
name: 'Parent Shirt',
});
const child = await store.article.createUnderParent({
googleAuthId,
@@ -44,19 +44,19 @@ async function run() {
});
// Check history before linking
let fatherHist = await store.article.getHistoryBySku(googleAuthId, father.sku);
assert.equal(fatherHist.length, 1);
assert.match(fatherHist[0].text, /angelegt/);
let parentHist = await store.article.getHistoryBySku(googleAuthId, parent.sku);
assert.equal(parentHist.length, 1);
assert.match(parentHist[0].text, /angelegt/);
// Link child to father
await store.article.setFatherBySku(googleAuthId, child.sku, father.sku);
// Link child to parent
await store.article.setParentBySku(googleAuthId, child.sku, parent.sku);
// Father history must now contain the conversion entry and the child entry!
fatherHist = await store.article.getHistoryBySku(googleAuthId, father.sku);
const hasConversion = fatherHist.some((h) => h.text.includes('Artikel zu Vaterartikel konvertiert'));
const hasVariantAdded = fatherHist.some((h) => h.text.includes('hinzugefügt'));
assert.ok(hasConversion, `Father history missing conversion row: ${JSON.stringify(fatherHist)}`);
assert.ok(hasVariantAdded, `Father history missing variant added row: ${JSON.stringify(fatherHist)}`);
// Parent history must now contain the conversion entry and the child entry!
parentHist = await store.article.getHistoryBySku(googleAuthId, parent.sku);
const hasConversion = parentHist.some((h) => h.text.includes('Artikel zu Vaterartikel konvertiert'));
const hasVariantAdded = parentHist.some((h) => h.text.includes('hinzugefügt'));
assert.ok(hasConversion, `Parent history missing conversion row: ${JSON.stringify(parentHist)}`);
assert.ok(hasVariantAdded, `Parent history missing variant added row: ${JSON.stringify(parentHist)}`);
// Child history must contain the assignment entry
const childHist = await store.article.getHistoryBySku(googleAuthId, child.sku);
@@ -67,15 +67,15 @@ async function run() {
const importRes = await store.article.importRows({
googleAuthId,
rows: [
{ sku: 'IMP-FATHER', name: 'Imported Father', parentId: company.rootCategoryId },
{ sku: 'IMP-CHILD', name: 'Imported Child', parentId: company.rootCategoryId, fatherSku: 'IMP-FATHER' },
{ sku: 'IMP-PARENT', name: 'Imported Parent', parentId: company.rootCategoryId },
{ sku: 'IMP-CHILD', name: 'Imported Child', parentId: company.rootCategoryId, parentSku: 'IMP-PARENT' },
],
});
assert.equal(importRes.errors.length, 0);
const impFatherHist = await store.article.getHistoryBySku(googleAuthId, 'IMP-FATHER');
const impHasConversion = impFatherHist.some((h) => h.text.includes('Artikel zu Vaterartikel konvertiert'));
assert.ok(impHasConversion, `Imported father missing conversion row: ${JSON.stringify(impFatherHist)}`);
const impParentHist = await store.article.getHistoryBySku(googleAuthId, 'IMP-PARENT');
const impHasConversion = impParentHist.some((h) => h.text.includes('Artikel zu Vaterartikel konvertiert'));
assert.ok(impHasConversion, `Imported parent missing conversion row: ${JSON.stringify(impParentHist)}`);
console.log('3. Testing flexible attributevalue request shapes in setByArticleSku...');
const attrName = await store.attributename.create(googleAuthId, { name: 'Farbe' });

View File

@@ -119,31 +119,31 @@ async function run() {
assert.equal(tomb.taxrate, 19);
assert.ok(tomb.deleted_at);
// 5. Test cascading deletion with father and variants
console.log('5. Testing father with variant child deletion...');
const father = await store.article.createUnderParent({
// 5. Test cascading deletion with parent and variants
console.log('5. Testing parent with variant child deletion...');
const parent = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Pants',
});
const attrName = await store.attributename.create(googleAuthId, { name: 'Größe' });
await store.article.toggleFather(googleAuthId, father.sku, {
isFather: true,
await store.article.toggleParent(googleAuthId, parent.sku, {
isParent: true,
attributeNameId: attrName.id,
});
const child = await store.article.addChild(googleAuthId, father.sku, { value: 'M' });
const child = await store.article.addChild(googleAuthId, parent.sku, { value: 'M' });
await store.article.setPricesBySku(googleAuthId, child.article.sku, [
{ price: 49.99, tax: 19, minmultiplier: 1 },
]);
// Delete child via removeChild with deleteArticle
console.log('Deleting child via removeChild({ deleteArticle: true })...');
await store.article.removeChild(googleAuthId, father.sku, child.article.sku, { deleteArticle: true });
await store.article.removeChild(googleAuthId, parent.sku, child.article.sku, { deleteArticle: true });
// Add another child and delete father with cascade: true
const child2 = await store.article.addChild(googleAuthId, father.sku, { value: 'L' });
console.log('Deleting father with cascade: true...');
await store.article.delete(googleAuthId, father.sku, { cascade: true });
// Add another child and delete parent with cascade: true
const child2 = await store.article.addChild(googleAuthId, parent.sku, { value: 'L' });
console.log('Deleting parent with cascade: true...');
await store.article.delete(googleAuthId, parent.sku, { cascade: true });
const allTombstones = provider.db.prepare('SELECT * FROM articletombstone ORDER BY id ASC').all();
assert.equal(allTombstones.length, 4);
@@ -164,9 +164,9 @@ async function run() {
assert.equal(allTombstones[2].name, 'Pants - L');
assert.equal(allTombstones[2].sku, child2.article.sku);
// 4th tombstone: Pants (father deleted in cascade)
// 4th tombstone: Pants (parent deleted in cascade)
assert.equal(allTombstones[3].name, 'Pants');
assert.equal(allTombstones[3].sku, father.sku);
assert.equal(allTombstones[3].sku, parent.sku);
// 6. Test posterminal maxknowntombstoneid column and updates
console.log('6. Testing posterminal maxknowntombstoneid column...');

View File

@@ -0,0 +1,109 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing uniqueness guards: attributename / attributevalue / propertyname ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-unique-names-'));
const provider = new BetterSqlite3Provider({
filename: join(tempDir, 'test.db'),
storageDir: join(tempDir, 'storage'),
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const googleAuthId = await store.google_auth.add('unique-tester@example.com');
const company = await store.company.create({
name: 'Unique Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
console.log('1. POST /attributename with a duplicate name must throw a stable conflict error...');
const an1 = await store.attributename.create(googleAuthId, { name: 'Farbe' });
await assert.rejects(
() => store.attributename.create(googleAuthId, { name: 'Farbe' }),
{ message: 'Attribute name already exists' },
);
console.log('2. POST /attributename/{id}/value with a duplicate value...');
const av1 = await store.attributevalue.create(googleAuthId, an1.id, { value: 'Rot' });
await assert.rejects(
() => store.attributevalue.create(googleAuthId, an1.id, { value: 'Rot' }),
{ message: 'Attribute value already exists' },
);
console.log('3. POST /propertyname with a duplicate name...');
const pn1 = await store.propertyname.create(googleAuthId, { name: 'Notiz' });
await assert.rejects(
() => store.propertyname.create(googleAuthId, { name: 'Notiz' }),
{ message: 'Property name already exists' },
);
console.log('4. PATCH rename onto an existing name/value...');
const an2 = await store.attributename.create(googleAuthId, { name: 'Größe' });
await assert.rejects(
() => store.attributename.update(googleAuthId, an2.id, { name: 'Farbe' }),
{ message: 'Attribute name already exists' },
);
const av2 = await store.attributevalue.create(googleAuthId, an1.id, { value: 'Blau' });
await assert.rejects(
() => store.attributevalue.update(googleAuthId, av2.id, { value: 'Rot' }),
{ message: 'Attribute value already exists' },
);
const pn2 = await store.propertyname.create(googleAuthId, { name: 'Länge' });
await assert.rejects(
() => store.propertyname.update(googleAuthId, pn2.id, { name: 'Notiz' }),
{ message: 'Property name already exists' },
);
console.log('5. Nothing was persisted by the rejected writes...');
const anList = await store.attributename.list(googleAuthId);
assert.deepEqual(anList.map((a) => a.name).sort(), ['Farbe', 'Größe']);
const avList = await store.attributevalue.listByAttributeId(googleAuthId, an1.id);
assert.deepEqual(avList.map((v) => v.value).sort(), ['Blau', 'Rot']);
const pnList = await store.propertyname.list(googleAuthId);
assert.deepEqual(pnList.map((p) => p.name).sort(), ['Länge', 'Notiz']);
console.log('6. Renames to a free name still work...');
await store.attributename.update(googleAuthId, an2.id, { name: 'Größe 2' });
await store.propertyname.update(googleAuthId, pn2.id, { name: 'Länge 2' });
await store.attributevalue.update(googleAuthId, av2.id, { value: 'Grün' });
const renamed = await store.attributevalue.listByAttributeId(googleAuthId, an1.id);
assert.ok(renamed.some((v) => v.value === 'Grün'));
console.log('7. Same name in a different company is allowed (uniqueness is company-scoped)...');
const otherId = await store.google_auth.add('other-tester@example.com');
const otherCompany = await store.company.create({
name: 'Other Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId: otherId,
});
await store.google_auth.setSelectedCompany(otherId, otherCompany.id);
const foreign = await store.attributename.create(otherId, { name: 'Farbe' });
assert.ok(foreign.id);
console.log('\nPASS - all uniqueness guards throw stable, greppable errors and persist nothing.');
} finally {
await provider.purge();
rmSync(tempDir, { recursive: true, force: true });
}
}
run().catch((err) => {
console.error('\nFAIL:', err?.message ?? err);
process.exit(1);
});

View File

@@ -35,65 +35,65 @@ async function run() {
const art1 = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Root Father Shirt',
name: 'Root Parent Shirt',
});
const art1Res = await store.article.getBySku(googleAuthId, art1.sku);
assert.equal(art1Res.article.slug, 'root-father-shirt', `Expected slug 'root-father-shirt', got '${art1Res.article.slug}'`);
assert.equal(art1Res.article.slug, 'root-parent-shirt', `Expected slug 'root-parent-shirt', got '${art1Res.article.slug}'`);
const art2 = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Root Father Shirt',
name: 'Root Parent Shirt',
});
const art2Res = await store.article.getBySku(googleAuthId, art2.sku);
assert.equal(art2Res.article.slug, 'root-father-shirt-2', `Expected slug 'root-father-shirt-2', got '${art2Res.article.slug}'`);
assert.equal(art2Res.article.slug, 'root-parent-shirt-2', `Expected slug 'root-parent-shirt-2', got '${art2Res.article.slug}'`);
const art3 = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'Root Father Shirt',
name: 'Root Parent Shirt',
});
const art3Res = await store.article.getBySku(googleAuthId, art3.sku);
assert.equal(art3Res.article.slug, 'root-father-shirt-3', `Expected slug 'root-father-shirt-3', got '${art3Res.article.slug}'`);
assert.equal(art3Res.article.slug, 'root-parent-shirt-3', `Expected slug 'root-parent-shirt-3', got '${art3Res.article.slug}'`);
console.log('2. Testing updateBySku self-collision and renaming...');
// Update art2 without changing name
await store.article.updateBySku(googleAuthId, art2.sku, { unitamount: 2 });
let updated2 = await store.article.getBySku(googleAuthId, art2.sku);
assert.equal(updated2.article.slug, 'root-father-shirt-2', `Expected unchanged slug, got '${updated2.article.slug}'`);
assert.equal(updated2.article.slug, 'root-parent-shirt-2', `Expected unchanged slug, got '${updated2.article.slug}'`);
// Update art2 with same name
await store.article.updateBySku(googleAuthId, art2.sku, { name: 'Root Father Shirt' });
await store.article.updateBySku(googleAuthId, art2.sku, { name: 'Root Parent Shirt' });
updated2 = await store.article.getBySku(googleAuthId, art2.sku);
assert.equal(updated2.article.slug, 'root-father-shirt-2', `Expected slug to remain 'root-father-shirt-2', got '${updated2.article.slug}'`);
assert.equal(updated2.article.slug, 'root-parent-shirt-2', `Expected slug to remain 'root-parent-shirt-2', got '${updated2.article.slug}'`);
// Rename art2 to something else
await store.article.updateBySku(googleAuthId, art2.sku, { name: 'Unique Shirt' });
updated2 = await store.article.getBySku(googleAuthId, art2.sku);
assert.equal(updated2.article.slug, 'unique-shirt', `Expected slug 'unique-shirt', got '${updated2.article.slug}'`);
// Rename art3 to 'Root Father Shirt' again: should take root-father-shirt-2 now that it is free
await store.article.updateBySku(googleAuthId, art3.sku, { name: 'Root Father Shirt' });
// Rename art3 to 'Root Parent Shirt' again: should take root-parent-shirt-2 now that it is free
await store.article.updateBySku(googleAuthId, art3.sku, { name: 'Root Parent Shirt' });
const updated3 = await store.article.getBySku(googleAuthId, art3.sku);
assert.equal(updated3.article.slug, 'root-father-shirt-2', `Expected slug to fill gap 'root-father-shirt-2', got '${updated3.article.slug}'`);
assert.equal(updated3.article.slug, 'root-parent-shirt-2', `Expected slug to fill gap 'root-parent-shirt-2', got '${updated3.article.slug}'`);
console.log('3. Testing addChild duplicate slugs...');
const attrName = await store.attributename.create(googleAuthId, { name: 'Größe' });
await store.article.toggleFather(googleAuthId, art1.sku, {
isFather: true,
await store.article.toggleParent(googleAuthId, art1.sku, {
isParent: true,
attributeNameId: attrName.id,
});
const child1 = await store.article.addChild(googleAuthId, art1.sku, {
value: 'M',
});
assert.equal(child1.article.slug, 'root-father-shirt-m', `Expected child slug 'root-father-shirt-m', got '${child1.article.slug}'`);
assert.equal(child1.article.slug, 'root-parent-shirt-m', `Expected child slug 'root-parent-shirt-m', got '${child1.article.slug}'`);
// Add another child with name that yields same slug
const child2 = await store.article.addChild(googleAuthId, art1.sku, {
value: 'L',
name: 'Root Father Shirt - M',
name: 'Root Parent Shirt - M',
});
assert.equal(child2.article.slug, 'root-father-shirt-m-2', `Expected child slug 'root-father-shirt-m-2', got '${child2.article.slug}'`);
assert.equal(child2.article.slug, 'root-parent-shirt-m-2', `Expected child slug 'root-parent-shirt-m-2', got '${child2.article.slug}'`);
console.log('4. Testing importRows duplicate slugs in same batch...');
const importRes = await store.article.importRows({
@@ -128,10 +128,10 @@ async function run() {
const artCompany2 = await store.article.createUnderParent({
googleAuthId,
parentId: company2.rootCategoryId,
name: 'Root Father Shirt',
name: 'Root Parent Shirt',
});
const artCompany2Detail = await store.article.getBySku(googleAuthId, artCompany2.sku);
assert.equal(artCompany2Detail.article.slug, 'root-father-shirt', `Expected 'root-father-shirt' in company 2, got '${artCompany2Detail.article.slug}'`);
assert.equal(artCompany2Detail.article.slug, 'root-parent-shirt', `Expected 'root-parent-shirt' in company 2, got '${artCompany2Detail.article.slug}'`);
console.log('✅ ALL SLUG TESTS PASSED');
} finally {

View File

@@ -6,7 +6,7 @@ import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing Variant Flow: Next-Free SKU, Fathersort Reclaim, and Duplicate Protection ---');
console.log('--- Testing Variant Flow: Next-Free SKU, Parentsort Reclaim, and Duplicate Protection ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-variants-'));
const dbFile = join(tempDir, 'test.db');
@@ -31,35 +31,35 @@ async function run() {
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
// Create father article
const father = await store.article.createUnderParent({
// Create parent article
const parent = await store.article.createUnderParent({
googleAuthId,
parentId: company.rootCategoryId,
name: 'T-Shirt',
});
const attrName = await store.attributename.create(googleAuthId, { name: 'Farbe' });
await store.article.toggleFather(googleAuthId, father.sku, {
isFather: true,
await store.article.toggleParent(googleAuthId, parent.sku, {
isParent: true,
attributeNameId: attrName.id,
});
console.log('1. Add initial children (Rot, Blau, Grün)...');
const child1 = await store.article.addChild(googleAuthId, father.sku, { value: 'Rot' });
const child2 = await store.article.addChild(googleAuthId, father.sku, { value: 'Blau' });
const child3 = await store.article.addChild(googleAuthId, father.sku, { value: 'Grün' });
const child1 = await store.article.addChild(googleAuthId, parent.sku, { value: 'Rot' });
const child2 = await store.article.addChild(googleAuthId, parent.sku, { value: 'Blau' });
const child3 = await store.article.addChild(googleAuthId, parent.sku, { value: 'Grün' });
assert.equal(child1.article.sku, `${father.sku}-1`);
assert.equal(child2.article.sku, `${father.sku}-2`);
assert.equal(child3.article.sku, `${father.sku}-3`);
assert.equal(child1.article.sku, `${parent.sku}-1`);
assert.equal(child2.article.sku, `${parent.sku}-2`);
assert.equal(child3.article.sku, `${parent.sku}-3`);
let list = await store.article.listChildren(googleAuthId, father.sku);
let list = await store.article.listChildren(googleAuthId, parent.sku);
assert.equal(list.length, 3);
assert.deepEqual(list.map((c) => c.fathersort), [0, 1, 2]);
assert.deepEqual(list.map((c) => c.parentsort), [0, 1, 2]);
console.log('2. Testing Duplicate Variant Protection (Defect 3)...');
await assert.rejects(
async () => {
await store.article.addChild(googleAuthId, father.sku, { value: 'Rot' });
await store.article.addChild(googleAuthId, parent.sku, { value: 'Rot' });
},
(err) => {
assert.match(err.message, /Variant with this attribute value already exists/);
@@ -69,52 +69,82 @@ async function run() {
);
console.log('3. Unlink middle child (child2 / SKU-2) -> leaves orphan holding SKU-2 (Bug A & B)...');
await store.article.removeChild(googleAuthId, father.sku, child2.article.sku, {
await store.article.removeChild(googleAuthId, parent.sku, child2.article.sku, {
deleteArticle: false,
});
// Check that remaining children's fathersort was recompacted
list = await store.article.listChildren(googleAuthId, father.sku);
// Check that remaining children's parentsort was recompacted
list = await store.article.listChildren(googleAuthId, parent.sku);
assert.equal(list.length, 2);
assert.deepEqual(list.map((c) => c.sku), [child1.article.sku, child3.article.sku]);
assert.deepEqual(list.map((c) => c.fathersort), [0, 1]);
assert.deepEqual(list.map((c) => c.parentsort), [0, 1]);
// Check orphan still exists in DB
const orphan = await store.article.getBySku(googleAuthId, child2.article.sku);
assert.equal(orphan.article.fatherArticleId, null);
assert.equal(orphan.article.sku, `${father.sku}-2`);
assert.equal(orphan.article.parentArticleId, null);
assert.equal(orphan.article.sku, `${parent.sku}-2`);
console.log('4. Add new child (Gelb) -> SKU must NOT collide with orphan (Bug A: next free index)...');
const child4 = await store.article.addChild(googleAuthId, father.sku, { value: 'Gelb' });
const child4 = await store.article.addChild(googleAuthId, parent.sku, { value: 'Gelb' });
// Since SKU-1, SKU-2 (orphan), SKU-3 exist, next free index is 4
assert.equal(child4.article.sku, `${father.sku}-4`);
assert.equal(child4.article.sku, `${parent.sku}-4`);
list = await store.article.listChildren(googleAuthId, father.sku);
list = await store.article.listChildren(googleAuthId, parent.sku);
assert.equal(list.length, 3);
assert.deepEqual(list.map((c) => c.fathersort), [0, 1, 2]);
assert.deepEqual(list.map((c) => c.parentsort), [0, 1, 2]);
console.log('5. Testing gaps in fathersort (Evidence: father with child at fathersort: 1)...');
console.log('5. Testing gaps in parentsort (Evidence: parent with child at parentsort: 1)...');
// Unlink child3 and child4 so only child1 remains
await store.article.removeChild(googleAuthId, father.sku, child3.article.sku, { deleteArticle: true });
await store.article.removeChild(googleAuthId, father.sku, child4.article.sku, { deleteArticle: true });
await store.article.removeChild(googleAuthId, parent.sku, child3.article.sku, { deleteArticle: true });
await store.article.removeChild(googleAuthId, parent.sku, child4.article.sku, { deleteArticle: true });
// Manually force child1's fathersort: 1 to simulate pre-existing gap state (like father 94)
// Manually force child1's parentsort: 1 to simulate pre-existing gap state (like parent 94)
await provider.query(
`UPDATE article SET fathersort = 1 WHERE sku = @sku`,
`UPDATE article SET parentsort = 1 WHERE sku = @sku`,
{ sku: child1.article.sku }
);
list = await store.article.listChildren(googleAuthId, father.sku);
list = await store.article.listChildren(googleAuthId, parent.sku);
assert.equal(list.length, 1);
assert.equal(list[0].fathersort, 1); // exactly like father 94
assert.equal(list[0].parentsort, 1); // exactly like parent 94
// Now adding a variant MUST NOT hit UNIQUE constraint failed (409)
console.log('Adding variant to father with child at fathersort: 1...');
const child5 = await store.article.addChild(googleAuthId, father.sku, { value: 'Schwarz' });
console.log('Adding variant to parent with child at parentsort: 1...');
const child5 = await store.article.addChild(googleAuthId, parent.sku, { value: 'Schwarz' });
assert.ok(child5);
list = await store.article.listChildren(googleAuthId, father.sku);
list = await store.article.listChildren(googleAuthId, parent.sku);
assert.equal(list.length, 2);
// Fathersort should have picked 0 (next free non-negative integer)
assert.deepEqual(list.map((c) => c.fathersort), [0, 1]);
// Parentsort should have picked 0 (next free non-negative integer)
assert.deepEqual(list.map((c) => c.parentsort), [0, 1]);
console.log('6. Testing rejecting sku when creating via addChild...');
await assert.rejects(
async () => {
await store.article.addChild(googleAuthId, parent.sku, {
sku: 'SOME-CUSTOM-SKU',
value: 'Lila',
});
},
(err) => {
assert.match(err.message, /sku is not allowed when creating a child article/);
return true;
},
'Expected sku on addChild to be rejected'
);
console.log('7. Testing attaching existing orphan article (SKU-2) via attachChild...');
const reconnected = await store.article.attachChild(googleAuthId, parent.sku, {
sku: child2.article.sku,
value: 'Blau',
});
assert.equal(reconnected.article.sku, child2.article.sku);
assert.equal(reconnected.article.parentArticleId, parent.id);
console.log('8. Testing idempotent attachChild on same parent...');
const idempotentResult = await store.article.attachChild(googleAuthId, parent.sku, {
sku: child2.article.sku,
value: 'Blau',
});
assert.equal(idempotentResult.article.sku, child2.article.sku);
console.log('✅ ALL VARIANT FLOW TESTS PASSED');
} finally {

View File

@@ -5,6 +5,8 @@ import companyCreate from './store/company/create.js';
import companyUpdate from './store/company/update.js';
import companyDelete from './store/company/delete.js';
import companyList from './store/company/list.js';
import companyAddHistory from './store/company/addHistory.js';
import companyGetHistory from './store/company/getHistory.js';
import categoryGetTreeForCompany from './store/category/getTreeForCompany.js';
import categoryGetCategoryArticles from './store/category/getCategoryArticles.js';
import categoryCreateUnderParent from './store/category/createUnderParent.js';
@@ -30,9 +32,10 @@ import articleSetBrandIdBySku from './store/article/setBrandIdBySku.js';
import articleSetWeightBySku from './store/article/setWeightBySku.js';
import articleSetTrackStockBySku from './store/article/setTrackStockBySku.js';
import articleSetCategoryIdsBySku from './store/article/setCategoryIdsBySku.js';
import articleSetFatherBySku from './store/article/setFatherBySku.js';
import articleToggleFather from './store/article/toggleFather.js';
import articleSetParentBySku from './store/article/setParentBySku.js';
import articleToggleParent from './store/article/toggleParent.js';
import articleAddChild from './store/article/addChild.js';
import articleAttachChild from './store/article/attachChild.js';
import articleRemoveChild from './store/article/removeChild.js';
import articleListChildren from './store/article/listChildren.js';
import articleImportRows from './store/article/importRows.js';
@@ -234,6 +237,8 @@ export default class LibDashStore {
update: companyUpdate(provider),
delete: companyDelete(provider),
list: companyList(provider),
addHistory: companyAddHistory(provider),
getHistory: companyGetHistory(provider),
};
this.category = {
@@ -262,9 +267,10 @@ export default class LibDashStore {
setWeightBySku: articleSetWeightBySku(provider),
setTrackStockBySku: articleSetTrackStockBySku(provider),
setCategoryIdsBySku: articleSetCategoryIdsBySku(provider),
setFatherBySku: articleSetFatherBySku(provider),
toggleFather: articleToggleFather(provider),
setParentBySku: articleSetParentBySku(provider),
toggleParent: articleToggleParent(provider),
addChild: articleAddChild(provider),
attachChild: articleAttachChild(provider),
removeChild: articleRemoveChild(provider),
listChildren: articleListChildren(provider),
getArticlelist: articlelistGetItems(provider),

View File

@@ -34,6 +34,7 @@ export const TABLES = [
'articleean',
'articlehistory',
'entityhistory',
'companyhistory',
'entityaddress',
'entitycontact',
'article',

View File

@@ -21,7 +21,7 @@ OUTER APPLY (
ORDER BY bp.sort ASC, bp.id ASC
) first_brand_pic
LEFT JOIN pic brand_pic ON brand_pic.id = first_brand_pic.pic_id
LEFT JOIN article father ON father.id = a.fatherarticle_id
LEFT JOIN article parent ON parent.id = a.parentarticle_id
`;
export const ARTICLE_LIST_COLUMNS = `
@@ -32,6 +32,7 @@ export const ARTICLE_LIST_COLUMNS = `
pr.net,
t.rate AS taxRate,
a.track_stock,
a.is_parent AS isParent,
CASE
WHEN a.optionslist_id IS NOT NULL THEN (
SELECT COUNT(*)
@@ -51,10 +52,10 @@ export const ARTICLE_LIST_COLUMNS = `
(
SELECT COUNT(*)
FROM article child
WHERE child.fatherarticle_id = a.id
WHERE child.parentarticle_id = a.id
) AS childCount,
a.fatherarticle_id AS fatherArticleId,
father.sku AS fatherSku,
a.parentarticle_id AS parentArticleId,
parent.sku AS parentSku,
pic.hash AS fullHash,
pic.thumbw AS thumbW,
pic.thumbh AS thumbH,
@@ -74,7 +75,7 @@ INNER JOIN categoryarticle ca ON ca.article_id = a.id
WHERE a.sku = @sku AND (@companyId IS NULL OR a.company_id = @companyId)
`;
/** Resolve an article id by sku (used to wire father/child compound links). */
/** Resolve an article id by sku (used to wire parent/child compound links). */
export const GET_ID_BY_SKU = `
SELECT TOP 1 id AS articleId
FROM article
@@ -88,49 +89,49 @@ FROM articleean
WHERE ean = @ean
`;
/** Link an article to its father article and mark the father. */
export const UPDATE_FATHER_BY_SKU = `
/** Link an article to its parent article and mark the parent. */
export const UPDATE_PARENT_BY_SKU = `
UPDATE article
SET fatherarticle_id = @fatherArticleId,
fathersort = COALESCE(@fathersort, 0)
SET parentarticle_id = @parentArticleId,
parentsort = COALESCE(@parentsort, 0)
WHERE sku = @sku AND (@companyId IS NULL OR company_id = @companyId)
`;
export const SET_IS_FATHER = `
export const SET_IS_PARENT = `
UPDATE article
SET is_father = 1
SET is_parent = 1
WHERE id = @articleId
`;
export const TOGGLE_FATHER = `
export const TOGGLE_PARENT = `
UPDATE article
SET is_father = @isFather,
fatherattributename_id = @fatherAttributeNameId
SET is_parent = @isParent,
parentattributename_id = @parentAttributeNameId
WHERE id = @articleId
`;
export const CREATE_CHILD = `
INSERT INTO article (
company_id, sku, unit_id, unitamount, weight, track_stock, brand_id, is_father, fatherarticle_id, fathersort
company_id, sku, unit_id, unitamount, weight, track_stock, brand_id, is_parent, parentarticle_id, parentsort
)
OUTPUT INSERTED.id
VALUES (
@companyId, @sku, @unitId, @unitamount, @weight, @trackStock, @brandId, 0, @fatherArticleId, @fathersort
@companyId, @sku, @unitId, @unitamount, @weight, @trackStock, @brandId, 0, @parentArticleId, @parentsort
)
`;
export const UNLINK_CHILD = `
UPDATE article
SET fatherarticle_id = NULL,
fathersort = 0
WHERE id = @articleId AND fatherarticle_id = @fatherArticleId
SET parentarticle_id = NULL,
parentsort = 0
WHERE id = @articleId AND parentarticle_id = @parentArticleId
`;
export const LIST_CHILDREN_BY_FATHER_ID = `
export const LIST_CHILDREN_BY_PARENT_ID = `
SELECT
child.id,
child.sku,
child.fathersort,
child.parentsort,
child.track_stock AS trackStock,
child.unitamount,
child.weight,
@@ -144,14 +145,14 @@ LEFT JOIN (
articleattributevalue aav
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = @attributeId
) ON aav.article_id = child.id
WHERE child.fatherarticle_id = @fatherArticleId
ORDER BY child.fathersort ASC, child.id ASC
WHERE child.parentarticle_id = @parentArticleId
ORDER BY child.parentsort ASC, child.id ASC
`;
export const SET_FATHER_SORT = `
export const SET_PARENT_SORT = `
UPDATE article
SET fathersort = @fathersort
WHERE id = @articleId AND fatherarticle_id = @fatherArticleId
SET parentsort = @parentsort
WHERE id = @articleId AND parentarticle_id = @parentArticleId
`;
/** SKU → article/category + company auth in one round-trip. */
@@ -483,19 +484,19 @@ SELECT
(
SELECT COUNT(*)
FROM article child
WHERE child.fatherarticle_id = a.id
WHERE child.parentarticle_id = a.id
) AS childCount,
a.is_father AS isFather,
a.fatherattributename_id AS fatherAttributeNameId,
fan.name AS fatherAttributeName,
fan.exclusive AS fatherAttributeExclusive,
a.fatherarticle_id AS fatherArticleId,
fa.sku AS fatherSku,
a.is_parent AS isParent,
a.parentattributename_id AS parentAttributeNameId,
fan.name AS parentAttributeName,
fan.exclusive AS parentAttributeExclusive,
a.parentarticle_id AS parentArticleId,
fa.sku AS parentSku,
(
SELECT
child.id,
child.sku,
child.fathersort,
child.parentsort,
at_child.name,
av.id AS attributeValueId,
av.value AS attributeValue
@@ -503,10 +504,10 @@ SELECT
LEFT JOIN articletext at_child ON at_child.article_id = child.id AND at_child.language_id = auth.languageId
LEFT JOIN (
articleattributevalue aav
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = a.fatherattributename_id
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = a.parentattributename_id
) ON aav.article_id = child.id
WHERE child.fatherarticle_id = a.id
ORDER BY child.fathersort ASC, child.id ASC
WHERE child.parentarticle_id = a.id
ORDER BY child.parentsort ASC, child.id ASC
FOR JSON PATH
) AS childrenJson,
(
@@ -636,8 +637,8 @@ SELECT
ORDER BY bp2.sort ASC, bp2.id ASC
) first_brand_pic
LEFT JOIN pic bp ON bp.id = first_brand_pic.pic_id
LEFT JOIN article fa ON fa.id = a.fatherarticle_id
LEFT JOIN attributename fan ON fan.id = a.fatherattributename_id
LEFT JOIN article fa ON fa.id = a.parentarticle_id
LEFT JOIN attributename fan ON fan.id = a.parentattributename_id
WHERE a.id = auth.articleId
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
) AS articleJson

View File

@@ -1,13 +1,13 @@
export const GET_CHILD_COUNT = `
SELECT COUNT(*) AS count
FROM article
WHERE fatherarticle_id = @articleId
WHERE parentarticle_id = @articleId
`;
export const GET_CHILDREN_IDS = `
SELECT id
FROM article
WHERE fatherarticle_id = @id
WHERE parentarticle_id = @id
`;
export const GET_LIST_IDS = `

View File

@@ -36,10 +36,10 @@ export const ARTICLE_DETAIL_COLUMNS = `
(
SELECT COUNT(*)
FROM article child
WHERE child.fatherarticle_id = a.id
WHERE child.parentarticle_id = a.id
) AS childCount,
a.fatherarticle_id AS fatherArticleId,
father.sku AS fatherSku
a.parentarticle_id AS parentArticleId,
parent.sku AS parentSku
`;
export const GET_DETAIL_BY_SKU = `
@@ -59,7 +59,7 @@ OUTER APPLY (
ORDER BY bp.sort ASC, bp.id ASC
) first_brand_pic
LEFT JOIN pic brand_pic ON brand_pic.id = first_brand_pic.pic_id
LEFT JOIN article father ON father.id = a.fatherarticle_id
LEFT JOIN article parent ON parent.id = a.parentarticle_id
WHERE a.sku = @sku AND (@companyId IS NULL OR a.company_id = @companyId)
`;
@@ -80,7 +80,7 @@ OUTER APPLY (
ORDER BY bp.sort ASC, bp.id ASC
) first_brand_pic
LEFT JOIN pic brand_pic ON brand_pic.id = first_brand_pic.pic_id
LEFT JOIN article father ON father.id = a.fatherarticle_id
LEFT JOIN article parent ON parent.id = a.parentarticle_id
WHERE a.id = @articleId
`;

View File

@@ -45,7 +45,7 @@ FROM categoryarticle ca
INNER JOIN article a ON a.id = ca.article_id
${ARTICLE_LIST_JOINS}
WHERE ca.category_id = @categoryId
AND a.fatherarticle_id IS NULL
AND a.parentarticle_id IS NULL
ORDER BY a.id
`;

View File

@@ -0,0 +1,19 @@
export const INSERT = `
INSERT INTO companyhistory (company_id, companyuser_id, [text])
OUTPUT INSERTED.id, INSERTED.companyuser_id AS companyuserId, INSERTED.[date], INSERTED.[text]
VALUES (@companyId, @companyuserId, @text)
`;
export const LIST_BY_COMPANY_ID = `
SELECT id, companyuser_id AS companyuserId, [date], [text]
FROM companyhistory
WHERE company_id = @companyId
ORDER BY [date] DESC, id DESC
`;
export const LIST_BY_COMPANY_ID_LIMITED = `
SELECT TOP (@limit) id, companyuser_id AS companyuserId, [date], [text]
FROM companyhistory
WHERE company_id = @companyId
ORDER BY [date] DESC, id DESC
`;

View File

@@ -109,10 +109,10 @@ VALUES (
)
`;
export const CHECK_ARTICLE_IS_FATHER = `
export const CHECK_ARTICLE_IS_PARENT = `
SELECT
a.id,
a.is_father AS isFather,
a.is_parent AS isParent,
COALESCE(
(SELECT at.name FROM articletext at WHERE at.article_id = a.id AND at.language_id = co.default_language_id),
(SELECT MIN(at2.name) FROM articletext at2 WHERE at2.article_id = a.id),

View File

@@ -253,7 +253,7 @@ SELECT TOP (@limit)
SELECT TOP 1 p2.hash
FROM articlepic ap2
INNER JOIN pic p2 ON p2.id = ap2.pic_id
WHERE a.fatherarticle_id IS NOT NULL AND ap2.article_id = a.fatherarticle_id
WHERE a.parentarticle_id IS NOT NULL AND ap2.article_id = a.parentarticle_id
ORDER BY ap2.sort ASC, ap2.id ASC
)
) AS imgHash,
@@ -263,15 +263,15 @@ SELECT TOP (@limit)
WHERE ae.article_id = a.id
ORDER BY ae.id ASC
) AS barcode,
a.is_father AS isParent,
a.fatherarticle_id AS parentArticleId,
a.is_parent AS isParent,
a.parentarticle_id AS parentArticleId,
(
SELECT TOP 1 CONCAT(fan.name, '|', av.value, '|', fan.sort, '|', a.fathersort)
SELECT TOP 1 CONCAT(fan.name, '|', av.value, '|', fan.sort, '|', a.parentsort)
FROM article fa
INNER JOIN attributename fan ON fan.id = fa.fatherattributename_id
INNER JOIN attributename fan ON fan.id = fa.parentattributename_id
INNER JOIN articleattributevalue aav ON aav.article_id = a.id
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = fan.id
WHERE a.fatherarticle_id IS NOT NULL AND fa.id = a.fatherarticle_id
WHERE a.parentarticle_id IS NOT NULL AND fa.id = a.parentarticle_id
) AS variantName,
CASE
WHEN a.articlelist_id IS NOT NULL AND EXISTS (

View File

@@ -323,10 +323,10 @@ BEGIN
brand_id INT NULL,
articlelist_id INT NULL,
optionslist_id INT NULL,
is_father BIT NOT NULL CONSTRAINT DF_article_is_father DEFAULT 0,
fatherarticle_id INT NULL,
fatherattributename_id INT NULL,
fathersort INT NOT NULL CONSTRAINT DF_article_fathersort DEFAULT 0,
is_parent BIT NOT NULL CONSTRAINT DF_article_is_parent DEFAULT 0,
parentarticle_id INT NULL,
parentattributename_id INT NULL,
parentsort INT NOT NULL CONSTRAINT DF_article_parentsort DEFAULT 0,
created_at DATETIME2 NOT NULL CONSTRAINT DF_article_created_at DEFAULT SYSUTCDATETIME(),
updated_at DATETIME2 NOT NULL CONSTRAINT DF_article_updated_at DEFAULT SYSUTCDATETIME(),
CONSTRAINT FK_article_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION,
@@ -334,23 +334,23 @@ BEGIN
CONSTRAINT FK_article_brand FOREIGN KEY (brand_id) REFERENCES brand(id) ON DELETE SET NULL,
CONSTRAINT FK_article_articlelist FOREIGN KEY (articlelist_id) REFERENCES articlelist(id) ON DELETE SET NULL,
CONSTRAINT FK_article_optionslist FOREIGN KEY (optionslist_id) REFERENCES optionslist(id) ON DELETE SET NULL,
CONSTRAINT FK_article_fatherarticle FOREIGN KEY (fatherarticle_id) REFERENCES article(id) ON DELETE NO ACTION,
CONSTRAINT FK_article_fatherattributename FOREIGN KEY (fatherattributename_id) REFERENCES attributename(id) ON DELETE NO ACTION,
CONSTRAINT FK_article_parentarticle FOREIGN KEY (parentarticle_id) REFERENCES article(id) ON DELETE NO ACTION,
CONSTRAINT FK_article_parentattributename FOREIGN KEY (parentattributename_id) REFERENCES attributename(id) ON DELETE NO ACTION,
CONSTRAINT UQ_article_company_sku UNIQUE (company_id, sku)
);
-- Only enforce sibling sort uniqueness among children of the same father.
-- Only enforce sibling sort uniqueness among children of the same parent.
-- (SQL Server UNIQUE treats NULLs as equal, which would block multiple root articles.)
CREATE UNIQUE INDEX UQ_article_fatherarticle_fathersort
ON article (fatherarticle_id, fathersort)
WHERE fatherarticle_id IS NOT NULL;
CREATE UNIQUE INDEX UQ_article_parentarticle_parentsort
ON article (parentarticle_id, parentsort)
WHERE parentarticle_id IS NOT NULL;
END
ELSE
BEGIN
IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('article') AND name = 'fatherattributename_id')
IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('article') AND name = 'parentattributename_id')
BEGIN
ALTER TABLE article ADD fatherattributename_id INT NULL;
ALTER TABLE article ADD CONSTRAINT FK_article_fatherattributename FOREIGN KEY (fatherattributename_id) REFERENCES attributename(id) ON DELETE NO ACTION;
ALTER TABLE article ADD parentattributename_id INT NULL;
ALTER TABLE article ADD CONSTRAINT FK_article_parentattributename FOREIGN KEY (parentattributename_id) REFERENCES attributename(id) ON DELETE NO ACTION;
END
IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('article') AND name = 'track_stock')
@@ -374,6 +374,20 @@ BEGIN
);
END
`;
export const INIT_COMPANYHISTORY = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'companyhistory')
BEGIN
CREATE TABLE companyhistory (
id INT IDENTITY(1,1) PRIMARY KEY,
company_id INT NOT NULL,
companyuser_id INT NULL,
[date] DATETIME2 NOT NULL CONSTRAINT DF_companyhistory_date DEFAULT SYSUTCDATETIME(),
[text] NVARCHAR(MAX) NOT NULL,
CONSTRAINT FK_companyhistory_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
CONSTRAINT FK_companyhistory_companyuser FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE SET NULL
);
END
`;
export const INIT_ARTICLETOMBSTONE = `
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'articletombstone')
@@ -1015,7 +1029,7 @@ BEGIN
value NVARCHAR(MAX) NOT NULL,
sort INT NOT NULL CONSTRAINT DF_articleproperty_sort DEFAULT 0,
CONSTRAINT FK_articleproperty_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
CONSTRAINT FK_articleproperty_property FOREIGN KEY (property_id) REFERENCES propertyname(id) ON DELETE CASCADE
CONSTRAINT FK_articleproperty_property FOREIGN KEY (property_id) REFERENCES propertyname(id) ON DELETE NO ACTION
);
END
`;
@@ -1058,7 +1072,7 @@ BEGIN
attributevalue_id INT NOT NULL,
sort INT NOT NULL CONSTRAINT DF_articleattributevalue_sort DEFAULT 0,
CONSTRAINT FK_articleattributevalue_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
CONSTRAINT FK_articleattributevalue_attributevalue FOREIGN KEY (attributevalue_id) REFERENCES attributevalue(id) ON DELETE CASCADE,
CONSTRAINT FK_articleattributevalue_attributevalue FOREIGN KEY (attributevalue_id) REFERENCES attributevalue(id) ON DELETE NO ACTION,
CONSTRAINT UQ_articleattributevalue_article_attributevalue UNIQUE (article_id, attributevalue_id)
);
END
@@ -1074,7 +1088,7 @@ BEGIN
value NVARCHAR(MAX) NOT NULL,
sort INT NOT NULL CONSTRAINT DF_categoryproperty_sort DEFAULT 0,
CONSTRAINT FK_categoryproperty_category FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE,
CONSTRAINT FK_categoryproperty_property FOREIGN KEY (property_id) REFERENCES propertyname(id) ON DELETE CASCADE
CONSTRAINT FK_categoryproperty_property FOREIGN KEY (property_id) REFERENCES propertyname(id) ON DELETE NO ACTION
);
END
`;
@@ -1147,6 +1161,7 @@ export const SCHEMA_SCRIPTS = [
INIT_ARTICLEHISTORY,
INIT_ARTICLETOMBSTONE,
INIT_ENTITYHISTORY,
INIT_COMPANYHISTORY,
INIT_SYNCPOS,
INIT_COMPANYSETTING,
INIT_POSTERMINAL,

View File

@@ -434,9 +434,9 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor {
`ALTER TABLE article ADD COLUMN track_stock INTEGER NOT NULL DEFAULT 1`,
);
}
if (articleCols.length > 0 && !articleCols.includes('fatherattributename_id')) {
if (articleCols.length > 0 && !articleCols.includes('parentattributename_id')) {
this.db.exec(
`ALTER TABLE article ADD COLUMN fatherattributename_id INTEGER REFERENCES attributename(id) ON DELETE NO ACTION`,
`ALTER TABLE article ADD COLUMN parentattributename_id INTEGER REFERENCES attributename(id) ON DELETE NO ACTION`,
);
}

View File

@@ -7,9 +7,9 @@ export const {
DELETE_CATEGORIES_BY_ARTICLE, DELETE_EANS_BY_ARTICLE, EAN_EXISTS,
GET_BRAND_BY_COMPANY_AND_ID,
GET_CATEGORY_ID_BY_SKU, GET_CATEGORY_TREE_FLAT, GET_ID_BY_SKU,
LIST_CHILDREN_BY_FATHER_ID,
SET_FATHER_SORT,
SET_IS_FATHER, TOGGLE_FATHER, UNLINK_CHILD, UPDATE_BRAND, UPDATE_FATHER_BY_SKU, UPDATE_TRACK_STOCK, UPDATE_UNIT,
LIST_CHILDREN_BY_PARENT_ID,
SET_PARENT_SORT,
SET_IS_PARENT, TOGGLE_PARENT, UNLINK_CHILD, UPDATE_BRAND, UPDATE_PARENT_BY_SKU, UPDATE_TRACK_STOCK, UPDATE_UNIT,
UPDATE_UNITAMOUNT, UPDATE_UNIT_AND_AMOUNT, UPDATE_WEIGHT,
} = queries;
@@ -30,7 +30,7 @@ LEFT JOIN pic brand_pic ON brand_pic.id = (
SELECT bp.pic_id FROM brandpic bp
WHERE bp.brand_id = b.id ORDER BY bp.sort, bp.id LIMIT 1
)
LEFT JOIN article father ON father.id = a.fatherarticle_id
LEFT JOIN article parent ON parent.id = a.parentarticle_id
`;
export const RESOLVE_ARTICLE_ACCESS_BY_SKU = `
@@ -160,16 +160,16 @@ SELECT auth.*,
LEFT JOIN articletext at_item ON at_item.article_id = a_item.id AND at_item.language_id = auth.languageId
WHERE ala.articlelist_id = a.articlelist_id
ORDER BY ala.sort ASC, ala.id ASC)),
'childCount', (SELECT COUNT(*) FROM article WHERE fatherarticle_id = a.id),
'isFather', a.is_father,
'fatherAttributeNameId', a.fatherattributename_id,
'fatherAttributeName', fan.name,
'fatherAttributeExclusive', fan.exclusive,
'fatherArticleId', a.fatherarticle_id, 'fatherSku', father.sku,
'childCount', (SELECT COUNT(*) FROM article WHERE parentarticle_id = a.id),
'isParent', a.is_parent,
'parentAttributeNameId', a.parentattributename_id,
'parentAttributeName', fan.name,
'parentAttributeExclusive', fan.exclusive,
'parentArticleId', a.parentarticle_id, 'parentSku', parent.sku,
'childrenJson', json((SELECT COALESCE(json_group_array(json_object(
'id', child.id,
'sku', child.sku,
'fathersort', child.fathersort,
'parentsort', child.parentsort,
'name', at_child.name,
'attributeValueId', av.id,
'attributeValue', av.value
@@ -177,10 +177,10 @@ SELECT auth.*,
LEFT JOIN articletext at_child ON at_child.article_id = child.id AND at_child.language_id = auth.languageId
LEFT JOIN (
articleattributevalue aav
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = a.fatherattributename_id
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = a.parentattributename_id
) ON aav.article_id = child.id
WHERE child.fatherarticle_id = a.id
ORDER BY child.fathersort ASC, child.id ASC)),
WHERE child.parentarticle_id = a.id
ORDER BY child.parentsort ASC, child.id ASC)),
'categoryIdsJson', json(${JSON_CATEGORIES}),
'photosJson', json((SELECT COALESCE(json_group_array(json_object(
'sort', sort, 'fullHash', hash, 'fullW', w, 'fullH', h,
@@ -270,6 +270,6 @@ LEFT JOIN brand b ON b.id = a.brand_id
LEFT JOIN pic bp ON bp.id = (
SELECT pic_id FROM brandpic WHERE brand_id = b.id ORDER BY sort, id LIMIT 1
)
LEFT JOIN article father ON father.id = a.fatherarticle_id
LEFT JOIN attributename fan ON fan.id = a.fatherattributename_id
LEFT JOIN article parent ON parent.id = a.parentarticle_id
LEFT JOIN attributename fan ON fan.id = a.parentattributename_id
`;

View File

@@ -19,7 +19,7 @@ LEFT JOIN pic brand_pic ON brand_pic.id = (
WHERE bp.brand_id = b.id
ORDER BY bp.sort, bp.id LIMIT 1
)
LEFT JOIN article father ON father.id = a.fatherarticle_id
LEFT JOIN article parent ON parent.id = a.parentarticle_id
`;
export const GET_DETAIL_BY_SKU = `

View File

@@ -65,7 +65,7 @@ FROM categoryarticle ca
INNER JOIN article a ON a.id = ca.article_id
${ARTICLE_LIST_JOINS}
WHERE ca.category_id = @categoryId
AND a.fatherarticle_id IS NULL
AND a.parentarticle_id IS NULL
ORDER BY a.id
`;

View File

@@ -0,0 +1,13 @@
import * as source from '../../mssql/queries/companyhistory.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'companyhistory');
export const { INSERT, LIST_BY_COMPANY_ID } = queries;
export const LIST_BY_COMPANY_ID_LIMITED = `
SELECT id, companyuser_id AS companyuserId, date, text
FROM companyhistory
WHERE company_id = @companyId
ORDER BY date DESC, id DESC
LIMIT @limit
`;

View File

@@ -3,7 +3,7 @@ import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'manifestDoc');
export const {
CHECK_ARTICLE_IS_FATHER, CREATE_LINE, CREATE_MANIFEST, DELETE_LINES_BY_MANIFEST,
CHECK_ARTICLE_IS_PARENT, CREATE_LINE, CREATE_MANIFEST, DELETE_LINES_BY_MANIFEST,
DELETE_MANIFEST, GET_MANIFEST_HEADER, GET_MANIFEST_LINES, LIST_MANIFESTS,
ORDER_TYPE, QUOTE_TYPE, UPDATE_MANIFEST,
} = queries;

View File

@@ -78,7 +78,7 @@ SELECT
SELECT p2.hash
FROM articlepic ap2
INNER JOIN pic p2 ON p2.id = ap2.pic_id
WHERE a.fatherarticle_id IS NOT NULL AND ap2.article_id = a.fatherarticle_id
WHERE a.parentarticle_id IS NOT NULL AND ap2.article_id = a.parentarticle_id
ORDER BY ap2.sort ASC, ap2.id ASC
LIMIT 1
)
@@ -90,15 +90,15 @@ SELECT
ORDER BY ae.id ASC
LIMIT 1
) AS barcode,
a.is_father AS isParent,
a.fatherarticle_id AS parentArticleId,
a.is_parent AS isParent,
a.parentarticle_id AS parentArticleId,
(
SELECT fan.name || '|' || av.value || '|' || fan.sort || '|' || a.fathersort
SELECT fan.name || '|' || av.value || '|' || fan.sort || '|' || a.parentsort
FROM article fa
INNER JOIN attributename fan ON fan.id = fa.fatherattributename_id
INNER JOIN attributename fan ON fan.id = fa.parentattributename_id
INNER JOIN articleattributevalue aav ON aav.article_id = a.id
INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = fan.id
WHERE a.fatherarticle_id IS NOT NULL AND fa.id = a.fatherarticle_id
WHERE a.parentarticle_id IS NOT NULL AND fa.id = a.parentarticle_id
LIMIT 1
) AS variantName,
CASE

View File

@@ -222,10 +222,10 @@ CREATE TABLE IF NOT EXISTS article (
brand_id INTEGER,
articlelist_id INTEGER,
optionslist_id INTEGER,
is_father INTEGER NOT NULL DEFAULT 0,
fatherarticle_id INTEGER,
fathersort INTEGER NOT NULL DEFAULT 0,
fatherattributename_id INTEGER,
is_parent INTEGER NOT NULL DEFAULT 0,
parentarticle_id INTEGER,
parentsort INTEGER NOT NULL DEFAULT 0,
parentattributename_id INTEGER,
created_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
updated_at TEXT NOT NULL DEFAULT (${UTC_NOW}),
UNIQUE (company_id, sku),
@@ -234,12 +234,12 @@ CREATE TABLE IF NOT EXISTS article (
FOREIGN KEY (brand_id) REFERENCES brand(id) ON DELETE SET NULL,
FOREIGN KEY (articlelist_id) REFERENCES articlelist(id) ON DELETE SET NULL,
FOREIGN KEY (optionslist_id) REFERENCES optionslist(id) ON DELETE SET NULL,
FOREIGN KEY (fatherarticle_id) REFERENCES article(id) ON DELETE NO ACTION,
FOREIGN KEY (fatherattributename_id) REFERENCES attributename(id) ON DELETE NO ACTION
FOREIGN KEY (parentarticle_id) REFERENCES article(id) ON DELETE NO ACTION,
FOREIGN KEY (parentattributename_id) REFERENCES attributename(id) ON DELETE NO ACTION
);
CREATE UNIQUE INDEX IF NOT EXISTS UQ_article_fatherarticle_fathersort
ON article(fatherarticle_id, fathersort)
WHERE fatherarticle_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS UQ_article_parentarticle_parentsort
ON article(parentarticle_id, parentsort)
WHERE parentarticle_id IS NOT NULL;
`;
export const INIT_ARTICLEEAN = `
@@ -536,6 +536,17 @@ CREATE TABLE IF NOT EXISTS articlehistory (
FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE SET NULL
);
`;
export const INIT_COMPANYHISTORY = `
CREATE TABLE IF NOT EXISTS companyhistory (
id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL,
companyuser_id INTEGER,
"date" TEXT NOT NULL DEFAULT (${UTC_NOW}),
"text" TEXT NOT NULL,
FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE,
FOREIGN KEY (companyuser_id) REFERENCES companyuser(id) ON DELETE SET NULL
);
`;
export const INIT_ARTICLETOMBSTONE = `
CREATE TABLE IF NOT EXISTS articletombstone (
@@ -705,7 +716,7 @@ CREATE TABLE IF NOT EXISTS articleproperty (
value TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
FOREIGN KEY (property_id) REFERENCES propertyname(id) ON DELETE CASCADE
FOREIGN KEY (property_id) REFERENCES propertyname(id) ON DELETE NO ACTION
);
`;
@@ -739,7 +750,7 @@ CREATE TABLE IF NOT EXISTS articleattributevalue (
attributevalue_id INTEGER NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE,
FOREIGN KEY (attributevalue_id) REFERENCES attributevalue(id) ON DELETE CASCADE,
FOREIGN KEY (attributevalue_id) REFERENCES attributevalue(id) ON DELETE NO ACTION,
UNIQUE (article_id, attributevalue_id)
);
`;
@@ -752,7 +763,7 @@ CREATE TABLE IF NOT EXISTS categoryproperty (
value TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE,
FOREIGN KEY (property_id) REFERENCES propertyname(id) ON DELETE CASCADE
FOREIGN KEY (property_id) REFERENCES propertyname(id) ON DELETE NO ACTION
);
`;
@@ -818,6 +829,7 @@ export const SCHEMA_SCRIPTS = [
INIT_ARTICLEHISTORY,
INIT_ARTICLETOMBSTONE,
INIT_ENTITYHISTORY,
INIT_COMPANYHISTORY,
INIT_SYNCPOS,
INIT_COMPANYSETTING,
INIT_POSTERMINAL,

View File

@@ -16,11 +16,13 @@ import resolveArticleBySku from './resolveArticleBySku.js';
import getBySku from './getBySku.js';
import makeRunner from '../runner.js';
import resolveUniqueSlug from './resolveUniqueSlug.js';
import attachChild from './attachChild.js';
export default function addChild(provider) {
const resolve = resolveArticleBySku(provider);
const load = getBySku(provider);
const resolveSlug = resolveUniqueSlug(provider);
const attach = attachChild(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteArticle : mssqlArticle;
@@ -32,47 +34,56 @@ export default function addChild(provider) {
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Adds a child article to a father article.
* Adds a child article to a parent article (creates and attaches with auto-generated SKU).
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} fatherSku - The father article SKU.
* @param {string} parentSku - The parent article SKU.
* @param {object} options
* @param {number} [options.attributeValueId] - Existing attribute value ID for father's attribute.
* @param {string} [options.value] - String value to find or create under father's attribute.
* @param {string} [options.name] - Custom name for child article (defaults to father name + value).
* @param {boolean} [options.attach=false] - If true, attaches an existing article instead of creating.
* @param {number} [options.attributeValueId] - Existing attribute value ID for parent's attribute.
* @param {string} [options.value] - String value to find or create under parent's attribute.
* @param {string} [options.name] - Custom name for child article (defaults to parent name + value).
* @param {number} [options.unitamount] - Optional unitamount override.
* @param {number} [options.weight] - Optional weight override.
* @param {boolean|number} [options.track_stock] - Optional track_stock override.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The created child article detail.
*/
return async (googleAuthId, fatherSku, options = {}, tx) => {
return async (googleAuthId, parentSku, options = {}, tx) => {
if (options.attach) {
return attach(googleAuthId, parentSku, options, tx);
}
const run = async (runner) => {
const { articleId: fatherId, companyId, languageId, companyuserId } = await resolve(
if (options.sku || options.childSku) {
throw new Error('sku is not allowed when creating a child article; omit sku to auto-generate or use PUT to attach an existing article');
}
const { articleId: parentId, companyId, languageId, companyuserId } = await resolve(
googleAuthId,
fatherSku,
parentSku,
runner,
);
// Fetch father article fields
const fatherRes = await runner.query(
'SELECT id, sku, company_id, unit_id, unitamount, weight, track_stock, brand_id, is_father, fatherarticle_id, fatherattributename_id FROM article WHERE id = @fatherId',
{ fatherId },
// Fetch parent article fields
const parentRes = await runner.query(
'SELECT id, sku, company_id, unit_id, unitamount, weight, track_stock, brand_id, is_parent, parentarticle_id, parentattributename_id FROM article WHERE id = @parentId',
{ parentId },
);
const father = fatherRes.rows[0];
if (!father) {
throw new Error('Father article not found');
const parent = parentRes.rows[0];
if (!parent) {
throw new Error('Parent article not found');
}
if (!father.is_father || !father.fatherattributename_id) {
throw new Error('Article is not a father article');
if (!parent.is_parent || !parent.parentattributename_id) {
throw new Error('Article is not a parent article');
}
// Fetch father's text / name
const fatherTextRes = await runner.query(
'SELECT name FROM articletext WHERE article_id = @fatherId AND language_id = @languageId',
{ fatherId, languageId },
// Fetch parent's text / name
const parentTextRes = await runner.query(
'SELECT name FROM articletext WHERE article_id = @parentId AND language_id = @languageId',
{ parentId, languageId },
);
const fatherName = fatherTextRes.rows[0]?.name || father.sku;
const parentName = parentTextRes.rows[0]?.name || parent.sku;
// Determine attribute value
let finalValueId = null;
@@ -87,15 +98,15 @@ export default function addChild(provider) {
companyId,
});
const row = valRes.rows[0];
if (!row || row.attributeId !== father.fatherattributename_id) {
throw new Error('Attribute value not found or does not match father attribute');
if (!row || row.attributeId !== parent.parentattributename_id) {
throw new Error('Attribute value not found or does not match parent attribute');
}
finalValueId = row.id;
finalValue = row.value;
} else if (typeof options.value === 'string' && options.value.trim()) {
const valStr = options.value.trim();
const existingValRes = await runner.query(attrValQueries.GET_BY_ATTRIBUTE_AND_VALUE, {
attributeId: father.fatherattributename_id,
attributeId: parent.parentattributename_id,
value: valStr,
});
if (existingValRes.rows[0]) {
@@ -103,11 +114,11 @@ export default function addChild(provider) {
finalValue = existingValRes.rows[0].value;
} else {
const sortRes = await runner.query(attrValQueries.GET_NEXT_SORT, {
attributeId: father.fatherattributename_id,
attributeId: parent.parentattributename_id,
});
const sort = sortRes.rows[0]?.sort ?? 0;
const createdValRes = await runner.query(attrValQueries.CREATE, {
attributeId: father.fatherattributename_id,
attributeId: parent.parentattributename_id,
value: valStr,
sort,
});
@@ -118,88 +129,74 @@ export default function addChild(provider) {
throw new Error('Attribute value is required for child article');
}
// Check if father already has a variant with this attribute value
// Check if parent already has a variant with this attribute value
const existingVariantRes = await runner.query(
`SELECT a.id
FROM article a
INNER JOIN articleattributevalue aav ON aav.article_id = a.id
WHERE a.fatherarticle_id = @fatherId
WHERE a.parentarticle_id = @parentId
AND aav.attributevalue_id = @finalValueId`,
{ fatherId, finalValueId },
{ parentId, finalValueId },
);
if (existingVariantRes.rows.length > 0) {
throw new Error('Variant with this attribute value already exists');
}
// Compute child SKU:
// If options.sku is provided, use it.
// Otherwise, derive using next free index: fatherSku-1, fatherSku-2, ...
let childSku = (typeof options.sku === 'string' && options.sku.trim()) || null;
if (!childSku) {
const existingSkusRes = await runner.query(
'SELECT sku FROM article WHERE company_id = @companyId AND sku LIKE @prefix',
{ companyId, prefix: `${father.sku}-%` },
);
const usedIndices = new Set();
const prefix = `${father.sku}-`;
for (const row of existingSkusRes.rows) {
if (row.sku && row.sku.startsWith(prefix)) {
const numPart = row.sku.slice(prefix.length);
if (/^\d+$/.test(numPart)) {
usedIndices.add(parseInt(numPart, 10));
}
// Compute child SKU using next free index: parentSku-1, parentSku-2, ...
const existingSkusRes = await runner.query(
'SELECT sku FROM article WHERE company_id = @companyId AND sku LIKE @prefix',
{ companyId, prefix: `${parent.sku}-%` },
);
const usedIndices = new Set();
const prefix = `${parent.sku}-`;
for (const row of existingSkusRes.rows) {
if (row.sku && row.sku.startsWith(prefix)) {
const numPart = row.sku.slice(prefix.length);
if (/^\d+$/.test(numPart)) {
usedIndices.add(parseInt(numPart, 10));
}
}
let nextIndex = 1;
while (usedIndices.has(nextIndex)) {
nextIndex++;
}
childSku = `${father.sku}-${nextIndex}`;
}
// Check if child SKU already exists (e.g. orphan holding SKU or custom SKU)
const existingChildRes = await runner.query(
'SELECT id FROM article WHERE sku = @childSku AND company_id = @companyId',
{ childSku, companyId },
);
if (existingChildRes.rows[0]) {
throw new Error(`Child article "${childSku}" already exists`);
let nextIndex = 1;
while (usedIndices.has(nextIndex)) {
nextIndex++;
}
const childSku = `${parent.sku}-${nextIndex}`;
// Next free fathersort (smallest non-negative integer not used by any child of this father)
// Next free parentsort (smallest non-negative integer not used by any child of this parent)
const existingSortsRes = await runner.query(
'SELECT fathersort FROM article WHERE fatherarticle_id = @fatherId ORDER BY fathersort ASC',
{ fatherId },
'SELECT parentsort FROM article WHERE parentarticle_id = @parentId ORDER BY parentsort ASC',
{ parentId },
);
const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.fathersort)));
let fathersort = 0;
while (usedSorts.has(fathersort)) {
fathersort++;
const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.parentsort)));
let parentsort = 0;
while (usedSorts.has(parentsort)) {
parentsort++;
}
// Create child article
// Auto-create child article
const rawTrack = options.track_stock ?? options.trackStock;
const finalTrackStock = rawTrack == null ? father.track_stock : (rawTrack ? 1 : 0);
const finalTrackStock = rawTrack == null ? parent.track_stock : (rawTrack ? 1 : 0);
const childRes = await runner.query(queries.CREATE_CHILD, {
companyId,
sku: childSku,
unitId: father.unit_id,
unitamount: options.unitamount ?? father.unitamount,
weight: options.weight ?? father.weight,
unitId: parent.unit_id,
unitamount: options.unitamount ?? parent.unitamount,
weight: options.weight ?? parent.weight,
trackStock: finalTrackStock,
brandId: father.brand_id,
fatherArticleId: fatherId,
fathersort,
brandId: parent.brand_id,
parentArticleId: parentId,
parentsort,
});
const childId = childRes.rows[0]?.id ?? childRes.insertId;
// Copy existing attribute values from father (ancestor attributes that father has)
const fatherAttrsRes = await runner.query(
'SELECT attributevalue_id, sort FROM articleattributevalue WHERE article_id = @fatherId',
{ fatherId },
// Copy existing attribute values from parent (ancestor attributes that parent has)
const parentAttrsRes = await runner.query(
'SELECT attributevalue_id, sort FROM articleattributevalue WHERE article_id = @parentId',
{ parentId },
);
for (const faRow of fatherAttrsRes.rows) {
for (const faRow of parentAttrsRes.rows) {
if (faRow.attributevalue_id !== finalValueId) {
await runner.query(articleAttrValQueries.INSERT_ROW, {
articleId: childId,
@@ -216,10 +213,10 @@ export default function addChild(provider) {
sort: 0,
});
// Copy father's categories
// Copy parent's categories
const catRes = await runner.query(
'SELECT category_id FROM categoryarticle WHERE article_id = @fatherId',
{ fatherId },
'SELECT category_id FROM categoryarticle WHERE article_id = @parentId',
{ parentId },
);
for (const catRow of catRes.rows) {
await runner.query(addCategoryArticleSql, {
@@ -229,7 +226,7 @@ export default function addChild(provider) {
}
// Create article text
const childName = options.name?.trim() || `${fatherName} - ${finalValue}`;
const childName = options.name?.trim() || `${parentName} - ${finalValue}`;
const childSlug = await resolveSlug(runner, {
companyId,
languageId,
@@ -246,12 +243,12 @@ export default function addChild(provider) {
await runner.query(insertHistorySql, {
articleId: childId,
companyuserId,
text: `Artikel "${childName}" (${childSku}) als Variante von "${father.sku}" angelegt`,
text: `Artikel "${childName}" (${childSku}) als Variante von "${parent.sku}" angelegt`,
});
// History on father
// History on parent
await runner.query(insertHistorySql, {
articleId: fatherId,
articleId: parentId,
companyuserId,
text: `Variante "${childName}" (${childSku}) hinzugefügt`,
});

View File

@@ -0,0 +1,321 @@
import * as mssqlArticle from '../../providers/mssql/queries/article.js';
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
import * as mssqlAttrVal from '../../providers/mssql/queries/attributevalue.js';
import * as sqliteAttrVal from '../../providers/sqlite3/queries/attributevalue.js';
import * as mssqlArticleAttrVal from '../../providers/mssql/queries/articleattributevalue.js';
import * as sqliteArticleAttrVal from '../../providers/sqlite3/queries/articleattributevalue.js';
import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js';
import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js';
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import getBySku from './getBySku.js';
import listChildren from './listChildren.js';
import makeRunner from '../runner.js';
export default function attachChild(provider) {
const resolve = resolveArticleBySku(provider);
const load = getBySku(provider);
const list = listChildren(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const queries = isSqlite ? sqliteArticle : mssqlArticle;
const attrValQueries = isSqlite ? sqliteAttrVal : mssqlAttrVal;
const articleAttrValQueries = isSqlite ? sqliteArticleAttrVal : mssqlArticleAttrVal;
const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE;
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Attaches an existing article as a child to a parent article.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} parentSku - The parent article SKU.
* @param {object} options
* @param {string} options.sku - The child article SKU to attach.
* @param {string} [options.childSku] - Alias for options.sku.
* @param {number} [options.attributeValueId] - Existing attribute value ID for parent's attribute.
* @param {string} [options.value] - String value to find or create under parent's attribute.
* @param {boolean} [options.reparent=false] - Whether to allow re-parenting if child belongs to another parent.
* @param {number} [options.unitamount] - Optional unitamount override.
* @param {number} [options.weight] - Optional weight override.
* @param {boolean|number} [options.track_stock] - Optional track_stock override.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{article: object, children: Array<object>}>}
*/
return async (googleAuthId, parentSku, options = {}, tx) => {
const run = async (runner) => {
const childSku = (typeof options.sku === 'string' && options.sku.trim())
|| (typeof options.childSku === 'string' && options.childSku.trim())
|| null;
if (!childSku) {
throw new Error('sku is required to attach an existing article');
}
const { articleId: parentId, companyId, languageId, companyuserId } = await resolve(
googleAuthId,
parentSku,
runner,
);
// Fetch parent article fields
const parentRes = await runner.query(
'SELECT id, sku, company_id, is_parent, parentattributename_id FROM article WHERE id = @parentId',
{ parentId },
);
const parent = parentRes.rows[0];
if (!parent) {
throw new Error('Parent article not found');
}
if (!parent.is_parent || !parent.parentattributename_id) {
throw new Error('Article is not a parent article');
}
// Check child article exists in company
const childRes = await runner.query(
'SELECT id, sku, company_id, parentarticle_id, is_parent FROM article WHERE sku = @childSku AND company_id = @companyId',
{ childSku, companyId },
);
const child = childRes.rows[0];
if (!child) {
throw new Error('Article not found');
}
if (child.id === parentId) {
throw new Error('An article cannot be a child of itself');
}
// Determine attribute value
let finalValueId = null;
let finalValue = null;
if (options.attributeValueId != null) {
if (typeof options.attributeValueId !== 'number' || !Number.isInteger(options.attributeValueId) || options.attributeValueId <= 0) {
throw new Error('Invalid attributeValueId');
}
const valRes = await runner.query(attrValQueries.GET_BY_ID_AND_COMPANY, {
id: options.attributeValueId,
companyId,
});
const row = valRes.rows[0];
if (!row || row.attributeId !== parent.parentattributename_id) {
throw new Error('Attribute value not found or does not match parent attribute');
}
finalValueId = row.id;
finalValue = row.value;
} else if (typeof options.value === 'string' && options.value.trim()) {
const valStr = options.value.trim();
const existingValRes = await runner.query(attrValQueries.GET_BY_ATTRIBUTE_AND_VALUE, {
attributeId: parent.parentattributename_id,
value: valStr,
});
if (existingValRes.rows[0]) {
finalValueId = existingValRes.rows[0].id;
finalValue = existingValRes.rows[0].value;
} else {
const sortRes = await runner.query(attrValQueries.GET_NEXT_SORT, {
attributeId: parent.parentattributename_id,
});
const sort = sortRes.rows[0]?.sort ?? 0;
const createdValRes = await runner.query(attrValQueries.CREATE, {
attributeId: parent.parentattributename_id,
value: valStr,
sort,
});
finalValueId = createdValRes.rows[0]?.id ?? createdValRes.insertId;
finalValue = valStr;
}
} else {
throw new Error('Attribute value is required for child article');
}
const isAlreadyChildOfThisParent = child.parentarticle_id === parentId;
if (child.parentarticle_id != null && !isAlreadyChildOfThisParent && !options.reparent) {
throw new Error('Article already belongs to another parent');
}
// Check if parent already has a different child with this attribute value
const existingVariantRes = await runner.query(
`SELECT a.id
FROM article a
INNER JOIN articleattributevalue aav ON aav.article_id = a.id
WHERE a.parentarticle_id = @parentId
AND a.id != @childId
AND aav.attributevalue_id = @finalValueId`,
{ parentId, childId: child.id, finalValueId },
);
if (existingVariantRes.rows.length > 0) {
throw new Error('Variant with this attribute value already exists');
}
// If re-parenting from another parent, unparent and recompact previous parent
if (child.parentarticle_id != null && !isAlreadyChildOfThisParent && options.reparent) {
const prevParentId = child.parentarticle_id;
await runner.query(insertHistorySql, {
articleId: prevParentId,
companyuserId,
text: `Variante (${childSku}) getrennt (umgehängt zu "${parent.sku}")`,
});
// Recompact previous parent's children
const prevRemainingRes = await runner.query(
'SELECT id, parentsort FROM article WHERE parentarticle_id = @prevParentId AND id != @childId ORDER BY parentsort ASC, id ASC',
{ prevParentId, childId: child.id },
);
for (let i = 0; i < prevRemainingRes.rows.length; i++) {
const row = prevRemainingRes.rows[i];
if (Number(row.parentsort) !== i) {
await runner.query(queries.SET_PARENT_SORT, {
articleId: row.id,
parentArticleId: prevParentId,
parentsort: i,
});
}
}
}
// Assign parentsort if not already child of this parent
let parentsort;
if (isAlreadyChildOfThisParent) {
const currentSortRes = await runner.query(
'SELECT parentsort FROM article WHERE id = @childId',
{ childId: child.id },
);
parentsort = Number(currentSortRes.rows[0]?.parentsort ?? 0);
} else {
const existingSortsRes = await runner.query(
'SELECT parentsort FROM article WHERE parentarticle_id = @parentId ORDER BY parentsort ASC',
{ parentId },
);
const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.parentsort)));
parentsort = 0;
while (usedSorts.has(parentsort)) {
parentsort++;
}
}
// Link child to parent
await runner.query(queries.UPDATE_PARENT_BY_SKU, {
sku: childSku,
companyId,
parentArticleId: parentId,
parentsort,
});
// Apply optional field overrides
if (options.unitamount != null) {
await runner.query('UPDATE article SET unitamount = @unitamount WHERE id = @childId', {
childId: child.id,
unitamount: options.unitamount,
});
}
if (options.weight != null) {
await runner.query(queries.UPDATE_WEIGHT, {
articleId: child.id,
weight: options.weight,
});
}
const rawTrack = options.track_stock ?? options.trackStock;
if (rawTrack != null) {
await runner.query(queries.UPDATE_TRACK_STOCK, {
articleId: child.id,
trackStock: rawTrack ? 1 : 0,
});
}
// Remove existing attribute value for this parent's attribute on child (if any)
if (parent.parentattributename_id) {
await runner.query(
`DELETE FROM articleattributevalue
WHERE article_id = @childId
AND attributevalue_id IN (
SELECT id FROM attributevalue WHERE attribute_id = @attributeId
)`,
{ childId: child.id, attributeId: parent.parentattributename_id },
);
}
await runner.query(articleAttrValQueries.INSERT_ROW, {
articleId: child.id,
attributeValueId: finalValueId,
sort: 0,
});
// Copy ancestor attributes from parent (if missing on child)
const parentAttrsRes = await runner.query(
'SELECT attributevalue_id, sort FROM articleattributevalue WHERE article_id = @parentId',
{ parentId },
);
for (const faRow of parentAttrsRes.rows) {
if (faRow.attributevalue_id !== finalValueId) {
const hasAttrRes = await runner.query(
'SELECT 1 FROM articleattributevalue WHERE article_id = @childId AND attributevalue_id = @attrValId',
{ childId: child.id, attrValId: faRow.attributevalue_id },
);
if (hasAttrRes.rows.length === 0) {
await runner.query(articleAttrValQueries.INSERT_ROW, {
articleId: child.id,
attributeValueId: faRow.attributevalue_id,
sort: faRow.sort,
});
}
}
}
// Copy parent's categories (if missing on child)
const catRes = await runner.query(
'SELECT category_id FROM categoryarticle WHERE article_id = @parentId',
{ parentId },
);
for (const catRow of catRes.rows) {
const hasCatRes = await runner.query(
'SELECT 1 FROM categoryarticle WHERE article_id = @childId AND category_id = @categoryId',
{ childId: child.id, categoryId: catRow.category_id },
);
if (hasCatRes.rows.length === 0) {
await runner.query(addCategoryArticleSql, {
categoryId: catRow.category_id,
articleId: child.id,
});
}
}
// Fetch child name for history
const childTextRes = await runner.query(
'SELECT name FROM articletext WHERE article_id = @childId AND language_id = @languageId',
{ childId: child.id, languageId },
);
const childName = childTextRes.rows[0]?.name || childSku;
// History only if not already child
if (!isAlreadyChildOfThisParent) {
await runner.query(insertHistorySql, {
articleId: child.id,
companyuserId,
text: `Artikel "${childName}" (${childSku}) als Variante von "${parent.sku}" zugewiesen`,
});
await runner.query(insertHistorySql, {
articleId: parentId,
companyuserId,
text: `Variante "${childName}" (${childSku}) hinzugefügt`,
});
}
const updated = await load(googleAuthId, childSku, runner);
const children = await list(googleAuthId, parentSku, runner);
return {
article: updated.article,
children,
};
};
if (tx) {
return run(makeRunner(provider, tx));
}
if (typeof provider?.transaction === 'function') {
return provider.transaction(run);
}
return run(makeRunner(provider));
};
}

View File

@@ -69,11 +69,11 @@ export default function del(provider) {
await deleteArticleAndLists(id);
};
const fatherRes = await runner.query(
'SELECT fatherarticle_id FROM article WHERE id = @articleId',
const parentRes = await runner.query(
'SELECT parentarticle_id FROM article WHERE id = @articleId',
{ articleId },
);
const fatherArticleId = fatherRes.rows[0]?.fatherarticle_id;
const parentArticleId = parentRes.rows[0]?.parentarticle_id;
const childCount = await runner.query(queries.GET_CHILD_COUNT, { articleId });
if (childCount.rows[0].count > 0) {
@@ -85,18 +85,18 @@ export default function del(provider) {
await deleteArticleAndLists(articleId);
}
if (fatherArticleId != null) {
if (parentArticleId != null) {
const remainingChildrenRes = await runner.query(
'SELECT id, fathersort FROM article WHERE fatherarticle_id = @fatherArticleId ORDER BY fathersort ASC, id ASC',
{ fatherArticleId },
'SELECT id, parentsort FROM article WHERE parentarticle_id = @parentArticleId ORDER BY parentsort ASC, id ASC',
{ parentArticleId },
);
for (let i = 0; i < remainingChildrenRes.rows.length; i++) {
const childRow = remainingChildrenRes.rows[i];
if (Number(childRow.fathersort) !== i) {
await runner.query(articleQueries.SET_FATHER_SORT, {
if (Number(childRow.parentsort) !== i) {
await runner.query(articleQueries.SET_PARENT_SORT, {
articleId: childRow.id,
fatherArticleId,
fathersort: i,
parentArticleId,
parentsort: i,
});
}
}

View File

@@ -18,7 +18,7 @@ export default function getBySku(provider) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, track_stock: boolean, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null}}>} The full article detail.
* @returns {Promise<{article: {id: number, sku: string, name: string, slug: string, unitamount: number|null, weight: number|null, track_stock: boolean, unit: {si: string|null, basesi: string|null, basesifactor: number|null, symbol: string|null, name: string|null}, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null, categoryIds: number[], photos: Array<{sort: number, fullHash: string, fullW: number, fullH: number, fullSize: number, thumbW: number, thumbH: number, thumbHash: string}>, eans: string[], prices: Array<{minmultiplier: number, net: number, taxRate: number, customergroupId: number}>, suppliers: Array<{id: number, articleId: number, entityId: number, supplierName: string|null, sku: string|null, pu: number, stock: number|null, sort: number, priceId: number|null, price: {net: number, taxId: number, taxRate?: number}|null}>, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null}}>} The full article detail.
* @throws {Error} If the article is not found or the user is not authorized.
*
* @example
@@ -118,16 +118,16 @@ export default function getBySku(provider) {
sort: Number(item.sort ?? 0),
})),
childCount: a.childCount,
isFather: Boolean(a.isFather),
fatherAttributeNameId: a.fatherAttributeNameId ?? null,
fatherAttributeName: a.fatherAttributeName ?? null,
fatherAttributeExclusive: a.fatherAttributeExclusive != null ? Boolean(a.fatherAttributeExclusive) : null,
fatherArticleId: a.fatherArticleId,
fatherSku: a.fatherSku,
isParent: Boolean(a.isParent),
parentAttributeNameId: a.parentAttributeNameId ?? null,
parentAttributeName: a.parentAttributeName ?? null,
parentAttributeExclusive: a.parentAttributeExclusive != null ? Boolean(a.parentAttributeExclusive) : null,
parentArticleId: a.parentArticleId,
parentSku: a.parentSku,
children: parseJsonArray(a.childrenJson).map((c) => ({
id: c.id,
sku: c.sku,
fathersort: c.fathersort,
parentsort: c.parentsort,
name: c.name ?? null,
attributeValueId: c.attributeValueId ?? null,
attributeValue: c.attributeValue ?? null,

View File

@@ -87,7 +87,7 @@ export default function importRows(provider) {
* Imports article rows, creating articles by their SKU and returning per-row
* import, skip and error outcomes.
*
* The import runs in **two passes** so a `fatherSku` that references a row
* The import runs in **two passes** so a `parentSku` that references a row
* appearing later in the file still resolves. A row is **skipped** when its
* SKU already exists in the company (upsert mode is not applied), and an EAN
* is skipped when already used by another row.
@@ -97,7 +97,7 @@ export default function importRows(provider) {
* @param {Array<object>} input.rows - The article rows to import.
* @param {string} input.rows[].sku - The article SKU.
* @param {string} input.rows[].name - The article name.
* @param {string} [input.rows[].vk] - `'V'` (father/compound) or `'K'` (variant) marker.
* @param {string} [input.rows[].vk] - `'V'` (parent/compound) or `'K'` (variant) marker.
* @param {number|string} [input.rows[].parentId] - The id of the parent category.
* @param {string} [input.rows[].parentCategory] - The category breadcrumb path, e.g. `'Flowers | CBN'`.
* @param {string} [input.rows[].brand] - The brand name to upsert.
@@ -105,7 +105,7 @@ export default function importRows(provider) {
* @param {number|string} [input.rows[].tax] - The tax rate in percent (e.g. `19`).
* @param {Array<string>|string} [input.rows[].eans] - The EANs to set.
* @param {boolean|number} [input.rows[].track_stock=true] - Whether inventory stock is tracked.
* @param {string} [input.rows[].fatherSku] - The father article SKU for a V/K compound link.
* @param {string} [input.rows[].parentSku] - The parent article SKU for a V/K compound link.
* @param {number|string} [input.rows[].deposit] - The deposit (Pfand) value; currently ignored.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{imported: Array<object>, skipped: Array<object>, errors: Array<object>}>} The import outcome.
@@ -114,7 +114,7 @@ export default function importRows(provider) {
* const result = await store.article.importRows({
* googleAuthId: 42,
* rows: [
* { sku: '42001190-1', name: '420Strainz 1g', vk: 'K', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '11,00', tax: 19, eans: ['4262557690455'], fatherSku: '42001190' },
* { sku: '42001190-1', name: '420Strainz 1g', vk: 'K', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '11,00', tax: 19, eans: ['4262557690455'], parentSku: '42001190' },
* { sku: '42001190', name: '420Strainz', vk: 'V', parentCategory: 'Flowers | CBN', brand: 'Seedstockers', net: '0,00', tax: 19 },
* ],
* });
@@ -275,63 +275,63 @@ export default function importRows(provider) {
}
}
// Pass 2: wire V/K father links (fathers may be created in pass 1).
// Pass 2: wire V/K parent links (parents may be created in pass 1).
for (const row of rows) {
const sku = String(row?.sku ?? '').trim();
const fatherSku =
row?.fatherSku != null ? String(row.fatherSku).trim() : '';
if (!sku || !fatherSku) continue;
const parentSku =
row?.parentSku != null ? String(row.parentSku).trim() : '';
if (!sku || !parentSku) continue;
try {
await runInTx(async (runner) => {
const child = await runner.query(articleQueries.GET_ID_BY_SKU, { sku, companyId });
const childId = child.rows[0]?.articleId;
if (childId) {
const father = await runner.query(
'SELECT id, is_father FROM article WHERE sku = @sku AND (@companyId IS NULL OR company_id = @companyId)',
{ sku: fatherSku, companyId },
const parent = await runner.query(
'SELECT id, is_parent FROM article WHERE sku = @sku AND (@companyId IS NULL OR company_id = @companyId)',
{ sku: parentSku, companyId },
);
const fatherRow = father.rows[0];
const fatherId = fatherRow?.id;
if (fatherId && fatherId !== childId) {
const wasFather = Boolean(fatherRow.is_father);
const parentRow = parent.rows[0];
const parentId = parentRow?.id;
if (parentId && parentId !== childId) {
const wasParent = Boolean(parentRow.is_parent);
const existingSortsRes = await runner.query(
'SELECT fathersort FROM article WHERE fatherarticle_id = @fatherId ORDER BY fathersort ASC',
{ fatherId },
'SELECT parentsort FROM article WHERE parentarticle_id = @parentId ORDER BY parentsort ASC',
{ parentId },
);
const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.fathersort)));
let fathersort = 0;
while (usedSorts.has(fathersort)) {
fathersort++;
const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.parentsort)));
let parentsort = 0;
while (usedSorts.has(parentsort)) {
parentsort++;
}
await runner.query(articleQueries.UPDATE_FATHER_BY_SKU, {
await runner.query(articleQueries.UPDATE_PARENT_BY_SKU, {
sku,
companyId,
fatherArticleId: fatherId,
fathersort,
parentArticleId: parentId,
parentsort,
});
await runner.query(articleQueries.SET_IS_FATHER, {
articleId: fatherId,
await runner.query(articleQueries.SET_IS_PARENT, {
articleId: parentId,
});
if (!wasFather) {
if (!wasParent) {
await runner.query(insertHistorySql, {
articleId: fatherId,
articleId: parentId,
companyuserId,
text: 'Artikel zu Vaterartikel konvertiert',
});
}
const childName = String(row?.name ?? sku).trim();
await runner.query(insertHistorySql, {
articleId: fatherId,
articleId: parentId,
companyuserId,
text: `Variante "${childName}" (${sku}) hinzugefügt`,
});
await runner.query(insertHistorySql, {
articleId: childId,
companyuserId,
text: `Artikel "${childName}" (${sku}) als Variante von "${fatherSku}" zugewiesen`,
text: `Artikel "${childName}" (${sku}) als Variante von "${parentSku}" zugewiesen`,
});
}
}

View File

@@ -9,40 +9,40 @@ export default function listChildren(provider) {
const queries = isSqlite ? sqliteArticle : mssqlArticle;
/**
* Lists all children of a father article.
* Lists all children of a parent article.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} fatherSku - The father article SKU.
* @param {string} parentSku - The parent article SKU.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<Array<{id: number, sku: string, fathersort: number, trackStock: boolean, unitamount: number, weight: number, name: string|null, slug: string|null, attributeValueId: number|null, attributeValue: string|null}>>}
* @returns {Promise<Array<{id: number, sku: string, parentsort: number, trackStock: boolean, unitamount: number, weight: number, name: string|null, slug: string|null, attributeValueId: number|null, attributeValue: string|null}>>}
*/
return async (googleAuthId, fatherSku, tx) => {
return async (googleAuthId, parentSku, tx) => {
const run = async (runner) => {
const { articleId: fatherId, languageId } = await resolve(
const { articleId: parentId, languageId } = await resolve(
googleAuthId,
fatherSku,
parentSku,
runner,
);
const fatherRes = await runner.query(
'SELECT is_father, fatherattributename_id FROM article WHERE id = @fatherId',
{ fatherId },
const parentRes = await runner.query(
'SELECT is_parent, parentattributename_id FROM article WHERE id = @parentId',
{ parentId },
);
const father = fatherRes.rows[0];
if (!father || !father.is_father) {
const parent = parentRes.rows[0];
if (!parent || !parent.is_parent) {
return [];
}
const result = await runner.query(queries.LIST_CHILDREN_BY_FATHER_ID, {
fatherArticleId: fatherId,
const result = await runner.query(queries.LIST_CHILDREN_BY_PARENT_ID, {
parentArticleId: parentId,
languageId,
attributeId: father.fatherattributename_id,
attributeId: parent.parentattributename_id,
});
return result.rows.map((row) => ({
id: row.id,
sku: row.sku,
fathersort: Number(row.fathersort ?? 0),
parentsort: Number(row.parentsort ?? 0),
trackStock: Boolean(row.trackStock ?? 1),
unitamount: Number(row.unitamount ?? 1),
weight: Number(row.weight ?? 0),

View File

@@ -35,8 +35,8 @@ function shapeArticleDetail(row, categoryIds, photos, eans, prices) {
optionsCount: row.optionsCount,
articleListCount: row.articleListCount,
childCount: row.childCount,
fatherArticleId: row.fatherArticleId,
fatherSku: row.fatherSku,
parentArticleId: row.parentArticleId,
parentSku: row.parentSku,
};
}

View File

@@ -15,34 +15,34 @@ export default function removeChild(provider) {
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Removes a child article from a father article, either by unlinking or deleting it.
* Removes a child article from a parent article, either by unlinking or deleting it.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} fatherSku - The father article SKU.
* @param {string} parentSku - The parent article SKU.
* @param {string} childSku - The child article SKU.
* @param {object} [options]
* @param {boolean} [options.deleteArticle=false] - Whether to delete the child article entity entirely.
* @param {boolean} [options.cascade=true] - Whether to cascade delete child subtrees when deleteArticle is true.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{success: boolean, fatherSku: string, childSku: string, deleted: boolean}>}
* @returns {Promise<{success: boolean, parentSku: string, childSku: string, deleted: boolean}>}
*/
return async (googleAuthId, fatherSku, childSku, options = {}, tx) => {
return async (googleAuthId, parentSku, childSku, options = {}, tx) => {
const run = async (runner) => {
const { articleId: fatherId, languageId, companyuserId } = await resolve(
const { articleId: parentId, languageId, companyuserId } = await resolve(
googleAuthId,
fatherSku,
parentSku,
runner,
);
const { articleId: childId } = await resolve(googleAuthId, childSku, runner);
// Verify child relationship
const childRes = await runner.query(
'SELECT fatherarticle_id FROM article WHERE id = @childId',
'SELECT parentarticle_id FROM article WHERE id = @childId',
{ childId },
);
const child = childRes.rows[0];
if (!child || child.fatherarticle_id !== fatherId) {
throw new Error('Child article does not belong to this father');
if (!child || child.parentarticle_id !== parentId) {
throw new Error('Child article does not belong to this parent');
}
// Fetch child name before any deletion (needed for history regardless of path)
@@ -54,18 +54,18 @@ export default function removeChild(provider) {
);
const childName = childNameRes.rows[0]?.name || childSku;
const recompactFatherSort = async () => {
const recompactParentSort = async () => {
const remainingChildrenRes = await runner.query(
'SELECT id, fathersort FROM article WHERE fatherarticle_id = @fatherId ORDER BY fathersort ASC, id ASC',
{ fatherId },
'SELECT id, parentsort FROM article WHERE parentarticle_id = @parentId ORDER BY parentsort ASC, id ASC',
{ parentId },
);
for (let i = 0; i < remainingChildrenRes.rows.length; i++) {
const childRow = remainingChildrenRes.rows[i];
if (Number(childRow.fathersort) !== i) {
await runner.query(queries.SET_FATHER_SORT, {
if (Number(childRow.parentsort) !== i) {
await runner.query(queries.SET_PARENT_SORT, {
articleId: childRow.id,
fatherArticleId: fatherId,
fathersort: i,
parentArticleId: parentId,
parentsort: i,
});
}
}
@@ -75,67 +75,67 @@ export default function removeChild(provider) {
const cascade = options.cascade !== undefined ? Boolean(options.cascade) : true;
await del(googleAuthId, childSku, { cascade }, runner);
await recompactFatherSort();
await recompactParentSort();
// History on father (child article is gone so we can only log on father)
// History on parent (child article is gone so we can only log on parent)
await runner.query(insertHistorySql, {
articleId: fatherId,
articleId: parentId,
companyuserId,
text: `Variante "${childName}" (${childSku}) gelöscht`,
});
return {
success: true,
fatherSku,
parentSku,
childSku,
deleted: true,
};
}
// Fetch father's attribute name id
const fatherRes = await runner.query(
'SELECT fatherattributename_id FROM article WHERE id = @fatherId',
{ fatherId },
// Fetch parent's attribute name id
const parentRes = await runner.query(
'SELECT parentattributename_id FROM article WHERE id = @parentId',
{ parentId },
);
const fatherAttributeNameId = fatherRes.rows[0]?.fatherattributename_id;
const parentAttributeNameId = parentRes.rows[0]?.parentattributename_id;
// Unlink child from father
// Unlink child from parent
await runner.query(queries.UNLINK_CHILD, {
articleId: childId,
fatherArticleId: fatherId,
parentArticleId: parentId,
});
// Remove the child's attribute value link for the father's attribute
if (fatherAttributeNameId) {
// Remove the child's attribute value link for the parent's attribute
if (parentAttributeNameId) {
await runner.query(
`DELETE FROM articleattributevalue
WHERE article_id = @childId
AND attributevalue_id IN (
SELECT id FROM attributevalue WHERE attribute_id = @fatherAttributeNameId
SELECT id FROM attributevalue WHERE attribute_id = @parentAttributeNameId
)`,
{ childId, fatherAttributeNameId },
{ childId, parentAttributeNameId },
);
}
await recompactFatherSort();
await recompactParentSort();
// Add history on child
await runner.query(insertHistorySql, {
articleId: childId,
companyuserId,
text: `Artikel "${childSku}" von Vaterartikel "${fatherSku}" getrennt`,
text: `Artikel "${childSku}" von Vaterartikel "${parentSku}" getrennt`,
});
// Add history on father
// Add history on parent
await runner.query(insertHistorySql, {
articleId: fatherId,
articleId: parentId,
companyuserId,
text: `Variante "${childName}" (${childSku}) getrennt`,
});
return {
success: true,
fatherSku,
parentSku,
childSku,
deleted: false,
};

View File

@@ -8,7 +8,7 @@ import queryBySku from './queryBySku.js';
import resolveArticleBySku from './resolveArticleBySku.js';
import makeRunner from '../runner.js';
export default function setFatherBySku(provider) {
export default function setParentBySku(provider) {
const resolve = resolveArticleBySku(provider);
const load = queryBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
@@ -18,20 +18,20 @@ export default function setFatherBySku(provider) {
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Links an article to a father article (V/K compound) by their SKUs.
* Links an article to a parent article (V/K compound) by their SKUs.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The child article SKU.
* @param {string|null} fatherSku - The father article SKU, or `null` to unlink.
* @param {string|null} parentSku - The parent article SKU, or `null` to unlink.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<object>} The updated child article.
* @throws {Error} If the article or father is not found, or the link is invalid.
* @throws {Error} If the article or parent is not found, or the link is invalid.
*
* @example
* const article = await store.article.setFatherBySku(42, '42001190-1', '42001190');
* // => { id, sku, name, fatherSku: '42001190', ... }
* const article = await store.article.setParentBySku(42, '42001190-1', '42001190');
* // => { id, sku, name, parentSku: '42001190', ... }
*/
return async (googleAuthId, sku, fatherSku, tx) => {
return async (googleAuthId, sku, parentSku, tx) => {
const runSet = async (runner) => {
const { articleId, languageId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner);
@@ -41,34 +41,34 @@ export default function setFatherBySku(provider) {
);
const childName = childTextRes.rows[0]?.name || sku;
if (fatherSku == null || fatherSku === '') {
const prevFatherRes = await runner.query(
'SELECT fatherarticle_id FROM article WHERE id = @articleId',
if (parentSku == null || parentSku === '') {
const prevParentRes = await runner.query(
'SELECT parentarticle_id FROM article WHERE id = @articleId',
{ articleId },
);
const prevFatherId = prevFatherRes.rows[0]?.fatherarticle_id;
const prevParentId = prevParentRes.rows[0]?.parentarticle_id;
await runner.query(queries.UPDATE_FATHER_BY_SKU, {
await runner.query(queries.UPDATE_PARENT_BY_SKU, {
sku,
companyId,
fatherArticleId: null,
parentArticleId: null,
});
if (prevFatherId) {
const prevFatherTextRes = await runner.query(
'SELECT sku FROM article WHERE id = @prevFatherId',
{ prevFatherId },
if (prevParentId) {
const prevParentTextRes = await runner.query(
'SELECT sku FROM article WHERE id = @prevParentId',
{ prevParentId },
);
const prevFatherSku = prevFatherTextRes.rows[0]?.sku || String(prevFatherId);
const prevParentSku = prevParentTextRes.rows[0]?.sku || String(prevParentId);
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Artikel "${sku}" von Vaterartikel "${prevFatherSku}" getrennt`,
text: `Artikel "${sku}" von Vaterartikel "${prevParentSku}" getrennt`,
});
await runner.query(insertHistorySql, {
articleId: prevFatherId,
articleId: prevParentId,
companyuserId,
text: `Variante "${childName}" (${sku}) getrennt`,
});
@@ -81,52 +81,52 @@ export default function setFatherBySku(provider) {
return unlinked;
}
const father = await runner.query(
'SELECT id, is_father, sku FROM article WHERE sku = @sku AND (@companyId IS NULL OR company_id = @companyId)',
{ sku: fatherSku, companyId },
const parent = await runner.query(
'SELECT id, is_parent, sku FROM article WHERE sku = @sku AND (@companyId IS NULL OR company_id = @companyId)',
{ sku: parentSku, companyId },
);
const fatherRow = father.rows[0];
const fatherId = fatherRow?.id;
if (!fatherId) {
throw new Error('Father not found');
const parentRow = parent.rows[0];
const parentId = parentRow?.id;
if (!parentId) {
throw new Error('Parent not found');
}
if (fatherId === articleId) {
throw new Error('Invalid father');
if (parentId === articleId) {
throw new Error('Invalid parent');
}
const wasFather = Boolean(fatherRow.is_father);
const wasParent = Boolean(parentRow.is_parent);
const existingSortsRes = await runner.query(
'SELECT fathersort FROM article WHERE fatherarticle_id = @fatherId ORDER BY fathersort ASC',
{ fatherId },
'SELECT parentsort FROM article WHERE parentarticle_id = @parentId ORDER BY parentsort ASC',
{ parentId },
);
const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.fathersort)));
let fathersort = 0;
while (usedSorts.has(fathersort)) {
fathersort++;
const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.parentsort)));
let parentsort = 0;
while (usedSorts.has(parentsort)) {
parentsort++;
}
await runner.query(queries.UPDATE_FATHER_BY_SKU, {
await runner.query(queries.UPDATE_PARENT_BY_SKU, {
sku,
companyId,
fatherArticleId: fatherId,
fathersort,
parentArticleId: parentId,
parentsort,
});
await runner.query(queries.SET_IS_FATHER, {
articleId: fatherId,
await runner.query(queries.SET_IS_PARENT, {
articleId: parentId,
});
if (!wasFather) {
if (!wasParent) {
await runner.query(insertHistorySql, {
articleId: fatherId,
articleId: parentId,
companyuserId,
text: 'Artikel zu Vaterartikel konvertiert',
});
}
await runner.query(insertHistorySql, {
articleId: fatherId,
articleId: parentId,
companyuserId,
text: `Variante "${childName}" (${sku}) hinzugefügt`,
});
@@ -134,7 +134,7 @@ export default function setFatherBySku(provider) {
await runner.query(insertHistorySql, {
articleId,
companyuserId,
text: `Artikel "${sku}" als Variante von "${fatherSku}" zugewiesen`,
text: `Artikel "${sku}" als Variante von "${parentSku}" zugewiesen`,
});
const article = await load(articleId, languageId, runner);

View File

@@ -86,9 +86,9 @@ export default function setPricesBySku(provider) {
throw new Error('POS customer group required');
}
const artCheck = await runner.query('SELECT is_father FROM article WHERE id = @articleId', { articleId });
if (artCheck.rows[0]?.is_father) {
throw new Error('A father article cannot have prices');
const artCheck = await runner.query('SELECT is_parent FROM article WHERE id = @articleId', { articleId });
if (artCheck.rows[0]?.is_parent) {
throw new Error('A parent article cannot have prices');
}
// Read old prices for history

View File

@@ -10,7 +10,7 @@ import resolveArticleBySku from './resolveArticleBySku.js';
import getBySku from './getBySku.js';
import makeRunner from '../runner.js';
export default function toggleFather(provider) {
export default function toggleParent(provider) {
const resolve = resolveArticleBySku(provider);
const load = getBySku(provider);
const isSqlite = provider?.dialect === 'sqlite3';
@@ -21,39 +21,39 @@ export default function toggleFather(provider) {
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
/**
* Toggles an article into or out of father mode.
* Toggles an article into or out of parent mode.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
* @param {object} options
* @param {boolean} options.isFather - Whether the article should be a father article.
* @param {number} [options.attributeNameId] - The attribute name id required when enabling father mode.
* @param {number} [options.fatherAttributeNameId] - Alias for attributeNameId.
* @param {boolean} options.isParent - Whether the article should be a parent article.
* @param {number} [options.attributeNameId] - The attribute name id required when enabling parent mode.
* @param {number} [options.parentAttributeNameId] - Alias for attributeNameId.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<object>} The updated article detail.
*/
return async (googleAuthId, sku, { isFather, attributeNameId, fatherAttributeNameId } = {}, tx) => {
return async (googleAuthId, sku, { isParent, attributeNameId, parentAttributeNameId } = {}, tx) => {
const run = async (runner) => {
const { articleId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner);
const targetIsFather = Boolean(isFather);
const targetAttrId = attributeNameId ?? fatherAttributeNameId;
const targetIsParent = Boolean(isParent);
const targetAttrId = attributeNameId ?? parentAttributeNameId;
if (targetIsFather) {
if (targetIsParent) {
if (!targetAttrId || Number(targetAttrId) <= 0) {
throw new Error('attributeNameId is required to become a father article');
throw new Error('attributeNameId is required to become a parent article');
}
const currentRes = await runner.query(
'SELECT fatherarticle_id, articlelist_id, optionslist_id FROM article WHERE id = @articleId',
'SELECT parentarticle_id, articlelist_id, optionslist_id FROM article WHERE id = @articleId',
{ articleId },
);
const currentArt = currentRes.rows[0];
if (currentArt?.articlelist_id != null) {
throw new Error('A Stückliste article cannot become a father');
throw new Error('A Stückliste article cannot become a parent');
}
if (currentArt?.optionslist_id != null) {
throw new Error('An article with options cannot become a father');
throw new Error('An article with options cannot become a parent');
}
const stockRes = await runner.query(
@@ -70,7 +70,7 @@ export default function toggleFather(provider) {
);
const totalStock = Number(stockRes.rows[0]?.totalStock ?? 0);
if (totalStock !== 0) {
throw new Error('An article with existing stock cannot become a father article');
throw new Error('An article with existing stock cannot become a parent article');
}
const supRes = await runner.query(
@@ -78,7 +78,7 @@ export default function toggleFather(provider) {
{ articleId },
);
if ((supRes.rows[0]?.count ?? 0) > 0) {
throw new Error('An article with assigned suppliers cannot become a father article');
throw new Error('An article with assigned suppliers cannot become a parent article');
}
// Fetch existing prices before deletion to record in articlehistory
@@ -96,22 +96,22 @@ export default function toggleFather(provider) {
await runner.query('DELETE FROM price WHERE article_id = @articleId', { articleId });
await runner.query('UPDATE article SET track_stock = 0 WHERE id = @articleId', { articleId });
// If article is a child, verify attribute is not used by father or any father up the chain
let currentFatherId = currentArt?.fatherarticle_id;
// If article is a child, verify attribute is not used by parent or any parent up the chain
let currentParentId = currentArt?.parentarticle_id;
const visited = new Set();
while (currentFatherId != null && !visited.has(currentFatherId)) {
visited.add(currentFatherId);
while (currentParentId != null && !visited.has(currentParentId)) {
visited.add(currentParentId);
const fRes = await runner.query(
'SELECT a.fatherarticle_id, a.fatherattributename_id, an.name AS fatherAttributeName FROM article a LEFT JOIN attributename an ON an.id = a.fatherattributename_id WHERE a.id = @currentFatherId',
{ currentFatherId },
'SELECT a.parentarticle_id, a.parentattributename_id, an.name AS parentAttributeName FROM article a LEFT JOIN attributename an ON an.id = a.parentattributename_id WHERE a.id = @currentParentId',
{ currentParentId },
);
const fRow = fRes.rows[0];
if (!fRow) break;
if (fRow.fatherattributename_id != null && Number(fRow.fatherattributename_id) === Number(targetAttrId)) {
const attrName = fRow.fatherAttributeName || 'ancestor';
throw new Error(`Attribute "${attrName}" is already used by an ancestor father article`);
if (fRow.parentattributename_id != null && Number(fRow.parentattributename_id) === Number(targetAttrId)) {
const attrName = fRow.parentAttributeName || 'ancestor';
throw new Error(`Attribute "${attrName}" is already used by an ancestor parent article`);
}
currentFatherId = fRow.fatherarticle_id;
currentParentId = fRow.parentarticle_id;
}
// Verify attribute exists and belongs to company
@@ -132,16 +132,16 @@ export default function toggleFather(provider) {
{ articleId, attributeId: Number(targetAttrId) },
);
if (existingValRes.rows.length > 0) {
throw new Error('A father article cannot have attribute values for its variant attribute');
throw new Error('A parent article cannot have attribute values for its variant attribute');
}
await runner.query(queries.TOGGLE_FATHER, {
await runner.query(queries.TOGGLE_PARENT, {
articleId,
isFather: 1,
fatherAttributeNameId: Number(targetAttrId),
isParent: 1,
parentAttributeNameId: Number(targetAttrId),
});
// Add history entry recording father conversion and deleted prices
// Add history entry recording parent conversion and deleted prices
const historyLines = [
`Artikel zu Vaterartikel konvertiert (Varianten-Attribut: "${attributeName}")`,
];
@@ -170,17 +170,17 @@ export default function toggleFather(provider) {
text: historyLines.join('\n'),
});
} else {
// Disabling father: verify zero children
// Disabling parent: verify zero children
const childCountRes = await runner.query(getChildCountSql, { articleId });
const childCount = childCountRes.rows[0]?.count ?? 0;
if (childCount > 0) {
throw new Error('Cannot disable father mode on an article that still has children');
throw new Error('Cannot disable parent mode on an article that still has children');
}
await runner.query(queries.TOGGLE_FATHER, {
await runner.query(queries.TOGGLE_PARENT, {
articleId,
isFather: 0,
fatherAttributeNameId: null,
isParent: 0,
parentAttributeNameId: null,
});
await runner.query(insertHistorySql, {

View File

@@ -123,39 +123,39 @@ export default function setByArticleSku(provider) {
});
}
// Check father / child constraints
// Check parent / child constraints
const artRes = await runner.query(
'SELECT id, is_father, fatherarticle_id, fatherattributename_id FROM article WHERE id = @articleId',
'SELECT id, is_parent, parentarticle_id, parentattributename_id FROM article WHERE id = @articleId',
{ articleId },
);
const article = artRes.rows[0];
if (article?.is_father && article.fatherattributename_id) {
const hasFatherAttr = validated.some((v) => v.attributeId === article.fatherattributename_id);
if (hasFatherAttr) {
throw new Error('A father article cannot have attribute values for its variant attribute');
if (article?.is_parent && article.parentattributename_id) {
const hasParentAttr = validated.some((v) => v.attributeId === article.parentattributename_id);
if (hasParentAttr) {
throw new Error('A parent article cannot have attribute values for its variant attribute');
}
}
if (article?.fatherarticle_id != null) {
let currentFatherId = article.fatherarticle_id;
if (article?.parentarticle_id != null) {
let currentParentId = article.parentarticle_id;
const visited = new Set();
while (currentFatherId != null && !visited.has(currentFatherId)) {
visited.add(currentFatherId);
const fatherRes = await runner.query(
'SELECT a.fatherarticle_id, a.fatherattributename_id, an.name AS fatherAttributeName FROM article a LEFT JOIN attributename an ON an.id = a.fatherattributename_id WHERE a.id = @currentFatherId',
{ currentFatherId },
while (currentParentId != null && !visited.has(currentParentId)) {
visited.add(currentParentId);
const parentRes = await runner.query(
'SELECT a.parentarticle_id, a.parentattributename_id, an.name AS parentAttributeName FROM article a LEFT JOIN attributename an ON an.id = a.parentattributename_id WHERE a.id = @currentParentId',
{ currentParentId },
);
const fRow = fatherRes.rows[0];
const fRow = parentRes.rows[0];
if (!fRow) break;
if (fRow.fatherattributename_id != null) {
const hasFatherAttr = validated.some((v) => v.attributeId === fRow.fatherattributename_id);
if (!hasFatherAttr) {
const fatherAttrName = fRow.fatherAttributeName || 'ancestor variant attribute';
throw new Error(`Child article must have an attribute value for the father attribute "${fatherAttrName}"`);
if (fRow.parentattributename_id != null) {
const hasParentAttr = validated.some((v) => v.attributeId === fRow.parentattributename_id);
if (!hasParentAttr) {
const parentAttrName = fRow.parentAttributeName || 'ancestor variant attribute';
throw new Error(`Child article must have an attribute value for the parent attribute "${parentAttrName}"`);
}
}
currentFatherId = fRow.fatherarticle_id;
currentParentId = fRow.parentarticle_id;
}
}

View File

@@ -64,7 +64,7 @@ export default function setItemsByArticleSku(provider) {
const { articleId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner);
const artRes = await runner.query(
'SELECT id, is_father, articlelist_id, optionslist_id FROM article WHERE id = @articleId',
'SELECT id, is_parent, articlelist_id, optionslist_id FROM article WHERE id = @articleId',
{ articleId },
);
const article = artRes.rows[0];
@@ -72,8 +72,8 @@ export default function setItemsByArticleSku(provider) {
throw new Error('Article not found');
}
if (article.is_father) {
throw new Error('A father article cannot be a Stückliste');
if (article.is_parent) {
throw new Error('A parent article cannot be a Stückliste');
}
// If items is empty: clear Stückliste

View File

@@ -27,7 +27,8 @@ export default function setByArticleSku(provider) {
/**
* Replaces all properties for an article by its SKU.
* Enforces exclusive flag on property names.
* Enforces exclusive flag on property names. An exclusive property that already has a value
* is never overwritten: a different value throws `Exclusive property cannot be overwritten: <name>`.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {string} sku - The article SKU.
@@ -79,6 +80,12 @@ export default function setByArticleSku(provider) {
}
const value = item.value != null ? String(item.value).trim() : '';
if (isExclusive) {
const existing = oldProps.find((p) => p.propertyId === propertyId);
if (existing && existing.value !== '' && existing.value !== value) {
throw new Error(`Exclusive property cannot be overwritten: ${prop.name}`);
}
}
const sort = item.sort != null && Number.isInteger(item.sort) ? item.sort : i;
validated.push({
propertyId,

View File

@@ -91,9 +91,9 @@ export default function create(provider) {
runner,
);
const artCheck = await runner.query('SELECT is_father FROM article WHERE id = @articleId', { articleId });
if (artCheck.rows[0]?.is_father) {
throw new Error('A father article cannot have suppliers');
const artCheck = await runner.query('SELECT is_parent FROM article WHERE id = @articleId', { articleId });
if (artCheck.rows[0]?.is_parent) {
throw new Error('A parent article cannot have suppliers');
}
await assertSupplier(runner, companyId, entityId);

View File

@@ -6,6 +6,7 @@ import {
CREATE as SQLITE_CREATE,
GET_NEXT_SORT as SQLITE_GET_NEXT_SORT,
} from '../../providers/sqlite3/queries/attributename.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
@@ -25,6 +26,8 @@ export default function create(provider) {
* @param {boolean} [input.exclusive=false] - Whether values are mutually exclusive on an article.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>}
*
* @throws {Error} If the name is missing or already exists for the company.
*/
return async (googleAuthId, input = {}, tx) => {
const name = input.name?.trim();
@@ -43,12 +46,20 @@ export default function create(provider) {
const exclusive = Boolean(input.exclusive) ? 1 : 0;
const result = await runner.query(createSql, {
companyId,
name,
sort,
exclusive,
});
let result;
try {
result = await runner.query(createSql, {
companyId,
name,
sort,
exclusive,
});
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new Error('Attribute name already exists');
}
throw err;
}
const id = result.rows[0]?.id ?? result.insertId;
return {

View File

@@ -6,6 +6,7 @@ import {
GET_BY_COMPANY_AND_ID as SQLITE_GET,
DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE,
} from '../../providers/sqlite3/queries/attributename.js';
import { isForeignKeyConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
@@ -22,6 +23,7 @@ export default function deleteAttributename(provider) {
* @param {number} id - The attribute name id.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<void>}
* @throws {Error} If attribute name not found or in use.
*/
return async (googleAuthId, id, tx) => {
const runner = makeRunner(provider, tx);
@@ -32,6 +34,13 @@ export default function deleteAttributename(provider) {
throw new Error('Attribute name not found');
}
await runner.query(deleteSql, { id, companyId });
try {
await runner.query(deleteSql, { id, companyId });
} catch (err) {
if (isForeignKeyConstraintError(err)) {
throw new Error('Attribute name in use');
}
throw err;
}
};
}

View File

@@ -6,6 +6,7 @@ import {
GET_BY_COMPANY_AND_ID as SQLITE_GET,
UPDATE_BY_ID as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/attributename.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
@@ -32,6 +33,8 @@ export default function update(provider) {
* @param {boolean} [input.exclusive] - New exclusive flag.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{id: number, name: string, sort: number, exclusive: boolean, values: Array}>}
*
* @throws {Error} If the attribute name is unknown, the name is missing, or the name is already taken.
*/
return async (googleAuthId, id, input = {}, tx) => {
if (!id || !Number.isInteger(Number(id))) {
@@ -53,13 +56,20 @@ export default function update(provider) {
const sort = input.sort !== undefined && Number.isInteger(input.sort) ? input.sort : existing.rows[0].sort;
const exclusive = input.exclusive !== undefined ? (Boolean(input.exclusive) ? 1 : 0) : existing.rows[0].exclusive;
await runner.query(updateSql, {
id,
companyId,
name,
sort,
exclusive,
});
try {
await runner.query(updateSql, {
id,
companyId,
name,
sort,
exclusive,
});
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new Error('Attribute name already exists');
}
throw err;
}
return {
id,

View File

@@ -12,6 +12,7 @@ import {
import {
GET_BY_COMPANY_AND_ID as SQLITE_GET_ATTR,
} from '../../providers/sqlite3/queries/attributename.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
@@ -32,6 +33,8 @@ export default function create(provider) {
* @param {number} [input.sort] - Optional sort order.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{id: number, attributeId: number, value: string, sort: number}>}
*
* @throws {Error} If the value is missing, the attribute is unknown, or the value already exists for the attribute.
*/
return async (googleAuthId, attributeId, input = {}, tx) => {
const attrId = Number(attributeId);
@@ -57,11 +60,19 @@ export default function create(provider) {
sort = next.rows[0].sort;
}
const result = await runner.query(createSql, {
attributeId: attrId,
value,
sort,
});
let result;
try {
result = await runner.query(createSql, {
attributeId: attrId,
value,
sort,
});
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new Error('Attribute value already exists');
}
throw err;
}
const id = result.rows[0]?.id ?? result.insertId;
return {

View File

@@ -6,6 +6,7 @@ import {
GET_BY_ID_AND_COMPANY as SQLITE_GET,
DELETE_BY_ID as SQLITE_DELETE,
} from '../../providers/sqlite3/queries/attributevalue.js';
import { isForeignKeyConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
@@ -22,6 +23,7 @@ export default function deleteAttributeValue(provider) {
* @param {number} id - The attribute value id.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<void>}
* @throws {Error} If attribute value not found or in use.
*/
return async (googleAuthId, id, tx) => {
const valId = Number(id);
@@ -37,6 +39,13 @@ export default function deleteAttributeValue(provider) {
throw new Error('Attribute value not found');
}
await runner.query(deleteSql, { id: valId });
try {
await runner.query(deleteSql, { id: valId });
} catch (err) {
if (isForeignKeyConstraintError(err)) {
throw new Error('Attribute value in use');
}
throw err;
}
};
}

View File

@@ -6,6 +6,7 @@ import {
GET_BY_ID_AND_COMPANY as SQLITE_GET,
UPDATE_BY_ID as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/attributevalue.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
@@ -25,6 +26,8 @@ export default function update(provider) {
* @param {number} [input.sort] - New sort order.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{id: number, attributeId: number, value: string, sort: number}>}
*
* @throws {Error} If the attribute value is unknown, the value is missing, or the value is already taken.
*/
return async (googleAuthId, id, input = {}, tx) => {
const valId = Number(id);
@@ -46,11 +49,18 @@ export default function update(provider) {
}
const sort = input.sort !== undefined && Number.isInteger(input.sort) ? input.sort : existing.rows[0].sort;
await runner.query(updateSql, {
id: valId,
value,
sort,
});
try {
await runner.query(updateSql, {
id: valId,
value,
sort,
});
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new Error('Attribute value already exists');
}
throw err;
}
return {
id: valId,

View File

@@ -11,7 +11,7 @@ export default function getCategoryArticles(provider) {
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} categoryId - The id of the category.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, fatherArticleId: number|null, fatherSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>} The articles in the category.
* @returns {Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>} The articles in the category.
*
* @example
* const articles = await store.category.getCategoryArticles(42, 5);

View File

@@ -25,6 +25,9 @@ export default function queryCategoryArticles(provider) {
return {
...rest,
track_stock: Boolean(rest.track_stock ?? 1),
// MSSQL returns BIT as boolean, SQLite as 0/1 integer. Normalize so both
// dialects expose isParent as a real boolean, matching article/getBySku.
isParent: Boolean(rest.isParent),
brand: brandName
? {
id: brandId ?? null,

View File

@@ -0,0 +1,54 @@
import { INSERT as MSSQL_INSERT } from '../../providers/mssql/queries/companyhistory.js';
import { INSERT as SQLITE_INSERT } from '../../providers/sqlite3/queries/companyhistory.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function addHistory(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_INSERT : MSSQL_INSERT;
/**
* Adds an entry to the company history for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object|string} input - The history input object or text string.
* @param {string} [input.text] - The history entry text.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{id: number, companyuserId: number|null, date: string, text: string}>} The inserted entry.
* @throws {Error} If text is missing or user is not authorized.
*
* @example
* const entry = await store.company.addHistory(42, { text: 'API suggestion: add barcode scanner endpoint' });
* // => { id, companyuserId, date, text }
*/
return async (googleAuthId, input, tx) => {
let rawText = typeof input === 'string' ? input : input?.text;
const text = typeof rawText === 'string' ? rawText.trim() : '';
if (!text) {
throw new Error('Text required');
}
let resolvedTx = tx;
if (input != null && typeof input === 'object' && input._isTx) {
resolvedTx = input;
}
const runner = makeRunner(provider, resolvedTx);
const { companyId, companyuserId } = await access(googleAuthId, runner);
const result = await runner.query(sql, {
companyId,
companyuserId: companyuserId ?? null,
text,
});
const row = result.rows[0];
return {
id: row.id,
companyuserId: row.companyuserId ?? null,
date: row.date,
text: row.text,
};
};
}

View File

@@ -0,0 +1,54 @@
import { LIST_BY_COMPANY_ID as MSSQL_QUERY, LIST_BY_COMPANY_ID_LIMITED as MSSQL_QUERY_LIMITED } from '../../providers/mssql/queries/companyhistory.js';
import { LIST_BY_COMPANY_ID as SQLITE_QUERY, LIST_BY_COMPANY_ID_LIMITED as SQLITE_QUERY_LIMITED } from '../../providers/sqlite3/queries/companyhistory.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function getHistory(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_QUERY : MSSQL_QUERY;
const sqlLimited = isSqlite ? SQLITE_QUERY_LIMITED : MSSQL_QUERY_LIMITED;
/**
* Returns the company history for the user's selected company, newest first.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [opts] - Optional options.
* @param {number} [opts.limit] - Maximum number of entries to return (newest first).
* @param {object} [tx] - Optional transaction.
* @returns {Promise<Array<{id: number, companyuserId: number|null, date: string, text: string}>>} The company history entries.
*
* @example
* const history = await store.company.getHistory(42);
* // => [{ id, companyuserId, date, text }]
*
* const recent = await store.company.getHistory(42, { limit: 5 });
* // => up to 5 most recent entries
*/
return async (googleAuthId, opts, tx) => {
let limit = null;
let resolvedTx = tx;
if (opts != null && typeof opts === 'object' && !opts._isTx) {
limit = opts.limit != null ? Number(opts.limit) : null;
} else if (opts != null) {
resolvedTx = opts;
}
const runner = makeRunner(provider, resolvedTx);
const { companyId } = await access(googleAuthId, runner);
let result;
if (limit != null && Number.isInteger(limit) && limit > 0) {
result = await runner.query(sqlLimited, { companyId, limit });
} else {
result = await runner.query(sql, { companyId });
}
return result.rows.map((row) => ({
id: row.id,
companyuserId: row.companyuserId ?? null,
date: row.date,
text: row.text,
}));
};
}

View File

@@ -1,9 +1,9 @@
import {
CHECK_ARTICLE_IS_FATHER as MSSQL_CHECK_ARTICLE_IS_FATHER,
CHECK_ARTICLE_IS_PARENT as MSSQL_CHECK_ARTICLE_IS_PARENT,
CREATE_LINE as MSSQL_CREATE_LINE,
} from '../providers/mssql/queries/manifestDoc.js';
import {
CHECK_ARTICLE_IS_FATHER as SQLITE_CHECK_ARTICLE_IS_FATHER,
CHECK_ARTICLE_IS_PARENT as SQLITE_CHECK_ARTICLE_IS_PARENT,
CREATE_LINE as SQLITE_CREATE_LINE,
} from '../providers/sqlite3/queries/manifestDoc.js';
import makeRunner from './runner.js';
@@ -122,20 +122,20 @@ export async function insertLines(
const { manifestId, lines, ensureTax } = params;
const isSqlite = runner?.dialect === 'sqlite3';
const createLineSql = isSqlite ? SQLITE_CREATE_LINE : MSSQL_CREATE_LINE;
const checkFatherSql = isSqlite ? SQLITE_CHECK_ARTICLE_IS_FATHER : MSSQL_CHECK_ARTICLE_IS_FATHER;
const checkParentSql = isSqlite ? SQLITE_CHECK_ARTICLE_IS_PARENT : MSSQL_CHECK_ARTICLE_IS_PARENT;
for (const line of lines) {
let resolvedName = line.name;
let resolvedPu = line.pu;
if (line.sku) {
const fatherCheck = await runner.query(checkFatherSql, {
const parentCheck = await runner.query(checkParentSql, {
manifestId,
sku: line.sku,
});
const art = fatherCheck.rows[0];
if (art?.isFather) {
throw new Error(`A father article cannot be booked on documents (${line.sku})`);
const art = parentCheck.rows[0];
if (art?.isParent) {
throw new Error(`A parent article cannot be booked on documents (${line.sku})`);
}
if (!resolvedName && art?.articleName) {
resolvedName = art.articleName;

View File

@@ -64,7 +64,7 @@ export default function setOptionsByArticleSku(provider) {
const { articleId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner);
const artRes = await runner.query(
'SELECT id, is_father, articlelist_id, optionslist_id FROM article WHERE id = @articleId',
'SELECT id, is_parent, articlelist_id, optionslist_id FROM article WHERE id = @articleId',
{ articleId },
);
const article = artRes.rows[0];
@@ -72,8 +72,8 @@ export default function setOptionsByArticleSku(provider) {
throw new Error('Article not found');
}
if (article.is_father) {
throw new Error('A father article cannot have options');
if (article.is_parent) {
throw new Error('A parent article cannot have options');
}
// If options is empty: clear optionslist

View File

@@ -6,6 +6,7 @@ import {
CREATE as SQLITE_CREATE,
GET_NEXT_SORT as SQLITE_GET_NEXT_SORT,
} from '../../providers/sqlite3/queries/propertyname.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
@@ -25,6 +26,8 @@ export default function create(provider) {
* @param {boolean} [input.exclusive=false] - Whether values are mutually exclusive on an article.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{id: number, name: string, sort: number, exclusive: boolean}>}
*
* @throws {Error} If the name is missing or already exists for the company.
*/
return async (googleAuthId, input = {}, tx) => {
const name = input.name?.trim();
@@ -43,12 +46,20 @@ export default function create(provider) {
const exclusive = Boolean(input.exclusive) ? 1 : 0;
const result = await runner.query(createSql, {
companyId,
name,
sort,
exclusive,
});
let result;
try {
result = await runner.query(createSql, {
companyId,
name,
sort,
exclusive,
});
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new Error('Property name already exists');
}
throw err;
}
const id = result.rows[0]?.id ?? result.insertId;
return { id, name, sort, exclusive: Boolean(exclusive) };

View File

@@ -6,6 +6,7 @@ import {
GET_BY_COMPANY_AND_ID as SQLITE_GET,
DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE,
} from '../../providers/sqlite3/queries/propertyname.js';
import { isForeignKeyConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
@@ -22,6 +23,7 @@ export default function deletePropertyname(provider) {
* @param {number} id - The property name id.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<void>}
* @throws {Error} If property name not found or in use.
*/
return async (googleAuthId, id, tx) => {
const runner = makeRunner(provider, tx);
@@ -32,6 +34,13 @@ export default function deletePropertyname(provider) {
throw new Error('Property name not found');
}
await runner.query(deleteSql, { id, companyId });
try {
await runner.query(deleteSql, { id, companyId });
} catch (err) {
if (isForeignKeyConstraintError(err)) {
throw new Error('Property name in use');
}
throw err;
}
};
}

View File

@@ -6,6 +6,7 @@ import {
GET_BY_COMPANY_AND_ID as SQLITE_GET,
UPDATE_BY_ID as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/propertyname.js';
import { isUniqueConstraintError } from '../../providers/provider.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
@@ -26,6 +27,8 @@ export default function update(provider) {
* @param {boolean} [input.exclusive] - New exclusive flag.
* @param {object} [tx] - Optional transaction.
* @returns {Promise<{id: number, name: string, sort: number, exclusive: boolean}>}
*
* @throws {Error} If the property name is unknown, the name is missing, or the name is already taken.
*/
return async (googleAuthId, id, input = {}, tx) => {
if (!id || !Number.isInteger(Number(id))) {
@@ -47,13 +50,30 @@ export default function update(provider) {
const sort = input.sort !== undefined && Number.isInteger(input.sort) ? input.sort : existing.rows[0].sort;
const exclusive = input.exclusive !== undefined ? (Boolean(input.exclusive) ? 1 : 0) : existing.rows[0].exclusive;
await runner.query(updateSql, {
id,
companyId,
name,
sort,
exclusive,
});
if (exclusive && !existing.rows[0].exclusive) {
const multi = await runner.query(
`SELECT article_id FROM articleproperty WHERE property_id = @id GROUP BY article_id HAVING COUNT(*) > 1`,
{ id },
);
if (multi.rows.length > 0) {
throw new Error(`Cannot set property exclusive: ${multi.rows.length} article(s) have multiple values`);
}
}
try {
await runner.query(updateSql, {
id,
companyId,
name,
sort,
exclusive,
});
} catch (err) {
if (isUniqueConstraintError(err)) {
throw new Error('Property name already exists');
}
throw err;
}
return { id, name, sort, exclusive: Boolean(exclusive) };
};

View File

@@ -116,11 +116,11 @@ export default function adjust(provider) {
const resolvedArticles = [];
for (const item of normalizedLines) {
const resolved = await resolveArticle(googleAuthId, item.sku, activeTx);
const artRes = await runner.query('SELECT is_father, articlelist_id, track_stock FROM article WHERE id = @articleId', {
const artRes = await runner.query('SELECT is_parent, articlelist_id, track_stock FROM article WHERE id = @articleId', {
articleId: resolved.articleId,
});
if (artRes.rows[0]?.is_father) {
throw new Error(`A father article cannot have stock (${item.sku})`);
if (artRes.rows[0]?.is_parent) {
throw new Error(`A parent article cannot have stock (${item.sku})`);
}
if (artRes.rows[0]?.articlelist_id != null) {