diff --git a/API.article.md b/API.article.md index b1a3f57..4d8dd1f 100644 --- a/API.article.md +++ b/API.article.md @@ -181,6 +181,8 @@ Returns the change history of an article by its SKU. | --- | --- | --- | | `googleAuthId` | `number` | The id of the Google auth user. | | `sku` | `string` | The article SKU. | +| `[opts]` | `object` | Optional options. | +| `[opts.limit]` | `number` | Maximum number of entries to return (newest first). | | `[tx]` | `object` | Optional transaction. | **Returns** @@ -192,6 +194,9 @@ Returns the change history of an article by its SKU. ```js const history = await store.article.getHistoryBySku(42, 'SKU-1'); // => [{ id, companyuserId, date, text }] + +const recent = await store.article.getHistoryBySku(42, 'SKU-1', { limit: 5 }); +// => up to 5 most recent entries ``` ## `article.importRows` @@ -362,6 +367,27 @@ Removes a child article from a father article, either by unlinking or deleting i `Promise<{success: boolean, fatherSku: string, childSku: string, deleted: boolean}>` +## `article.resolveUniqueSlug` + +*Source: `src/store/article/resolveUniqueSlug.js`* + +Resolves a company- and language-unique slug for an article. Disambiguates duplicate slugs with a numeric suffix (-2, -3, ...). + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `runner` | `object` | DB runner or tx. | +| `params` | `object` | | +| `params.companyId` | `number` | The company ID. | +| `params.languageId` | `number` | The language ID. | +| `params.name` | `string` | The article name. | +| `[params.excludeArticleId]` | `number` | Optional article ID to exclude (e.g. for updates). | + +**Returns** + +`Promise` — The unique slug. + ## `article.setBrandIdBySku` *Source: `src/store/article/setBrandIdBySku.js`* diff --git a/API.categoryattributevalue.md b/API.categoryattributevalue.md new file mode 100644 index 0000000..e959564 --- /dev/null +++ b/API.categoryattributevalue.md @@ -0,0 +1,40 @@ +# categoryattributevalue + +This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. + +## `categoryattributevalue.listByCategoryId` + +*Source: `src/store/categoryattributevalue/listByCategoryId.js`* + +Lists the attribute values assigned to a category by its ID. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `categoryId` | `number` | The category ID. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise>` + +## `categoryattributevalue.setByCategoryId` + +*Source: `src/store/categoryattributevalue/setByCategoryId.js`* + +Replaces all attribute values for a category by its ID. Validates company ownership and enforces the `exclusive` flag on attributes. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `categoryId` | `number` | The category ID. | +| `values` | `Array` | Attribute value IDs or items. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise>` diff --git a/API.categoryproperty.md b/API.categoryproperty.md new file mode 100644 index 0000000..9bf807a --- /dev/null +++ b/API.categoryproperty.md @@ -0,0 +1,59 @@ +# categoryproperty + +This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. + +## `categoryproperty.delete` + +*Source: `src/store/categoryproperty/delete.js`* + +Deletes a property from a category by categoryId and propertyId. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `categoryId` | `number` | The category ID. | +| `propertyId` | `number` | The property id. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise` + +## `categoryproperty.listByCategoryId` + +*Source: `src/store/categoryproperty/listByCategoryId.js`* + +Lists the properties of a category by its ID. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `categoryId` | `number` | The category ID. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise>` + +## `categoryproperty.setByCategoryId` + +*Source: `src/store/categoryproperty/setByCategoryId.js`* + +Replaces all properties for a category by its ID. Enforces exclusive flag on property names. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `categoryId` | `number` | The category ID. | +| `properties` | `Array<{propertyId: number, value: string, sort?: number}>` | The properties to set. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise>` diff --git a/API.md b/API.md index 31a684f..6d4c752 100644 --- a/API.md +++ b/API.md @@ -89,13 +89,14 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `delete(googleAuthId: number, sku: string, optionsOrTx?: object|*, maybeTx?: object): Promise` - `deleteImage(googleAuthId: number, sku: string, fullHash: string, tx?: object): Promise` - `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}}>` - - `getHistoryBySku(googleAuthId: number, sku: string, tx?: object): Promise>` + - `getHistoryBySku(googleAuthId: number, sku: string, opts?: {limit?: number}, tx?: object): Promise>` - `importRows(input: {googleAuthId: number, rows: Array}, tx?: object): Promise<{imported: Array, skipped: Array, errors: Array}>` - `listChildren(googleAuthId: number, fatherSku: string, tx?: object): Promise>` - `listExport(googleAuthId: number, categoryId?: number|null, tx?: object): Promise>` - `listSearchIndex(googleAuthId: number, tx?: object): Promise, categoryIds: Array}>>` - `orderImages(googleAuthId: number, sku: string, input: {fullHashes: Array}, tx?: object): Promise<{fullHashes: Array}>` - `removeChild(googleAuthId: number, fatherSku: string, childSku: string, options?: {deleteArticle?: boolean, cascade?: boolean}, tx?: object): Promise<{success: boolean, fatherSku: string, childSku: string, deleted: boolean}>` + - `resolveUniqueSlug(runner: object, params: {companyId: number, languageId: number, name: string, excludeArticleId?: number}): Promise` - `setBrandIdBySku(googleAuthId: number, sku: string, brandId: number|null, tx?: object): Promise` - `setCategoryIdsBySku(googleAuthId: number, sku: string, categoryIds: Array, tx?: object): Promise` - `setEansBySku(googleAuthId: number, sku: string, eans: Array, tx?: object): Promise` @@ -212,6 +213,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `getProductList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise>` - `list(googleAuthId: number, tx?: object): Promise>` - `pair(id: number, input: {token: string, clientserial?: string, pairedAt?: string}, tx?: object): Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>` + - `setMaxKnownTombstoneId(id: number, maxknowntombstoneid: number|null, tx?: object): Promise<{id: number, maxknowntombstoneid: number|null}>` - `setOff(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array}>` - `setOffAll(googleAuthId: number, posterminalId: number, tx?: object): Promise<{id: number, categoryIds: Array}>` - `setOn(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array}>` @@ -272,6 +274,13 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - [articlelist](API.articlelist.md) - `getItemsByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise>` - `setItemsByArticleSku(googleAuthId: number, sku: string, items?: Array<{articleId?: number, sku?: string, count?: number, sort?: number}>, tx?: object): Promise>` +- [categoryattributevalue](API.categoryattributevalue.md) + - `listByCategoryId(googleAuthId: number, categoryId: number, tx?: object): Promise>` + - `setByCategoryId(googleAuthId: number, categoryId: number, values: Array, tx?: object): Promise>` +- [categoryproperty](API.categoryproperty.md) + - `delete(googleAuthId: number, categoryId: number, propertyId: number, tx?: object): Promise` + - `listByCategoryId(googleAuthId: number, categoryId: number, tx?: object): Promise>` + - `setByCategoryId(googleAuthId: number, categoryId: number, properties: Array<{propertyId: number, value: string, sort?: number}>, tx?: object): Promise>` - [optionslist](API.optionslist.md) - `getOptionsByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise>` - `setOptionsByArticleSku(googleAuthId: number, sku: string, options?: Array<{articleId?: number, sku?: string, count?: number, sort?: number, mandated?: boolean}>, tx?: object): Promise>` diff --git a/API.posterminal.md b/API.posterminal.md index 7b434ce..8113172 100644 --- a/API.posterminal.md +++ b/API.posterminal.md @@ -280,6 +280,24 @@ Completes POS device pairing for a terminal. `Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>` +## `posterminal.setMaxKnownTombstoneId` + +*Source: `src/store/posterminal/setMaxKnownTombstoneId.js`* + +Updates the max known tombstone ID for a POS terminal. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `id` | `number` | The posterminal id. | +| `maxknowntombstoneid` | `number|null` | The highest synced tombstone ID. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise<{id: number, maxknowntombstoneid: number|null}>` + ## `posterminal.setOff` *Source: `src/store/posterminal/setOff.js`* diff --git a/generate-schema-graph.js b/generate-schema-graph.js index 5750f13..8ff21d6 100644 --- a/generate-schema-graph.js +++ b/generate-schema-graph.js @@ -170,6 +170,9 @@ function addInferredEdges(tables, edges) { const match = column.name.match(/^(.+)_id$/i); if (!match) continue; + // Tombstone tables store references to deleted entities, not active foreign keys. + if (tableName.includes('tombstone')) continue; + const candidate = match[1]; if (!tables.has(candidate) || candidate === tableName) continue; if (explicitlyConstrained.has(`${tableName}.${column.name}`)) continue; @@ -351,6 +354,7 @@ const CLUSTERS = [ 'article', 'articleean', 'articlehistory', + 'articletombstone', 'articlelist', 'articlelistarticle', 'optionslist', @@ -374,7 +378,20 @@ const CLUSTERS = [ tables: ['tax', 'price'], }, { id: 'units', label: 'Units', tables: ['unit'] }, - { id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'printer'] }, + { id: 'pos', label: 'POS', tables: ['posterminal', 'posterminalcategory', 'printer', 'labeltemplate'] }, + { + id: 'attributes', + label: 'Properties & Attributes', + tables: [ + 'propertyname', + 'articleproperty', + 'categoryproperty', + 'attributename', + 'attributevalue', + 'articleattributevalue', + 'categoryattributevalue', + ], + }, ]; const COLORS = { diff --git a/schema-graph.dot b/schema-graph.dot index 62f85b1..039e181 100644 --- a/schema-graph.dot +++ b/schema-graph.dot @@ -10,16 +10,16 @@ digraph schema { style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; meta [label=<
meta
keyNVARCHAR(255)
valueNVARCHAR(MAX)
>]; - customergroup [label=<
customergroup
idINT PK
company_id?INT FK
nameNVARCHAR(255)
sortINT
>]; + customergroup [label=<
customergroup
idINT PK
company_id?INT FK
nameNVARCHAR(255)
sortINT
updated_atDATETIME2
>]; numbers [label=<
numbers
idINT PK
company_idINT FK
typeNVARCHAR(100)
prefixNVARCHAR(50)
paddingINT
nextINT
>]; entityaddress [label=<
entityaddress
idINT PK
entity_idINT FK
first_name?NVARCHAR(255)
name?NVARCHAR(255)
street?NVARCHAR(255)
streetnumber?NVARCHAR(50)
zip?NVARCHAR(50)
city?NVARCHAR(255)
county?NVARCHAR(255)
addresstypeNVARCHAR(50)
sortINT
>]; entity [label=<
entity
idINT PK
customergroup_idINT FK
first_name?NVARCHAR(255)
nameNVARCHAR(255)
number?NVARCHAR(100)
created_atDATETIME2
updated_atDATETIME2
>]; companyuser [label=<
companyuser
idINT PK
company_idINT FK
google_auth_idINT FK
>]; entitycontact [label=<
entitycontact
idINT PK
entity_idINT FK
contacttypeNVARCHAR(50)
contactNVARCHAR(255)
sortINT
>]; - company [label=<
company
idINT PK
poscustomergroup_idINT FK
root_category_id?INT FK
taxrate?INT FK
reducedtaxrate?INT FK
taxfreerate?INT FK
default_language_id?INT FK
nameNVARCHAR(255)
>]; syncpos [label=<
syncpos
idINT PK
company_idINT FK
paircodeNVARCHAR(50)
token?NVARCHAR(255)
>]; entityhistory [label=<
entityhistory
idINT PK
entity_idINT FK
companyuser_id?INT FK
dateDATETIME2
textNVARCHAR(MAX)
>]; companysetting [label=<
companysetting
idINT PK
company_idINT FK
keyNVARCHAR(255)
valueNVARCHAR(MAX)
frontendBIT
writeonlyBIT
>]; + company [label=<
company
idINT PK
poscustomergroup_idINT FK
taxrate?INT FK
reducedtaxrate?INT FK
taxfreerate?INT FK
root_category_id?INT FK
default_language_id?INT FK
nameNVARCHAR(255)
>]; google_auth [label=<
google_auth
idINT PK
selected_company?INT FK
emailNVARCHAR(320)
picture?NVARCHAR(2048)
picturebinary?VARBINARY(MAX)
sub?NVARCHAR(255)
created_atDATETIME2
>]; } subgraph cluster_i18n { @@ -27,42 +27,43 @@ digraph schema { style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; language [label=<
language
idINT PK
iso639NVARCHAR(10)
>]; - articletext [label=<
articletext
idINT PK
article_idINT FK
language_idINT FK
nameNVARCHAR(255)
slugNVARCHAR(255)
seo?NVARCHAR(MAX)
desc?NVARCHAR(MAX)
>]; categorytext [label=<
categorytext
idINT PK
category_idINT FK
language_idINT FK
nameNVARCHAR(255)
slugNVARCHAR(255)
seo?NVARCHAR(MAX)
desc?NVARCHAR(MAX)
>]; - unittext [label=<
unittext
idINT PK
unit_idINT FK
language_idINT FK
symbolNVARCHAR(50)
nameNVARCHAR(255)
>]; + articletext [label=<
articletext
idINT PK
article_idINT FK
language_idINT FK
nameNVARCHAR(255)
slugNVARCHAR(255)
seo?NVARCHAR(MAX)
desc?NVARCHAR(MAX)
>]; + unittext [label=<
unittext
idINT PK
language_idINT FK
unit_idINT FK
symbolNVARCHAR(50)
nameNVARCHAR(255)
>]; } subgraph cluster_catalog { label="Catalog"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - brand [label=<
brand
idINT PK
company_idINT FK
nameNVARCHAR(255)
gsprpinfo?NVARCHAR(MAX)
>]; - articlelist [label=<
articlelist
idINT PK
>]; - articlelistarticle [label=<
articlelistarticle
idINT PK
articlelist_idINT FK
article_idINT FK
countINT
sortINT
>]; - optionslist [label=<
optionslist
idINT PK
>]; - optionslistarticle [label=<
optionslistarticle
idINT PK
optionslist_idINT FK
article_idINT FK
countINT
sortINT
mandatedBIT
>]; + categoryarticle [label=<
categoryarticle
idINT PK
category_idINT FK
article_idINT FK
>]; + category [label=<
category
idINT PK
parent_id?INT FK
sortINT
updated_atDATETIME2
>]; articlehistory [label=<
articlehistory
idINT PK
companyuser_id?INT FK
article_idINT FK
dateDATETIME2
textNVARCHAR(MAX)
>]; + article [label=<
article
idINT PK
company_idINT FK
fatherattributename_id?INT FK
fatherarticle_id?INT FK
articlelist_id?INT FK
optionslist_id?INT FK
brand_id?INT FK
unit_idINT FK
sku?NVARCHAR(100)
unitamountFLOAT
weightFLOAT
track_stockBIT
is_fatherBIT
fathersortINT
created_atDATETIME2
updated_atDATETIME2
>]; articleean [label=<
articleean
idINT PK
article_idINT FK
eanNVARCHAR(50)
>]; - article [label=<
article
idINT PK
company_idINT FK
brand_id?INT FK
articlelist_id?INT FK
optionslist_id?INT FK
fatherarticle_id?INT FK
unit_idINT FK
sku?NVARCHAR(100)
unitamountFLOAT
weightFLOAT
track_stockBIT
is_fatherBIT
fathersortINT
created_atDATETIME2
updated_atDATETIME2
>]; - articlesupplier [label=<
articlesupplier
idINT PK
entity_idINT FK
article_idINT FK
price_id?INT FK
sku?NVARCHAR(100)
stock?FLOAT
puFLOAT
sortINT
>]; - category [label=<
category
idINT PK
parent_id?INT FK
sortINT
>]; - categoryarticle [label=<
categoryarticle
idINT PK
article_idINT FK
category_idINT FK
>]; + articlesupplier [label=<
articlesupplier
idINT PK
entity_idINT FK
price_id?INT FK
article_idINT FK
sku?NVARCHAR(100)
stock?FLOAT
puFLOAT
sortINT
>]; + articlelist [label=<
articlelist
idINT PK
>]; + articlelistarticle [label=<
articlelistarticle
idINT PK
article_idINT FK
articlelist_idINT FK
countINT
sortINT
>]; + optionslist [label=<
optionslist
idINT PK
>]; + optionslistarticle [label=<
optionslistarticle
idINT PK
article_idINT FK
optionslist_idINT FK
countINT
sortINT
mandatedBIT
>]; + brand [label=<
brand
idINT PK
company_idINT FK
nameNVARCHAR(255)
gsprpinfo?NVARCHAR(MAX)
>]; + articletombstone [label=<
articletombstone
idINT PK
company_idINT FK
article_id?INT
sku?NVARCHAR(100)
name?NVARCHAR(255)
taxrate?DECIMAL(18,4)
net?FLOAT
deleted_atDATETIME2
>]; } subgraph cluster_media { label="Media"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; pic [label=<
pic
idINT PK
wINT
hINT
sizeBIGINT
hashNVARCHAR(64)
thumbwINT
thumbhINT
thumbhashNVARCHAR(64)
sourceNVARCHAR(MAX)
fullbinaryVARBINARY(MAX)
thumbbinaryVARBINARY(MAX)
>]; - brandpic [label=<
brandpic
idINT PK
brand_idINT FK
pic_idINT FK
sortINT
>]; - articlepic [label=<
articlepic
idINT PK
article_idINT FK
pic_idINT FK
sortINT
>]; categorypic [label=<
categorypic
idINT PK
pic_idINT FK
category_idINT FK
sortINT
>]; + articlepic [label=<
articlepic
idINT PK
pic_idINT FK
article_idINT FK
sortINT
>]; + brandpic [label=<
brandpic
idINT PK
pic_idINT FK
brand_idINT FK
sortINT
>]; file [label=<
file
idINT PK
hashNVARCHAR(64)
mimetypeNVARCHAR(255)
nameNVARCHAR(255)
sizeBIGINT
createdDATETIME2
modifiedDATETIME2
contentVARBINARY(MAX)
thumbavif?VARBINARY(MAX)
thumbx?INT
thumby?INT
meta?NVARCHAR(MAX)
>]; } subgraph cluster_manifests { label="Documents"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - ledger [label=<
ledger
idINT PK
manifestarticle_idINT FK
warehouse_id?INT FK
transaction_typeNVARCHAR(100)
amountFLOAT
transaction_textNVARCHAR(MAX)
hash_versionINT
prev_hash?NVARCHAR(128)
created_atDATETIME2
>]; manifestarticle [label=<
manifestarticle
idINT PK
manifest_idINT FK
sku?NVARCHAR(100)
name?NVARCHAR(255)
puFLOAT
amountFLOAT
netFLOAT
comment?NVARCHAR(MAX)
rebateFLOAT
taxrateFLOAT
>]; + ledger [label=<
ledger
idINT PK
manifestarticle_idINT FK
warehouse_id?INT FK
transaction_typeNVARCHAR(100)
amountFLOAT
transaction_textNVARCHAR(MAX)
hash_versionINT
prev_hash?NVARCHAR(128)
created_atDATETIME2
>]; manifest [label=<
manifest
idINT PK
entity_id?INT FK
company_idINT FK
numberNVARCHAR(100)
typeNVARCHAR(100)
external_number?NVARCHAR(100)
dateDATETIME2
created_atDATETIME2
>]; manifestattachment [label=<
manifestattachment
idINT PK
manifest_idINT FK
file_idINT FK
sortINT
>]; warehouse [label=<
warehouse
idINT PK
company_idINT FK
nameNVARCHAR(255)
sortINT
>]; @@ -72,7 +73,7 @@ digraph schema { style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; tax [label=<
tax
idINT PK
rateDECIMAL(18,4)
>]; - price [label=<
price
idINT PK
customergroup_idINT FK
article_idINT FK
tax_idINT FK
minmultiplierDECIMAL(18,4)
netFLOAT
>]; + price [label=<
price
idINT PK
customergroup_idINT FK
tax_idINT FK
article_idINT FK
minmultiplierDECIMAL(18,4)
netFLOAT
>]; } subgraph cluster_units { label="Units"; labeljust="l"; fontsize=15; @@ -84,77 +85,105 @@ digraph schema { label="POS"; labeljust="l"; fontsize=15; style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; fontcolor="#e2e8f0"; margin=14; - posterminalcategory [label=<
posterminalcategory
idINT PK
posterminal_idINT FK
category_idINT FK
>]; - posterminal [label=<
posterminal
idINT PK
company_idINT FK
nameNVARCHAR(255)
token?NVARCHAR(255)
pairingpinNVARCHAR(50)
>]; + posterminalcategory [label=<
posterminalcategory
idINT PK
category_idINT FK
posterminal_idINT FK
>]; + posterminal [label=<
posterminal
idINT PK
company_idINT FK
nameNVARCHAR(255)
token?NVARCHAR(255)
pairingpinNVARCHAR(50)
paired_at?DATETIME2
clientserial?NVARCHAR(255)
maxknowntombstoneid?INT
>]; printer [label=<
printer
idINT PK
company_idINT FK
addressNVARCHAR(255)
typeNVARCHAR(50)
model?NVARCHAR(255)
serial?NVARCHAR(255)
sortINT
desc?NVARCHAR(MAX)
>]; + labeltemplate [label=<
labeltemplate
idINT PK
company_idINT FK
nameNVARCHAR(255)
widthINT
heightINT
dpiINT
typeNVARCHAR(50)
contentNVARCHAR(MAX)
thumb?VARBINARY(MAX)
>]; + } + subgraph cluster_attributes { + label="Properties & Attributes"; labeljust="l"; fontsize=15; + style="rounded,filled"; fillcolor="#202a3a"; color="#3f4c5f"; + fontcolor="#e2e8f0"; margin=14; + attributevalue [label=<
attributevalue
idINT PK
attribute_idINT FK
valueNVARCHAR(255)
sortINT
>]; + attributename [label=<
attributename
idINT PK
company_idINT FK
nameNVARCHAR(255)
sortINT
exclusiveBIT
>]; + propertyname [label=<
propertyname
idINT PK
company_idINT FK
nameNVARCHAR(255)
sortINT
exclusiveBIT
>]; + categoryproperty [label=<
categoryproperty
idINT PK
property_idINT FK
category_idINT FK
valueNVARCHAR(MAX)
sortINT
>]; + categoryattributevalue [label=<
categoryattributevalue
idINT PK
attributevalue_idINT FK
category_idINT FK
sortINT
>]; + articleproperty [label=<
articleproperty
idINT PK
property_idINT FK
article_idINT FK
valueNVARCHAR(MAX)
sortINT
>]; + articleattributevalue [label=<
articleattributevalue
idINT PK
attributevalue_idINT FK
article_idINT FK
sortINT
>]; } __legend [label=<
Legend
PKprimary key
FKforeign key
?nullable column
—FOREIGN KEY constraint
- -naming convention only, no constraint
>]; - google_auth:selected_company:w -> company:in9:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_google_auth_selected_company", weight=3]; + google_auth:selected_company:se -> company:in4:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_google_auth_selected_company", weight=3]; category:parent_id:e -> category:id:e [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_category_parent", constraint=false]; company:root_category_id:e -> category:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_company_root_category", weight=3]; - company:default_language_id:w -> language:in1:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_company_default_language", constraint=false, weight=1]; - company:taxrate:w -> tax:in1:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_company_taxrate", constraint=false, weight=1]; - company:reducedtaxrate:w -> tax:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_company_reducedtaxrate", constraint=false, weight=1]; - company:taxfreerate:w -> tax:in2:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_company_taxfreerate", constraint=false, weight=1]; - customergroup:company_id:w -> company:in4:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_customergroup_company", weight=3]; - company:poscustomergroup_id:w -> customergroup:in2:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_company_poscustomergroup", constraint=false, weight=1]; - entity:customergroup_id:sw -> customergroup:in0:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_entity_customergroup", weight=3]; - entityaddress:entity_id:ne -> entity:in1:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_entityaddress_entity", weight=3]; - entitycontact:entity_id:sw -> entity:in2:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_entitycontact_entity", weight=3]; - entityhistory:entity_id:sw -> entity:in3:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_entityhistory_entity", weight=3]; + company:default_language_id:e -> language:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_company_default_language", constraint=false, weight=1]; + company:taxrate:e -> tax:in3:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_company_taxrate", constraint=false, weight=1]; + company:reducedtaxrate:e -> tax:in1:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_company_reducedtaxrate", constraint=false, weight=1]; + company:taxfreerate:e -> tax:in2:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_company_taxfreerate", constraint=false, weight=1]; + customergroup:company_id:w -> company:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_customergroup_company", weight=3]; + company:poscustomergroup_id:w -> customergroup:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_company_poscustomergroup", constraint=false, weight=1]; + entity:customergroup_id:w -> customergroup:in0:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_entity_customergroup", weight=3]; + entityaddress:entity_id:ne -> entity:in0:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_entityaddress_entity", weight=3]; + entitycontact:entity_id:se -> entity:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_entitycontact_entity", weight=3]; + entityhistory:entity_id:sw -> entity:in2:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_entityhistory_entity", weight=3]; entityhistory:companyuser_id:se -> companyuser:in0:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_entityhistory_companyuser", constraint=false, weight=1]; - unit:company_id:w -> company:in12:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_unit_company", weight=3]; - unittext:unit_id:e -> unit:in1:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_unittext_unit", weight=3]; + unit:company_id:e -> company:in13:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_unit_company", weight=3]; + unittext:unit_id:sw -> unit:in1:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_unittext_unit", weight=3]; unittext:language_id:w -> language:in3:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_unittext_language", constraint=false, weight=1]; - brand:company_id:sw -> company:in10:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_brand_company", weight=3]; - article:company_id:w -> company:in11:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_article_company", weight=3]; + brand:company_id:w -> company:in11:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_brand_company", weight=3]; + article:company_id:w -> company:in10:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_article_company", weight=3]; article:unit_id:e -> unit:in0:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_article_unit", constraint=false, weight=1]; - article:brand_id:w -> brand:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_article_brand", constraint=false, weight=1]; - article:articlelist_id:w -> articlelist:in1:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_article_articlelist", constraint=false, weight=1]; - article:optionslist_id:w -> optionslist:in1:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_article_optionslist", constraint=false, weight=1]; + article:brand_id:e -> brand:in1:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_article_brand", constraint=false, weight=1]; + article:articlelist_id:e -> articlelist:in0:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_article_articlelist", constraint=false, weight=1]; + article:optionslist_id:e -> optionslist:in0:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_article_optionslist", constraint=false, weight=1]; article:fatherarticle_id:e -> article:id:e [color="#c79bff", fontcolor="#c79bff", tooltip="FK_article_fatherarticle", constraint=false]; - articlehistory:article_id:se -> article:in0:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_articlehistory_article", weight=3]; - articlehistory:companyuser_id:e -> companyuser:in1:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_articlehistory_companyuser", constraint=false, weight=1]; - articleean:article_id:se -> article:in3:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_articleean_article", weight=3]; - articlepic:article_id:sw -> article:in7:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_articlepic_article", weight=3]; - articlepic:pic_id:e -> pic:in1:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_articlepic_pic", constraint=false, weight=1]; - brandpic:brand_id:e -> brand:in1:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_brandpic_brand", weight=3]; - brandpic:pic_id:w -> pic:in2:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_brandpic_pic", constraint=false, weight=1]; - articlelistarticle:articlelist_id:ne -> articlelist:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_articlelistarticle_articlelist", weight=3]; - articlelistarticle:article_id:w -> article:in1:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_articlelistarticle_article", constraint=false, weight=1]; - optionslistarticle:optionslist_id:se -> optionslist:in0:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_optionslistarticle_optionslist", weight=3]; - optionslistarticle:article_id:w -> article:in2:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_optionslistarticle_article", constraint=false, weight=1]; - categoryarticle:category_id:nw -> category:in3:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_categoryarticle_category", weight=3]; - categoryarticle:article_id:sw -> article:in6:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_categoryarticle_article", constraint=false, weight=1]; - categorypic:category_id:sw -> category:in4:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_categorypic_category", weight=3]; - categorypic:pic_id:w -> pic:in0:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_categorypic_pic", constraint=false, weight=1]; - categorytext:category_id:w -> category:in5:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_categorytext_category", weight=3]; - categorytext:language_id:e -> language:in2:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_categorytext_language", constraint=false, weight=1]; - articletext:article_id:w -> article:in9:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_articletext_article", weight=3]; - articletext:language_id:w -> language:in0:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_articletext_language", constraint=false, weight=1]; - price:tax_id:w -> tax:in3:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_price_tax", constraint=false, weight=1]; - price:article_id:e -> article:in8:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_price_article", weight=3]; - price:customergroup_id:w -> customergroup:in1:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_price_customergroup", constraint=false, weight=1]; - articlesupplier:article_id:nw -> article:in5:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_articlesupplier_article", weight=3]; - articlesupplier:entity_id:w -> entity:in4:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_articlesupplier_entity", constraint=false, weight=1]; - articlesupplier:price_id:e -> price:in0:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_articlesupplier_price", constraint=false, weight=1]; - manifest:company_id:e -> company:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_manifest_company", weight=3]; - manifest:entity_id:e -> entity:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_manifest_entity", constraint=false, weight=1]; - manifestarticle:manifest_id:se -> manifest:in0:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_manifestarticle_manifest", weight=3]; - warehouse:company_id:e -> company:in1:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_warehouse_company", weight=3]; - ledger:manifestarticle_id:sw -> manifestarticle:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_ledger_manifestarticle", weight=3]; - ledger:warehouse_id:e -> warehouse:in0:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_ledger_warehouse", constraint=false, weight=1]; - numbers:company_id:se -> company:in5:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_numbers_company", weight=3]; - companyuser:google_auth_id:e -> google_auth:in0:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_companyuser_google_auth", constraint=false, weight=1]; - companyuser:company_id:ne -> company:in6:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_companyuser_company", weight=3]; - syncpos:company_id:nw -> company:in7:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_syncpos_company", weight=3]; - companysetting:company_id:sw -> company:in8:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_companysetting_company", weight=3]; - posterminal:company_id:e -> company:in2:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_posterminal_company", weight=3]; - posterminalcategory:posterminal_id:sw -> posterminal:in0:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_posterminalcategory_posterminal", weight=3]; - posterminalcategory:category_id:e -> category:in1:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_posterminalcategory_category", constraint=false, weight=1]; - printer:company_id:se -> company:in3:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_printer_company", weight=3]; - manifestattachment:manifest_id:sw -> manifest:in1:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_manifestattachment_manifest", weight=3]; - manifestattachment:file_id:e -> file:in0:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_manifestattachment_file", constraint=false, weight=1]; + article:fatherattributename_id:sw -> attributename:in2:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_article_fatherattributename", constraint=false, weight=1]; + article:fatherattributename_id:sw -> attributename:in2:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_article_fatherattributename", constraint=false, weight=1]; + articlehistory:article_id:se -> article:in5:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_articlehistory_article", weight=3]; + articlehistory:companyuser_id:e -> companyuser:in1:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_articlehistory_companyuser", constraint=false, weight=1]; + articletombstone:company_id:e -> company:in12:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_articletombstone_company", weight=3]; + articleean:article_id:sw -> article:in7:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_articleean_article", weight=3]; + articlepic:article_id:se -> article:in3:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_articlepic_article", weight=3]; + articlepic:pic_id:w -> pic:in2:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_articlepic_pic", constraint=false, weight=1]; + brandpic:brand_id:e -> brand:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_brandpic_brand", weight=3]; + brandpic:pic_id:w -> pic:in0:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_brandpic_pic", constraint=false, weight=1]; + articlelistarticle:articlelist_id:se -> articlelist:in1:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_articlelistarticle_articlelist", weight=3]; + articlelistarticle:article_id:e -> article:in10:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_articlelistarticle_article", constraint=false, weight=1]; + optionslistarticle:optionslist_id:sw -> optionslist:in1:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_optionslistarticle_optionslist", weight=3]; + optionslistarticle:article_id:e -> article:in9:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_optionslistarticle_article", constraint=false, weight=1]; + categoryarticle:category_id:se -> category:in4:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_categoryarticle_category", weight=3]; + categoryarticle:article_id:se -> article:in4:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_categoryarticle_article", constraint=false, weight=1]; + categorypic:category_id:sw -> category:in3:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_categorypic_category", weight=3]; + categorypic:pic_id:w -> pic:in1:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_categorypic_pic", constraint=false, weight=1]; + categorytext:category_id:e -> category:in6:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_categorytext_category", weight=3]; + categorytext:language_id:w -> language:in2:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_categorytext_language", constraint=false, weight=1]; + articletext:article_id:w -> article:in11:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_articletext_article", weight=3]; + articletext:language_id:e -> language:in1:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_articletext_language", constraint=false, weight=1]; + price:tax_id:w -> tax:in0:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_price_tax", constraint=false, weight=1]; + price:article_id:e -> article:in2:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_price_article", weight=3]; + price:customergroup_id:e -> customergroup:in2:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_price_customergroup", constraint=false, weight=1]; + articlesupplier:article_id:sw -> article:in8:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_articlesupplier_article", weight=3]; + articlesupplier:entity_id:w -> entity:in4:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_articlesupplier_entity", constraint=false, weight=1]; + articlesupplier:price_id:w -> price:in0:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_articlesupplier_price", constraint=false, weight=1]; + manifest:company_id:se -> company:in6:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_manifest_company", weight=3]; + manifest:entity_id:w -> entity:in3:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_manifest_entity", constraint=false, weight=1]; + manifestarticle:manifest_id:sw -> manifest:in0:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_manifestarticle_manifest", weight=3]; + warehouse:company_id:sw -> company:in7:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_warehouse_company", weight=3]; + ledger:manifestarticle_id:sw -> manifestarticle:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_ledger_manifestarticle", weight=3]; + ledger:warehouse_id:e -> warehouse:in0:n [color="#8f95ff", fontcolor="#8f95ff", tooltip="FK_ledger_warehouse", constraint=false, weight=1]; + numbers:company_id:e -> company:in1:n [color="#58d18a", fontcolor="#58d18a", tooltip="FK_numbers_company", weight=3]; + companyuser:google_auth_id:e -> google_auth:in0:n [color="#ff8f66", fontcolor="#ff8f66", tooltip="FK_companyuser_google_auth", constraint=false, weight=1]; + companyuser:company_id:w -> company:in3:n [color="#4fc4e8", fontcolor="#4fc4e8", tooltip="FK_companyuser_company", weight=3]; + syncpos:company_id:se -> company:in2:n [color="#a98cff", fontcolor="#a98cff", tooltip="FK_syncpos_company", weight=3]; + companysetting:company_id:nw -> company:in5:n [color="#ff6f6f", fontcolor="#ff6f6f", tooltip="FK_companysetting_company", weight=3]; + posterminal:company_id:w -> company:in14:n [color="#4ecb8f", fontcolor="#4ecb8f", tooltip="FK_posterminal_company", weight=3]; + posterminalcategory:posterminal_id:ne -> posterminal:in0:n [color="#5aa7e6", fontcolor="#5aa7e6", tooltip="FK_posterminalcategory_posterminal", weight=3]; + posterminalcategory:category_id:w -> category:in7:n [color="#ff7aa8", fontcolor="#ff7aa8", tooltip="FK_posterminalcategory_category", constraint=false, weight=1]; + printer:company_id:e -> company:in15:n [color="#e0b25a", fontcolor="#e0b25a", tooltip="FK_printer_company", weight=3]; + labeltemplate:company_id:w -> company:in16:n [color="#56c8c8", fontcolor="#56c8c8", tooltip="FK_labeltemplate_company", weight=3]; + manifestattachment:manifest_id:sw -> manifest:in1:n [color="#ff5f9e", fontcolor="#ff5f9e", tooltip="FK_manifestattachment_manifest", weight=3]; + manifestattachment:file_id:e -> file:in0:n [color="#ffb25a", fontcolor="#ffb25a", tooltip="FK_manifestattachment_file", constraint=false, weight=1]; + propertyname:company_id:sw -> company:in9:n [color="#56d68a", fontcolor="#56d68a", tooltip="FK_propertyname_company", weight=3]; + articleproperty:article_id:w -> article:in0:n [color="#66b3ff", fontcolor="#66b3ff", tooltip="FK_articleproperty_article", weight=3]; + articleproperty:property_id:w -> propertyname:in0:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_articleproperty_property", constraint=false, weight=1]; + attributename:company_id:w -> company:in8:n [color="#ff6b6b", fontcolor="#ff6b6b", tooltip="FK_attributename_company", weight=3]; + attributevalue:attribute_id:se -> attributename:in0:n [color="#6bdf9a", fontcolor="#6bdf9a", tooltip="FK_attributevalue_attribute", weight=3]; + articleattributevalue:article_id:e -> article:in1:n [color="#5aa7ff", fontcolor="#5aa7ff", tooltip="FK_articleattributevalue_article", weight=3]; + articleattributevalue:attributevalue_id:w -> attributevalue:in1:n [color="#f5c15c", fontcolor="#f5c15c", tooltip="FK_articleattributevalue_attributevalue", constraint=false, weight=1]; + categoryproperty:category_id:e -> category:in1:n [color="#c79bff", fontcolor="#c79bff", tooltip="FK_categoryproperty_category", weight=3]; + categoryproperty:property_id:w -> propertyname:in1:n [color="#ff9f5a", fontcolor="#ff9f5a", tooltip="FK_categoryproperty_property", constraint=false, weight=1]; + categoryattributevalue:category_id:e -> category:in2:n [color="#5cd6d6", fontcolor="#5cd6d6", tooltip="FK_categoryattributevalue_category", weight=3]; + categoryattributevalue:attributevalue_id:w -> attributevalue:in0:n [color="#ff7fc2", fontcolor="#ff7fc2", tooltip="FK_categoryattributevalue_attributevalue", constraint=false, weight=1]; } \ No newline at end of file diff --git a/schema-graph.svg b/schema-graph.svg index 581f796..bbb5246 100644 --- a/schema-graph.svg +++ b/schema-graph.svg @@ -4,635 +4,526 @@ - - + + schema - + cluster_identity - -Identity & Company + +Identity & Company cluster_i18n - -Localization + +Localization cluster_catalog - -Catalog + +Catalog cluster_media - -Media + +Media cluster_manifests - -Documents + +Documents cluster_commerce - -Pricing + +Pricing cluster_units - -Units + +Units cluster_pos - -POS + +POS + + +cluster_attributes + +Properties & Attributes meta - - -meta - - -key -NVARCHAR(255) - - -value -NVARCHAR(MAX) + + +meta + + +key +NVARCHAR(255) + + +value +NVARCHAR(MAX) customergroup - - - - - -customergroup - - -id -INT -PK - - -company_id -? -INT -FK - - -name -NVARCHAR(255) - - -sort -INT + + + + + +customergroup + + +id +INT +PK + + +company_id +? +INT +FK + + +name +NVARCHAR(255) + + +sort +INT + + +updated_at +DATETIME2 - + company - - - - - - - - - - - - - - - -company - - -id -INT -PK - - -poscustomergroup_id -INT -FK - - -root_category_id -? -INT -FK - - -taxrate -? -INT -FK - - -reducedtaxrate -? -INT -FK - - -taxfreerate -? -INT -FK - - -default_language_id -? -INT -FK - - -name -NVARCHAR(255) + + + + + + + + + + + + + + + + + + + +company + + +id +INT +PK + + +poscustomergroup_id +INT +FK + + +taxrate +? +INT +FK + + +reducedtaxrate +? +INT +FK + + +taxfreerate +? +INT +FK + + +root_category_id +? +INT +FK + + +default_language_id +? +INT +FK + + +name +NVARCHAR(255) customergroup:w->company:n - - + + numbers - - -numbers - - -id -INT -PK - - -company_id -INT -FK - - -type -NVARCHAR(100) - - -prefix -NVARCHAR(50) - - -padding -INT - - -next -INT + + +numbers + + +id +INT +PK + + +company_id +INT +FK + + +type +NVARCHAR(100) + + +prefix +NVARCHAR(50) + + +padding +INT + + +next +INT - -numbers:se->company:n - - - + +numbers:e->company:n + + + entityaddress - - -entityaddress - - -id -INT -PK - - -entity_id -INT -FK - - -first_name? -NVARCHAR(255) - - -name? -NVARCHAR(255) - - -street? -NVARCHAR(255) - - -streetnumber? -NVARCHAR(50) - - -zip? -NVARCHAR(50) - - -city? -NVARCHAR(255) - - -county? -NVARCHAR(255) - - -addresstype -NVARCHAR(50) - - -sort -INT + + +entityaddress + + +id +INT +PK + + +entity_id +INT +FK + + +first_name? +NVARCHAR(255) + + +name? +NVARCHAR(255) + + +street? +NVARCHAR(255) + + +streetnumber? +NVARCHAR(50) + + +zip? +NVARCHAR(50) + + +city? +NVARCHAR(255) + + +county? +NVARCHAR(255) + + +addresstype +NVARCHAR(50) + + +sort +INT entity - - - - - - - -entity - - -id -INT -PK - - -customergroup_id -INT -FK - - -first_name? -NVARCHAR(255) - - -name -NVARCHAR(255) - - -number? -NVARCHAR(100) - - -created_at -DATETIME2 - - -updated_at -DATETIME2 + + + + + + + +entity + + +id +INT +PK + + +customergroup_id +INT +FK + + +first_name? +NVARCHAR(255) + + +name +NVARCHAR(255) + + +number? +NVARCHAR(100) + + +created_at +DATETIME2 + + +updated_at +DATETIME2 entityaddress:ne->entity:n - - + + -entity:sw->customergroup:n +entity:w->customergroup:n - - + + companyuser - - - - -companyuser - - -id -INT -PK - - -company_id -INT -FK - - -google_auth_id -INT -FK + + + + +companyuser + + +id +INT +PK + + +company_id +INT +FK + + +google_auth_id +INT +FK - -companyuser:ne->company:n - - - + +companyuser:w->company:n + + + google_auth - - - -google_auth - - -id -INT -PK - - -selected_company -? -INT -FK - - -email -NVARCHAR(320) - - -picture? -NVARCHAR(2048) - - -picturebinary? -VARBINARY(MAX) - - -sub? -NVARCHAR(255) - - -created_at -DATETIME2 + + + +google_auth + + +id +INT +PK + + +selected_company +? +INT +FK + + +email +NVARCHAR(320) + + +picture? +NVARCHAR(2048) + + +picturebinary? +VARBINARY(MAX) + + +sub? +NVARCHAR(255) + + +created_at +DATETIME2 - + companyuser:e->google_auth:n - - - + + + entitycontact - - -entitycontact - - -id -INT -PK - - -entity_id -INT -FK - - -contacttype -NVARCHAR(50) - - -contact -NVARCHAR(255) - - -sort -INT + + +entitycontact + + +id +INT +PK + + +entity_id +INT +FK + + +contacttype +NVARCHAR(50) + + +contact +NVARCHAR(255) + + +sort +INT -entitycontact:sw->entity:n +entitycontact:se->entity:n - - - - - - - -company:w->customergroup:n - - - - - - - - -language - - - - - - -language - - -id -INT -PK - - -iso639 -NVARCHAR(10) - - - -company:w->language:n - - - - - - - - -category - - - - - - - - -category - - -id -INT -PK - - -parent_id -? -INT -FK - - -sort -INT - - - -company:e->category:n - - - - - - - - -tax - - - - - - -tax - - -id -INT -PK - - -rate -DECIMAL(18,4) - - - -company:w->tax:n - - - - - - - - -company:w->tax:n - - - - - - - - -company:w->tax:n - - - + + - + syncpos - - -syncpos - - -id -INT -PK - - -company_id -INT -FK - - -paircode -NVARCHAR(50) - - -token? -NVARCHAR(255) + + +syncpos + + +id +INT +PK + + +company_id +INT +FK + + +paircode +NVARCHAR(50) + + +token? +NVARCHAR(255) - -syncpos:nw->company:n - - - + +syncpos:se->company:n + + + - + entityhistory - - -entityhistory - - -id -INT -PK - - -entity_id -INT -FK - - -companyuser_id -? -INT -FK - - -date -DATETIME2 - - -text -NVARCHAR(MAX) + + +entityhistory + + +id +INT +PK + + +entity_id +INT +FK + + +companyuser_id +? +INT +FK + + +date +DATETIME2 + + +text +NVARCHAR(MAX) entityhistory:sw->entity:n - - + + @@ -640,742 +531,514 @@ entityhistory:se->companyuser:n - - + + - + companysetting - - -companysetting - - -id -INT -PK - - -company_id -INT -FK - - -key -NVARCHAR(255) - - -value -NVARCHAR(MAX) - - -frontend -BIT - - -writeonly -BIT + + +companysetting + + +id +INT +PK + + +company_id +INT +FK + + +key +NVARCHAR(255) + + +value +NVARCHAR(MAX) + + +frontend +BIT + + +writeonly +BIT - -companysetting:sw->company:n - - - + +companysetting:nw->company:n + + + + + + + + +company:w->customergroup:n + + + + + + + + +language + + + + + + +language + + +id +INT +PK + + +iso639 +NVARCHAR(10) + + + +company:e->language:n + + + + + + + + +category + + + + + + + + + + +category + + +id +INT +PK + + +parent_id +? +INT +FK + + +sort +INT + + +updated_at +DATETIME2 + + + +company:e->category:n + + + + + + + + +tax + + + + + + +tax + + +id +INT +PK + + +rate +DECIMAL(18,4) + + + +company:e->tax:n + + + + + + + + +company:e->tax:n + + + + + + + + +company:e->tax:n + + + -google_auth:w->company:n +google_auth:se->company:n - - - - - - - -articletext - - -articletext - - -id -INT -PK - - -article_id -INT -FK - - -language_id -INT -FK - - -name -NVARCHAR(255) - - -slug -NVARCHAR(255) - - -seo? -NVARCHAR(MAX) - - -desc? -NVARCHAR(MAX) - - - -articletext:w->language:n - - - - - - - - -article - - - - - - - - - - - - -article - - -id -INT -PK - - -company_id -INT -FK - - -brand_id -? -INT -FK - - -articlelist_id -? -INT -FK - - -optionslist_id -? -INT -FK - - -fatherarticle_id -? -INT -FK - - -unit_id -INT -FK - - -sku? -NVARCHAR(100) - - -unitamount -FLOAT - - -weight -FLOAT - - -track_stock -BIT - - -is_father -BIT - - -fathersort -INT - - -created_at -DATETIME2 - - -updated_at -DATETIME2 - - - -articletext:w->article:n - - - + + - + categorytext - - -categorytext - - -id -INT -PK - - -category_id -INT -FK - - -language_id -INT -FK - - -name -NVARCHAR(255) - - -slug -NVARCHAR(255) - - -seo? -NVARCHAR(MAX) - - -desc? -NVARCHAR(MAX) + + +categorytext + + +id +INT +PK + + +category_id +INT +FK + + +language_id +INT +FK + + +name +NVARCHAR(255) + + +slug +NVARCHAR(255) + + +seo? +NVARCHAR(MAX) + + +desc? +NVARCHAR(MAX) - -categorytext:e->language:n - - - + +categorytext:w->language:n + + + - -categorytext:w->category:n - - - + +categorytext:e->category:n + + + + + + + + +articletext + + +articletext + + +id +INT +PK + + +article_id +INT +FK + + +language_id +INT +FK + + +name +NVARCHAR(255) + + +slug +NVARCHAR(255) + + +seo? +NVARCHAR(MAX) + + +desc? +NVARCHAR(MAX) + + + +articletext:e->language:n + + + + + + + + +article + + + + + + + + + + + + + + +article + + +id +INT +PK + + +company_id +INT +FK + + +fatherattributename_id +? +INT +FK + + +fatherarticle_id +? +INT +FK + + +articlelist_id +? +INT +FK + + +optionslist_id +? +INT +FK + + +brand_id +? +INT +FK + + +unit_id +INT +FK + + +sku? +NVARCHAR(100) + + +unitamount +FLOAT + + +weight +FLOAT + + +track_stock +BIT + + +is_father +BIT + + +fathersort +INT + + +created_at +DATETIME2 + + +updated_at +DATETIME2 + + + +articletext:w->article:n + + + unittext - - -unittext - - -id -INT -PK - - -unit_id -INT -FK - - -language_id -INT -FK - - -symbol -NVARCHAR(50) - - -name -NVARCHAR(255) + + +unittext + + +id +INT +PK + + +language_id +INT +FK + + +unit_id +INT +FK + + +symbol +NVARCHAR(50) + + +name +NVARCHAR(255) unittext:w->language:n - - + + - + unit - - - - -unit - - -id -INT -PK - - -company_id -INT -FK - - -si -NVARCHAR(50) - - -basesi -NVARCHAR(50) - - -basesifactor -FLOAT + + + + +unit + + +id +INT +PK + + +company_id +INT +FK + + +si +NVARCHAR(50) + + +basesi +NVARCHAR(50) + + +basesifactor +FLOAT -unittext:e->unit:n +unittext:sw->unit:n - - + + - + -brand - - - - -brand - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -gsprpinfo? -NVARCHAR(MAX) +categoryarticle + + +categoryarticle + + +id +INT +PK + + +category_id +INT +FK + + +article_id +INT +FK - - -brand:sw->company:n - - - + + +categoryarticle:se->category:n + + + - - -articlelist - - - - -articlelist - - -id -INT -PK - - - -articlelistarticle - - -articlelistarticle - - -id -INT -PK - - -articlelist_id -INT -FK - - -article_id -INT -FK - - -count -INT - - -sort -INT - - - -articlelistarticle:ne->articlelist:n - - - - - - - - -articlelistarticle:w->article:n - - - - - - - - -optionslist - - - - -optionslist - - -id -INT -PK - - - -optionslistarticle - - -optionslistarticle - - -id -INT -PK - - -optionslist_id -INT -FK - - -article_id -INT -FK - - -count -INT - - -sort -INT - - -mandated -BIT - - - -optionslistarticle:se->optionslist:n - - - - - - - - -optionslistarticle:w->article:n - - - - - - - - -articlehistory - - -articlehistory - - -id -INT -PK - - -companyuser_id -? -INT -FK - - -article_id -INT -FK - - -date -DATETIME2 - - -text -NVARCHAR(MAX) - - - -articlehistory:e->companyuser:n - - - - - - - - -articlehistory:se->article:n - - - - - - - - -articleean - - -articleean - - -id -INT -PK - - -article_id -INT -FK - - -ean -NVARCHAR(50) - - - -articleean:se->article:n - - - - - - - - -article:w->company:n - - - - - - - - -article:w->brand:n - - - - - - - - -article:w->articlelist:n - - - - - - - - -article:w->optionslist:n - - - - - - - - -article:e->article:e - - - - - - - - -article:e->unit:n - - - - - - - - -articlesupplier - - -articlesupplier - - -id -INT -PK - - -entity_id -INT -FK - - -article_id -INT -FK - - -price_id -? -INT -FK - - -sku? -NVARCHAR(100) - - -stock? -FLOAT - - -pu -FLOAT - - -sort -INT - - - -articlesupplier:w->entity:n - - - - - - - - -articlesupplier:nw->article:n - - - - - - - - -price - - - -price - - -id -INT -PK - - -customergroup_id -INT -FK - - -article_id -INT -FK - - -tax_id -INT -FK - - -minmultiplier -DECIMAL(18,4) - - -net -FLOAT - - - -articlesupplier:e->price:n - - - + + +categoryarticle:se->article:n + + + @@ -1383,753 +1046,1552 @@ category:e->category:e - - + + - + + +articlehistory + + +articlehistory + + +id +INT +PK + + +companyuser_id +? +INT +FK + + +article_id +INT +FK + + +date +DATETIME2 + + +text +NVARCHAR(MAX) + + + +articlehistory:e->companyuser:n + + + + + + + + +articlehistory:se->article:n + + + + + + + + +article:w->company:n + + + + + + + + +article:e->article:e + + + + + + + + +articlelist + + + + +articlelist + + +id +INT +PK + + + +article:e->articlelist:n + + + + + + + + +optionslist + + + + +optionslist + + +id +INT +PK + + + +article:e->optionslist:n + + + + + + + -categoryarticle - - -categoryarticle - - -id -INT -PK - - -article_id -INT -FK - - -category_id -INT -FK +brand + + + + +brand + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +gsprpinfo? +NVARCHAR(MAX) - - -categoryarticle:sw->article:n - - - + + +article:e->brand:n + + + - + + +article:e->unit:n + + + + + + + + +attributename + + + + + +attributename + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +sort +INT + + +exclusive +BIT + + + +article:sw->attributename:n + + + + + + + + +article:sw->attributename:n + + + + + + + + +articleean + + +articleean + + +id +INT +PK + + +article_id +INT +FK + + +ean +NVARCHAR(50) + + + +articleean:sw->article:n + + + + + + + + +articlesupplier + + +articlesupplier + + +id +INT +PK + + +entity_id +INT +FK + + +price_id +? +INT +FK + + +article_id +INT +FK + + +sku? +NVARCHAR(100) + + +stock? +FLOAT + + +pu +FLOAT + + +sort +INT + + + +articlesupplier:w->entity:n + + + + + + + + +articlesupplier:sw->article:n + + + + + + + + +price + + + +price + + +id +INT +PK + + +customergroup_id +INT +FK + + +tax_id +INT +FK + + +article_id +INT +FK + + +minmultiplier +DECIMAL(18,4) + + +net +FLOAT + + + +articlesupplier:w->price:n + + + + + + + + +articlelistarticle + + +articlelistarticle + + +id +INT +PK + + +article_id +INT +FK + + +articlelist_id +INT +FK + + +count +INT + + +sort +INT + + -categoryarticle:nw->category:n - - - +articlelistarticle:e->article:n + + + + + + + + +articlelistarticle:se->articlelist:n + + + + + + + + +optionslistarticle + + +optionslistarticle + + +id +INT +PK + + +article_id +INT +FK + + +optionslist_id +INT +FK + + +count +INT + + +sort +INT + + +mandated +BIT + + + +optionslistarticle:e->article:n + + + + + + + + +optionslistarticle:sw->optionslist:n + + + + + + + + +brand:w->company:n + + + + + + + + +articletombstone + + +articletombstone + + +id +INT +PK + + +company_id +INT +FK + + +article_id? +INT + + +sku? +NVARCHAR(100) + + +name? +NVARCHAR(255) + + +taxrate? +DECIMAL(18,4) + + +net? +FLOAT + + +deleted_at +DATETIME2 + + + +articletombstone:e->company:n + + + - -pic - - - - - -pic - - -id -INT -PK - - -w -INT - - -h -INT - - -size -BIGINT - - -hash -NVARCHAR(64) - - -thumbw -INT - - -thumbh -INT - - -thumbhash -NVARCHAR(64) - - -source -NVARCHAR(MAX) - - -fullbinary -VARBINARY(MAX) - - -thumbbinary -VARBINARY(MAX) - - -brandpic - - -brandpic - - -id -INT -PK - - -brand_id -INT -FK - - -pic_id -INT -FK - - -sort -INT - - - -brandpic:e->brand:n - - - - - - - - -brandpic:w->pic:n - - - - - - - - -articlepic - - -articlepic - - -id -INT -PK - - -article_id -INT -FK - - -pic_id -INT -FK - - -sort -INT - - - -articlepic:sw->article:n - - - - - - - - -articlepic:e->pic:n - - - - - +pic + + + + + +pic + + +id +INT +PK + + +w +INT + + +h +INT + + +size +BIGINT + + +hash +NVARCHAR(64) + + +thumbw +INT + + +thumbh +INT + + +thumbhash +NVARCHAR(64) + + +source +NVARCHAR(MAX) + + +fullbinary +VARBINARY(MAX) + + +thumbbinary +VARBINARY(MAX) - + categorypic - - -categorypic - - -id -INT -PK - - -pic_id -INT -FK - - -category_id -INT -FK - - -sort -INT + + +categorypic + + +id +INT +PK + + +pic_id +INT +FK + + +category_id +INT +FK + + +sort +INT - + categorypic:sw->category:n - - - + + + - + categorypic:w->pic:n - - - + + + + + + + + +articlepic + + +articlepic + + +id +INT +PK + + +pic_id +INT +FK + + +article_id +INT +FK + + +sort +INT + + + +articlepic:se->article:n + + + + + + + + +articlepic:w->pic:n + + + + + + + + +brandpic + + +brandpic + + +id +INT +PK + + +pic_id +INT +FK + + +brand_id +INT +FK + + +sort +INT + + + +brandpic:e->brand:n + + + + + + + + +brandpic:w->pic:n + + + - -file - - - -file - - -id -INT -PK - - -hash -NVARCHAR(64) - - -mimetype -NVARCHAR(255) - - -name -NVARCHAR(255) - - -size -BIGINT - - -created -DATETIME2 - - -modified -DATETIME2 - - -content -VARBINARY(MAX) - - -thumbavif? -VARBINARY(MAX) - - -thumbx? -INT - - -thumby? -INT - - -meta? -NVARCHAR(MAX) - - -ledger - - -ledger - - -id -INT -PK - - -manifestarticle_id -INT -FK - - -warehouse_id -? -INT -FK - - -transaction_type -NVARCHAR(100) - - -amount -FLOAT - - -transaction_text -NVARCHAR(MAX) - - -hash_version -INT - - -prev_hash? -NVARCHAR(128) - - -created_at -DATETIME2 +file + + + +file + + +id +INT +PK + + +hash +NVARCHAR(64) + + +mimetype +NVARCHAR(255) + + +name +NVARCHAR(255) + + +size +BIGINT + + +created +DATETIME2 + + +modified +DATETIME2 + + +content +VARBINARY(MAX) + + +thumbavif? +VARBINARY(MAX) + + +thumbx? +INT + + +thumby? +INT + + +meta? +NVARCHAR(MAX) manifestarticle - - - -manifestarticle - - -id -INT -PK - - -manifest_id -INT -FK - - -sku? -NVARCHAR(100) - - -name? -NVARCHAR(255) - - -pu -FLOAT - - -amount -FLOAT - - -net -FLOAT - - -comment? -NVARCHAR(MAX) - - -rebate -FLOAT - - -taxrate -FLOAT + + + +manifestarticle + + +id +INT +PK + + +manifest_id +INT +FK + + +sku? +NVARCHAR(100) + + +name? +NVARCHAR(255) + + +pu +FLOAT + + +amount +FLOAT + + +net +FLOAT + + +comment? +NVARCHAR(MAX) + + +rebate +FLOAT + + +taxrate +FLOAT + + + +manifest + + + + +manifest + + +id +INT +PK + + +entity_id +? +INT +FK + + +company_id +INT +FK + + +number +NVARCHAR(100) + + +type +NVARCHAR(100) + + +external_number? +NVARCHAR(100) + + +date +DATETIME2 + + +created_at +DATETIME2 + + + +manifestarticle:sw->manifest:n + + + + + + + + +ledger + + +ledger + + +id +INT +PK + + +manifestarticle_id +INT +FK + + +warehouse_id +? +INT +FK + + +transaction_type +NVARCHAR(100) + + +amount +FLOAT + + +transaction_text +NVARCHAR(MAX) + + +hash_version +INT + + +prev_hash? +NVARCHAR(128) + + +created_at +DATETIME2 - + ledger:sw->manifestarticle:n - - - + + + - + warehouse - - - -warehouse - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -sort -INT + + + +warehouse + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +sort +INT - + ledger:e->warehouse:n - - - - - - - - -manifest - - - - -manifest - - -id -INT -PK - - -entity_id -? -INT -FK - - -company_id -INT -FK - - -number -NVARCHAR(100) - - -type -NVARCHAR(100) - - -external_number? -NVARCHAR(100) - - -date -DATETIME2 - - -created_at -DATETIME2 - - - -manifestarticle:se->manifest:n - - - + + + - -manifest:e->entity:n - - - + +manifest:w->entity:n + + + - -manifest:e->company:n - - - + +manifest:se->company:n + + + - + manifestattachment - - -manifestattachment - - -id -INT -PK - - -manifest_id -INT -FK - - -file_id -INT -FK - - -sort -INT + + +manifestattachment + + +id +INT +PK + + +manifest_id +INT +FK + + +file_id +INT +FK + + +sort +INT - + manifestattachment:e->file:n - - - + + + - + manifestattachment:sw->manifest:n - - - + + + - -warehouse:e->company:n - - - + +warehouse:sw->company:n + + + - -price:w->customergroup:n - - - + +price:e->customergroup:n + + + - + price:e->article:n - - - + + + - + price:w->tax:n - - - + + + -unit:w->company:n +unit:e->company:n - - + + - + posterminalcategory - - -posterminalcategory - - -id -INT -PK - - -posterminal_id -INT -FK - - -category_id -INT -FK + + +posterminalcategory + + +id +INT +PK + + +category_id +INT +FK + + +posterminal_id +INT +FK - -posterminalcategory:e->category:n - - - + +posterminalcategory:w->category:n + + + - + posterminal - - - -posterminal - - -id -INT -PK - - -company_id -INT -FK - - -name -NVARCHAR(255) - - -token? -NVARCHAR(255) - - -pairingpin -NVARCHAR(50) + + + +posterminal + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +token? +NVARCHAR(255) + + +pairingpin +NVARCHAR(50) + + +paired_at? +DATETIME2 + + +clientserial? +NVARCHAR(255) + + +maxknowntombstoneid? +INT - -posterminalcategory:sw->posterminal:n - - - + +posterminalcategory:ne->posterminal:n + + + - -posterminal:e->company:n - - - + +posterminal:w->company:n + + + - + printer - - -printer - - -id -INT -PK - - -company_id -INT -FK - - -address -NVARCHAR(255) - - -type -NVARCHAR(50) - - -model? -NVARCHAR(255) - - -serial? -NVARCHAR(255) - - -sort -INT - - -desc? -NVARCHAR(MAX) + + +printer + + +id +INT +PK + + +company_id +INT +FK + + +address +NVARCHAR(255) + + +type +NVARCHAR(50) + + +model? +NVARCHAR(255) + + +serial? +NVARCHAR(255) + + +sort +INT + + +desc? +NVARCHAR(MAX) - -printer:se->company:n - - - + +printer:e->company:n + + + + + + + + +labeltemplate + + +labeltemplate + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +width +INT + + +height +INT + + +dpi +INT + + +type +NVARCHAR(50) + + +content +NVARCHAR(MAX) + + +thumb? +VARBINARY(MAX) + + + +labeltemplate:w->company:n + + + + + + + + +attributevalue + + + + +attributevalue + + +id +INT +PK + + +attribute_id +INT +FK + + +value +NVARCHAR(255) + + +sort +INT + + + +attributevalue:se->attributename:n + + + + + + + + +attributename:w->company:n + + + + + + + + +propertyname + + + + +propertyname + + +id +INT +PK + + +company_id +INT +FK + + +name +NVARCHAR(255) + + +sort +INT + + +exclusive +BIT + + + +propertyname:sw->company:n + + + + + + + + +categoryproperty + + +categoryproperty + + +id +INT +PK + + +property_id +INT +FK + + +category_id +INT +FK + + +value +NVARCHAR(MAX) + + +sort +INT + + + +categoryproperty:e->category:n + + + + + + + + +categoryproperty:w->propertyname:n + + + + + + + + +categoryattributevalue + + +categoryattributevalue + + +id +INT +PK + + +attributevalue_id +INT +FK + + +category_id +INT +FK + + +sort +INT + + + +categoryattributevalue:e->category:n + + + + + + + + +categoryattributevalue:w->attributevalue:n + + + + + + + + +articleproperty + + +articleproperty + + +id +INT +PK + + +property_id +INT +FK + + +article_id +INT +FK + + +value +NVARCHAR(MAX) + + +sort +INT + + + +articleproperty:w->article:n + + + + + + + + +articleproperty:w->propertyname:n + + + + + + + + +articleattributevalue + + +articleattributevalue + + +id +INT +PK + + +attributevalue_id +INT +FK + + +article_id +INT +FK + + +sort +INT + + + +articleattributevalue:e->article:n + + + + + + + + +articleattributevalue:w->attributevalue:n + + + - + __legend - - - -Legend - -PK - -primary key - -FK - -foreign key - -? - -nullable column - -— - -FOREIGN KEY constraint - -- - - -naming convention only, no constraint + + + +Legend + +PK + +primary key + +FK + +foreign key + +? + +nullable column + +— + +FOREIGN KEY constraint + +- - + +naming convention only, no constraint diff --git a/scripts/test-categoryproperty-and-attrvalue.js b/scripts/test-categoryproperty-and-attrvalue.js new file mode 100644 index 0000000..03b0ff6 --- /dev/null +++ b/scripts/test-categoryproperty-and-attrvalue.js @@ -0,0 +1,175 @@ +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 CategoryProperty & CategoryAttributeValue ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-cat-prop-attr-')); + const dbFile = join(tempDir, 'test.db'); + const storageDir = join(tempDir, 'storage'); + + const provider = new BetterSqlite3Provider({ + filename: dbFile, + storageDir, + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + const googleAuthId = await store.google_auth.add('tester@example.com'); + const company = await store.company.create({ + name: 'Test Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + // Create a subcategory under root + const category = await store.category.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Test Category', + }); + assert.ok(category.id > 0); + + // 1. Test CategoryProperty + console.log('1. Testing categoryproperty CRUD...'); + const prop1 = await store.propertyname.create(googleAuthId, { + name: 'Material Care', + exclusive: true, + }); + const prop2 = await store.propertyname.create(googleAuthId, { + name: 'Season', + exclusive: false, + }); + + // Set properties + const setProps = await store.categoryproperty.setByCategoryId(googleAuthId, category.id, [ + { propertyId: prop1.id, value: 'Dry Clean Only', sort: 10 }, + { propertyId: prop2.id, value: 'Summer 2026', sort: 20 }, + ]); + assert.equal(setProps.length, 2); + + // List properties + const listedProps = await store.categoryproperty.listByCategoryId(googleAuthId, category.id); + assert.equal(listedProps.length, 2); + assert.equal(listedProps[0].name, 'Material Care'); + assert.equal(listedProps[0].value, 'Dry Clean Only'); + assert.equal(listedProps[0].exclusive, true); + assert.equal(listedProps[1].name, 'Season'); + assert.equal(listedProps[1].value, 'Summer 2026'); + assert.equal(listedProps[1].exclusive, false); + + // Test exclusive violation on categoryproperty + await assert.rejects( + async () => { + await store.categoryproperty.setByCategoryId(googleAuthId, category.id, [ + { propertyId: prop1.id, value: 'Val 1' }, + { propertyId: prop1.id, value: 'Val 2' }, + ]); + }, + /exclusive/i, + 'Should reject multiple values for exclusive property' + ); + + // Delete single property + await store.categoryproperty.delete(googleAuthId, category.id, prop1.id); + const afterDeleteProps = await store.categoryproperty.listByCategoryId(googleAuthId, category.id); + assert.equal(afterDeleteProps.length, 1); + assert.equal(afterDeleteProps[0].propertyId, prop2.id); + + console.log('CategoryProperty tests passed!'); + + // 2. Test CategoryAttributeValue + console.log('2. Testing categoryattributevalue CRUD...'); + const attrExclusive = await store.attributename.create(googleAuthId, { + name: 'Department', + exclusive: true, + }); + const valDeptMen = await store.attributevalue.create(googleAuthId, attrExclusive.id, { + value: 'Men', + }); + const valDeptWomen = await store.attributevalue.create(googleAuthId, attrExclusive.id, { + value: 'Women', + }); + + const attrMulti = await store.attributename.create(googleAuthId, { + name: 'Tags', + exclusive: false, + }); + const valTagSale = await store.attributevalue.create(googleAuthId, attrMulti.id, { + value: 'Sale', + }); + const valTagNew = await store.attributevalue.create(googleAuthId, attrMulti.id, { + value: 'New', + }); + + // Bulk set with numeric array + const setAttrValues = await store.categoryattributevalue.setByCategoryId(googleAuthId, category.id, [ + valDeptMen.id, + valTagSale.id, + valTagNew.id, + ]); + assert.equal(setAttrValues.length, 3); + + // List attribute values + const listedAttrValues = await store.categoryattributevalue.listByCategoryId(googleAuthId, category.id); + assert.equal(listedAttrValues.length, 3); + const deptMatch = listedAttrValues.find((v) => v.attributeName === 'Department'); + assert.ok(deptMatch); + assert.equal(deptMatch.value, 'Men'); + assert.equal(deptMatch.exclusive, true); + + // Test exclusive violation + await assert.rejects( + async () => { + await store.categoryattributevalue.setByCategoryId(googleAuthId, category.id, [ + valDeptMen.id, + valDeptWomen.id, + ]); + }, + /exclusive/i, + 'Should reject multiple values for exclusive attribute' + ); + + // Test flexible object shapes in setByCategoryId + const setWithObjs = await store.categoryattributevalue.setByCategoryId(googleAuthId, category.id, [ + { attributeValueId: valDeptWomen.id, sort: 5 }, + { id: valTagSale.id, sort: 15 }, + ]); + assert.equal(setWithObjs.length, 2); + const listedAfterObjSet = await store.categoryattributevalue.listByCategoryId(googleAuthId, category.id); + assert.equal(listedAfterObjSet.length, 2); + assert.equal(listedAfterObjSet[0].value, 'Women'); + assert.equal(listedAfterObjSet[0].sort, 5); + + // 3. Test cascade deletion on category delete + console.log('3. Testing cascade deletion when category is deleted...'); + await store.category.delete({ googleAuthId, id: category.id }); + + // Directly query database to ensure categoryproperty and categoryattributevalue rows were deleted by FK cascade + const remainingProps = await provider.query('SELECT * FROM categoryproperty WHERE category_id = @id', { id: category.id }); + assert.equal(remainingProps.rows.length, 0); + + const remainingAttrValues = await provider.query('SELECT * FROM categoryattributevalue WHERE category_id = @id', { id: category.id }); + assert.equal(remainingAttrValues.rows.length, 0); + + console.log('All CategoryProperty and CategoryAttributeValue tests passed successfully!'); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + } +} + +run().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/scripts/test-history-and-attrvalue.js b/scripts/test-history-and-attrvalue.js new file mode 100644 index 0000000..04bfe7e --- /dev/null +++ b/scripts/test-history-and-attrvalue.js @@ -0,0 +1,121 @@ +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 Father History & AttributeValue Shapes ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-hist-attr-')); + const dbFile = join(tempDir, 'test.db'); + const storageDir = join(tempDir, 'storage'); + + const provider = new BetterSqlite3Provider({ + filename: dbFile, + storageDir, + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + const googleAuthId = await store.google_auth.add('tester@example.com'); + const company = await store.company.create({ + name: 'Test Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + console.log('1. Testing setFatherBySku history logging...'); + const father = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Father Shirt', + }); + const child = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Child Shirt Red', + }); + + // Check history before linking + let fatherHist = await store.article.getHistoryBySku(googleAuthId, father.sku); + assert.equal(fatherHist.length, 1); + assert.match(fatherHist[0].text, /angelegt/); + + // Link child to father + await store.article.setFatherBySku(googleAuthId, child.sku, father.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)}`); + + // Child history must contain the assignment entry + const childHist = await store.article.getHistoryBySku(googleAuthId, child.sku); + const hasAssigned = childHist.some((h) => h.text.includes('als Variante von')); + assert.ok(hasAssigned, `Child history missing variant assigned row: ${JSON.stringify(childHist)}`); + + console.log('2. Testing importRows Pass 2 history logging...'); + 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' }, + ], + }); + 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)}`); + + console.log('3. Testing flexible attributevalue request shapes in setByArticleSku...'); + const attrName = await store.attributename.create(googleAuthId, { name: 'Farbe' }); + const val1 = await store.attributevalue.create(googleAuthId, attrName.id, { value: 'Rot' }); + const val2 = await store.attributevalue.create(googleAuthId, attrName.id, { value: 'Blau' }); + + // Test A: Array of IDs [val1.id, val2.id] + let res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [val1.id]); + assert.equal(res.length, 1); + assert.equal(res[0].attributeValueId, val1.id); + + // Test B: Array of objects with .id [{ id: val1.id }] + res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [{ id: val2.id }]); + assert.equal(res.length, 1); + assert.equal(res[0].attributeValueId, val2.id); + + // Test C: Array of objects with .attributeValueId + res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [{ attributeValueId: val1.id }]); + assert.equal(res.length, 1); + assert.equal(res[0].attributeValueId, val1.id); + + // Test D: Array of objects with attributeId and value + res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [{ attributeId: attrName.id, value: 'Blau' }]); + assert.equal(res.length, 1); + assert.equal(res[0].attributeValueId, val2.id); + + // Test E: Array of objects with attributeName and value + res = await store.articleattributevalue.setByArticleSku(googleAuthId, child.sku, [{ attributeName: 'Farbe', value: 'Rot' }]); + assert.equal(res.length, 1); + assert.equal(res[0].attributeValueId, val1.id); + + console.log('✅ ALL TESTS PASSED SUCCESSFULLY'); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + } +} + +run().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/scripts/test-stueckliste-stock.js b/scripts/test-stueckliste-stock.js index 23989bd..440fa30 100644 --- a/scripts/test-stueckliste-stock.js +++ b/scripts/test-stueckliste-stock.js @@ -80,20 +80,29 @@ async function run() { assert.equal(wh1BundleStock, 5, 'Bundle stock in Hauptlager should be 5 (min(10/1, 10/2))'); assert.equal(wh2BundleStock, 0, 'Bundle stock in Filiale should be 0'); - // Book in 3x Stückliste bundle into Filiale - const adjustResult = await store.stock.adjust(googleAuthId, { - sku: bundle.sku, - warehouseId: wh2.id, - amount: 3, - text: 'Production bundle book-in', - }); + // Verify store.stock.adjust rejects adjustment on Stückliste + await assert.rejects( + () => + store.stock.adjust(googleAuthId, { + sku: bundle.sku, + warehouseId: wh2.id, + amount: 3, + text: 'Attempt to adjust Stückliste directly', + }), + (err) => { + assert.match(err.message, /A Stückliste cannot have manual stock adjustments/); + return true; + }, + 'Should reject stock adjust on Stückliste bundle' + ); - // Verify adjust result lines - assert.equal(adjustResult.lines.length, 2, 'Should expand into 2 component lines'); - const comp1Line = adjustResult.lines.find((l) => l.sku === comp1.sku); - const comp2Line = adjustResult.lines.find((l) => l.sku === comp2.sku); - assert.equal(comp1Line.amount, 3, 'Component 1 should get 3 * 1 = 3'); - assert.equal(comp2Line.amount, 6, 'Component 2 should get 3 * 2 = 6'); + // Book components directly into Filiale: 3x comp1, 6x comp2 + await store.stock.adjust(googleAuthId, { + lines: [ + { sku: comp1.sku, warehouseId: wh2.id, amount: 3, text: 'Direct component 1 book-in' }, + { sku: comp2.sku, warehouseId: wh2.id, amount: 6, text: 'Direct component 2 book-in' }, + ], + }); // Verify bundle article itself has NO entries in the ledger const bundleLedger = await store.ledger.listBySku(googleAuthId, bundle.sku); @@ -112,17 +121,17 @@ async function run() { const wh2AfterBookIn = afterBundleBookIn.article.warehouses.find((w) => w.id === wh2.id).stock; assert.equal(wh2AfterBookIn, 3, 'Bundle stock in Filiale should be 3'); - // Test booking out 1x bundle from Filiale + // Test booking out components from Filiale: -1x comp1, -2x comp2 (equivalent to 1 bundle sale) await store.stock.adjust(googleAuthId, { - sku: bundle.sku, - warehouseId: wh2.id, - amount: -1, - text: 'Sale bundle book-out', + lines: [ + { sku: comp1.sku, warehouseId: wh2.id, amount: -1, text: 'Component 1 book-out' }, + { sku: comp2.sku, warehouseId: wh2.id, amount: -2, text: 'Component 2 book-out' }, + ], }); const afterRemoval = await store.article.getBySku(googleAuthId, bundle.sku); const wh2AfterRemoval = afterRemoval.article.warehouses.find((w) => w.id === wh2.id).stock; - assert.equal(wh2AfterRemoval, 2, 'Bundle stock in Filiale should be 2 after -1 removal'); + assert.equal(wh2AfterRemoval, 2, 'Bundle stock in Filiale should be 2 after component removal'); // Verify components stock after removal in Filiale: comp1 = 2, comp2 = 4 const comp1After = await store.article.getBySku(googleAuthId, comp1.sku); diff --git a/scripts/test-tombstone.js b/scripts/test-tombstone.js new file mode 100644 index 0000000..8b0b6b5 --- /dev/null +++ b/scripts/test-tombstone.js @@ -0,0 +1,210 @@ +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 articletombstone table and deletion flow ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-tombstone-')); + const dbFile = join(tempDir, 'test.db'); + const storageDir = join(tempDir, 'storage'); + + const provider = new BetterSqlite3Provider({ + filename: dbFile, + storageDir, + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + // 1. Verify table exists and has expected columns + console.log('1. Checking articletombstone schema...'); + const tableInfo = provider.db.pragma('table_info("articletombstone")'); + const colNames = tableInfo.map((c) => c.name); + console.log('articletombstone columns:', colNames); + assert.deepEqual(colNames, [ + 'id', + 'company_id', + 'article_id', + 'sku', + 'name', + 'taxrate', + 'net', + 'deleted_at', + ]); + + // Setup company first + console.log('2. Setting up base data...'); + const googleAuthId = await store.google_auth.add('tombstone-tester@example.com'); + const company = await store.company.create({ + name: 'Tombstone Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + // 3. Test CHECK constraint (article_id IS NOT NULL OR sku IS NOT NULL) + console.log('3. Testing CHECK constraint...'); + // Setting both article_id and sku to NULL should fail + assert.throws(() => { + provider.db.prepare(` + INSERT INTO articletombstone (company_id, article_id, sku, name) + VALUES (?, NULL, NULL, 'Test') + `).run(company.id); + }, /CHECK constraint failed/); + + // article_id present, sku null -> should succeed + const r1 = provider.db.prepare(` + INSERT INTO articletombstone (company_id, article_id, sku, name) + VALUES (?, 999, NULL, 'Only ID') + `).run(company.id); + assert.ok(r1.lastInsertRowid); + + // sku present, article_id null -> should succeed + const r2 = provider.db.prepare(` + INSERT INTO articletombstone (company_id, article_id, sku, name) + VALUES (?, NULL, 'SKU-ONLY', 'Only SKU') + `).run(company.id); + assert.ok(r2.lastInsertRowid); + + // Clean up manual test entries + provider.db.prepare('DELETE FROM articletombstone').run(); + + // 4. Test article deletion flow + console.log('4. Creating test article...'); + + const article = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Coffee Mug', + }); + + // Set prices for the article + await store.article.setPricesBySku(googleAuthId, article.sku, [ + { price: 12.50, tax: 19, minmultiplier: 1 }, + ]); + + // Verify article exists + const detailBefore = await store.article.getBySku(googleAuthId, article.sku); + assert.equal(detailBefore.article.name, 'Coffee Mug'); + assert.equal(detailBefore.article.prices[0].net, 12.50); + assert.equal(detailBefore.article.prices[0].taxRate, 19); + + // Delete the article + console.log('4. Deleting article and checking tombstone creation...'); + await store.article.delete(googleAuthId, article.sku); + + // Verify article is deleted + await assert.rejects( + () => store.article.getBySku(googleAuthId, article.sku), + /Article not found/ + ); + + // Verify tombstone record + const tombstones = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(article.sku); + assert.equal(tombstones.length, 1); + const tomb = tombstones[0]; + console.log('Created tombstone:', tomb); + assert.equal(tomb.company_id, company.id); + assert.equal(tomb.article_id, article.id); + assert.equal(tomb.sku, article.sku); + assert.equal(tomb.name, 'Coffee Mug'); + assert.equal(tomb.net, 12.50); + 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({ + 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, + attributeNameId: attrName.id, + }); + const child = await store.article.addChild(googleAuthId, father.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 }); + + // 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 }); + + const allTombstones = provider.db.prepare('SELECT * FROM articletombstone ORDER BY id ASC').all(); + assert.equal(allTombstones.length, 4); + + // 1st tombstone: Coffee Mug + assert.equal(allTombstones[0].name, 'Coffee Mug'); + assert.equal(allTombstones[0].sku, '1'); + assert.equal(allTombstones[0].net, 12.5); + assert.equal(allTombstones[0].taxrate, 19); + + // 2nd tombstone: Pants - M (deleted via removeChild) + assert.equal(allTombstones[1].name, 'Pants - M'); + assert.equal(allTombstones[1].sku, child.article.sku); + assert.equal(allTombstones[1].net, 49.99); + assert.equal(allTombstones[1].taxrate, 19); + + // 3rd tombstone: Pants - L (child deleted in cascade) + assert.equal(allTombstones[2].name, 'Pants - L'); + assert.equal(allTombstones[2].sku, child2.article.sku); + + // 4th tombstone: Pants (father deleted in cascade) + assert.equal(allTombstones[3].name, 'Pants'); + assert.equal(allTombstones[3].sku, father.sku); + + // 6. Test posterminal maxknowntombstoneid column and updates + console.log('6. Testing posterminal maxknowntombstoneid column...'); + const posterminalCols = provider.db.pragma('table_info("posterminal")').map((c) => c.name); + assert.ok(posterminalCols.includes('maxknowntombstoneid'), 'posterminal should have maxknowntombstoneid column'); + + const terminal = await store.posterminal.upsert(googleAuthId, { + name: 'POS Terminal 1', + pairingpin: '1234', + }); + assert.equal(terminal.maxknowntombstoneid, null); + + // Pair terminal + await store.posterminal.pair(terminal.id, { + token: 'tok-123', + clientserial: 'SN-001', + pairedAt: new Date().toISOString(), + }); + + const foundByToken = await store.posterminal.findByToken('tok-123'); + assert.equal(foundByToken.id, terminal.id); + assert.equal(foundByToken.maxknowntombstoneid, null); + + // Update maxknowntombstoneid + await store.posterminal.setMaxKnownTombstoneId(terminal.id, 4); + + const foundAfterUpdate = await store.posterminal.findByToken('tok-123'); + assert.equal(foundAfterUpdate.maxknowntombstoneid, 4); + + console.log('✅ ALL TOMBSTONE TESTS PASSED'); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + } +} + +run().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/scripts/test-unique-slugs.js b/scripts/test-unique-slugs.js new file mode 100644 index 0000000..1fc65f9 --- /dev/null +++ b/scripts/test-unique-slugs.js @@ -0,0 +1,147 @@ +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 Unique Slugs Disambiguation ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-slugs-')); + const dbFile = join(tempDir, 'test.db'); + const storageDir = join(tempDir, 'storage'); + + const provider = new BetterSqlite3Provider({ + filename: dbFile, + storageDir, + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + const googleAuthId = await store.google_auth.add('slug-tester@example.com'); + const company = await store.company.create({ + name: 'Slug Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + console.log('1. Testing createUnderParent duplicate slugs...'); + const art1 = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Root Father 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}'`); + + const art2 = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Root Father 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}'`); + + const art3 = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Root Father 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}'`); + + 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}'`); + + // Update art2 with same name + await store.article.updateBySku(googleAuthId, art2.sku, { name: 'Root Father 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}'`); + + // 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' }); + 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}'`); + + 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, + 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}'`); + + // 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', + }); + assert.equal(child2.article.slug, 'root-father-shirt-m-2', `Expected child slug 'root-father-shirt-m-2', got '${child2.article.slug}'`); + + console.log('4. Testing importRows duplicate slugs in same batch...'); + const importRes = await store.article.importRows({ + googleAuthId, + rows: [ + { sku: 'IMP-1', name: 'Batch Shirt', parentId: company.rootCategoryId }, + { sku: 'IMP-2', name: 'Batch Shirt', parentId: company.rootCategoryId }, + { sku: 'IMP-3', name: 'Batch Shirt', parentId: company.rootCategoryId }, + ], + }); + assert.equal(importRes.errors.length, 0, `Import had errors: ${JSON.stringify(importRes.errors)}`); + assert.equal(importRes.imported.length, 3); + + const imp1 = await store.article.getBySku(googleAuthId, 'IMP-1'); + const imp2 = await store.article.getBySku(googleAuthId, 'IMP-2'); + const imp3 = await store.article.getBySku(googleAuthId, 'IMP-3'); + + assert.equal(imp1.article.slug, 'batch-shirt'); + assert.equal(imp2.article.slug, 'batch-shirt-2'); + assert.equal(imp3.article.slug, 'batch-shirt-3'); + + console.log('5. Testing company isolation...'); + const company2 = await store.company.create({ + name: 'Other Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company2.id); + + const artCompany2 = await store.article.createUnderParent({ + googleAuthId, + parentId: company2.rootCategoryId, + name: 'Root Father 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}'`); + + console.log('✅ ALL SLUG TESTS PASSED'); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + } +} + +run().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/scripts/test-variant-flow.js b/scripts/test-variant-flow.js new file mode 100644 index 0000000..b71ed8f --- /dev/null +++ b/scripts/test-variant-flow.js @@ -0,0 +1,130 @@ +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 Variant Flow: Next-Free SKU, Fathersort Reclaim, and Duplicate Protection ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-variants-')); + const dbFile = join(tempDir, 'test.db'); + const storageDir = join(tempDir, 'storage'); + + const provider = new BetterSqlite3Provider({ + filename: dbFile, + storageDir, + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + const googleAuthId = await store.google_auth.add('variant-tester@example.com'); + const company = await store.company.create({ + name: 'Variant Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + // Create father article + const father = 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, + 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' }); + + assert.equal(child1.article.sku, `${father.sku}-1`); + assert.equal(child2.article.sku, `${father.sku}-2`); + assert.equal(child3.article.sku, `${father.sku}-3`); + + let list = await store.article.listChildren(googleAuthId, father.sku); + assert.equal(list.length, 3); + assert.deepEqual(list.map((c) => c.fathersort), [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' }); + }, + (err) => { + assert.match(err.message, /Variant with this attribute value already exists/); + return true; + }, + 'Expected duplicate variant attribute value to be rejected' + ); + + 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, { + deleteArticle: false, + }); + + // Check that remaining children's fathersort was recompacted + list = await store.article.listChildren(googleAuthId, father.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]); + + // 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`); + + 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' }); + // Since SKU-1, SKU-2 (orphan), SKU-3 exist, next free index is 4 + assert.equal(child4.article.sku, `${father.sku}-4`); + + list = await store.article.listChildren(googleAuthId, father.sku); + assert.equal(list.length, 3); + assert.deepEqual(list.map((c) => c.fathersort), [0, 1, 2]); + + console.log('5. Testing gaps in fathersort (Evidence: father with child at fathersort: 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 }); + + // Manually force child1's fathersort: 1 to simulate pre-existing gap state (like father 94) + await provider.query( + `UPDATE article SET fathersort = 1 WHERE sku = @sku`, + { sku: child1.article.sku } + ); + list = await store.article.listChildren(googleAuthId, father.sku); + assert.equal(list.length, 1); + assert.equal(list[0].fathersort, 1); // exactly like father 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' }); + assert.ok(child5); + list = await store.article.listChildren(googleAuthId, father.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]); + + console.log('✅ ALL VARIANT FLOW TESTS PASSED'); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + } +} + +run().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/src/index.js b/src/index.js index 48e0ac7..40bcd3f 100644 --- a/src/index.js +++ b/src/index.js @@ -113,6 +113,7 @@ import posterminalGetDeletedEntityCount from './store/posterminal/getDeletedEnti import posterminalGetMaxOrderIdCount from './store/posterminal/getMaxOrderIdCount.js'; import posterminalGetCategoryList from './store/posterminal/getCategoryList.js'; import posterminalGetProductList from './store/posterminal/getProductList.js'; +import posterminalSetMaxKnownTombstoneId from './store/posterminal/setMaxKnownTombstoneId.js'; import langCreate from './store/lang/create.js'; import langDelete from './store/lang/delete.js'; import langList from './store/lang/list.js'; @@ -181,6 +182,11 @@ import attributevalueOrder from './store/attributevalue/order.js'; import attributevalueDelete from './store/attributevalue/delete.js'; import articleattributevalueListByArticleSku from './store/articleattributevalue/listByArticleSku.js'; import articleattributevalueSetByArticleSku from './store/articleattributevalue/setByArticleSku.js'; +import categorypropertyListByCategoryId from './store/categoryproperty/listByCategoryId.js'; +import categorypropertySetByCategoryId from './store/categoryproperty/setByCategoryId.js'; +import categorypropertyDelete from './store/categoryproperty/delete.js'; +import categoryattributevalueListByCategoryId from './store/categoryattributevalue/listByCategoryId.js'; +import categoryattributevalueSetByCategoryId from './store/categoryattributevalue/setByCategoryId.js'; import articlelistGetItems from './store/articlelist/getItemsByArticleSku.js'; import articlelistSetItems from './store/articlelist/setItemsByArticleSku.js'; import optionslistGetOptions from './store/optionslist/getOptionsByArticleSku.js'; @@ -388,6 +394,7 @@ export default class LibDashStore { getMaxOrderIdCount: posterminalGetMaxOrderIdCount(provider), getCategoryList: posterminalGetCategoryList(provider), getProductList: posterminalGetProductList(provider), + setMaxKnownTombstoneId: posterminalSetMaxKnownTombstoneId(provider), }; this.google_auth = { @@ -490,6 +497,17 @@ export default class LibDashStore { setByArticleSku: articleattributevalueSetByArticleSku(provider), }; + this.categoryproperty = { + listByCategoryId: categorypropertyListByCategoryId(provider), + setByCategoryId: categorypropertySetByCategoryId(provider), + delete: categorypropertyDelete(provider), + }; + + this.categoryattributevalue = { + listByCategoryId: categoryattributevalueListByCategoryId(provider), + setByCategoryId: categoryattributevalueSetByCategoryId(provider), + }; + this.articlelist = { getItemsByArticleSku: articlelistGetItems(provider), setItemsByArticleSku: articlelistSetItems(provider), diff --git a/src/providers/mssql/queries/articleDelete.js b/src/providers/mssql/queries/articleDelete.js index 5d63ed1..6c32ee4 100644 --- a/src/providers/mssql/queries/articleDelete.js +++ b/src/providers/mssql/queries/articleDelete.js @@ -17,6 +17,29 @@ WHERE id = @articleId `; +export const CREATE_TOMBSTONE = ` +INSERT INTO articletombstone (company_id, article_id, sku, name, taxrate, net) +SELECT + a.company_id, + a.id AS article_id, + a.sku, + COALESCE( + (SELECT TOP 1 at.name FROM articletext at WHERE at.article_id = a.id AND at.language_id = co.default_language_id), + (SELECT TOP 1 at.name FROM articletext at WHERE at.article_id = a.id ORDER BY at.id ASC) + ) AS name, + COALESCE( + (SELECT TOP 1 t.rate FROM price pr INNER JOIN tax t ON t.id = pr.tax_id WHERE pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1), + (SELECT TOP 1 t.rate FROM price pr INNER JOIN tax t ON t.id = pr.tax_id WHERE pr.article_id = a.id ORDER BY pr.id ASC) + ) AS taxrate, + COALESCE( + (SELECT TOP 1 pr.net FROM price pr WHERE pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1), + (SELECT TOP 1 pr.net FROM price pr WHERE pr.article_id = a.id ORDER BY pr.id ASC) + ) AS net +FROM article a +INNER JOIN company co ON co.id = a.company_id +WHERE a.id = @articleId +`; + export const DELETE_PICS_BY_ARTICLE = ` DELETE p FROM pic p diff --git a/src/providers/mssql/queries/articlehistory.js b/src/providers/mssql/queries/articlehistory.js index 4163ba3..6925301 100644 --- a/src/providers/mssql/queries/articlehistory.js +++ b/src/providers/mssql/queries/articlehistory.js @@ -9,3 +9,10 @@ FROM articlehistory WHERE article_id = @articleId ORDER BY [date] DESC, id DESC `; + +export const LIST_BY_ARTICLE_ID_LIMITED = ` +SELECT TOP (@limit) id, companyuser_id AS companyuserId, [date], [text] +FROM articlehistory +WHERE article_id = @articleId +ORDER BY [date] DESC, id DESC +`; diff --git a/src/providers/mssql/queries/articletext.js b/src/providers/mssql/queries/articletext.js index bcfae68..b7ea523 100644 --- a/src/providers/mssql/queries/articletext.js +++ b/src/providers/mssql/queries/articletext.js @@ -8,3 +8,13 @@ UPDATE articletext SET name = @name, slug = @slug WHERE article_id = @articleId AND language_id = @languageId `; + +export const LIST_SLUGS_BY_COMPANY_AND_LANGUAGE = ` +SELECT at.slug, at.article_id AS articleId +FROM articletext at +INNER JOIN article a ON a.id = at.article_id +WHERE a.company_id = @companyId + AND at.language_id = @languageId + AND (at.slug = @baseSlug OR at.slug LIKE @likePattern) +`; + diff --git a/src/providers/mssql/queries/categoryattributevalue.js b/src/providers/mssql/queries/categoryattributevalue.js new file mode 100644 index 0000000..3bba063 --- /dev/null +++ b/src/providers/mssql/queries/categoryattributevalue.js @@ -0,0 +1,41 @@ +export const LIST_BY_CATEGORY_ID = ` +SELECT + cav.id, + cav.category_id AS categoryId, + an.id AS attributeId, + an.name AS attributeName, + an.exclusive, + av.id AS attributeValueId, + av.value, + cav.sort +FROM categoryattributevalue cav +INNER JOIN attributevalue av ON av.id = cav.attributevalue_id +INNER JOIN attributename an ON an.id = av.attribute_id +WHERE cav.category_id = @categoryId +ORDER BY an.sort ASC, an.id ASC, cav.sort ASC, cav.id ASC +`; + +export const DELETE_BY_CATEGORY_ID = ` +DELETE FROM categoryattributevalue +WHERE category_id = @categoryId +`; + +export const INSERT_ROW = ` +INSERT INTO categoryattributevalue (category_id, attributevalue_id, sort) +OUTPUT INSERTED.id, INSERTED.category_id AS categoryId, INSERTED.attributevalue_id AS attributeValueId, INSERTED.sort +VALUES (@categoryId, @attributeValueId, @sort) +`; + +export const GET_VALUE_DETAIL = ` +SELECT + av.id AS attributeValueId, + av.value, + av.sort, + an.id AS attributeId, + an.name AS attributeName, + an.exclusive, + an.company_id AS companyId +FROM attributevalue av +INNER JOIN attributename an ON an.id = av.attribute_id +WHERE av.id = @attributeValueId +`; diff --git a/src/providers/mssql/queries/categoryproperty.js b/src/providers/mssql/queries/categoryproperty.js new file mode 100644 index 0000000..5984e93 --- /dev/null +++ b/src/providers/mssql/queries/categoryproperty.js @@ -0,0 +1,30 @@ +export const LIST_BY_CATEGORY_ID = ` +SELECT + cp.id, + cp.category_id AS categoryId, + cp.property_id AS propertyId, + pn.name AS name, + pn.exclusive, + cp.value, + cp.sort +FROM categoryproperty cp +INNER JOIN propertyname pn ON pn.id = cp.property_id +WHERE cp.category_id = @categoryId +ORDER BY cp.sort ASC, cp.id ASC +`; + +export const DELETE_BY_CATEGORY_ID = ` +DELETE FROM categoryproperty +WHERE category_id = @categoryId +`; + +export const DELETE_BY_CATEGORY_AND_PROPERTY_ID = ` +DELETE FROM categoryproperty +WHERE category_id = @categoryId AND property_id = @propertyId +`; + +export const INSERT_ROW = ` +INSERT INTO categoryproperty (category_id, property_id, value, sort) +OUTPUT INSERTED.id, INSERTED.category_id AS categoryId, INSERTED.property_id AS propertyId, INSERTED.value, INSERTED.sort +VALUES (@categoryId, @propertyId, @value, @sort) +`; diff --git a/src/providers/mssql/queries/manifestDoc.js b/src/providers/mssql/queries/manifestDoc.js index ae41c03..d102268 100644 --- a/src/providers/mssql/queries/manifestDoc.js +++ b/src/providers/mssql/queries/manifestDoc.js @@ -108,3 +108,28 @@ VALUES ( @taxrate ) `; + +export const CHECK_ARTICLE_IS_FATHER = ` +SELECT + a.id, + a.is_father AS isFather, + 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), + a.sku + ) AS articleName, + COALESCE( + (SELECT MIN(asup.pu) FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku), + (SELECT MIN(asup2.pu) FROM articlesupplier asup2 WHERE asup2.article_id = a.id), + a.unitamount, + 1 + ) AS pu +FROM article a +INNER JOIN manifest m ON m.company_id = a.company_id +INNER JOIN company co ON co.id = m.company_id +WHERE m.id = @manifestId + AND (a.sku = @sku OR EXISTS ( + SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = @sku + )) +`; + diff --git a/src/providers/mssql/queries/posterminal.js b/src/providers/mssql/queries/posterminal.js index 80ddf56..8f8430e 100644 --- a/src/providers/mssql/queries/posterminal.js +++ b/src/providers/mssql/queries/posterminal.js @@ -5,7 +5,8 @@ SELECT token, pairingpin, paired_at AS pairedAt, - clientserial AS clientserial + clientserial AS clientserial, + maxknowntombstoneid AS maxknowntombstoneid FROM posterminal WHERE company_id = @companyId ORDER BY id @@ -19,7 +20,8 @@ SELECT token, pairingpin, paired_at AS pairedAt, - clientserial AS clientserial + clientserial AS clientserial, + maxknowntombstoneid AS maxknowntombstoneid FROM posterminal WHERE id = @id `; @@ -32,7 +34,8 @@ SELECT token, pairingpin, paired_at AS pairedAt, - clientserial AS clientserial + clientserial AS clientserial, + maxknowntombstoneid AS maxknowntombstoneid FROM posterminal WHERE pairingpin = @pairingpin `; @@ -44,17 +47,23 @@ WHERE id = @id `; export const CREATE = ` -INSERT INTO posterminal (company_id, name, token, pairingpin, paired_at, clientserial) -OUTPUT INSERTED.id, INSERTED.name, INSERTED.token, INSERTED.pairingpin, INSERTED.paired_at AS pairedAt, INSERTED.clientserial AS clientserial -VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial) +INSERT INTO posterminal (company_id, name, token, pairingpin, paired_at, clientserial, maxknowntombstoneid) +OUTPUT INSERTED.id, INSERTED.name, INSERTED.token, INSERTED.pairingpin, INSERTED.paired_at AS pairedAt, INSERTED.clientserial AS clientserial, INSERTED.maxknowntombstoneid AS maxknowntombstoneid +VALUES (@companyId, @name, @token, @pairingpin, @pairedAt, @clientserial, @maxknowntombstoneid) `; export const UPDATE = ` UPDATE posterminal -SET name = @name, token = @token, pairingpin = @pairingpin, paired_at = @pairedAt, clientserial = @clientserial +SET name = @name, token = @token, pairingpin = @pairingpin, paired_at = @pairedAt, clientserial = @clientserial, maxknowntombstoneid = @maxknowntombstoneid WHERE id = @id AND company_id = @companyId `; +export const SET_MAX_KNOWN_TOMBSTONE_ID = ` +UPDATE posterminal +SET maxknowntombstoneid = @maxknowntombstoneid +WHERE id = @id +`; + export const DELETE_BY_COMPANY_AND_ID = ` DELETE FROM posterminal WHERE id = @id AND company_id = @companyId @@ -122,7 +131,8 @@ SELECT token, pairingpin, paired_at AS pairedAt, - clientserial AS clientserial + clientserial AS clientserial, + maxknowntombstoneid AS maxknowntombstoneid FROM posterminal WHERE token = @token `; diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index fc72d78..2ee7f0f 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -352,10 +352,11 @@ 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; END -END -ELSE IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('article') AND name = 'track_stock') -BEGIN - ALTER TABLE article ADD track_stock BIT NOT NULL CONSTRAINT DF_article_track_stock DEFAULT 1; + + IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('article') AND name = 'track_stock') + BEGIN + ALTER TABLE article ADD track_stock BIT NOT NULL CONSTRAINT DF_article_track_stock DEFAULT 1; + END END `; @@ -374,6 +375,27 @@ BEGIN END `; +export const INIT_ARTICLETOMBSTONE = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'articletombstone') +BEGIN + CREATE TABLE articletombstone ( + id INT IDENTITY(1,1) PRIMARY KEY, + company_id INT NOT NULL, + article_id INT NULL, + sku NVARCHAR(100) NULL, + name NVARCHAR(255) NULL, + taxrate DECIMAL(18,4) NULL, + net FLOAT NULL, + deleted_at DATETIME2 NOT NULL CONSTRAINT DF_articletombstone_deleted_at DEFAULT SYSUTCDATETIME(), + CONSTRAINT CK_articletombstone_article_or_sku CHECK (article_id IS NOT NULL OR sku IS NOT NULL), + CONSTRAINT FK_articletombstone_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE + ); + + CREATE INDEX IX_articletombstone_company_deleted + ON articletombstone (company_id, deleted_at); +END +`; + export const INIT_ARTICLEEAN = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'articleean') BEGIN @@ -791,10 +813,18 @@ BEGIN pairingpin NVARCHAR(50) NOT NULL, paired_at DATETIME2 NULL, clientserial NVARCHAR(255) NULL, + maxknowntombstoneid INT NULL, CONSTRAINT FK_posterminal_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE ); CREATE UNIQUE INDEX UQ_posterminal_token ON posterminal(token) WHERE token IS NOT NULL; END +ELSE +BEGIN + IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('posterminal') AND name = 'maxknowntombstoneid') + BEGIN + ALTER TABLE posterminal ADD maxknowntombstoneid INT NULL; + END +END `; export const INIT_POSTERMINALCATEGORY = ` @@ -980,6 +1010,36 @@ BEGIN END `; +export const INIT_CATEGORYPROPERTY = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'categoryproperty') +BEGIN + CREATE TABLE categoryproperty ( + id INT IDENTITY(1,1) PRIMARY KEY, + category_id INT NOT NULL, + property_id INT NOT NULL, + 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 + ); +END +`; + +export const INIT_CATEGORYATTRIBUTEVALUE = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'categoryattributevalue') +BEGIN + CREATE TABLE categoryattributevalue ( + id INT IDENTITY(1,1) PRIMARY KEY, + category_id INT NOT NULL, + attributevalue_id INT NOT NULL, + sort INT NOT NULL CONSTRAINT DF_categoryattributevalue_sort DEFAULT 0, + CONSTRAINT FK_categoryattributevalue_category FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE, + CONSTRAINT FK_categoryattributevalue_attributevalue FOREIGN KEY (attributevalue_id) REFERENCES attributevalue(id) ON DELETE CASCADE, + CONSTRAINT UQ_categoryattributevalue_category_attributevalue UNIQUE (category_id, attributevalue_id) + ); +END +`; + /** DDL scripts in dependency order for schema init. */ export const SCHEMA_SCRIPTS = [ INIT_META, @@ -1017,6 +1077,8 @@ export const SCHEMA_SCRIPTS = [ INIT_ARTICLE_SUPPLIER, INIT_ARTICLEPROPERTY, INIT_ARTICLEATTRIBUTEVALUE, + INIT_CATEGORYPROPERTY, + INIT_CATEGORYATTRIBUTEVALUE, INIT_MANIFEST, INIT_MANIFESTARTICLE, INIT_WAREHOUSE, @@ -1026,6 +1088,7 @@ export const SCHEMA_SCRIPTS = [ INIT_PEEK_NEXT_NUMBER_WITH_PREFIX_AND_PADDING, INIT_COMPANYUSER, INIT_ARTICLEHISTORY, + INIT_ARTICLETOMBSTONE, INIT_ENTITYHISTORY, INIT_SYNCPOS, INIT_COMPANYSETTING, diff --git a/src/providers/sqlite3/index.js b/src/providers/sqlite3/index.js index 5ba9494..3f1e1c9 100644 --- a/src/providers/sqlite3/index.js +++ b/src/providers/sqlite3/index.js @@ -426,6 +426,9 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor { if (posterminalCols.length > 0 && !posterminalCols.includes('clientserial')) { this.db.exec(`ALTER TABLE posterminal ADD COLUMN clientserial TEXT`); } + if (posterminalCols.length > 0 && !posterminalCols.includes('maxknowntombstoneid')) { + this.db.exec(`ALTER TABLE posterminal ADD COLUMN maxknowntombstoneid INTEGER`); + } const propertynameCols = this.db.pragma('table_info("propertyname")').map((c) => c.name); if (propertynameCols.length > 0 && !propertynameCols.includes('exclusive')) { diff --git a/src/providers/sqlite3/queries/articleDelete.js b/src/providers/sqlite3/queries/articleDelete.js index 11cc3a0..a1a4500 100644 --- a/src/providers/sqlite3/queries/articleDelete.js +++ b/src/providers/sqlite3/queries/articleDelete.js @@ -7,6 +7,29 @@ export const { GET_CHILDREN_IDS, GET_LIST_IDS, } = queries; +export const CREATE_TOMBSTONE = ` +INSERT INTO articletombstone (company_id, article_id, sku, name, taxrate, net) +SELECT + a.company_id, + a.id AS article_id, + a.sku, + COALESCE( + (SELECT at.name FROM articletext at WHERE at.article_id = a.id AND at.language_id = co.default_language_id), + (SELECT at.name FROM articletext at WHERE at.article_id = a.id ORDER BY at.id ASC LIMIT 1) + ) AS name, + COALESCE( + (SELECT t.rate FROM price pr INNER JOIN tax t ON t.id = pr.tax_id WHERE pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1), + (SELECT t.rate FROM price pr INNER JOIN tax t ON t.id = pr.tax_id WHERE pr.article_id = a.id ORDER BY pr.id ASC LIMIT 1) + ) AS taxrate, + COALESCE( + (SELECT pr.net FROM price pr WHERE pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1), + (SELECT pr.net FROM price pr WHERE pr.article_id = a.id ORDER BY pr.id ASC LIMIT 1) + ) AS net +FROM article a +INNER JOIN company co ON co.id = a.company_id +WHERE a.id = @articleId +`; + export const DELETE_PICS_BY_ARTICLE = ` DELETE FROM pic WHERE id IN (SELECT pic_id FROM articlepic WHERE article_id = @articleId) diff --git a/src/providers/sqlite3/queries/articlehistory.js b/src/providers/sqlite3/queries/articlehistory.js index dfd0c1a..e85534b 100644 --- a/src/providers/sqlite3/queries/articlehistory.js +++ b/src/providers/sqlite3/queries/articlehistory.js @@ -2,4 +2,12 @@ import * as source from '../../mssql/queries/articlehistory.js'; import { translateQueries } from './translate.js'; const queries = translateQueries(source, 'articlehistory'); -export const { INSERT, LIST_BY_ARTICLE_ID } = queries; \ No newline at end of file +export const { INSERT, LIST_BY_ARTICLE_ID } = queries; + +export const LIST_BY_ARTICLE_ID_LIMITED = ` +SELECT id, companyuser_id AS companyuserId, date, text +FROM articlehistory +WHERE article_id = @articleId +ORDER BY date DESC, id DESC +LIMIT @limit +`; \ No newline at end of file diff --git a/src/providers/sqlite3/queries/articletext.js b/src/providers/sqlite3/queries/articletext.js index 478695e..4dfe5d8 100644 --- a/src/providers/sqlite3/queries/articletext.js +++ b/src/providers/sqlite3/queries/articletext.js @@ -2,4 +2,8 @@ import * as source from '../../mssql/queries/articletext.js'; import { translateQueries } from './translate.js'; const queries = translateQueries(source, 'articletext'); -export const { CREATE, UPDATE_BY_ARTICLE_AND_LANGUAGE } = queries; \ No newline at end of file +export const { + CREATE, + UPDATE_BY_ARTICLE_AND_LANGUAGE, + LIST_SLUGS_BY_COMPANY_AND_LANGUAGE, +} = queries; \ No newline at end of file diff --git a/src/providers/sqlite3/queries/categoryattributevalue.js b/src/providers/sqlite3/queries/categoryattributevalue.js new file mode 100644 index 0000000..82862ca --- /dev/null +++ b/src/providers/sqlite3/queries/categoryattributevalue.js @@ -0,0 +1,10 @@ +import * as source from '../../mssql/queries/categoryattributevalue.js'; +import { translateQueries } from './translate.js'; + +const queries = translateQueries(source, 'categoryattributevalue'); +export const { + LIST_BY_CATEGORY_ID, + DELETE_BY_CATEGORY_ID, + INSERT_ROW, + GET_VALUE_DETAIL, +} = queries; diff --git a/src/providers/sqlite3/queries/categoryproperty.js b/src/providers/sqlite3/queries/categoryproperty.js new file mode 100644 index 0000000..26049e1 --- /dev/null +++ b/src/providers/sqlite3/queries/categoryproperty.js @@ -0,0 +1,10 @@ +import * as source from '../../mssql/queries/categoryproperty.js'; +import { translateQueries } from './translate.js'; + +const queries = translateQueries(source, 'categoryproperty'); +export const { + LIST_BY_CATEGORY_ID, + DELETE_BY_CATEGORY_ID, + DELETE_BY_CATEGORY_AND_PROPERTY_ID, + INSERT_ROW, +} = queries; diff --git a/src/providers/sqlite3/queries/manifestDoc.js b/src/providers/sqlite3/queries/manifestDoc.js index 175e120..cbe1913 100644 --- a/src/providers/sqlite3/queries/manifestDoc.js +++ b/src/providers/sqlite3/queries/manifestDoc.js @@ -3,7 +3,7 @@ import { translateQueries } from './translate.js'; const queries = translateQueries(source, 'manifestDoc'); export const { - CREATE_LINE, CREATE_MANIFEST, DELETE_LINES_BY_MANIFEST, DELETE_MANIFEST, - GET_MANIFEST_HEADER, GET_MANIFEST_LINES, LIST_MANIFESTS, ORDER_TYPE, - QUOTE_TYPE, UPDATE_MANIFEST, + CHECK_ARTICLE_IS_FATHER, 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; \ No newline at end of file diff --git a/src/providers/sqlite3/queries/posterminal.js b/src/providers/sqlite3/queries/posterminal.js index 2b2e433..b00fb8e 100644 --- a/src/providers/sqlite3/queries/posterminal.js +++ b/src/providers/sqlite3/queries/posterminal.js @@ -5,7 +5,7 @@ const queries = translateQueries(source, 'posterminal'); export const { CREATE, DELETE_BY_COMPANY_AND_ID, GET_BY_ID, LIST_BY_COMPANY, LIST_CATEGORY_IDS, POSTERMINAL_IDS_BY_COMPANY, SET_OFF, SET_OFF_ALL, UPDATE, - FIND_BY_PAIRINGPIN, PAIR, + FIND_BY_PAIRINGPIN, PAIR, SET_MAX_KNOWN_TOMBSTONE_ID, FIND_BY_TOKEN, COUNT_PRODUCTS, COUNT_CATEGORIES, COUNT_CUSTOMERS, COUNT_CUSTOMER_GROUPS, COUNT_COMPOSITE_PRODUCTS, MAX_ORDER_ID, LIST_POS_PRODUCT_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS, LIST_POS_PRODUCT_PRICES, diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index cc4966e..d9c6413 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -495,6 +495,23 @@ CREATE TABLE IF NOT EXISTS articlehistory ( ); `; +export const INIT_ARTICLETOMBSTONE = ` +CREATE TABLE IF NOT EXISTS articletombstone ( + id INTEGER PRIMARY KEY, + company_id INTEGER NOT NULL, + article_id INTEGER, + sku TEXT COLLATE NOCASE, + name TEXT, + taxrate NUMERIC, + net REAL, + deleted_at TEXT NOT NULL DEFAULT (${UTC_NOW}), + CHECK (article_id IS NOT NULL OR sku IS NOT NULL), + FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS IX_articletombstone_company_deleted + ON articletombstone (company_id, deleted_at); +`; + export const INIT_ENTITYHISTORY = ` CREATE TABLE IF NOT EXISTS entityhistory ( id INTEGER PRIMARY KEY, @@ -539,6 +556,7 @@ CREATE TABLE IF NOT EXISTS posterminal ( pairingpin TEXT NOT NULL, paired_at TEXT, clientserial TEXT, + maxknowntombstoneid INTEGER, FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS UQ_posterminal_token @@ -684,6 +702,30 @@ CREATE TABLE IF NOT EXISTS articleattributevalue ( ); `; +export const INIT_CATEGORYPROPERTY = ` +CREATE TABLE IF NOT EXISTS categoryproperty ( + id INTEGER PRIMARY KEY, + category_id INTEGER NOT NULL, + property_id INTEGER NOT NULL, + 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 +); +`; + +export const INIT_CATEGORYATTRIBUTEVALUE = ` +CREATE TABLE IF NOT EXISTS categoryattributevalue ( + id INTEGER PRIMARY KEY, + category_id INTEGER NOT NULL, + attributevalue_id INTEGER NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (category_id) REFERENCES category(id) ON DELETE CASCADE, + FOREIGN KEY (attributevalue_id) REFERENCES attributevalue(id) ON DELETE CASCADE, + UNIQUE (category_id, attributevalue_id) +); +`; + /** DDL scripts in dependency order for SQLite schema initialization. */ export const SCHEMA_SCRIPTS = [ INIT_META, @@ -720,6 +762,8 @@ export const SCHEMA_SCRIPTS = [ INIT_ARTICLE_SUPPLIER, INIT_ARTICLEPROPERTY, INIT_ARTICLEATTRIBUTEVALUE, + INIT_CATEGORYPROPERTY, + INIT_CATEGORYATTRIBUTEVALUE, INIT_MANIFEST, INIT_MANIFESTARTICLE, INIT_WAREHOUSE, @@ -727,6 +771,7 @@ export const SCHEMA_SCRIPTS = [ INIT_NUMBERS, INIT_COMPANYUSER, INIT_ARTICLEHISTORY, + INIT_ARTICLETOMBSTONE, INIT_ENTITYHISTORY, INIT_SYNCPOS, INIT_COMPANYSETTING, diff --git a/src/store/article/addChild.js b/src/store/article/addChild.js index 2b52a9c..1a16f46 100644 --- a/src/store/article/addChild.js +++ b/src/store/article/addChild.js @@ -15,11 +15,12 @@ import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries import resolveArticleBySku from './resolveArticleBySku.js'; import getBySku from './getBySku.js'; import makeRunner from '../runner.js'; -import slugify from '../slugify.js'; +import resolveUniqueSlug from './resolveUniqueSlug.js'; export default function addChild(provider) { const resolve = resolveArticleBySku(provider); const load = getBySku(provider); + const resolveSlug = resolveUniqueSlug(provider); const isSqlite = provider?.dialect === 'sqlite3'; const queries = isSqlite ? sqliteArticle : mssqlArticle; @@ -117,10 +118,46 @@ export default function addChild(provider) { throw new Error('Attribute value is required for child article'); } - // Compute child SKU: father.sku + '-' + finalValueId - const childSku = `${father.sku}-${finalValueId}`; + // Check if father 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 + AND aav.attributevalue_id = @finalValueId`, + { fatherId, finalValueId }, + ); + if (existingVariantRes.rows.length > 0) { + throw new Error('Variant with this attribute value already exists'); + } - // Check if child SKU 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)); + } + } + } + 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 }, @@ -129,9 +166,16 @@ export default function addChild(provider) { throw new Error(`Child article "${childSku}" already exists`); } - // Next fathersort - const countRes = await runner.query(getChildCountSql, { articleId: fatherId }); - const fathersort = countRes.rows[0]?.count ?? 0; + // Next free fathersort (smallest non-negative integer not used by any child of this father) + const existingSortsRes = await runner.query( + 'SELECT fathersort FROM article WHERE fatherarticle_id = @fatherId ORDER BY fathersort ASC', + { fatherId }, + ); + const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.fathersort))); + let fathersort = 0; + while (usedSorts.has(fathersort)) { + fathersort++; + } // Create child article const rawTrack = options.track_stock ?? options.trackStock; @@ -186,7 +230,11 @@ export default function addChild(provider) { // Create article text const childName = options.name?.trim() || `${fatherName} - ${finalValue}`; - const childSlug = slugify(childName); + const childSlug = await resolveSlug(runner, { + companyId, + languageId, + name: childName, + }); await runner.query(createArticleTextSql, { articleId: childId, languageId, @@ -194,13 +242,20 @@ export default function addChild(provider) { slug: childSlug, }); - // History + // History on child await runner.query(insertHistorySql, { articleId: childId, companyuserId, text: `Artikel "${childName}" (${childSku}) als Variante von "${father.sku}" angelegt`, }); + // History on father + await runner.query(insertHistorySql, { + articleId: fatherId, + companyuserId, + text: `Variante "${childName}" (${childSku}) hinzugefügt`, + }); + return load(googleAuthId, childSku, runner); }; diff --git a/src/store/article/createUnderParent.js b/src/store/article/createUnderParent.js index d206758..eb7f3e8 100644 --- a/src/store/article/createUnderParent.js +++ b/src/store/article/createUnderParent.js @@ -11,12 +11,13 @@ import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; import ensureDefaultUnit from '../unit/ensureDefault.js'; import nextSku from '../numbers/nextSku.js'; import makeRunner from '../runner.js'; -import slugify from '../slugify.js'; +import resolveUniqueSlug from './resolveUniqueSlug.js'; export default function createUnderParent(provider) { const access = resolveCategoryAccess(provider); const ensureUnit = ensureDefaultUnit(provider); const allocateSku = nextSku(provider); + const resolveSlug = resolveUniqueSlug(provider); const isSqlite = provider?.dialect === 'sqlite3'; const createArticleSql = isSqlite ? SQLITE_CREATE_ARTICLE : MSSQL_CREATE_ARTICLE; @@ -53,7 +54,7 @@ export default function createUnderParent(provider) { const unitId = await ensureUnit(runner, languageId, companyId); const sku = await allocateSku(companyId, runner); - const slug = slugify(name); + const slug = await resolveSlug(runner, { companyId, languageId, name }); let article; try { diff --git a/src/store/article/delete.js b/src/store/article/delete.js index b4e8c48..3da56f3 100644 --- a/src/store/article/delete.js +++ b/src/store/article/delete.js @@ -1,5 +1,7 @@ import * as mssqlQueries from '../../providers/mssql/queries/articleDelete.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/articleDelete.js'; +import * as mssqlArticle from '../../providers/mssql/queries/article.js'; +import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; import resolveArticleBySku from './resolveArticleBySku.js'; import makeRunner from '../runner.js'; @@ -7,6 +9,7 @@ export default function del(provider) { const resolve = resolveArticleBySku(provider); const isSqlite = provider?.dialect === 'sqlite3'; const queries = isSqlite ? sqliteQueries : mssqlQueries; + const articleQueries = isSqlite ? sqliteArticle : mssqlArticle; /** * Deletes an article by its SKU, refusing when it still has children unless cascade is true. @@ -40,6 +43,8 @@ export default function del(provider) { const listIds = await runner.query(queries.GET_LIST_IDS, { articleId: id }); const { articlelistId, optionslistId } = listIds.rows[0] ?? {}; + await runner.query(queries.CREATE_TOMBSTONE, { articleId: id }); + await runner.query(queries.DELETE_PICS_BY_ARTICLE, { articleId: id }); const deleted = await runner.query(queries.DELETE_BY_ID, { articleId: id }); @@ -64,16 +69,38 @@ export default function del(provider) { await deleteArticleAndLists(id); }; + const fatherRes = await runner.query( + 'SELECT fatherarticle_id FROM article WHERE id = @articleId', + { articleId }, + ); + const fatherArticleId = fatherRes.rows[0]?.fatherarticle_id; + const childCount = await runner.query(queries.GET_CHILD_COUNT, { articleId }); if (childCount.rows[0].count > 0) { if (!cascade) { throw new Error('Article has children'); } await deleteSubtree(articleId); - return; + } else { + await deleteArticleAndLists(articleId); } - await deleteArticleAndLists(articleId); + if (fatherArticleId != null) { + const remainingChildrenRes = await runner.query( + 'SELECT id, fathersort FROM article WHERE fatherarticle_id = @fatherArticleId ORDER BY fathersort ASC, id ASC', + { fatherArticleId }, + ); + 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, { + articleId: childRow.id, + fatherArticleId, + fathersort: i, + }); + } + } + } }; if (tx) { diff --git a/src/store/article/getHistoryBySku.js b/src/store/article/getHistoryBySku.js index caed888..6c0c5bc 100644 --- a/src/store/article/getHistoryBySku.js +++ b/src/store/article/getHistoryBySku.js @@ -1,28 +1,50 @@ -import { LIST_BY_ARTICLE_ID as MSSQL_QUERY } from '../../providers/mssql/queries/articlehistory.js'; -import { LIST_BY_ARTICLE_ID as SQLITE_QUERY } from '../../providers/sqlite3/queries/articlehistory.js'; +import { LIST_BY_ARTICLE_ID as MSSQL_QUERY, LIST_BY_ARTICLE_ID_LIMITED as MSSQL_QUERY_LIMITED } from '../../providers/mssql/queries/articlehistory.js'; +import { LIST_BY_ARTICLE_ID as SQLITE_QUERY, LIST_BY_ARTICLE_ID_LIMITED as SQLITE_QUERY_LIMITED } from '../../providers/sqlite3/queries/articlehistory.js'; import resolveArticleBySku from './resolveArticleBySku.js'; import makeRunner from '../runner.js'; export default function getHistoryBySku(provider) { const resolve = resolveArticleBySku(provider); - const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY; + const isSqlite = provider?.dialect === 'sqlite3'; + const sql = isSqlite ? SQLITE_QUERY : MSSQL_QUERY; + const sqlLimited = isSqlite ? SQLITE_QUERY_LIMITED : MSSQL_QUERY_LIMITED; /** * Returns the change history of an article by its SKU. * * @param {number} googleAuthId - The id of the Google auth user. * @param {string} sku - The article SKU. + * @param {object} [opts] - Optional options. + * @param {number} [opts.limit] - Maximum number of entries to return (newest first). * @param {object} [tx] - Optional transaction. * @returns {Promise>} The article history entries. * * @example * const history = await store.article.getHistoryBySku(42, 'SKU-1'); * // => [{ id, companyuserId, date, text }] + * + * const recent = await store.article.getHistoryBySku(42, 'SKU-1', { limit: 5 }); + * // => up to 5 most recent entries */ - return async (googleAuthId, sku, tx) => { - const { articleId } = await resolve(googleAuthId, sku, tx); - const runner = makeRunner(provider, tx); - const result = await runner.query(sql, { articleId }); + return async (googleAuthId, sku, opts, tx) => { + // Support legacy call signature: getHistoryBySku(googleAuthId, sku, 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 { articleId } = await resolve(googleAuthId, sku, runner); + + let result; + if (limit != null && Number.isInteger(limit) && limit > 0) { + result = await runner.query(sqlLimited, { articleId, limit }); + } else { + result = await runner.query(sql, { articleId }); + } return result.rows.map((row) => ({ id: row.id, @@ -32,4 +54,3 @@ export default function getHistoryBySku(provider) { })); }; } - diff --git a/src/store/article/importRows.js b/src/store/article/importRows.js index f2e2d0f..33e5938 100644 --- a/src/store/article/importRows.js +++ b/src/store/article/importRows.js @@ -30,7 +30,7 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA import ensureByPath from '../category/ensureByPath.js'; import ensureDefaultUnit from '../unit/ensureDefault.js'; import makeRunner from '../runner.js'; -import slugify from '../slugify.js'; +import resolveUniqueSlug from './resolveUniqueSlug.js'; // Normalizes a numeric value that may arrive as a string using German decimal // notation (e.g. "11,00"), a JS number, or null/undefined. Returns null when empty. @@ -49,6 +49,7 @@ export default function importRows(provider) { const companyAccess = resolveSelectedCompanyAccess(provider); const ensureUnit = ensureDefaultUnit(provider); const ensureCategoryByPath = ensureByPath(provider); + const resolveSlug = resolveUniqueSlug(provider); const isSqlite = provider?.dialect === 'sqlite3'; const articleQueries = isSqlite ? sqliteArticle : mssqlArticle; @@ -167,7 +168,7 @@ export default function importRows(provider) { try { await runInTx(async (runner) => { const unitId = await ensureUnit(runner, companyId, languageId); - const slug = slugify(name); + const slug = await resolveSlug(runner, { companyId, languageId, name }); const rawTrack = row.track_stock ?? row.trackStock; const trackStock = rawTrack == null ? 1 : (rawTrack ? 1 : 0); @@ -286,22 +287,52 @@ export default function importRows(provider) { 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(articleQueries.GET_ID_BY_SKU, { sku: fatherSku, companyId }); - const fatherId = father.rows[0]?.articleId; + 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 fatherRow = father.rows[0]; + const fatherId = fatherRow?.id; if (fatherId && fatherId !== childId) { - const childCount = await runner.query(getChildCountSql, { - articleId: fatherId, - }); - const count = childCount.rows[0].count; + const wasFather = Boolean(fatherRow.is_father); + + const existingSortsRes = await runner.query( + 'SELECT fathersort FROM article WHERE fatherarticle_id = @fatherId ORDER BY fathersort ASC', + { fatherId }, + ); + const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.fathersort))); + let fathersort = 0; + while (usedSorts.has(fathersort)) { + fathersort++; + } await runner.query(articleQueries.UPDATE_FATHER_BY_SKU, { sku, companyId, fatherArticleId: fatherId, - fathersort: count, + fathersort, }); await runner.query(articleQueries.SET_IS_FATHER, { articleId: fatherId, }); + + if (!wasFather) { + await runner.query(insertHistorySql, { + articleId: fatherId, + companyuserId, + text: 'Artikel zu Vaterartikel konvertiert', + }); + } + const childName = String(row?.name ?? sku).trim(); + await runner.query(insertHistorySql, { + articleId: fatherId, + companyuserId, + text: `Variante "${childName}" (${sku}) hinzugefügt`, + }); + await runner.query(insertHistorySql, { + articleId: childId, + companyuserId, + text: `Artikel "${childName}" (${sku}) als Variante von "${fatherSku}" zugewiesen`, + }); } } }); diff --git a/src/store/article/removeChild.js b/src/store/article/removeChild.js index 5913e12..e60a662 100644 --- a/src/store/article/removeChild.js +++ b/src/store/article/removeChild.js @@ -28,7 +28,7 @@ export default function removeChild(provider) { */ return async (googleAuthId, fatherSku, childSku, options = {}, tx) => { const run = async (runner) => { - const { articleId: fatherId, companyuserId } = await resolve( + const { articleId: fatherId, languageId, companyuserId } = await resolve( googleAuthId, fatherSku, runner, @@ -45,9 +45,45 @@ export default function removeChild(provider) { throw new Error('Child article does not belong to this father'); } + // Fetch child name before any deletion (needed for history regardless of path) + const childNameRes = await runner.query( + `SELECT at.name FROM articletext at + INNER JOIN article a ON a.id = at.article_id + WHERE a.id = @childId AND at.language_id = @languageId`, + { childId, languageId }, + ); + const childName = childNameRes.rows[0]?.name || childSku; + + const recompactFatherSort = async () => { + const remainingChildrenRes = await runner.query( + 'SELECT id, fathersort FROM article WHERE fatherarticle_id = @fatherId ORDER BY fathersort ASC, id ASC', + { fatherId }, + ); + 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, { + articleId: childRow.id, + fatherArticleId: fatherId, + fathersort: i, + }); + } + } + }; + if (options.deleteArticle) { const cascade = options.cascade !== undefined ? Boolean(options.cascade) : true; await del(googleAuthId, childSku, { cascade }, runner); + + await recompactFatherSort(); + + // History on father (child article is gone so we can only log on father) + await runner.query(insertHistorySql, { + articleId: fatherId, + companyuserId, + text: `Variante "${childName}" (${childSku}) gelöscht`, + }); + return { success: true, fatherSku, @@ -81,13 +117,22 @@ export default function removeChild(provider) { ); } - // Add history + await recompactFatherSort(); + + // Add history on child await runner.query(insertHistorySql, { articleId: childId, companyuserId, text: `Artikel "${childSku}" von Vaterartikel "${fatherSku}" getrennt`, }); + // Add history on father + await runner.query(insertHistorySql, { + articleId: fatherId, + companyuserId, + text: `Variante "${childName}" (${childSku}) getrennt`, + }); + return { success: true, fatherSku, diff --git a/src/store/article/resolveUniqueSlug.js b/src/store/article/resolveUniqueSlug.js new file mode 100644 index 0000000..271bf03 --- /dev/null +++ b/src/store/article/resolveUniqueSlug.js @@ -0,0 +1,51 @@ +import { LIST_SLUGS_BY_COMPANY_AND_LANGUAGE as MSSQL_LIST_SLUGS } from '../../providers/mssql/queries/articletext.js'; +import { LIST_SLUGS_BY_COMPANY_AND_LANGUAGE as SQLITE_LIST_SLUGS } from '../../providers/sqlite3/queries/articletext.js'; +import slugify from '../slugify.js'; + +export default function resolveUniqueSlug(provider) { + const isSqlite = provider?.dialect === 'sqlite3'; + const listSlugsSql = isSqlite ? SQLITE_LIST_SLUGS : MSSQL_LIST_SLUGS; + + /** + * Resolves a company- and language-unique slug for an article. + * Disambiguates duplicate slugs with a numeric suffix (-2, -3, ...). + * + * @param {object} runner - DB runner or tx. + * @param {object} params + * @param {number} params.companyId - The company ID. + * @param {number} params.languageId - The language ID. + * @param {string} params.name - The article name. + * @param {number} [params.excludeArticleId] - Optional article ID to exclude (e.g. for updates). + * @returns {Promise} The unique slug. + */ + return async (runner, { companyId, languageId, name, excludeArticleId }) => { + const baseSlug = slugify(name) || 'article'; + const likePattern = `${baseSlug}-%`; + + const res = await runner.query(listSlugsSql, { + companyId, + languageId, + baseSlug, + likePattern, + }); + + const existingSlugs = new Set(); + for (const row of res.rows) { + const artId = row.articleId ?? row.article_id; + if (excludeArticleId != null && artId === excludeArticleId) { + continue; + } + existingSlugs.add(row.slug); + } + + if (!existingSlugs.has(baseSlug)) { + return baseSlug; + } + + let suffix = 2; + while (existingSlugs.has(`${baseSlug}-${suffix}`)) { + suffix++; + } + return `${baseSlug}-${suffix}`; + }; +} diff --git a/src/store/article/setBrandIdBySku.js b/src/store/article/setBrandIdBySku.js index 0934685..196a67f 100644 --- a/src/store/article/setBrandIdBySku.js +++ b/src/store/article/setBrandIdBySku.js @@ -47,25 +47,29 @@ export default function setBrandIdBySku(provider) { throw new Error('Invalid brandId'); } - // Read old brand for history + // Read old brand name (null = no brand assigned) const oldBrandRes = await runner.query( `SELECT b.name FROM article a LEFT JOIN brand b ON b.id = a.brand_id WHERE a.id = @articleId`, { articleId }, ); - const oldBrandName = oldBrandRes.rows[0]?.name ?? ''; + const oldBrandName = oldBrandRes.rows[0]?.name ?? null; let resolvedBrandId = null; - let newBrandName = ''; + let newBrandName = null; if (brandId !== null) { - const brand = await runner.query(getBrandSql, { - brandId, - companyId, - }); - if (!brand.rows[0]) { + // Verify brand belongs to this company + const brandCheck = await runner.query(getBrandSql, { brandId, companyId }); + if (!brandCheck.rows[0]) { throw new Error('Brand not found'); } resolvedBrandId = brandId; - newBrandName = brand.rows[0].name ?? ''; + + // GET_BRAND_BY_COMPANY_AND_ID only selects `id`, so fetch name separately + const brandNameRes = await runner.query( + 'SELECT name FROM brand WHERE id = @brandId', + { brandId }, + ); + newBrandName = brandNameRes.rows[0]?.name ?? null; } const result = await runner.query(updateBrandSql, { @@ -77,12 +81,12 @@ export default function setBrandIdBySku(provider) { throw new Error('Article not found'); } - // Write history if brand changed + // null = no brand; treat as distinct sentinel so first assignment is logged if (oldBrandName !== newBrandName) { await runner.query(insertHistorySql, { articleId, companyuserId, - text: `Marke geändert von "${oldBrandName}" auf "${newBrandName}"`, + text: `Marke ge\u00e4ndert von "${oldBrandName ?? ''}" auf "${newBrandName ?? ''}"`, }); } @@ -94,4 +98,3 @@ export default function setBrandIdBySku(provider) { return article; }; } - diff --git a/src/store/article/setFatherBySku.js b/src/store/article/setFatherBySku.js index 0a706f5..fc36b87 100644 --- a/src/store/article/setFatherBySku.js +++ b/src/store/article/setFatherBySku.js @@ -2,6 +2,8 @@ import * as mssqlArticle from '../../providers/mssql/queries/article.js'; import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/queries/articleDelete.js'; import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.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 queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; import makeRunner from '../runner.js'; @@ -13,6 +15,7 @@ export default function setFatherBySku(provider) { const queries = isSqlite ? sqliteArticle : mssqlArticle; const getChildCountSql = isSqlite ? SQLITE_GET_CHILD_COUNT : MSSQL_GET_CHILD_COUNT; + const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY; /** * Links an article to a father article (V/K compound) by their SKUs. @@ -30,14 +33,47 @@ export default function setFatherBySku(provider) { */ return async (googleAuthId, sku, fatherSku, tx) => { const runSet = async (runner) => { - const { articleId, languageId, companyId } = await resolve(googleAuthId, sku, runner); + const { articleId, languageId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner); + + const childTextRes = await runner.query( + 'SELECT name FROM articletext WHERE article_id = @articleId AND language_id = @languageId', + { articleId, languageId }, + ); + const childName = childTextRes.rows[0]?.name || sku; if (fatherSku == null || fatherSku === '') { + const prevFatherRes = await runner.query( + 'SELECT fatherarticle_id FROM article WHERE id = @articleId', + { articleId }, + ); + const prevFatherId = prevFatherRes.rows[0]?.fatherarticle_id; + await runner.query(queries.UPDATE_FATHER_BY_SKU, { sku, companyId, fatherArticleId: null, }); + + if (prevFatherId) { + const prevFatherTextRes = await runner.query( + 'SELECT sku FROM article WHERE id = @prevFatherId', + { prevFatherId }, + ); + const prevFatherSku = prevFatherTextRes.rows[0]?.sku || String(prevFatherId); + + await runner.query(insertHistorySql, { + articleId, + companyuserId, + text: `Artikel "${sku}" von Vaterartikel "${prevFatherSku}" getrennt`, + }); + + await runner.query(insertHistorySql, { + articleId: prevFatherId, + companyuserId, + text: `Variante "${childName}" (${sku}) getrennt`, + }); + } + const unlinked = await load(articleId, languageId, runner); if (!unlinked) { throw new Error('Article not found'); @@ -45,8 +81,12 @@ export default function setFatherBySku(provider) { return unlinked; } - const father = await runner.query(queries.GET_ID_BY_SKU, { sku: fatherSku, companyId }); - const fatherId = father.rows[0]?.articleId; + 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 fatherRow = father.rows[0]; + const fatherId = fatherRow?.id; if (!fatherId) { throw new Error('Father not found'); } @@ -54,22 +94,49 @@ export default function setFatherBySku(provider) { throw new Error('Invalid father'); } - const childCount = await runner.query(getChildCountSql, { - articleId: fatherId, - }); - const count = childCount.rows[0].count; + const wasFather = Boolean(fatherRow.is_father); + + const existingSortsRes = await runner.query( + 'SELECT fathersort FROM article WHERE fatherarticle_id = @fatherId ORDER BY fathersort ASC', + { fatherId }, + ); + const usedSorts = new Set(existingSortsRes.rows.map((r) => Number(r.fathersort))); + let fathersort = 0; + while (usedSorts.has(fathersort)) { + fathersort++; + } await runner.query(queries.UPDATE_FATHER_BY_SKU, { sku, companyId, fatherArticleId: fatherId, - fathersort: count, + fathersort, }); await runner.query(queries.SET_IS_FATHER, { articleId: fatherId, }); + if (!wasFather) { + await runner.query(insertHistorySql, { + articleId: fatherId, + companyuserId, + text: 'Artikel zu Vaterartikel konvertiert', + }); + } + + await runner.query(insertHistorySql, { + articleId: fatherId, + companyuserId, + text: `Variante "${childName}" (${sku}) hinzugefügt`, + }); + + await runner.query(insertHistorySql, { + articleId, + companyuserId, + text: `Artikel "${sku}" als Variante von "${fatherSku}" zugewiesen`, + }); + const article = await load(articleId, languageId, runner); if (!article) { throw new Error('Article not found'); diff --git a/src/store/article/updateBySku.js b/src/store/article/updateBySku.js index 67ea0f8..d928a7b 100644 --- a/src/store/article/updateBySku.js +++ b/src/store/article/updateBySku.js @@ -11,7 +11,7 @@ import { GET_FULL_BY_SI as SQLITE_GET_UNIT } from '../../providers/sqlite3/queri import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; import makeRunner from '../runner.js'; -import slugify from '../slugify.js'; +import resolveUniqueSlug from './resolveUniqueSlug.js'; function formatUnitLabel(amount, symbol) { const sym = symbol ?? ''; @@ -24,6 +24,7 @@ function formatUnitLabel(amount, symbol) { export default function updateBySku(provider) { const resolve = resolveArticleBySku(provider); const load = queryBySku(provider); + const resolveSlug = resolveUniqueSlug(provider); const isSqlite = provider?.dialect === 'sqlite3'; const articleQueries = isSqlite ? sqliteArticle : mssqlArticle; @@ -144,7 +145,12 @@ export default function updateBySku(provider) { } if (hasName) { - const slug = slugify(patch.name); + const slug = await resolveSlug(runner, { + companyId, + languageId, + name: patch.name, + excludeArticleId: articleId, + }); const update = await runner.query(updateTextSql, { articleId, languageId, diff --git a/src/store/articleattributevalue/setByArticleSku.js b/src/store/articleattributevalue/setByArticleSku.js index 374c497..cb66a2e 100644 --- a/src/store/articleattributevalue/setByArticleSku.js +++ b/src/store/articleattributevalue/setByArticleSku.js @@ -44,7 +44,7 @@ export default function setByArticleSku(provider) { `SELECT aav.attributevalue_id AS attributeValueId, an.name AS attributeName, av.value FROM articleattributevalue aav INNER JOIN attributevalue av ON av.id = aav.attributevalue_id - INNER JOIN attributename an ON an.id = av.attributename_id + INNER JOIN attributename an ON an.id = av.attribute_id WHERE aav.article_id = @articleId ORDER BY aav.sort ASC, aav.id ASC`, { articleId }, @@ -57,7 +57,36 @@ export default function setByArticleSku(provider) { for (let i = 0; i < values.length; i++) { const item = values[i]; - const valId = typeof item === 'object' && item !== null ? Number(item.attributeValueId) : Number(item); + let rawId; + if (typeof item === 'object' && item !== null) { + rawId = item.attributeValueId ?? item.attributevalueId ?? item.attribute_value_id ?? item.valueId ?? item.id; + } else { + rawId = item; + } + let valId = Number(rawId); + + if ((!Number.isInteger(valId) || valId <= 0) && typeof item === 'object' && item !== null && typeof item.value === 'string' && item.value.trim()) { + const valStr = item.value.trim(); + let targetAttrId = item.attributeId != null ? Number(item.attributeId) : null; + if (!targetAttrId && (item.attributeName || item.name)) { + const attrName = String(item.attributeName || item.name).trim(); + const nameRes = await runner.query( + 'SELECT id FROM attributename WHERE company_id = @companyId AND name = @attrName', + { companyId, attrName }, + ); + targetAttrId = nameRes.rows[0]?.id; + } + if (targetAttrId) { + const valRes = await runner.query( + 'SELECT id FROM attributevalue WHERE attribute_id = @attributeId AND value = @value', + { attributeId: targetAttrId, value: valStr }, + ); + if (valRes.rows[0]?.id) { + valId = valRes.rows[0].id; + } + } + } + if (!Number.isInteger(valId) || valId <= 0) { throw new Error('Invalid attributeValueId'); } @@ -80,8 +109,8 @@ export default function setByArticleSku(provider) { exclusiveAttributesSeen.set(detail.attributeId, detail.attributeName); } - const sort = typeof item === 'object' && item !== null && item.sort != null && Number.isInteger(item.sort) - ? item.sort + const sort = typeof item === 'object' && item !== null && (item.sort != null || item.sort_order != null) && Number.isInteger(Number(item.sort ?? item.sort_order)) + ? Number(item.sort ?? item.sort_order) : i; validated.push({ diff --git a/src/store/articleproperty/delete.js b/src/store/articleproperty/delete.js index 67385a7..2f48edd 100644 --- a/src/store/articleproperty/delete.js +++ b/src/store/articleproperty/delete.js @@ -4,12 +4,16 @@ import { import { DELETE_BY_ARTICLE_AND_PROPERTY_ID as SQLITE_DELETE, } from '../../providers/sqlite3/queries/articleproperty.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 '../article/resolveArticleBySku.js'; import makeRunner from '../runner.js'; export default function deleteArticleProperty(provider) { const resolve = resolveArticleBySku(provider); - const deleteSql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; + const isSqlite = provider?.dialect === 'sqlite3'; + const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY; /** * Deletes a property from an article by SKU and propertyId. @@ -22,7 +26,26 @@ export default function deleteArticleProperty(provider) { */ return async (googleAuthId, sku, propertyId, tx) => { const runner = makeRunner(provider, tx); - const { articleId } = await resolve(googleAuthId, sku, runner); + const { articleId, companyuserId } = await resolve(googleAuthId, sku, runner); + + // Read the property name + value before deleting (for the history entry) + const propRes = await runner.query( + `SELECT ap.value, pn.name + FROM articleproperty ap + INNER JOIN propertyname pn ON pn.id = ap.property_id + WHERE ap.article_id = @articleId AND ap.property_id = @propertyId`, + { articleId, propertyId }, + ); + const prop = propRes.rows[0]; + await runner.query(deleteSql, { articleId, propertyId }); + + if (prop) { + await runner.query(insertHistorySql, { + articleId, + companyuserId, + text: `Eigenschaft "${prop.name}" (${prop.value}) gelöscht`, + }); + } }; } diff --git a/src/store/categoryattributevalue/listByCategoryId.js b/src/store/categoryattributevalue/listByCategoryId.js new file mode 100644 index 0000000..d096015 --- /dev/null +++ b/src/store/categoryattributevalue/listByCategoryId.js @@ -0,0 +1,34 @@ +import { LIST_BY_CATEGORY_ID as MSSQL_QUERY } from '../../providers/mssql/queries/categoryattributevalue.js'; +import { LIST_BY_CATEGORY_ID as SQLITE_QUERY } from '../../providers/sqlite3/queries/categoryattributevalue.js'; +import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; + +export default function listByCategoryId(provider) { + const resolve = resolveCategoryAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY; + + /** + * Lists the attribute values assigned to a category by its ID. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} categoryId - The category ID. + * @param {object} [tx] - Optional transaction. + * @returns {Promise>} + */ + return async (googleAuthId, categoryId, tx) => { + const runner = makeRunner(provider, tx); + await resolve(googleAuthId, categoryId, runner); + + const result = await runner.query(sql, { categoryId }); + return result.rows.map((row) => ({ + id: row.id, + categoryId: row.categoryId, + attributeId: row.attributeId, + attributeName: row.attributeName, + exclusive: Boolean(row.exclusive), + attributeValueId: row.attributeValueId, + value: row.value, + sort: row.sort, + })); + }; +} diff --git a/src/store/categoryattributevalue/setByCategoryId.js b/src/store/categoryattributevalue/setByCategoryId.js new file mode 100644 index 0000000..76c9cdf --- /dev/null +++ b/src/store/categoryattributevalue/setByCategoryId.js @@ -0,0 +1,155 @@ +import { + DELETE_BY_CATEGORY_ID as MSSQL_DELETE, + INSERT_ROW as MSSQL_INSERT, + GET_VALUE_DETAIL as MSSQL_GET_DETAIL, +} from '../../providers/mssql/queries/categoryattributevalue.js'; +import { + DELETE_BY_CATEGORY_ID as SQLITE_DELETE, + INSERT_ROW as SQLITE_INSERT, + GET_VALUE_DETAIL as SQLITE_GET_DETAIL, +} from '../../providers/sqlite3/queries/categoryattributevalue.js'; +import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; + +export default function setByCategoryId(provider) { + const resolve = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + const insertSql = isSqlite ? SQLITE_INSERT : MSSQL_INSERT; + const getDetailSql = isSqlite ? SQLITE_GET_DETAIL : MSSQL_GET_DETAIL; + + /** + * Replaces all attribute values for a category by its ID. + * Validates company ownership and enforces the `exclusive` flag on attributes. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} categoryId - The category ID. + * @param {Array} values - Attribute value IDs or items. + * @param {object} [tx] - Optional transaction. + * @returns {Promise>} + */ + return async (googleAuthId, categoryId, values = [], tx) => { + if (!Array.isArray(values)) { + throw new Error('Invalid attribute values array'); + } + + const runSet = async (runner) => { + const { companyId } = await resolve(googleAuthId, categoryId, runner); + + const validated = []; + const seenValues = new Set(); + const exclusiveAttributesSeen = new Map(); // attributeId -> attributeName + + for (let i = 0; i < values.length; i++) { + const item = values[i]; + let rawId; + if (typeof item === 'object' && item !== null) { + rawId = item.attributeValueId ?? item.attributevalueId ?? item.attribute_value_id ?? item.valueId ?? item.id; + } else { + rawId = item; + } + let valId = Number(rawId); + + if ((!Number.isInteger(valId) || valId <= 0) && typeof item === 'object' && item !== null && typeof item.value === 'string' && item.value.trim()) { + const valStr = item.value.trim(); + let targetAttrId = item.attributeId != null ? Number(item.attributeId) : null; + if (!targetAttrId && (item.attributeName || item.name)) { + const attrName = String(item.attributeName || item.name).trim(); + const nameRes = await runner.query( + 'SELECT id FROM attributename WHERE company_id = @companyId AND name = @attrName', + { companyId, attrName }, + ); + targetAttrId = nameRes.rows[0]?.id; + } + if (targetAttrId) { + const valRes = await runner.query( + 'SELECT id FROM attributevalue WHERE attribute_id = @targetAttrId AND value = @valStr', + { targetAttrId, valStr }, + ); + if (valRes.rows[0]?.id) { + valId = valRes.rows[0].id; + } + } + } + + if (!Number.isInteger(valId) || valId <= 0) { + throw new Error(`Invalid attributeValueId at index ${i}`); + } + + if (seenValues.has(valId)) { + continue; // de-duplicate identical value assignments + } + seenValues.add(valId); + + const detailRes = await runner.query(getDetailSql, { attributeValueId: valId }); + const detail = detailRes.rows[0]; + if (!detail) { + throw new Error(`Attribute value not found: ${valId}`); + } + if (detail.companyId !== companyId) { + throw new Error(`Attribute value does not belong to company: ${valId}`); + } + + const isExclusive = Boolean(detail.exclusive); + if (isExclusive) { + if (exclusiveAttributesSeen.has(detail.attributeId)) { + const attrName = exclusiveAttributesSeen.get(detail.attributeId); + throw new Error(`Attribute "${attrName}" is exclusive and can only have one value`); + } + exclusiveAttributesSeen.set(detail.attributeId, detail.attributeName); + } + + const sort = typeof item === 'object' && item !== null && item.sort != null && Number.isInteger(item.sort) + ? item.sort + : i; + + validated.push({ + attributeValueId: valId, + attributeId: detail.attributeId, + attributeName: detail.attributeName, + exclusive: isExclusive, + value: detail.value, + sort, + }); + } + + await runner.query(deleteSql, { categoryId }); + + const inserted = []; + for (const item of validated) { + const res = await runner.query(insertSql, { + categoryId, + attributeValueId: item.attributeValueId, + sort: item.sort, + }); + const id = res.rows[0]?.id ?? res.insertId; + inserted.push({ + id, + categoryId, + attributeId: item.attributeId, + attributeName: item.attributeName, + exclusive: item.exclusive, + attributeValueId: item.attributeValueId, + value: item.value, + sort: item.sort, + }); + } + + // Touch category updated_at + const touchSql = isSqlite + ? "UPDATE category SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = @categoryId" + : 'UPDATE category SET updated_at = SYSUTCDATETIME() WHERE id = @categoryId'; + await runner.query(touchSql, { categoryId }); + + return inserted; + }; + + if (tx) { + return runSet(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runSet); + } + return runSet(makeRunner(provider)); + }; +} diff --git a/src/store/categoryproperty/delete.js b/src/store/categoryproperty/delete.js new file mode 100644 index 0000000..227d575 --- /dev/null +++ b/src/store/categoryproperty/delete.js @@ -0,0 +1,36 @@ +import { + DELETE_BY_CATEGORY_AND_PROPERTY_ID as MSSQL_DELETE, +} from '../../providers/mssql/queries/categoryproperty.js'; +import { + DELETE_BY_CATEGORY_AND_PROPERTY_ID as SQLITE_DELETE, +} from '../../providers/sqlite3/queries/categoryproperty.js'; +import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; + +export default function deleteCategoryProperty(provider) { + const resolve = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + + /** + * Deletes a property from a category by categoryId and propertyId. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} categoryId - The category ID. + * @param {number} propertyId - The property id. + * @param {object} [tx] - Optional transaction. + * @returns {Promise} + */ + return async (googleAuthId, categoryId, propertyId, tx) => { + const runner = makeRunner(provider, tx); + await resolve(googleAuthId, categoryId, runner); + + await runner.query(deleteSql, { categoryId, propertyId }); + + // Touch category updated_at + const touchSql = isSqlite + ? "UPDATE category SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = @categoryId" + : 'UPDATE category SET updated_at = SYSUTCDATETIME() WHERE id = @categoryId'; + await runner.query(touchSql, { categoryId }); + }; +} diff --git a/src/store/categoryproperty/listByCategoryId.js b/src/store/categoryproperty/listByCategoryId.js new file mode 100644 index 0000000..0ac52d6 --- /dev/null +++ b/src/store/categoryproperty/listByCategoryId.js @@ -0,0 +1,33 @@ +import { LIST_BY_CATEGORY_ID as MSSQL_QUERY } from '../../providers/mssql/queries/categoryproperty.js'; +import { LIST_BY_CATEGORY_ID as SQLITE_QUERY } from '../../providers/sqlite3/queries/categoryproperty.js'; +import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; + +export default function listByCategoryId(provider) { + const resolve = resolveCategoryAccess(provider); + const sql = provider?.dialect === 'sqlite3' ? SQLITE_QUERY : MSSQL_QUERY; + + /** + * Lists the properties of a category by its ID. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} categoryId - The category ID. + * @param {object} [tx] - Optional transaction. + * @returns {Promise>} + */ + return async (googleAuthId, categoryId, tx) => { + const runner = makeRunner(provider, tx); + await resolve(googleAuthId, categoryId, runner); + + const result = await runner.query(sql, { categoryId }); + return result.rows.map((row) => ({ + id: row.id, + categoryId: row.categoryId, + propertyId: row.propertyId, + name: row.name, + exclusive: Boolean(row.exclusive), + value: row.value, + sort: row.sort, + })); + }; +} diff --git a/src/store/categoryproperty/setByCategoryId.js b/src/store/categoryproperty/setByCategoryId.js new file mode 100644 index 0000000..887100d --- /dev/null +++ b/src/store/categoryproperty/setByCategoryId.js @@ -0,0 +1,117 @@ +import { + DELETE_BY_CATEGORY_ID as MSSQL_DELETE, + INSERT_ROW as MSSQL_INSERT, +} from '../../providers/mssql/queries/categoryproperty.js'; +import { + DELETE_BY_CATEGORY_ID as SQLITE_DELETE, + INSERT_ROW as SQLITE_INSERT, +} from '../../providers/sqlite3/queries/categoryproperty.js'; +import { + GET_BY_COMPANY_AND_ID as MSSQL_GET_PROP, +} from '../../providers/mssql/queries/propertyname.js'; +import { + GET_BY_COMPANY_AND_ID as SQLITE_GET_PROP, +} from '../../providers/sqlite3/queries/propertyname.js'; +import resolveCategoryAccess from '../category/resolveCategoryAccess.js'; +import makeRunner from '../runner.js'; + +export default function setByCategoryId(provider) { + const resolve = resolveCategoryAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + const insertSql = isSqlite ? SQLITE_INSERT : MSSQL_INSERT; + const getPropSql = isSqlite ? SQLITE_GET_PROP : MSSQL_GET_PROP; + + /** + * Replaces all properties for a category by its ID. + * Enforces exclusive flag on property names. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} categoryId - The category ID. + * @param {Array<{propertyId: number, value: string, sort?: number}>} properties - The properties to set. + * @param {object} [tx] - Optional transaction. + * @returns {Promise>} + */ + return async (googleAuthId, categoryId, properties = [], tx) => { + if (!Array.isArray(properties)) { + throw new Error('Invalid properties array'); + } + + const runSet = async (runner) => { + const { companyId } = await resolve(googleAuthId, categoryId, runner); + + const validated = []; + const seenExclusiveProps = new Set(); + + for (let i = 0; i < properties.length; i++) { + const item = properties[i]; + const propertyId = Number(item.propertyId); + if (!Number.isInteger(propertyId) || propertyId <= 0) { + throw new Error('Invalid propertyId'); + } + + const propRes = await runner.query(getPropSql, { id: propertyId, companyId }); + const prop = propRes.rows[0]; + if (!prop) { + throw new Error(`Property not found: ${propertyId}`); + } + + const isExclusive = Boolean(prop.exclusive); + if (isExclusive) { + if (seenExclusiveProps.has(propertyId)) { + throw new Error(`Property "${prop.name}" is exclusive and can only have one value`); + } + seenExclusiveProps.add(propertyId); + } + + const value = item.value != null ? String(item.value).trim() : ''; + const sort = item.sort != null && Number.isInteger(item.sort) ? item.sort : i; + validated.push({ + propertyId, + name: prop.name, + exclusive: isExclusive, + value, + sort, + }); + } + + await runner.query(deleteSql, { categoryId }); + + const inserted = []; + for (const item of validated) { + const res = await runner.query(insertSql, { + categoryId, + propertyId: item.propertyId, + value: item.value, + sort: item.sort, + }); + const id = res.rows[0]?.id ?? res.insertId; + inserted.push({ + id, + categoryId, + propertyId: item.propertyId, + name: item.name, + exclusive: item.exclusive, + value: item.value, + sort: item.sort, + }); + } + + // Touch category updated_at + const touchSql = isSqlite + ? "UPDATE category SET updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = @categoryId" + : 'UPDATE category SET updated_at = SYSUTCDATETIME() WHERE id = @categoryId'; + await runner.query(touchSql, { categoryId }); + + return inserted; + }; + + if (tx) { + return runSet(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runSet); + } + return runSet(makeRunner(provider)); + }; +} diff --git a/src/store/manifestLines.js b/src/store/manifestLines.js index b1456bb..77a311a 100644 --- a/src/store/manifestLines.js +++ b/src/store/manifestLines.js @@ -1,5 +1,11 @@ -import { CREATE_LINE as MSSQL_CREATE_LINE } from '../providers/mssql/queries/manifestDoc.js'; -import { CREATE_LINE as SQLITE_CREATE_LINE } from '../providers/sqlite3/queries/manifestDoc.js'; +import { + CHECK_ARTICLE_IS_FATHER as MSSQL_CHECK_ARTICLE_IS_FATHER, + CREATE_LINE as MSSQL_CREATE_LINE, +} from '../providers/mssql/queries/manifestDoc.js'; +import { + CHECK_ARTICLE_IS_FATHER as SQLITE_CHECK_ARTICLE_IS_FATHER, + CREATE_LINE as SQLITE_CREATE_LINE, +} from '../providers/sqlite3/queries/manifestDoc.js'; import makeRunner from './runner.js'; /** @@ -24,7 +30,7 @@ export function normalizeLines(lines) { } const pu = - entry?.pu === undefined || entry?.pu === null ? 1 : entry.pu; + entry?.pu === undefined || entry?.pu === null ? null : entry.pu; const amount = entry?.amount; const net = entry?.net; const rebate = @@ -36,7 +42,7 @@ export function normalizeLines(lines) { : String(entry.comment); if ( - typeof pu !== 'number' || !Number.isFinite(pu) || pu <= 0 || + (pu !== null && (typeof pu !== 'number' || !Number.isFinite(pu) || pu <= 0)) || typeof amount !== 'number' || !Number.isFinite(amount) || typeof net !== 'number' || !Number.isFinite(net) || typeof rebate !== 'number' || !Number.isFinite(rebate) || @@ -116,8 +122,32 @@ 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; for (const line of lines) { + let resolvedName = line.name; + let resolvedPu = line.pu; + + if (line.sku) { + const fatherCheck = await runner.query(checkFatherSql, { + 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})`); + } + if (!resolvedName && art?.articleName) { + resolvedName = art.articleName; + } + if (resolvedPu == null && art?.pu != null) { + resolvedPu = art.pu; + } + } + + resolvedName = resolvedName || line.sku || null; + resolvedPu = resolvedPu ?? 1; + if (ensureTax) { await ensureTax(line.taxrate, runner); } @@ -125,8 +155,8 @@ export async function insertLines( await runner.query(createLineSql, { manifestId, sku: line.sku ?? null, - name: line.name ?? null, - pu: line.pu ?? 1, + name: resolvedName, + pu: resolvedPu, amount: line.amount, net: line.net, comment: line.comment, diff --git a/src/store/posterminal/setMaxKnownTombstoneId.js b/src/store/posterminal/setMaxKnownTombstoneId.js new file mode 100644 index 0000000..c960225 --- /dev/null +++ b/src/store/posterminal/setMaxKnownTombstoneId.js @@ -0,0 +1,27 @@ +import { SET_MAX_KNOWN_TOMBSTONE_ID as MSSQL_SET } from '../../providers/mssql/queries/posterminal.js'; +import { SET_MAX_KNOWN_TOMBSTONE_ID as SQLITE_SET } from '../../providers/sqlite3/queries/posterminal.js'; +import makeRunner from '../runner.js'; + +export default function setMaxKnownTombstoneId(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_SET : MSSQL_SET; + + /** + * Updates the max known tombstone ID for a POS terminal. + * + * @param {number} id - The posterminal id. + * @param {number|null} maxknowntombstoneid - The highest synced tombstone ID. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{id: number, maxknowntombstoneid: number|null}>} + */ + return async (id, maxknowntombstoneid, tx) => { + await makeRunner(provider, tx).query(sql, { + id, + maxknowntombstoneid: maxknowntombstoneid != null ? Number(maxknowntombstoneid) : null, + }); + + return { + id, + maxknowntombstoneid: maxknowntombstoneid != null ? Number(maxknowntombstoneid) : null, + }; + }; +} diff --git a/src/store/posterminal/upsert.js b/src/store/posterminal/upsert.js index cc11c6f..4e379f3 100644 --- a/src/store/posterminal/upsert.js +++ b/src/store/posterminal/upsert.js @@ -60,6 +60,11 @@ export default function upsert(provider) { throw new Error('Posterminal not found'); } + const maxknowntombstoneid = + input.maxknowntombstoneid !== undefined + ? input.maxknowntombstoneid + : (existing.rows[0].maxknowntombstoneid ?? null); + await runner.query(updateSql, { id, companyId, @@ -68,11 +73,13 @@ export default function upsert(provider) { pairingpin, pairedAt, clientserial, + maxknowntombstoneid, }); - return { id, name, token, pairingpin, pairedAt, clientserial }; + return { id, name, token, pairingpin, pairedAt, clientserial, maxknowntombstoneid }; } + const maxknowntombstoneid = input.maxknowntombstoneid ?? null; const created = await runner.query(createSql, { companyId, name, @@ -80,6 +87,7 @@ export default function upsert(provider) { pairingpin, pairedAt, clientserial, + maxknowntombstoneid, }); const row = created.rows[0] ?? { @@ -89,6 +97,7 @@ export default function upsert(provider) { pairingpin, pairedAt, clientserial, + maxknowntombstoneid, }; return { @@ -98,6 +107,7 @@ export default function upsert(provider) { pairingpin: row.pairingpin, pairedAt: row.pairedAt ?? null, clientserial: row.clientserial ?? null, + maxknowntombstoneid: row.maxknowntombstoneid ?? null, }; }; } diff --git a/src/store/stock/adjust.js b/src/store/stock/adjust.js index dfd5303..5042046 100644 --- a/src/store/stock/adjust.js +++ b/src/store/stock/adjust.js @@ -123,54 +123,16 @@ export default function adjust(provider) { throw new Error(`A father article cannot have stock (${item.sku})`); } - const articlelistId = artRes.rows[0]?.articlelist_id; - if (articlelistId != null) { - const compRes = await runner.query(` - SELECT - ala.article_id AS articleId, - ala.count, - a.sku, - at.name AS articleName - FROM articlelistarticle ala - INNER JOIN article a ON a.id = ala.article_id - LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId - WHERE ala.articlelist_id = @articlelistId - ORDER BY ala.sort ASC, ala.id ASC - `, { - articlelistId, - languageId: resolved.languageId, - }); - - if (compRes.rows.length === 0) { - throw new Error(`Stückliste has no items (${item.sku})`); - } - - for (const comp of compRes.rows) { - const compCount = Number(comp.count || 1); - const compAmount = item.amount * compCount; - const compText = item.text - ? `${item.text} (${item.sku})` - : (compAmount > 0 - ? `Manual stock addition (+${compAmount}) [${item.sku}]` - : `Manual stock removal (${compAmount}) [${item.sku}]`); - - resolvedArticles.push({ - sku: comp.sku, - warehouseId: item.warehouseId, - amount: compAmount, - text: compText, - articleId: comp.articleId, - articleName: comp.articleName || comp.sku, - }); - } - } else { - const nameRes = await runner.query(GET_ARTICLE_NAME_SQL, { - articleId: resolved.articleId, - languageId: resolved.languageId, - }); - const articleName = nameRes.rows[0]?.name ?? item.sku; - resolvedArticles.push({ ...item, ...resolved, articleName }); + if (artRes.rows[0]?.articlelist_id != null) { + throw new Error(`A Stückliste cannot have manual stock adjustments (${item.sku})`); } + + const nameRes = await runner.query(GET_ARTICLE_NAME_SQL, { + articleId: resolved.articleId, + languageId: resolved.languageId, + }); + const articleName = nameRes.rows[0]?.name ?? item.sku; + resolvedArticles.push({ ...item, ...resolved, articleName }); } // Allocate unique adjustment number using the dedicated adjustment number scheme