From 919713fa53ed411d2e222d2c2bf8994d9c17f885 Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 23 Oct 2026 01:38:08 +0200 Subject: [PATCH] u --- scratch/test-variants.js | 164 +++++++++++++++++++ src/providers/mssql/queries/article.js | 3 +- src/providers/mssql/queries/posterminal.js | 29 +++- src/providers/sqlite3/queries/posterminal.js | 16 +- src/store/article/addChild.js | 3 + src/store/article/removeChild.js | 6 + src/store/posterminal/getProductList.js | 48 +++++- 7 files changed, 250 insertions(+), 19 deletions(-) create mode 100644 scratch/test-variants.js diff --git a/scratch/test-variants.js b/scratch/test-variants.js new file mode 100644 index 0000000..e426584 --- /dev/null +++ b/scratch/test-variants.js @@ -0,0 +1,164 @@ +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 POS father/child variants and options ---'); + + 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('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); + + // 1. Create a category + const cat = await store.category.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Kleidung', + }); + + // 2. Create terminal and enable category + const terminal = await store.posterminal.upsert(googleAuthId, { + name: 'POS 1', + pairingpin: '1234', + }); + await store.posterminal.pair(terminal.id, { + token: 'pos-token-xyz', + clientserial: 'SN-001', + pairedAt: new Date().toISOString(), + }); + await store.posterminal.setOn(googleAuthId, terminal.id, cat.id); + + // 3. Create attribute name "Farbe" + const attrName = await store.attributename.create(googleAuthId, { + name: 'Farbe', + sort: 1, + exclusive: true, + }); + + // 4. Create base article and convert to parent + const tShirt = await store.article.createUnderParent({ + googleAuthId, + parentId: cat.id, + name: 'T-Shirt Basic', + sku: 'TSHIRT', + }); + await store.article.toggleParent(googleAuthId, tShirt.sku, { + isParent: true, + attributeNameId: attrName.id, + }); + + // 5. Add children "Rot" and "Blau" + console.log('Adding children "Rot" and "Blau"...'); + const childRot = await store.article.addChild(googleAuthId, tShirt.sku, { + value: 'Rot', + name: 'T-Shirt Basic - Rot', + }); + const childBlau = await store.article.addChild(googleAuthId, tShirt.sku, { + value: 'Blau', + name: 'T-Shirt Basic - Blau', + }); + + // 6. Query POS products + let prods = await store.posterminal.getProductList({ posterminalId: terminal.id }); + let parentProd = prods.find((p) => p.sku === 'TSHIRT'); + let rotProd = prods.find((p) => p.sku === childRot.article.sku); + let blauProd = prods.find((p) => p.sku === childBlau.article.sku); + + console.log('Parent product:', { + sku: parentProd.sku, + is_parent: parentProd.is_parent, + parent: parentProd.parent, + variants: parentProd.variants, + options: parentProd.options, + }); + + assert.equal(parentProd.is_parent, '1'); + assert.equal(parentProd.parent, '0'); + assert.equal(parentProd.variants, ''); + assert.deepEqual(parentProd.options, [{ name: 'Farbe', value: 'Rot,Blau' }]); + + console.log('Child Rot:', { + sku: rotProd.sku, + is_parent: rotProd.is_parent, + parent: rotProd.parent, + variants: rotProd.variants, + sort: rotProd.sort, + options: rotProd.options, + }); + + assert.equal(rotProd.is_parent, '0'); + assert.equal(rotProd.parent, String(parentProd._id)); + assert.equal(rotProd.variants, 'Rot'); + assert.equal(rotProd.sort, '0'); + assert.equal(rotProd.options, null); + + console.log('Child Blau:', { + sku: blauProd.sku, + is_parent: blauProd.is_parent, + parent: blauProd.parent, + variants: blauProd.variants, + sort: blauProd.sort, + options: blauProd.options, + }); + + assert.equal(blauProd.is_parent, '0'); + assert.equal(blauProd.parent, String(parentProd._id)); + assert.equal(blauProd.variants, 'Blau'); + assert.equal(blauProd.sort, '1'); + assert.equal(blauProd.options, null); + + // 7. Add third child "Grün" and verify options list update + console.log('Adding third child "Grün"...'); + const childGruen = await store.article.addChild(googleAuthId, tShirt.sku, { + value: 'Grün', + name: 'T-Shirt Basic - Grün', + }); + + prods = await store.posterminal.getProductList({ posterminalId: terminal.id }); + parentProd = prods.find((p) => p.sku === 'TSHIRT'); + assert.deepEqual(parentProd.options, [{ name: 'Farbe', value: 'Rot,Blau,Grün' }]); + console.log('Updated parent options:', parentProd.options); + + // 8. Remove child "Blau" and verify options list update + console.log('Removing child "Blau"...'); + await store.article.removeChild(googleAuthId, tShirt.sku, childBlau.article.sku, { deleteArticle: true }); + + prods = await store.posterminal.getProductList({ posterminalId: terminal.id }); + parentProd = prods.find((p) => p.sku === 'TSHIRT'); + assert.deepEqual(parentProd.options, [{ name: 'Farbe', value: 'Rot,Grün' }]); + console.log('Parent options after removal:', parentProd.options); + + console.log('✅ ALL FATHER/CHILD VARIANT 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/providers/mssql/queries/article.js b/src/providers/mssql/queries/article.js index 09a142b..31b5649 100644 --- a/src/providers/mssql/queries/article.js +++ b/src/providers/mssql/queries/article.js @@ -117,7 +117,8 @@ WHERE id = @articleId export const TOGGLE_PARENT = ` UPDATE article SET is_parent = @isParent, - parentattributename_id = @parentAttributeNameId + parentattributename_id = @parentAttributeNameId, + updated_at = SYSUTCDATETIME() WHERE id = @articleId `; diff --git a/src/providers/mssql/queries/posterminal.js b/src/providers/mssql/queries/posterminal.js index a17ef7d..0b02293 100644 --- a/src/providers/mssql/queries/posterminal.js +++ b/src/providers/mssql/queries/posterminal.js @@ -288,13 +288,15 @@ SELECT TOP (@limit) ) AS barcode, a.is_parent AS isParent, a.parentarticle_id AS parentArticleId, + a.parentsort AS parentsort, ( - SELECT TOP 1 CONCAT(fan.name, '|', av.value, '|', fan.sort, '|', a.parentsort) - FROM article fa - INNER JOIN attributename fan ON fan.id = fa.parentattributename_id - INNER JOIN articleattributevalue aav ON aav.article_id = a.id - INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = fan.id - WHERE a.parentarticle_id IS NOT NULL AND fa.id = a.parentarticle_id + SELECT TOP 1 av.value + FROM articleattributevalue aav + INNER JOIN attributevalue av ON av.id = aav.attributevalue_id + LEFT JOIN article fa ON fa.id = a.parentarticle_id + WHERE aav.article_id = a.id + AND (fa.parentattributename_id IS NULL OR av.attribute_id = fa.parentattributename_id) + ORDER BY CASE WHEN av.attribute_id = fa.parentattributename_id THEN 0 ELSE 1 END, aav.sort ASC, aav.id ASC ) AS variantName, CASE WHEN a.articlelist_id IS NOT NULL AND EXISTS ( @@ -502,6 +504,21 @@ LEFT JOIN tax opt_ctax ON opt_ctax.id = co.taxrate ORDER BY ola.sort ASC, ola.id ASC `; +export const LIST_POS_PARENT_OPTIONS = ` +SELECT + fa.id AS parentArticleId, + COALESCE(fan.name, 'Variante') AS attributeName, + av.value AS variantValue +FROM article fa +INNER JOIN posterminal pt ON pt.id = @posterminalId AND pt.company_id = fa.company_id +INNER JOIN article child ON child.parentarticle_id = fa.id +LEFT JOIN attributename fan ON fan.id = fa.parentattributename_id +LEFT JOIN articleattributevalue aav ON aav.article_id = child.id +LEFT JOIN attributevalue av ON av.id = aav.attributevalue_id AND (fa.parentattributename_id IS NULL OR av.attribute_id = fa.parentattributename_id) +WHERE fa.is_parent = 1 +ORDER BY fa.id ASC, child.parentsort ASC, child.id ASC +`; + export const RECORD_OBITUARY = ` INSERT INTO posterminalobituary (posterminal_id, entity_type, entity_id) SELECT pt.id, @entityType, @entityId diff --git a/src/providers/sqlite3/queries/posterminal.js b/src/providers/sqlite3/queries/posterminal.js index e0acc1b..bafc7f4 100644 --- a/src/providers/sqlite3/queries/posterminal.js +++ b/src/providers/sqlite3/queries/posterminal.js @@ -9,7 +9,7 @@ export const { 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, - LIST_POS_MANDATED_OPTIONS, + LIST_POS_MANDATED_OPTIONS, LIST_POS_PARENT_OPTIONS, TOUCH_LASTSEEN, RECORD_OBITUARY, COUNT_DELETED_ENTITIES, } = queries; @@ -176,13 +176,15 @@ SELECT ) AS barcode, a.is_parent AS isParent, a.parentarticle_id AS parentArticleId, + a.parentsort AS parentsort, ( - SELECT fan.name || '|' || av.value || '|' || fan.sort || '|' || a.parentsort - FROM article fa - INNER JOIN attributename fan ON fan.id = fa.parentattributename_id - INNER JOIN articleattributevalue aav ON aav.article_id = a.id - INNER JOIN attributevalue av ON av.id = aav.attributevalue_id AND av.attribute_id = fan.id - WHERE a.parentarticle_id IS NOT NULL AND fa.id = a.parentarticle_id + SELECT av.value + FROM articleattributevalue aav + INNER JOIN attributevalue av ON av.id = aav.attributevalue_id + LEFT JOIN article fa ON fa.id = a.parentarticle_id + WHERE aav.article_id = a.id + AND (fa.parentattributename_id IS NULL OR av.attribute_id = fa.parentattributename_id) + ORDER BY CASE WHEN av.attribute_id = fa.parentattributename_id THEN 0 ELSE 1 END, aav.sort ASC, aav.id ASC LIMIT 1 ) AS variantName, CASE diff --git a/src/store/article/addChild.js b/src/store/article/addChild.js index 54fd21a..0d8b585 100644 --- a/src/store/article/addChild.js +++ b/src/store/article/addChild.js @@ -271,6 +271,9 @@ export default function addChild(provider) { await initArticleWarehouseSummary(runner, { companyId, articleId: childId }); } + // Touch parent article updated_at so POS and other consumers refresh parent options + await runner.query(queries.TOUCH_UPDATED_AT, { articleId: parentId }); + return load(googleAuthId, childSku, runner); }; diff --git a/src/store/article/removeChild.js b/src/store/article/removeChild.js index 40bf5b9..fcc2e7f 100644 --- a/src/store/article/removeChild.js +++ b/src/store/article/removeChild.js @@ -86,6 +86,9 @@ export default function removeChild(provider) { childSku, }); + // Touch parent article updated_at so POS and other consumers refresh parent options + await runner.query(queries.TOUCH_UPDATED_AT, { articleId: parentId }); + return { success: true, parentSku, @@ -131,6 +134,9 @@ export default function removeChild(provider) { parentSku, }); + // Touch parent article updated_at so POS and other consumers refresh parent options + await runner.query(queries.TOUCH_UPDATED_AT, { articleId: parentId }); + return { success: true, parentSku, diff --git a/src/store/posterminal/getProductList.js b/src/store/posterminal/getProductList.js index 64c2c83..74de1fb 100644 --- a/src/store/posterminal/getProductList.js +++ b/src/store/posterminal/getProductList.js @@ -4,6 +4,7 @@ import { LIST_POS_CUSTOMER_GROUP_IDS as MSSQL_CG_IDS, LIST_POS_PRODUCT_PRICES as MSSQL_PRICES, LIST_POS_MANDATED_OPTIONS as MSSQL_MANDATED_OPTIONS, + LIST_POS_PARENT_OPTIONS as MSSQL_PARENT_OPTIONS, } from '../../providers/mssql/queries/posterminal.js'; import { LIST_POS_PRODUCTS as SQLITE_PRODUCTS, @@ -11,6 +12,7 @@ import { LIST_POS_CUSTOMER_GROUP_IDS as SQLITE_CG_IDS, LIST_POS_PRODUCT_PRICES as SQLITE_PRICES, LIST_POS_MANDATED_OPTIONS as SQLITE_MANDATED_OPTIONS, + LIST_POS_PARENT_OPTIONS as SQLITE_PARENT_OPTIONS, } from '../../providers/sqlite3/queries/posterminal.js'; import makeRunner from '../runner.js'; @@ -39,6 +41,7 @@ export default function getProductList(provider) { const cgIdsSql = isSqlite ? SQLITE_CG_IDS : MSSQL_CG_IDS; const pricesSql = isSqlite ? SQLITE_PRICES : MSSQL_PRICES; const mandatedOptionsSql = isSqlite ? SQLITE_MANDATED_OPTIONS : MSSQL_MANDATED_OPTIONS; + const parentOptionsSql = isSqlite ? SQLITE_PARENT_OPTIONS : MSSQL_PARENT_OPTIONS; /** * Lists products for a POS terminal modified after the cursor. @@ -55,7 +58,7 @@ export default function getProductList(provider) { const runner = makeRunner(provider, tx); const pid = Number(posterminalId); - const [prodRes, catRes, cgRes, priceRes, optRes] = await Promise.all([ + const [prodRes, catRes, cgRes, priceRes, optRes, parentOptRes] = await Promise.all([ runner.query(productsSql, { posterminalId: pid, cursor: normalizeCursor(cursor), @@ -65,6 +68,7 @@ export default function getProductList(provider) { runner.query(cgIdsSql, { posterminalId: pid }), runner.query(pricesSql, { posterminalId: pid }), runner.query(mandatedOptionsSql, { posterminalId: pid }), + runner.query(parentOptionsSql, { posterminalId: pid }), ]); const categoriesByArticle = new Map(); @@ -101,6 +105,21 @@ export default function getProductList(provider) { } } + const parentOptionsMap = new Map(); + for (const row of parentOptRes.rows) { + if (!row.variantValue) continue; + if (!parentOptionsMap.has(row.parentArticleId)) { + parentOptionsMap.set(row.parentArticleId, { + name: row.attributeName || 'Variante', + values: [], + }); + } + const opt = parentOptionsMap.get(row.parentArticleId); + if (!opt.values.includes(row.variantValue)) { + opt.values.push(row.variantValue); + } + } + const customerGroupIds = cgRes.rows.map((row) => row.id); return prodRes.rows.map((product) => { @@ -127,6 +146,24 @@ export default function getProductList(provider) { const depositName = mandatedOpt ? mandatedOpt.names.join(' + ') : ''; const depositPrice = mandatedOpt ? mandatedOpt.totalPrice.toFixed(2) : '0.0'; + const isParent = Boolean(product.isParent); + const parentArticleId = Number(product.parentArticleId || 0); + const isChild = parentArticleId > 0; + + const parentOpt = parentOptionsMap.get(product.id); + const options = isParent && parentOpt && parentOpt.values.length > 0 + ? [ + { + name: parentOpt.name, + value: parentOpt.values.join(','), + }, + ] + : null; + + const sortValue = isChild + ? String(product.parentsort ?? 0) + : String(product.sort ?? 0); + return { _id: String(product.id), imghash: product.imgHash ?? null, @@ -138,15 +175,16 @@ export default function getProductList(provider) { price: basePrice, created_at: formatDateTime(product.createdAt), lastChanged: String(product.lastChanged), - sort: String(product.sort ?? 0), + sort: sortValue, categories_id: categoryIds[0] ?? '0', categories: categoryIds.map((categoryId) => ({ categoryId })), prices, - is_parent: product.isParent ? '1' : '0', - parent: product.parentArticleId > 0 ? String(product.parentArticleId) : '0', - variants: product.variantName ?? '', + is_parent: isParent ? '1' : '0', + parent: isChild ? String(parentArticleId) : '0', + variants: isChild ? (product.variantName ?? '') : '', isCompositeProduct: product.isCompositeProduct, attributes: [], + options, use_stock: product.trackStock ? '1' : '0', quantity: String(product.fBestand ?? 0), deposit: hasDeposit ? '1' : '0',