diff --git a/API.article.md b/API.article.md index 0ed7296..b1a3f57 100644 --- a/API.article.md +++ b/API.article.md @@ -85,7 +85,7 @@ const article = await store.article.createUnderParent({ googleAuthId: 42, parent *Source: `src/store/article/delete.js`* -Deletes an article by its SKU, refusing when it still has children. +Deletes an article by its SKU, refusing when it still has children unless cascade is true. **Parameters** @@ -93,7 +93,8 @@ Deletes an article by its SKU, refusing when it still has children. | --- | --- | --- | | `googleAuthId` | `number` | The id of the Google auth user. | | `sku` | `string` | The article SKU. | -| `[tx]` | `object` | Optional transaction to run within. | +| `[optionsOrTx]` | `object|*` | Options object ({ cascade?: boolean }) or optional transaction. | +| `[maybeTx]` | `object` | Optional transaction if options was passed. | **Returns** @@ -101,12 +102,13 @@ Deletes an article by its SKU, refusing when it still has children. **Throws** -`Error` — If the article has children or is not found. +`Error` — If the article has children (when cascade=false) or is not found. **Example** ```js await store.article.delete(42, 'SKU-1'); +await store.article.delete(42, 'SKU-1', { cascade: true }); ``` ## `article.deleteImage` @@ -353,6 +355,7 @@ Removes a child article from a father article, either by unlinking or deleting i | `childSku` | `string` | The child article SKU. | | `[options]` | `object` | | | `[options.deleteArticle=false]` | `boolean` | Whether to delete the child article entity entirely. | +| `[options.cascade=true]` | `boolean` | Whether to cascade delete child subtrees when deleteArticle is true. | | `[tx]` | `object` | Optional transaction. | **Returns** diff --git a/API.articlesupplier.md b/API.articlesupplier.md index 4b9cc2b..0a28f51 100644 --- a/API.articlesupplier.md +++ b/API.articlesupplier.md @@ -107,7 +107,7 @@ const suppliers = await store.articlesupplier.listByArticleSku(42, 'SKU-1'); *Source: `src/store/articlesupplier/listByEntityAndSkus.js`* -Lists article-supplier assignments for a given supplier (entityId) and SKU list, including linked article details (name, slug, photos/articlepic, eans/articleean). +Lists article-supplier assignments for a given supplier (entityId) and SKU list, including linked article details (name, slug, photos, eans). **Parameters** @@ -120,7 +120,7 @@ Lists article-supplier assignments for a given supplier (entityId) and SKU list, **Returns** -`Promise, articlepic: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, eans: string[], articleean: string[]}}>>` — The supplier assignments with article details. +`Promise, eans: string[]}}>>` — The supplier assignments with article details. **Throws** diff --git a/API.category.md b/API.category.md index 2182bb1..b65a48e 100644 --- a/API.category.md +++ b/API.category.md @@ -76,7 +76,7 @@ Deletes a category, refusing to delete the company's root category. **Throws** -`Error` — If the category is the root category. +`Error` — If the category is the root category or contains articles/subcategories. **Example** diff --git a/API.md b/API.md index 3fa6f20..31a684f 100644 --- a/API.md +++ b/API.md @@ -86,7 +86,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `addChild(googleAuthId: number, fatherSku: string, options: {attributeValueId?: number, value?: string, name?: string, unitamount?: number, weight?: number, track_stock?: boolean|number}, tx?: object): Promise` - `addImage(googleAuthId: number, sku: string, image: Buffer, tx?: object): Promise<{picId: number, sort: number, full: {w: number, h: number, size: number}, thumb: {w: number, h: number}, fullHash: string, thumbHash: string}>` - `createUnderParent(input: {googleAuthId: number, parentId: number, name: string, track_stock?: boolean|number}, tx?: object): Promise<{id: number, parentId: number, name: string, sku: string, track_stock: boolean}>` - - `delete(googleAuthId: number, sku: string, tx?: object): Promise` + - `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>` @@ -95,7 +95,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `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}, tx?: object): Promise<{success: boolean, fatherSku: string, childSku: string, deleted: boolean}>` + - `removeChild(googleAuthId: number, fatherSku: string, childSku: string, options?: {deleteArticle?: boolean, cascade?: boolean}, tx?: object): Promise<{success: boolean, fatherSku: string, childSku: string, deleted: boolean}>` - `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` @@ -162,7 +162,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `create(googleAuthId: number, sku: string, input: {entityId: number, sku?: string, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}, tx?: object): Promise<{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}>` - `delete(googleAuthId: number, sku: string, id: number, tx?: object): Promise` - `listByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise>` - - `listByEntityAndSkus(googleAuthId: number, entityIdOrOptions: number|{entityId?: number, supplierId?: number, skus?: string[]|string}, maybeSkus?: string[]|string, tx?: object): Promise, articlepic: Array<{sort: number, thumbhash: string, hash: string, thumbw: number, thumbh: number}>, eans: string[], articleean: string[]}}>>` + - `listByEntityAndSkus(googleAuthId: number, entityIdOrOptions: number|{entityId?: number, supplierId?: number, skus?: string[]|string}, maybeSkus?: string[]|string, tx?: object): Promise, eans: string[]}}>>` - `order(googleAuthId: number, sku: string, input: {ids: Array}, tx?: object): Promise<{ids: Array}>` - `update(googleAuthId: number, sku: string, id: number, patch: {sku?: string|null, pu?: number, stock?: number|null, sort?: number, netPrice?: number|null, taxId?: number, taxRate?: number}, tx?: object): Promise<{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}>` - [delivery](API.delivery.md) diff --git a/src/store/article/toggleFather.js b/src/store/article/toggleFather.js index 5b80a1f..79698fe 100644 --- a/src/store/article/toggleFather.js +++ b/src/store/article/toggleFather.js @@ -4,6 +4,8 @@ import { GET_CHILD_COUNT as MSSQL_GET_CHILD_COUNT } from '../../providers/mssql/ import { GET_CHILD_COUNT as SQLITE_GET_CHILD_COUNT } from '../../providers/sqlite3/queries/articleDelete.js'; import * as mssqlAttr from '../../providers/mssql/queries/attributename.js'; import * as sqliteAttr from '../../providers/sqlite3/queries/attributename.js'; +import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; +import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js'; import resolveArticleBySku from './resolveArticleBySku.js'; import getBySku from './getBySku.js'; import makeRunner from '../runner.js'; @@ -16,6 +18,7 @@ export default function toggleFather(provider) { const queries = isSqlite ? sqliteArticle : mssqlArticle; const getChildCountSql = isSqlite ? SQLITE_GET_CHILD_COUNT : MSSQL_GET_CHILD_COUNT; const attrQueries = isSqlite ? sqliteAttr : mssqlAttr; + const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY; /** * Toggles an article into or out of father mode. @@ -31,7 +34,7 @@ export default function toggleFather(provider) { */ return async (googleAuthId, sku, { isFather, attributeNameId, fatherAttributeNameId } = {}, tx) => { const run = async (runner) => { - const { articleId, companyId } = await resolve(googleAuthId, sku, runner); + const { articleId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner); const targetIsFather = Boolean(isFather); const targetAttrId = attributeNameId ?? fatherAttributeNameId; @@ -78,6 +81,18 @@ export default function toggleFather(provider) { throw new Error('An article with assigned suppliers cannot become a father article'); } + // Fetch existing prices before deletion to record in articlehistory + const existingPricesRes = await runner.query( + `SELECT pr.net, pr.minmultiplier, t.rate AS taxRate, cg.name AS customerGroupName + FROM price pr + LEFT JOIN tax t ON t.id = pr.tax_id + LEFT JOIN customergroup cg ON cg.id = pr.customergroup_id + WHERE pr.article_id = @articleId + ORDER BY pr.customergroup_id, pr.minmultiplier`, + { articleId }, + ); + const existingPrices = existingPricesRes.rows; + await runner.query('DELETE FROM price WHERE article_id = @articleId', { articleId }); await runner.query('UPDATE article SET track_stock = 0 WHERE id = @articleId', { articleId }); @@ -107,6 +122,7 @@ export default function toggleFather(provider) { if (!attrRes.rows[0]) { throw new Error('Attribute name not found'); } + const attributeName = attrRes.rows[0].name; // Verify article does not have an attribute value for this attribute const existingValRes = await runner.query( @@ -124,6 +140,35 @@ export default function toggleFather(provider) { isFather: 1, fatherAttributeNameId: Number(targetAttrId), }); + + // Add history entry recording father conversion and deleted prices + const historyLines = [ + `Artikel zu Vaterartikel konvertiert (Varianten-Attribut: "${attributeName}")`, + ]; + + if (existingPrices.length > 0) { + const priceParts = existingPrices.map((p) => { + let str = `${p.net} €`; + if (p.taxRate != null) { + str += ` (${p.taxRate}% MwSt.)`; + } + if (p.minmultiplier > 1) { + str += ` ab ${p.minmultiplier} Stk.`; + } + if (p.customerGroupName) { + str += ` [${p.customerGroupName}]`; + } + return str; + }); + const label = existingPrices.length === 1 ? 'Bisheriger Preis gelöscht' : 'Bisherige Preise gelöscht'; + historyLines.push(`${label}: ${priceParts.join(', ')}`); + } + + await runner.query(insertHistorySql, { + articleId, + companyuserId, + text: historyLines.join('\n'), + }); } else { // Disabling father: verify zero children const childCountRes = await runner.query(getChildCountSql, { articleId }); @@ -137,6 +182,12 @@ export default function toggleFather(provider) { isFather: 0, fatherAttributeNameId: null, }); + + await runner.query(insertHistorySql, { + articleId, + companyuserId, + text: 'Vaterartikel-Modus deaktiviert', + }); } return load(googleAuthId, sku, runner); diff --git a/src/store/articlelist/setItemsByArticleSku.js b/src/store/articlelist/setItemsByArticleSku.js index 847382f..8920e17 100644 --- a/src/store/articlelist/setItemsByArticleSku.js +++ b/src/store/articlelist/setItemsByArticleSku.js @@ -94,17 +94,42 @@ export default function setItemsByArticleSku(provider) { for (let i = 0; i < items.length; i++) { const item = items[i]; - let itemArticleId = item?.articleId != null ? Number(item.articleId) : null; - - if (itemArticleId == null && item?.sku) { - const res = await runner.query( - 'SELECT id FROM article WHERE sku = @itemSku AND company_id = @companyId', - { itemSku: item.sku, companyId }, - ); - itemArticleId = res.rows[0]?.id; + if (!item || typeof item !== 'object') { + throw new Error(`Item at index ${i} must be an object`); } - if (!itemArticleId || Number.isNaN(itemArticleId) || itemArticleId <= 0) { + let itemArticleId = null; + + if (item.articleId !== undefined && item.articleId !== null) { + if ( + typeof item.articleId !== 'number' || + !Number.isInteger(item.articleId) || + item.articleId <= 0 + ) { + throw new Error(`Item at index ${i} must specify a valid articleId or sku`); + } + const res = await runner.query( + 'SELECT id FROM article WHERE id = @itemArticleId AND company_id = @companyId', + { itemArticleId: item.articleId, companyId }, + ); + if (!res.rows[0]) { + throw new Error(`Article not found for component: ${item.articleId}`); + } + itemArticleId = res.rows[0].id; + } else if (item.sku !== undefined && item.sku !== null) { + if (typeof item.sku !== 'string' || !item.sku.trim()) { + throw new Error(`Item at index ${i} must specify a valid articleId or sku`); + } + const itemSku = item.sku.trim(); + const res = await runner.query( + 'SELECT id FROM article WHERE sku = @itemSku AND company_id = @companyId', + { itemSku, companyId }, + ); + if (!res.rows[0]) { + throw new Error(`Article not found for component: ${itemSku}`); + } + itemArticleId = res.rows[0].id; + } else { throw new Error(`Item at index ${i} must specify a valid articleId or sku`); } @@ -117,16 +142,7 @@ export default function setItemsByArticleSku(provider) { } seenArticleIds.add(itemArticleId); - // Verify component article exists in same company - const itemArtRes = await runner.query( - 'SELECT id FROM article WHERE id = @itemArticleId AND company_id = @companyId', - { itemArticleId, companyId }, - ); - if (!itemArtRes.rows[0]) { - throw new Error(`Article not found for component: ${itemArticleId}`); - } - - const rawCount = item?.count; + const rawCount = item?.count === undefined ? 1 : item.count; if ( typeof rawCount !== 'number' || !Number.isInteger(rawCount) || @@ -136,7 +152,13 @@ export default function setItemsByArticleSku(provider) { throw new Error(`Invalid count for Stückliste item at index ${i}: must be a positive integer`); } const count = rawCount; - let sort = item?.sort != null && Number.isInteger(Number(item.sort)) ? Number(item.sort) : i; + + if (item.sort !== undefined && item.sort !== null) { + if (typeof item.sort !== 'number' || !Number.isInteger(item.sort)) { + throw new Error(`Invalid sort for Stückliste item at index ${i}: must be an integer`); + } + } + let sort = (typeof item?.sort === 'number' && Number.isInteger(item.sort)) ? item.sort : i; while (seenSorts.has(sort)) { sort++; diff --git a/src/store/category/delete.js b/src/store/category/delete.js index 2d17907..836dd35 100644 --- a/src/store/category/delete.js +++ b/src/store/category/delete.js @@ -15,18 +15,63 @@ export default function del(provider) { * @param {number} input.id - The id of the category to delete. * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} - * @throws {Error} If the category is the root category. + * @throws {Error} If the category is the root category or contains articles/subcategories. * * @example * await store.category.delete({ googleAuthId: 42, id: 5 }); */ return async ({ googleAuthId, id }, tx) => { - const { rootCategoryId } = await access(googleAuthId, id, tx); + const run = async (runner) => { + const { rootCategoryId } = await access(googleAuthId, id, runner); - if (id === rootCategoryId) { - throw new Error('Cannot delete root category'); + if (id === rootCategoryId) { + throw new Error('Cannot delete root category'); + } + + const childRes = await runner.query( + 'SELECT id FROM category WHERE parent_id = @id', + { id }, + ); + if (childRes.rows?.length > 0) { + throw new Error('Category has subcategories'); + } + + const artRes = await runner.query( + 'SELECT article_id FROM categoryarticle WHERE category_id = @id', + { id }, + ); + if (artRes.rows?.length > 0) { + throw new Error('Category has articles'); + } + + const posRes = await runner.query( + 'SELECT posterminal_id FROM posterminalcategory WHERE category_id = @id', + { id }, + ); + if (posRes.rows?.length > 0) { + throw new Error('Category is assigned to POS terminals'); + } + + try { + await runner.query(sql, { id }); + } catch (err) { + if ( + err.message?.includes('FOREIGN KEY constraint failed') || + err.message?.includes('foreign key constraint') || + err.message?.includes('REFERENCE constraint') + ) { + throw new Error('Category has articles'); + } + throw err; + } + }; + + if (tx) { + return run(makeRunner(provider, tx)); } - - await makeRunner(provider, tx).query(sql, { id }); + if (typeof provider?.transaction === 'function') { + return provider.transaction(run); + } + return run(makeRunner(provider)); }; } diff --git a/src/store/optionslist/setOptionsByArticleSku.js b/src/store/optionslist/setOptionsByArticleSku.js index f4949f7..4b6237c 100644 --- a/src/store/optionslist/setOptionsByArticleSku.js +++ b/src/store/optionslist/setOptionsByArticleSku.js @@ -94,17 +94,42 @@ export default function setOptionsByArticleSku(provider) { for (let i = 0; i < options.length; i++) { const item = options[i]; - let itemArticleId = item?.articleId != null ? Number(item.articleId) : null; - - if (itemArticleId == null && item?.sku) { - const res = await runner.query( - 'SELECT id FROM article WHERE sku = @itemSku AND company_id = @companyId', - { itemSku: item.sku, companyId }, - ); - itemArticleId = res.rows[0]?.id; + if (!item || typeof item !== 'object') { + throw new Error(`Option at index ${i} must be an object`); } - if (!itemArticleId || Number.isNaN(itemArticleId) || itemArticleId <= 0) { + let itemArticleId = null; + + if (item.articleId !== undefined && item.articleId !== null) { + if ( + typeof item.articleId !== 'number' || + !Number.isInteger(item.articleId) || + item.articleId <= 0 + ) { + throw new Error(`Option at index ${i} must specify a valid articleId or sku`); + } + const res = await runner.query( + 'SELECT id FROM article WHERE id = @itemArticleId AND company_id = @companyId', + { itemArticleId: item.articleId, companyId }, + ); + if (!res.rows[0]) { + throw new Error(`Article not found for option: ${item.articleId}`); + } + itemArticleId = res.rows[0].id; + } else if (item.sku !== undefined && item.sku !== null) { + if (typeof item.sku !== 'string' || !item.sku.trim()) { + throw new Error(`Option at index ${i} must specify a valid articleId or sku`); + } + const itemSku = item.sku.trim(); + const res = await runner.query( + 'SELECT id FROM article WHERE sku = @itemSku AND company_id = @companyId', + { itemSku, companyId }, + ); + if (!res.rows[0]) { + throw new Error(`Article not found for option: ${itemSku}`); + } + itemArticleId = res.rows[0].id; + } else { throw new Error(`Option at index ${i} must specify a valid articleId or sku`); } @@ -117,16 +142,7 @@ export default function setOptionsByArticleSku(provider) { } seenArticleIds.add(itemArticleId); - // Verify component article exists in same company - const itemArtRes = await runner.query( - 'SELECT id FROM article WHERE id = @itemArticleId AND company_id = @companyId', - { itemArticleId, companyId }, - ); - if (!itemArtRes.rows[0]) { - throw new Error(`Article not found for option: ${itemArticleId}`); - } - - const rawCount = item?.count; + const rawCount = item?.count === undefined ? 1 : item.count; if ( typeof rawCount !== 'number' || !Number.isInteger(rawCount) || @@ -136,13 +152,22 @@ export default function setOptionsByArticleSku(provider) { throw new Error(`Invalid count for options item at index ${i}: must be a positive integer`); } const count = rawCount; - let sort = item?.sort != null && Number.isInteger(Number(item.sort)) ? Number(item.sort) : i; + + if (item.sort !== undefined && item.sort !== null) { + if (typeof item.sort !== 'number' || !Number.isInteger(item.sort)) { + throw new Error(`Invalid sort for options item at index ${i}: must be an integer`); + } + } + let sort = (typeof item?.sort === 'number' && Number.isInteger(item.sort)) ? item.sort : i; while (seenSorts.has(sort)) { sort++; } seenSorts.add(sort); + if (item.mandated !== undefined && item.mandated !== null && typeof item.mandated !== 'boolean') { + throw new Error(`Invalid mandated for options item at index ${i}: must be a boolean`); + } const mandated = Boolean(item?.mandated); validated.push({