diff --git a/scripts/test-variant-flow.js b/scripts/test-variant-flow.js index b71ed8f..c69774e 100644 --- a/scripts/test-variant-flow.js +++ b/scripts/test-variant-flow.js @@ -116,6 +116,36 @@ async function run() { // Fathersort should have picked 0 (next free non-negative integer) assert.deepEqual(list.map((c) => c.fathersort), [0, 1]); + console.log('6. Testing rejecting sku when creating via addChild...'); + await assert.rejects( + async () => { + await store.article.addChild(googleAuthId, father.sku, { + sku: 'SOME-CUSTOM-SKU', + value: 'Lila', + }); + }, + (err) => { + assert.match(err.message, /sku is not allowed when creating a child article/); + return true; + }, + 'Expected sku on addChild to be rejected' + ); + + console.log('7. Testing attaching existing orphan article (SKU-2) via attachChild...'); + const reconnected = await store.article.attachChild(googleAuthId, father.sku, { + sku: child2.article.sku, + value: 'Blau', + }); + assert.equal(reconnected.article.sku, child2.article.sku); + assert.equal(reconnected.article.fatherArticleId, father.id); + + console.log('8. Testing idempotent attachChild on same father...'); + const idempotentResult = await store.article.attachChild(googleAuthId, father.sku, { + sku: child2.article.sku, + value: 'Blau', + }); + assert.equal(idempotentResult.article.sku, child2.article.sku); + console.log('✅ ALL VARIANT FLOW TESTS PASSED'); } finally { try { diff --git a/src/index.js b/src/index.js index 11dfc92..601146e 100644 --- a/src/index.js +++ b/src/index.js @@ -35,6 +35,7 @@ import articleSetCategoryIdsBySku from './store/article/setCategoryIdsBySku.js'; import articleSetFatherBySku from './store/article/setFatherBySku.js'; import articleToggleFather from './store/article/toggleFather.js'; import articleAddChild from './store/article/addChild.js'; +import articleAttachChild from './store/article/attachChild.js'; import articleRemoveChild from './store/article/removeChild.js'; import articleListChildren from './store/article/listChildren.js'; import articleImportRows from './store/article/importRows.js'; @@ -269,6 +270,7 @@ export default class LibDashStore { setFatherBySku: articleSetFatherBySku(provider), toggleFather: articleToggleFather(provider), addChild: articleAddChild(provider), + attachChild: articleAttachChild(provider), removeChild: articleRemoveChild(provider), listChildren: articleListChildren(provider), getArticlelist: articlelistGetItems(provider), diff --git a/src/store/article/addChild.js b/src/store/article/addChild.js index 1a16f46..8d592c6 100644 --- a/src/store/article/addChild.js +++ b/src/store/article/addChild.js @@ -16,11 +16,13 @@ import resolveArticleBySku from './resolveArticleBySku.js'; import getBySku from './getBySku.js'; import makeRunner from '../runner.js'; import resolveUniqueSlug from './resolveUniqueSlug.js'; +import attachChild from './attachChild.js'; export default function addChild(provider) { const resolve = resolveArticleBySku(provider); const load = getBySku(provider); const resolveSlug = resolveUniqueSlug(provider); + const attach = attachChild(provider); const isSqlite = provider?.dialect === 'sqlite3'; const queries = isSqlite ? sqliteArticle : mssqlArticle; @@ -32,11 +34,12 @@ export default function addChild(provider) { const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY; /** - * Adds a child article to a father article. + * Adds a child article to a father article (creates and attaches with auto-generated SKU). * * @param {number} googleAuthId - The id of the Google auth user. * @param {string} fatherSku - The father article SKU. * @param {object} options + * @param {boolean} [options.attach=false] - If true, attaches an existing article instead of creating. * @param {number} [options.attributeValueId] - Existing attribute value ID for father's attribute. * @param {string} [options.value] - String value to find or create under father's attribute. * @param {string} [options.name] - Custom name for child article (defaults to father name + value). @@ -47,7 +50,15 @@ export default function addChild(provider) { * @returns {Promise} The created child article detail. */ return async (googleAuthId, fatherSku, options = {}, tx) => { + if (options.attach) { + return attach(googleAuthId, fatherSku, options, tx); + } + const run = async (runner) => { + if (options.sku || options.childSku) { + throw new Error('sku is not allowed when creating a child article; omit sku to auto-generate or use PUT to attach an existing article'); + } + const { articleId: fatherId, companyId, languageId, companyuserId } = await resolve( googleAuthId, fatherSku, @@ -131,40 +142,26 @@ export default function addChild(provider) { throw new Error('Variant with this attribute value already exists'); } - // Compute child SKU: - // If options.sku is provided, use it. - // Otherwise, derive using next free index: fatherSku-1, fatherSku-2, ... - let childSku = (typeof options.sku === 'string' && options.sku.trim()) || null; - if (!childSku) { - const existingSkusRes = await runner.query( - 'SELECT sku FROM article WHERE company_id = @companyId AND sku LIKE @prefix', - { companyId, prefix: `${father.sku}-%` }, - ); - const usedIndices = new Set(); - const prefix = `${father.sku}-`; - for (const row of existingSkusRes.rows) { - if (row.sku && row.sku.startsWith(prefix)) { - const numPart = row.sku.slice(prefix.length); - if (/^\d+$/.test(numPart)) { - usedIndices.add(parseInt(numPart, 10)); - } + // Compute child SKU using next free index: fatherSku-1, fatherSku-2, ... + 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 }, - ); - if (existingChildRes.rows[0]) { - throw new Error(`Child article "${childSku}" already exists`); + let nextIndex = 1; + while (usedIndices.has(nextIndex)) { + nextIndex++; } + const childSku = `${father.sku}-${nextIndex}`; // Next free fathersort (smallest non-negative integer not used by any child of this father) const existingSortsRes = await runner.query( @@ -177,7 +174,7 @@ export default function addChild(provider) { fathersort++; } - // Create child article + // Auto-create child article const rawTrack = options.track_stock ?? options.trackStock; const finalTrackStock = rawTrack == null ? father.track_stock : (rawTrack ? 1 : 0); diff --git a/src/store/article/attachChild.js b/src/store/article/attachChild.js new file mode 100644 index 0000000..2968b31 --- /dev/null +++ b/src/store/article/attachChild.js @@ -0,0 +1,321 @@ +import * as mssqlArticle from '../../providers/mssql/queries/article.js'; +import * as sqliteArticle from '../../providers/sqlite3/queries/article.js'; +import * as mssqlAttrVal from '../../providers/mssql/queries/attributevalue.js'; +import * as sqliteAttrVal from '../../providers/sqlite3/queries/attributevalue.js'; +import * as mssqlArticleAttrVal from '../../providers/mssql/queries/articleattributevalue.js'; +import * as sqliteArticleAttrVal from '../../providers/sqlite3/queries/articleattributevalue.js'; +import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js'; +import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js'; +import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js'; +import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js'; +import resolveArticleBySku from './resolveArticleBySku.js'; +import getBySku from './getBySku.js'; +import listChildren from './listChildren.js'; +import makeRunner from '../runner.js'; + +export default function attachChild(provider) { + const resolve = resolveArticleBySku(provider); + const load = getBySku(provider); + const list = listChildren(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const queries = isSqlite ? sqliteArticle : mssqlArticle; + const attrValQueries = isSqlite ? sqliteAttrVal : mssqlAttrVal; + const articleAttrValQueries = isSqlite ? sqliteArticleAttrVal : mssqlArticleAttrVal; + const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE; + const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY; + + /** + * Attaches an existing article as a child to a father article. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {string} fatherSku - The father article SKU. + * @param {object} options + * @param {string} options.sku - The child article SKU to attach. + * @param {string} [options.childSku] - Alias for options.sku. + * @param {number} [options.attributeValueId] - Existing attribute value ID for father's attribute. + * @param {string} [options.value] - String value to find or create under father's attribute. + * @param {boolean} [options.reparent=false] - Whether to allow re-parenting if child belongs to another father. + * @param {number} [options.unitamount] - Optional unitamount override. + * @param {number} [options.weight] - Optional weight override. + * @param {boolean|number} [options.track_stock] - Optional track_stock override. + * @param {object} [tx] - Optional transaction. + * @returns {Promise<{article: object, children: Array}>} + */ + return async (googleAuthId, fatherSku, options = {}, tx) => { + const run = async (runner) => { + const childSku = (typeof options.sku === 'string' && options.sku.trim()) + || (typeof options.childSku === 'string' && options.childSku.trim()) + || null; + + if (!childSku) { + throw new Error('sku is required to attach an existing article'); + } + + const { articleId: fatherId, companyId, languageId, companyuserId } = await resolve( + googleAuthId, + fatherSku, + runner, + ); + + // Fetch father article fields + const fatherRes = await runner.query( + 'SELECT id, sku, company_id, is_father, fatherattributename_id FROM article WHERE id = @fatherId', + { fatherId }, + ); + const father = fatherRes.rows[0]; + if (!father) { + throw new Error('Father article not found'); + } + if (!father.is_father || !father.fatherattributename_id) { + throw new Error('Article is not a father article'); + } + + // Check child article exists in company + const childRes = await runner.query( + 'SELECT id, sku, company_id, fatherarticle_id, is_father FROM article WHERE sku = @childSku AND company_id = @companyId', + { childSku, companyId }, + ); + const child = childRes.rows[0]; + if (!child) { + throw new Error('Article not found'); + } + if (child.id === fatherId) { + throw new Error('An article cannot be a child of itself'); + } + + // Determine attribute value + let finalValueId = null; + let finalValue = null; + + if (options.attributeValueId != null) { + if (typeof options.attributeValueId !== 'number' || !Number.isInteger(options.attributeValueId) || options.attributeValueId <= 0) { + throw new Error('Invalid attributeValueId'); + } + const valRes = await runner.query(attrValQueries.GET_BY_ID_AND_COMPANY, { + id: options.attributeValueId, + companyId, + }); + const row = valRes.rows[0]; + if (!row || row.attributeId !== father.fatherattributename_id) { + throw new Error('Attribute value not found or does not match father attribute'); + } + finalValueId = row.id; + finalValue = row.value; + } else if (typeof options.value === 'string' && options.value.trim()) { + const valStr = options.value.trim(); + const existingValRes = await runner.query(attrValQueries.GET_BY_ATTRIBUTE_AND_VALUE, { + attributeId: father.fatherattributename_id, + value: valStr, + }); + if (existingValRes.rows[0]) { + finalValueId = existingValRes.rows[0].id; + finalValue = existingValRes.rows[0].value; + } else { + const sortRes = await runner.query(attrValQueries.GET_NEXT_SORT, { + attributeId: father.fatherattributename_id, + }); + const sort = sortRes.rows[0]?.sort ?? 0; + const createdValRes = await runner.query(attrValQueries.CREATE, { + attributeId: father.fatherattributename_id, + value: valStr, + sort, + }); + finalValueId = createdValRes.rows[0]?.id ?? createdValRes.insertId; + finalValue = valStr; + } + } else { + throw new Error('Attribute value is required for child article'); + } + + const isAlreadyChildOfThisFather = child.fatherarticle_id === fatherId; + + if (child.fatherarticle_id != null && !isAlreadyChildOfThisFather && !options.reparent) { + throw new Error('Article already belongs to another father'); + } + + // Check if father already has a different child with this attribute value + const existingVariantRes = await runner.query( + `SELECT a.id + FROM article a + INNER JOIN articleattributevalue aav ON aav.article_id = a.id + WHERE a.fatherarticle_id = @fatherId + AND a.id != @childId + AND aav.attributevalue_id = @finalValueId`, + { fatherId, childId: child.id, finalValueId }, + ); + if (existingVariantRes.rows.length > 0) { + throw new Error('Variant with this attribute value already exists'); + } + + // If re-parenting from another father, unparent and recompact previous father + if (child.fatherarticle_id != null && !isAlreadyChildOfThisFather && options.reparent) { + const prevFatherId = child.fatherarticle_id; + await runner.query(insertHistorySql, { + articleId: prevFatherId, + companyuserId, + text: `Variante (${childSku}) getrennt (umgehängt zu "${father.sku}")`, + }); + + // Recompact previous father's children + const prevRemainingRes = await runner.query( + 'SELECT id, fathersort FROM article WHERE fatherarticle_id = @prevFatherId AND id != @childId ORDER BY fathersort ASC, id ASC', + { prevFatherId, childId: child.id }, + ); + for (let i = 0; i < prevRemainingRes.rows.length; i++) { + const row = prevRemainingRes.rows[i]; + if (Number(row.fathersort) !== i) { + await runner.query(queries.SET_FATHER_SORT, { + articleId: row.id, + fatherArticleId: prevFatherId, + fathersort: i, + }); + } + } + } + + // Assign fathersort if not already child of this father + let fathersort; + if (isAlreadyChildOfThisFather) { + const currentSortRes = await runner.query( + 'SELECT fathersort FROM article WHERE id = @childId', + { childId: child.id }, + ); + fathersort = Number(currentSortRes.rows[0]?.fathersort ?? 0); + } else { + 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))); + fathersort = 0; + while (usedSorts.has(fathersort)) { + fathersort++; + } + } + + // Link child to father + await runner.query(queries.UPDATE_FATHER_BY_SKU, { + sku: childSku, + companyId, + fatherArticleId: fatherId, + fathersort, + }); + + // Apply optional field overrides + if (options.unitamount != null) { + await runner.query('UPDATE article SET unitamount = @unitamount WHERE id = @childId', { + childId: child.id, + unitamount: options.unitamount, + }); + } + if (options.weight != null) { + await runner.query(queries.UPDATE_WEIGHT, { + articleId: child.id, + weight: options.weight, + }); + } + const rawTrack = options.track_stock ?? options.trackStock; + if (rawTrack != null) { + await runner.query(queries.UPDATE_TRACK_STOCK, { + articleId: child.id, + trackStock: rawTrack ? 1 : 0, + }); + } + + // Remove existing attribute value for this father's attribute on child (if any) + if (father.fatherattributename_id) { + await runner.query( + `DELETE FROM articleattributevalue + WHERE article_id = @childId + AND attributevalue_id IN ( + SELECT id FROM attributevalue WHERE attribute_id = @attributeId + )`, + { childId: child.id, attributeId: father.fatherattributename_id }, + ); + } + await runner.query(articleAttrValQueries.INSERT_ROW, { + articleId: child.id, + attributeValueId: finalValueId, + sort: 0, + }); + + // Copy ancestor attributes from father (if missing on child) + const fatherAttrsRes = await runner.query( + 'SELECT attributevalue_id, sort FROM articleattributevalue WHERE article_id = @fatherId', + { fatherId }, + ); + for (const faRow of fatherAttrsRes.rows) { + if (faRow.attributevalue_id !== finalValueId) { + const hasAttrRes = await runner.query( + 'SELECT 1 FROM articleattributevalue WHERE article_id = @childId AND attributevalue_id = @attrValId', + { childId: child.id, attrValId: faRow.attributevalue_id }, + ); + if (hasAttrRes.rows.length === 0) { + await runner.query(articleAttrValQueries.INSERT_ROW, { + articleId: child.id, + attributeValueId: faRow.attributevalue_id, + sort: faRow.sort, + }); + } + } + } + + // Copy father's categories (if missing on child) + const catRes = await runner.query( + 'SELECT category_id FROM categoryarticle WHERE article_id = @fatherId', + { fatherId }, + ); + for (const catRow of catRes.rows) { + const hasCatRes = await runner.query( + 'SELECT 1 FROM categoryarticle WHERE article_id = @childId AND category_id = @categoryId', + { childId: child.id, categoryId: catRow.category_id }, + ); + if (hasCatRes.rows.length === 0) { + await runner.query(addCategoryArticleSql, { + categoryId: catRow.category_id, + articleId: child.id, + }); + } + } + + // Fetch child name for history + const childTextRes = await runner.query( + 'SELECT name FROM articletext WHERE article_id = @childId AND language_id = @languageId', + { childId: child.id, languageId }, + ); + const childName = childTextRes.rows[0]?.name || childSku; + + // History only if not already child + if (!isAlreadyChildOfThisFather) { + await runner.query(insertHistorySql, { + articleId: child.id, + companyuserId, + text: `Artikel "${childName}" (${childSku}) als Variante von "${father.sku}" zugewiesen`, + }); + + await runner.query(insertHistorySql, { + articleId: fatherId, + companyuserId, + text: `Variante "${childName}" (${childSku}) hinzugefügt`, + }); + } + + const updated = await load(googleAuthId, childSku, runner); + const children = await list(googleAuthId, fatherSku, runner); + + return { + article: updated.article, + children, + }; + }; + + if (tx) { + return run(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(run); + } + return run(makeRunner(provider)); + }; +}