This commit is contained in:
seb
2026-10-23 01:38:08 +02:00
parent 5fc316461d
commit 919713fa53
7 changed files with 250 additions and 19 deletions

164
scratch/test-variants.js Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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