u
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<object>} 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);
|
||||
|
||||
|
||||
321
src/store/article/attachChild.js
Normal file
321
src/store/article/attachChild.js
Normal file
@@ -0,0 +1,321 @@
|
||||
import * as mssqlArticle from '../../providers/mssql/queries/article.js';
|
||||
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
|
||||
import * as mssqlAttrVal from '../../providers/mssql/queries/attributevalue.js';
|
||||
import * as sqliteAttrVal from '../../providers/sqlite3/queries/attributevalue.js';
|
||||
import * as mssqlArticleAttrVal from '../../providers/mssql/queries/articleattributevalue.js';
|
||||
import * as sqliteArticleAttrVal from '../../providers/sqlite3/queries/articleattributevalue.js';
|
||||
import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js';
|
||||
import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js';
|
||||
import { INSERT as MSSQL_INSERT_HISTORY } from '../../providers/mssql/queries/articlehistory.js';
|
||||
import { INSERT as SQLITE_INSERT_HISTORY } from '../../providers/sqlite3/queries/articlehistory.js';
|
||||
import resolveArticleBySku from './resolveArticleBySku.js';
|
||||
import getBySku from './getBySku.js';
|
||||
import listChildren from './listChildren.js';
|
||||
import makeRunner from '../runner.js';
|
||||
|
||||
export default function attachChild(provider) {
|
||||
const resolve = resolveArticleBySku(provider);
|
||||
const load = getBySku(provider);
|
||||
const list = listChildren(provider);
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
|
||||
const queries = isSqlite ? sqliteArticle : mssqlArticle;
|
||||
const attrValQueries = isSqlite ? sqliteAttrVal : mssqlAttrVal;
|
||||
const articleAttrValQueries = isSqlite ? sqliteArticleAttrVal : mssqlArticleAttrVal;
|
||||
const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE;
|
||||
const insertHistorySql = isSqlite ? SQLITE_INSERT_HISTORY : MSSQL_INSERT_HISTORY;
|
||||
|
||||
/**
|
||||
* Attaches an existing article as a child to a 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<object>}>}
|
||||
*/
|
||||
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));
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user