diff --git a/scratch/test-deposit-options.js b/scratch/test-deposit-options.js new file mode 100644 index 0000000..ba0cfa2 --- /dev/null +++ b/scratch/test-deposit-options.js @@ -0,0 +1,179 @@ +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 deposit via mandated optionslist ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-deposit-')); + 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: 'Deposit 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: 'Getränke', + }); + + // 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 beverage article + const clubMate = await store.article.createUnderParent({ + googleAuthId, + parentId: cat.id, + name: 'Club Mate 0.5l', + sku: 'MATE-05', + }); + await store.article.setPricesBySku(googleAuthId, clubMate.sku, [ + { net: 1.5, tax: 19 }, + ]); + + // 4. Create deposit article + const pfandArt = await store.article.createUnderParent({ + googleAuthId, + parentId: cat.id, + name: 'Mehrwegpfand 0,08 €', + sku: 'PFAND-0.08', + track_stock: 0, + }); + await store.article.setPricesBySku(googleAuthId, pfandArt.sku, [ + { gross: 0.08, tax: 19 }, + ]); + + // 5. Query products BEFORE linking mandated option + let prods = await store.posterminal.getProductList({ posterminalId: terminal.id }); + let mateProd = prods.find((p) => p.sku === 'MATE-05'); + let pfandProd = prods.find((p) => p.sku === 'PFAND-0.08'); + + assert.ok(mateProd, 'Club Mate should be listed'); + assert.equal(mateProd.deposit, '0'); + assert.equal(mateProd.d_price, '0.0'); + assert.equal(mateProd.deposit_name, ''); + + assert.ok(pfandProd, 'Pfand article should be listed'); + assert.equal(pfandProd.deposit, '0'); + + // 6. Link Mehrwegpfand as mandated option to Club Mate + console.log('Linking Mehrwegpfand as mandated option...'); + await store.optionslist.setOptionsByArticleSku(googleAuthId, clubMate.sku, [ + { + articleId: pfandArt.id, + sku: pfandArt.sku, + count: 1, + sort: 0, + mandated: true, + }, + ]); + + // 7. Query products AFTER linking mandated option + prods = await store.posterminal.getProductList({ posterminalId: terminal.id }); + mateProd = prods.find((p) => p.sku === 'MATE-05'); + + console.log('Club Mate with deposit option:', { + sku: mateProd.sku, + deposit: mateProd.deposit, + d_price: mateProd.d_price, + deposit_name: mateProd.deposit_name, + }); + + assert.equal(mateProd.deposit, '1'); + assert.equal(mateProd.d_price, '0.08'); + assert.equal(mateProd.deposit_name, 'Mehrwegpfand 0,08 €'); + + // 8. Test option name change touching parent article + console.log('Testing option name change touching parent article...'); + const mateBeforeRename = (await provider.query('SELECT updated_at FROM article WHERE id = @id', { id: clubMate.id })).rows[0].updated_at; + await new Promise((r) => setTimeout(r, 1100)); // wait >1s for timestamp delta + + await store.article.updateBySku(googleAuthId, pfandArt.sku, { + name: 'Glas-Mehrwegpfand 0,08 €', + }); + + const mateAfterRename = (await provider.query('SELECT updated_at FROM article WHERE id = @id', { id: clubMate.id })).rows[0].updated_at; + assert.notEqual( + mateBeforeRename, + mateAfterRename, + 'Club Mate updated_at must be bumped when its option article is renamed' + ); + + prods = await store.posterminal.getProductList({ posterminalId: terminal.id }); + mateProd = prods.find((p) => p.sku === 'MATE-05'); + assert.equal(mateProd.deposit_name, 'Glas-Mehrwegpfand 0,08 €'); + console.log('Renamed deposit reflected on parent product:', mateProd.deposit_name); + + // 9. Test option price change touching parent article + console.log('Testing option price change touching parent article...'); + const mateBeforePrice = mateAfterRename; + await new Promise((r) => setTimeout(r, 1100)); + + await store.article.setPricesBySku(googleAuthId, pfandArt.sku, [ + { gross: 0.15, tax: 19 }, + ]); + + const mateAfterPrice = (await provider.query('SELECT updated_at FROM article WHERE id = @id', { id: clubMate.id })).rows[0].updated_at; + assert.notEqual( + mateBeforePrice, + mateAfterPrice, + 'Club Mate updated_at must be bumped when its option article price changes' + ); + + prods = await store.posterminal.getProductList({ posterminalId: terminal.id }); + mateProd = prods.find((p) => p.sku === 'MATE-05'); + assert.equal(mateProd.d_price, '0.15'); + console.log('New deposit price reflected on parent product:', mateProd.d_price); + + // 10. Test clearing options + console.log('Clearing options...'); + await store.optionslist.setOptionsByArticleSku(googleAuthId, clubMate.sku, []); + prods = await store.posterminal.getProductList({ posterminalId: terminal.id }); + mateProd = prods.find((p) => p.sku === 'MATE-05'); + + assert.equal(mateProd.deposit, '0'); + assert.equal(mateProd.d_price, '0.0'); + assert.equal(mateProd.deposit_name, ''); + + console.log('✅ ALL DEPOSIT OPTIONS 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 2d3853d..09a142b 100644 --- a/src/providers/mssql/queries/article.js +++ b/src/providers/mssql/queries/article.js @@ -228,6 +228,16 @@ SET updated_at = SYSUTCDATETIME() WHERE id = @articleId `; +export const TOUCH_OPTION_PARENT_ARTICLES_UPDATED_AT = ` +UPDATE article +SET updated_at = SYSUTCDATETIME() +WHERE optionslist_id IN ( + SELECT ola.optionslist_id + FROM optionslistarticle ola + WHERE ola.article_id = @articleId +) +`; + export const CREATE = ` INSERT INTO article (company_id, sku, unit_id, unitamount, weight, track_stock) OUTPUT INSERTED.id diff --git a/src/providers/mssql/queries/posterminal.js b/src/providers/mssql/queries/posterminal.js index c3ac97e..a17ef7d 100644 --- a/src/providers/mssql/queries/posterminal.js +++ b/src/providers/mssql/queries/posterminal.js @@ -483,6 +483,25 @@ WHERE pr.minmultiplier = 1 GROUP BY pr.article_id, pr.customergroup_id `; +export const LIST_POS_MANDATED_OPTIONS = ` +SELECT + a.id AS articleId, + ola.count, + COALESCE(opt_at.name, opt_a.sku) AS depositName, + opt_pr.net AS depositNetPrice, + COALESCE(opt_ptax.rate, opt_ctax.rate, 0) AS depositTaxRate +FROM article a +INNER JOIN optionslistarticle ola ON ola.optionslist_id = a.optionslist_id AND ola.mandated = 1 +INNER JOIN article opt_a ON opt_a.id = ola.article_id +INNER JOIN company co ON co.id = a.company_id +INNER JOIN posterminal pt ON pt.id = @posterminalId AND pt.company_id = a.company_id +LEFT JOIN articletext opt_at ON opt_at.article_id = opt_a.id AND opt_at.language_id = co.default_language_id +LEFT JOIN price opt_pr ON opt_pr.article_id = opt_a.id AND opt_pr.customergroup_id = co.poscustomergroup_id AND opt_pr.minmultiplier = 1 +LEFT JOIN tax opt_ptax ON opt_ptax.id = opt_pr.tax_id +LEFT JOIN tax opt_ctax ON opt_ctax.id = co.taxrate +ORDER BY ola.sort ASC, ola.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/article.js b/src/providers/sqlite3/queries/article.js index aef17f0..68cf2eb 100644 --- a/src/providers/sqlite3/queries/article.js +++ b/src/providers/sqlite3/queries/article.js @@ -11,7 +11,8 @@ export const { INSERT_POSTERMINAL_OBITUARY, LIST_CHILDREN_BY_PARENT_ID, LIST_PAIRED_POSTERMINAL_IDS_BY_ARTICLE, SET_PARENT_SORT, - SET_IS_PARENT, TOGGLE_PARENT, TOUCH_UPDATED_AT, UNLINK_CHILD, UPDATE_BRAND, UPDATE_PARENT_BY_SKU, UPDATE_TRACK_STOCK, UPDATE_UNIT, + SET_IS_PARENT, TOGGLE_PARENT, TOUCH_UPDATED_AT, TOUCH_OPTION_PARENT_ARTICLES_UPDATED_AT, + UNLINK_CHILD, UPDATE_BRAND, UPDATE_PARENT_BY_SKU, UPDATE_TRACK_STOCK, UPDATE_UNIT, UPDATE_UNITAMOUNT, UPDATE_UNIT_AND_AMOUNT, UPDATE_WEIGHT, } = queries; diff --git a/src/providers/sqlite3/queries/posterminal.js b/src/providers/sqlite3/queries/posterminal.js index bccefa1..e0acc1b 100644 --- a/src/providers/sqlite3/queries/posterminal.js +++ b/src/providers/sqlite3/queries/posterminal.js @@ -9,6 +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, TOUCH_LASTSEEN, RECORD_OBITUARY, COUNT_DELETED_ENTITIES, } = queries; diff --git a/src/store/article/delete.js b/src/store/article/delete.js index e56b322..5001f5e 100644 --- a/src/store/article/delete.js +++ b/src/store/article/delete.js @@ -49,6 +49,9 @@ export default function del(provider) { await runner.query(queries.DELETE_PICS_BY_ARTICLE, { articleId: id }); + // Touch any parent articles that used this article as an option before deleting it + await runner.query(articleQueries.TOUCH_OPTION_PARENT_ARTICLES_UPDATED_AT, { articleId: id }); + const deleted = await runner.query(queries.DELETE_BY_ID, { articleId: id }); if (!deleted.rowsAffected) { throw new Error('Article not found'); diff --git a/src/store/article/setPricesBySku.js b/src/store/article/setPricesBySku.js index 2e02fec..eab1661 100644 --- a/src/store/article/setPricesBySku.js +++ b/src/store/article/setPricesBySku.js @@ -6,8 +6,14 @@ import { CREATE as SQLITE_CREATE_PRICE, DELETE_BY_ARTICLE_AND_CUSTOMERGROUP as SQLITE_DELETE_PRICE, } from '../../providers/sqlite3/queries/price.js'; -import { TOUCH_UPDATED_AT as MSSQL_TOUCH } from '../../providers/mssql/queries/article.js'; -import { TOUCH_UPDATED_AT as SQLITE_TOUCH } from '../../providers/sqlite3/queries/article.js'; +import { + TOUCH_UPDATED_AT as MSSQL_TOUCH, + TOUCH_OPTION_PARENT_ARTICLES_UPDATED_AT as MSSQL_TOUCH_OPTION_PARENTS, +} from '../../providers/mssql/queries/article.js'; +import { + TOUCH_UPDATED_AT as SQLITE_TOUCH, + TOUCH_OPTION_PARENT_ARTICLES_UPDATED_AT as SQLITE_TOUCH_OPTION_PARENTS, +} from '../../providers/sqlite3/queries/article.js'; import taxEnsure from '../tax/ensure.js'; import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; @@ -68,6 +74,7 @@ export default function setPricesBySku(provider) { const createPriceSql = isSqlite ? SQLITE_CREATE_PRICE : MSSQL_CREATE_PRICE; const deletePriceSql = isSqlite ? SQLITE_DELETE_PRICE : MSSQL_DELETE_PRICE; const touchArticleSql = isSqlite ? SQLITE_TOUCH : MSSQL_TOUCH; + const touchOptionParentsSql = isSqlite ? SQLITE_TOUCH_OPTION_PARENTS : MSSQL_TOUCH_OPTION_PARENTS; /** * Replaces the price list of an article by its SKU. @@ -147,6 +154,9 @@ export default function setPricesBySku(provider) { // Touch article updated_at so POS and other consumers pick up the price change await runner.query(touchArticleSql, { articleId }); + // Touch any parent articles using this article as an option so their deposit price updates + await runner.query(touchOptionParentsSql, { articleId }); + 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 304ad9c..36d41eb 100644 --- a/src/store/article/updateBySku.js +++ b/src/store/article/updateBySku.js @@ -235,6 +235,9 @@ export default function updateBySku(provider) { // Touch article updated_at so all consumers pick up changes await runner.query(articleQueries.TOUCH_UPDATED_AT, { articleId }); + // Touch any parent articles that use this article as an option so their deposit name/details update on POS + await runner.query(articleQueries.TOUCH_OPTION_PARENT_ARTICLES_UPDATED_AT, { articleId }); + const article = await load(articleId, languageId, runner); if (!article) { throw new Error('Article not found'); diff --git a/src/store/optionslist/setOptionsByArticleSku.js b/src/store/optionslist/setOptionsByArticleSku.js index 56474f0..5970f95 100644 --- a/src/store/optionslist/setOptionsByArticleSku.js +++ b/src/store/optionslist/setOptionsByArticleSku.js @@ -1,5 +1,7 @@ import * as mssqlQueries from '../../providers/mssql/queries/optionslist.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/optionslist.js'; +import { TOUCH_UPDATED_AT as MSSQL_TOUCH } from '../../providers/mssql/queries/article.js'; +import { TOUCH_UPDATED_AT as SQLITE_TOUCH } from '../../providers/sqlite3/queries/article.js'; import resolveArticleBySku from '../article/resolveArticleBySku.js'; import getOptionsByArticleSku from './getOptionsByArticleSku.js'; import { @@ -46,6 +48,7 @@ export default function setOptionsByArticleSku(provider) { const getOptions = getOptionsByArticleSku(provider); const isSqlite = provider?.dialect === 'sqlite3'; const queries = isSqlite ? sqliteQueries : mssqlQueries; + const touchArticleSql = isSqlite ? SQLITE_TOUCH : MSSQL_TOUCH; /** * Sets or clears the options list for an article. @@ -108,6 +111,7 @@ export default function setOptionsByArticleSku(provider) { type: 'optionslist_cleared', }); } + await runner.query(touchArticleSql, { articleId }); } return []; } @@ -236,6 +240,9 @@ export default function setOptionsByArticleSku(provider) { options: result, }); + // Touch article updated_at so POS and other consumers pick up options/deposit changes + await runner.query(touchArticleSql, { articleId }); + return result; }; diff --git a/src/store/posterminal/getProductList.js b/src/store/posterminal/getProductList.js index 8b36411..64c2c83 100644 --- a/src/store/posterminal/getProductList.js +++ b/src/store/posterminal/getProductList.js @@ -3,12 +3,14 @@ import { LIST_POS_PRODUCT_CATEGORIES as MSSQL_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS as MSSQL_CG_IDS, LIST_POS_PRODUCT_PRICES as MSSQL_PRICES, + LIST_POS_MANDATED_OPTIONS as MSSQL_MANDATED_OPTIONS, } from '../../providers/mssql/queries/posterminal.js'; import { LIST_POS_PRODUCTS as SQLITE_PRODUCTS, LIST_POS_PRODUCT_CATEGORIES as SQLITE_CATEGORIES, LIST_POS_CUSTOMER_GROUP_IDS as SQLITE_CG_IDS, LIST_POS_PRODUCT_PRICES as SQLITE_PRICES, + LIST_POS_MANDATED_OPTIONS as SQLITE_MANDATED_OPTIONS, } from '../../providers/sqlite3/queries/posterminal.js'; import makeRunner from '../runner.js'; @@ -36,6 +38,7 @@ export default function getProductList(provider) { const categoriesSql = isSqlite ? SQLITE_CATEGORIES : MSSQL_CATEGORIES; 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; /** * Lists products for a POS terminal modified after the cursor. @@ -52,7 +55,7 @@ export default function getProductList(provider) { const runner = makeRunner(provider, tx); const pid = Number(posterminalId); - const [prodRes, catRes, cgRes, priceRes] = await Promise.all([ + const [prodRes, catRes, cgRes, priceRes, optRes] = await Promise.all([ runner.query(productsSql, { posterminalId: pid, cursor: normalizeCursor(cursor), @@ -61,6 +64,7 @@ export default function getProductList(provider) { runner.query(categoriesSql, { posterminalId: pid }), runner.query(cgIdsSql, { posterminalId: pid }), runner.query(pricesSql, { posterminalId: pid }), + runner.query(mandatedOptionsSql, { posterminalId: pid }), ]); const categoriesByArticle = new Map(); @@ -79,6 +83,24 @@ export default function getProductList(provider) { overridesByArticle.get(row.articleId).set(row.customerGroupId, row.netPrice); } + const mandatedOptionsByArticle = new Map(); + for (const row of optRes.rows) { + const gross = + Number(row.count || 1) * + Number(row.depositNetPrice || 0) * + (1 + Number(row.depositTaxRate || 0) / 100); + if (!mandatedOptionsByArticle.has(row.articleId)) { + mandatedOptionsByArticle.set(row.articleId, { + names: [row.depositName], + totalPrice: gross, + }); + } else { + const existing = mandatedOptionsByArticle.get(row.articleId); + existing.names.push(row.depositName); + existing.totalPrice += gross; + } + } + const customerGroupIds = cgRes.rows.map((row) => row.id); return prodRes.rows.map((product) => { @@ -100,6 +122,10 @@ export default function getProductList(provider) { }); const categoryIds = categoriesByArticle.get(product.id) ?? []; + const mandatedOpt = mandatedOptionsByArticle.get(product.id); + const hasDeposit = Boolean(mandatedOpt); + const depositName = mandatedOpt ? mandatedOpt.names.join(' + ') : ''; + const depositPrice = mandatedOpt ? mandatedOpt.totalPrice.toFixed(2) : '0.0'; return { _id: String(product.id), @@ -123,6 +149,9 @@ export default function getProductList(provider) { attributes: [], use_stock: product.trackStock ? '1' : '0', quantity: String(product.fBestand ?? 0), + deposit: hasDeposit ? '1' : '0', + d_price: depositPrice, + deposit_name: depositName, }; }); };