This commit is contained in:
seb
2026-10-23 00:47:38 +02:00
parent e31566e6ee
commit 5fc316461d
10 changed files with 266 additions and 4 deletions

View File

@@ -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);
});

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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');

View File

@@ -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');

View File

@@ -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');

View File

@@ -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;
};

View File

@@ -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,
};
});
};