diff --git a/API.md b/API.md index d74f44a..3fa6f20 100644 --- a/API.md +++ b/API.md @@ -202,12 +202,14 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `findByPairingpin(pairingpin: string, tx?: object): Promise` - `findByToken(token: string, tx?: object): Promise` - `getCategoryCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise` + - `getCategoryList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise>` - `getCompositeProductCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise` - `getCustomerCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise` - `getCustomerGroupCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise` - `getDeletedEntityCount(input?: {posterminalId?: number, cursor?: number}): Promise` - `getMaxOrderIdCount(input: {posterminalId: number}, tx?: object): Promise` - `getProductCount(input: {posterminalId: number, cursor?: number}, tx?: object): Promise` + - `getProductList(input: {posterminalId: number, cursor?: number, limit?: number}, tx?: object): Promise>` - `list(googleAuthId: number, tx?: object): Promise>` - `pair(id: number, input: {token: string, clientserial?: string, pairedAt?: string}, tx?: object): Promise<{id: number, token: string, clientserial: string|null, pairedAt: string|null}>` - `setOff(googleAuthId: number, posterminalId: number, categoryId: number, tx?: object): Promise<{id: number, categoryIds: Array}>` diff --git a/API.posterminal.md b/API.posterminal.md index d4b75ec..7b434ce 100644 --- a/API.posterminal.md +++ b/API.posterminal.md @@ -83,6 +83,26 @@ Counts categories assigned to the POS terminal updated after the given unix time `Promise` — The count of changed records. +## `posterminal.getCategoryList` + +*Source: `src/store/posterminal/getCategoryList.js`* + +Lists categories for a POS terminal modified after the cursor. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `input` | `object` | | +| `input.posterminalId` | `number` | The POS terminal id. | +| `[input.cursor=0]` | `number` | Unix timestamp cursor. | +| `[input.limit=20]` | `number` | Max items to return. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise>` + ## `posterminal.getCompositeProductCount` *Source: `src/store/posterminal/getCompositeProductCount.js`* @@ -195,6 +215,26 @@ Counts products assigned to the POS terminal updated after the given unix timest `Promise` — The count of changed records. +## `posterminal.getProductList` + +*Source: `src/store/posterminal/getProductList.js`* + +Lists products for a POS terminal modified after the cursor. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `input` | `object` | | +| `input.posterminalId` | `number` | The POS terminal id. | +| `[input.cursor=0]` | `number` | Unix timestamp cursor. | +| `[input.limit=20]` | `number` | Max items to return. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise>` + ## `posterminal.list` *Source: `src/store/posterminal/list.js`* diff --git a/scripts/test-stueckliste-stock.js b/scripts/test-stueckliste-stock.js new file mode 100644 index 0000000..23989bd --- /dev/null +++ b/scripts/test-stueckliste-stock.js @@ -0,0 +1,146 @@ +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 Stückliste Stock Reporting and Adjustment ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-stueckliste-')); + 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('stueckliste-stock@example.com'); + const company = await store.company.create({ + name: 'Stückliste Stock Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + // Create 2 warehouses + const wh1 = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' }); + const wh2 = await store.warehouse.create(googleAuthId, { name: 'Filiale' }); + + // Create 2 component articles + const comp1 = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Component Article 1', + }); + const comp2 = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Component Article 2', + }); + + // Create a bundle article + const bundle = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Stückliste Bundle', + }); + + // Configure Stückliste: 1x comp1 + 2x comp2 + await store.articlelist.setItemsByArticleSku(googleAuthId, bundle.sku, [ + { sku: comp1.sku, count: 1, sort: 0 }, + { sku: comp2.sku, count: 2, sort: 1 }, + ]); + + // Initial check: all have 0 stock + const initialBundle = await store.article.getBySku(googleAuthId, bundle.sku); + assert.equal(initialBundle.article.warehouses.find((w) => w.id === wh1.id).stock, 0); + assert.equal(initialBundle.article.warehouses.find((w) => w.id === wh2.id).stock, 0); + + // Book 10x comp1 and 10x comp2 into Hauptlager + await store.stock.adjust(googleAuthId, { + lines: [ + { sku: comp1.sku, warehouseId: wh1.id, amount: 10, text: 'Initial comp1' }, + { sku: comp2.sku, warehouseId: wh1.id, amount: 10, text: 'Initial comp2' }, + ], + }); + + // Check bundle stock in Hauptlager: min(10/1, 10/2) = 5 + const afterCompStock = await store.article.getBySku(googleAuthId, bundle.sku); + const wh1BundleStock = afterCompStock.article.warehouses.find((w) => w.id === wh1.id).stock; + const wh2BundleStock = afterCompStock.article.warehouses.find((w) => w.id === wh2.id).stock; + assert.equal(wh1BundleStock, 5, 'Bundle stock in Hauptlager should be 5 (min(10/1, 10/2))'); + assert.equal(wh2BundleStock, 0, 'Bundle stock in Filiale should be 0'); + + // Book in 3x Stückliste bundle into Filiale + const adjustResult = await store.stock.adjust(googleAuthId, { + sku: bundle.sku, + warehouseId: wh2.id, + amount: 3, + text: 'Production bundle book-in', + }); + + // Verify adjust result lines + assert.equal(adjustResult.lines.length, 2, 'Should expand into 2 component lines'); + const comp1Line = adjustResult.lines.find((l) => l.sku === comp1.sku); + const comp2Line = adjustResult.lines.find((l) => l.sku === comp2.sku); + assert.equal(comp1Line.amount, 3, 'Component 1 should get 3 * 1 = 3'); + assert.equal(comp2Line.amount, 6, 'Component 2 should get 3 * 2 = 6'); + + // Verify bundle article itself has NO entries in the ledger + const bundleLedger = await store.ledger.listBySku(googleAuthId, bundle.sku); + assert.equal(bundleLedger.length, 0, 'Bundle article must have NO ledger entries'); + + // Verify component articles have their ledger entries + const comp1Ledger = await store.ledger.listBySku(googleAuthId, comp1.sku, { warehouse_id: wh2.id }); + const comp2Ledger = await store.ledger.listBySku(googleAuthId, comp2.sku, { warehouse_id: wh2.id }); + assert.equal(comp1Ledger.length, 1); + assert.equal(comp1Ledger[0].amount, 3); + assert.equal(comp2Ledger.length, 1); + assert.equal(comp2Ledger[0].amount, 6); + + // Verify bundle stock report in Filiale is now 3 (min(3/1, 6/2) = 3) + const afterBundleBookIn = await store.article.getBySku(googleAuthId, bundle.sku); + const wh2AfterBookIn = afterBundleBookIn.article.warehouses.find((w) => w.id === wh2.id).stock; + assert.equal(wh2AfterBookIn, 3, 'Bundle stock in Filiale should be 3'); + + // Test booking out 1x bundle from Filiale + await store.stock.adjust(googleAuthId, { + sku: bundle.sku, + warehouseId: wh2.id, + amount: -1, + text: 'Sale bundle book-out', + }); + + const afterRemoval = await store.article.getBySku(googleAuthId, bundle.sku); + const wh2AfterRemoval = afterRemoval.article.warehouses.find((w) => w.id === wh2.id).stock; + assert.equal(wh2AfterRemoval, 2, 'Bundle stock in Filiale should be 2 after -1 removal'); + + // Verify components stock after removal in Filiale: comp1 = 2, comp2 = 4 + const comp1After = await store.article.getBySku(googleAuthId, comp1.sku); + const comp2After = await store.article.getBySku(googleAuthId, comp2.sku); + assert.equal(comp1After.article.warehouses.find((w) => w.id === wh2.id).stock, 2); + assert.equal(comp2After.article.warehouses.find((w) => w.id === wh2.id).stock, 4); + + console.log('✓ All Stückliste stock reporting and adjustment tests passed successfully!'); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + } +} + +run().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +}); diff --git a/src/index.js b/src/index.js index 2f56aa0..48e0ac7 100644 --- a/src/index.js +++ b/src/index.js @@ -111,6 +111,8 @@ import posterminalGetCustomerGroupCount from './store/posterminal/getCustomerGro import posterminalGetCompositeProductCount from './store/posterminal/getCompositeProductCount.js'; import posterminalGetDeletedEntityCount from './store/posterminal/getDeletedEntityCount.js'; import posterminalGetMaxOrderIdCount from './store/posterminal/getMaxOrderIdCount.js'; +import posterminalGetCategoryList from './store/posterminal/getCategoryList.js'; +import posterminalGetProductList from './store/posterminal/getProductList.js'; import langCreate from './store/lang/create.js'; import langDelete from './store/lang/delete.js'; import langList from './store/lang/list.js'; @@ -384,6 +386,8 @@ export default class LibDashStore { getCompositeProductCount: posterminalGetCompositeProductCount(provider), getDeletedEntityCount: posterminalGetDeletedEntityCount(provider), getMaxOrderIdCount: posterminalGetMaxOrderIdCount(provider), + getCategoryList: posterminalGetCategoryList(provider), + getProductList: posterminalGetProductList(provider), }; this.google_auth = { diff --git a/src/providers/mssql/queries/article.js b/src/providers/mssql/queries/article.js index 0a5ba1a..b4ea674 100644 --- a/src/providers/mssql/queries/article.js +++ b/src/providers/mssql/queries/article.js @@ -574,7 +574,33 @@ SELECT w.id, w.name, w.sort, - COALESCE(stock_calc.stock, 0) AS stock + CASE + WHEN a.articlelist_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM articlelistarticle ala WHERE ala.articlelist_id = a.articlelist_id + ) THEN COALESCE(( + SELECT CASE + WHEN MIN(COALESCE(comp_stock.stock, 0) / ala.count) < 0 THEN 0 + ELSE FLOOR(MIN(COALESCE(comp_stock.stock, 0) / ala.count)) + END + FROM articlelistarticle ala + LEFT JOIN ( + SELECT ca.id AS comp_id, SUM(l.amount) AS stock + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + INNER JOIN article ca ON ( + ma.sku = ca.sku + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = ca.id) + ) + WHERE m.company_id = a.company_id + AND l.warehouse_id = w.id + GROUP BY ca.id + ) comp_stock ON comp_stock.comp_id = ala.article_id + WHERE ala.articlelist_id = a.articlelist_id + ), 0) + WHEN a.articlelist_id IS NOT NULL THEN 0 + ELSE COALESCE(stock_calc.stock, 0) + END AS stock FROM warehouse w LEFT JOIN ( SELECT l.warehouse_id, SUM(l.amount) AS stock diff --git a/src/providers/mssql/queries/posterminal.js b/src/providers/mssql/queries/posterminal.js index 3e53510..80ddf56 100644 --- a/src/providers/mssql/queries/posterminal.js +++ b/src/providers/mssql/queries/posterminal.js @@ -195,3 +195,182 @@ FROM manifest m INNER JOIN posterminal pt ON pt.id = @posterminalId AND pt.company_id = m.company_id WHERE m.[type] = 'order' `; + +export const LIST_POS_CATEGORIES = ` +SELECT TOP (@limit) + c.id, + c.parent_id AS pid, + c.sort, + COALESCE(ct.name, '') AS name, + co.root_category_id AS rootCategoryId, + ( + SELECT TOP 1 p.hash + FROM categorypic cp + INNER JOIN pic p ON p.id = cp.pic_id + WHERE cp.category_id = c.id + ORDER BY cp.sort ASC, cp.id ASC + ) AS imgHash, + DATEDIFF_BIG(second, '1970-01-01', c.updated_at) AS lastChanged +FROM category c +INNER JOIN posterminalcategory pc ON pc.category_id = c.id +INNER JOIN posterminal pt ON pt.id = pc.posterminal_id +INNER JOIN company co ON co.id = pt.company_id +LEFT JOIN categorytext ct ON ct.category_id = c.id AND ct.language_id = co.default_language_id +WHERE pc.posterminal_id = @posterminalId + AND (co.root_category_id IS NULL OR c.id <> co.root_category_id) + AND DATEDIFF_BIG(second, '1970-01-01', c.updated_at) > @cursor +ORDER BY DATEDIFF_BIG(second, '1970-01-01', c.updated_at) ASC, c.id ASC +`; + +export const LIST_POS_PRODUCTS = ` +SELECT TOP (@limit) + a.id, + a.sku, + COALESCE(at.name, a.sku, '') AS name, + COALESCE(pr.net, 0) AS netPrice, + COALESCE(ptax.rate, ctax.rate, 0) AS taxRate, + a.created_at AS createdAt, + DATEDIFF_BIG(second, '1970-01-01', a.updated_at) AS lastChanged, + COALESCE( + ( + SELECT TOP 1 p.hash + FROM articlepic ap + INNER JOIN pic p ON p.id = ap.pic_id + WHERE ap.article_id = a.id + ORDER BY ap.sort ASC, ap.id ASC + ), + ( + SELECT TOP 1 p2.hash + FROM articlepic ap2 + INNER JOIN pic p2 ON p2.id = ap2.pic_id + WHERE a.fatherarticle_id IS NOT NULL AND ap2.article_id = a.fatherarticle_id + ORDER BY ap2.sort ASC, ap2.id ASC + ) + ) AS imgHash, + ( + SELECT TOP 1 ae.ean + FROM articleean ae + WHERE ae.article_id = a.id + ORDER BY ae.id ASC + ) AS barcode, + a.is_father AS isParent, + a.fatherarticle_id AS parentArticleId, + ( + SELECT TOP 1 CONCAT(fan.name, '|', av.value, '|', fan.sort, '|', a.fathersort) + FROM article fa + INNER JOIN attributename fan ON fan.id = fa.fatherattributename_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.fatherarticle_id IS NOT NULL AND fa.id = a.fatherarticle_id + ) AS variantName, + CASE + WHEN a.articlelist_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM articlelistarticle ala WHERE ala.articlelist_id = a.articlelist_id + ) THEN '1' + ELSE '0' + END AS isCompositeProduct, + ( + SELECT COUNT(*) + FROM categoryarticle ca2 + INNER JOIN article a2 ON a2.id = ca2.article_id + LEFT JOIN articletext at2 ON at2.article_id = a2.id AND at2.language_id = co.default_language_id + WHERE ca2.category_id = ( + SELECT MIN(ca1.category_id) + FROM categoryarticle ca1 + WHERE ca1.article_id = a.id + ) + AND ( + COALESCE(at2.name, a2.sku, '') < COALESCE(at.name, a.sku, '') + OR (COALESCE(at2.name, a2.sku, '') = COALESCE(at.name, a.sku, '') AND a2.id < a.id) + ) + ) AS sort, + a.track_stock AS trackStock, + CASE + WHEN a.articlelist_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM articlelistarticle ala WHERE ala.articlelist_id = a.articlelist_id + ) THEN COALESCE(( + SELECT CASE + WHEN MIN(COALESCE(comp_stock.stock, 0) / ala.count) < 0 THEN 0 + ELSE FLOOR(MIN(COALESCE(comp_stock.stock, 0) / ala.count)) + END + FROM articlelistarticle ala + LEFT JOIN ( + SELECT ca.id AS comp_id, SUM(l.amount) AS stock + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + INNER JOIN article ca ON ( + ma.sku = ca.sku + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = ca.id) + ) + WHERE m.company_id = a.company_id + GROUP BY ca.id + ) comp_stock ON comp_stock.comp_id = ala.article_id + WHERE ala.articlelist_id = a.articlelist_id + ), 0) + WHEN a.articlelist_id IS NOT NULL THEN 0 + ELSE COALESCE(( + SELECT SUM(l.amount) + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + WHERE m.company_id = a.company_id + AND ( + ma.sku = a.sku + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = a.id) + ) + ), 0) + END AS fBestand, + co.id AS companyId, + co.root_category_id AS rootCategoryId, + co.poscustomergroup_id AS posCustomerGroupId +FROM article a +INNER JOIN posterminal pt ON pt.id = @posterminalId AND pt.company_id = a.company_id +INNER JOIN company co ON co.id = a.company_id +LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = co.default_language_id +LEFT JOIN price pr ON pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1 +LEFT JOIN tax ptax ON ptax.id = pr.tax_id +LEFT JOIN tax ctax ON ctax.id = co.taxrate +WHERE EXISTS ( + SELECT 1 + FROM categoryarticle ca + INNER JOIN posterminalcategory pc ON pc.category_id = ca.category_id + WHERE ca.article_id = a.id + AND pc.posterminal_id = @posterminalId +) +AND DATEDIFF_BIG(second, '1970-01-01', a.updated_at) > @cursor +ORDER BY DATEDIFF_BIG(second, '1970-01-01', a.updated_at) ASC, a.id ASC +`; + +export const LIST_POS_PRODUCT_CATEGORIES = ` +SELECT + ca.article_id AS articleId, + ca.category_id AS categoryId +FROM categoryarticle ca +INNER JOIN posterminalcategory pc ON pc.category_id = ca.category_id +INNER JOIN posterminal pt ON pt.id = pc.posterminal_id +INNER JOIN company co ON co.id = pt.company_id +WHERE pc.posterminal_id = @posterminalId + AND (co.root_category_id IS NULL OR ca.category_id <> co.root_category_id) +ORDER BY ca.article_id ASC, ca.category_id ASC +`; + +export const LIST_POS_CUSTOMER_GROUP_IDS = ` +SELECT cg.id +FROM customergroup cg +INNER JOIN posterminal pt ON pt.id = @posterminalId AND pt.company_id = cg.company_id +WHERE cg.name <> 'Lieferanten' +ORDER BY cg.id ASC +`; + +export const LIST_POS_PRODUCT_PRICES = ` +SELECT + pr.article_id AS articleId, + pr.customergroup_id AS customerGroupId, + MIN(pr.net) AS netPrice +FROM price pr +INNER JOIN article a ON a.id = pr.article_id +INNER JOIN posterminal pt ON pt.id = @posterminalId AND pt.company_id = a.company_id +WHERE pr.minmultiplier = 1 +GROUP BY pr.article_id, pr.customergroup_id +`; diff --git a/src/providers/sqlite3/queries/article.js b/src/providers/sqlite3/queries/article.js index d6ef48d..4144124 100644 --- a/src/providers/sqlite3/queries/article.js +++ b/src/providers/sqlite3/queries/article.js @@ -215,7 +215,34 @@ SELECT auth.*, WHERE s.article_id = a.id ORDER BY s.sort, s.id ))), 'warehousesJson', json((SELECT COALESCE(json_group_array(json_object( - 'id', w.id, 'name', w.name, 'sort', w.sort, 'stock', COALESCE(stock_calc.stock, 0) + 'id', w.id, 'name', w.name, 'sort', w.sort, + 'stock', CASE + WHEN a.articlelist_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM articlelistarticle ala WHERE ala.articlelist_id = a.articlelist_id + ) THEN COALESCE(( + SELECT CASE + WHEN MIN(COALESCE(comp_stock.stock, 0) / ala.count) < 0 THEN 0 + ELSE CAST(MIN(COALESCE(comp_stock.stock, 0) / ala.count) AS INTEGER) + END + FROM articlelistarticle ala + LEFT JOIN ( + SELECT ca.id AS comp_id, SUM(l.amount) AS stock + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + INNER JOIN article ca ON ( + ma.sku = ca.sku + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = ca.id) + ) + WHERE m.company_id = a.company_id + AND l.warehouse_id = w.id + GROUP BY ca.id + ) comp_stock ON comp_stock.comp_id = ala.article_id + WHERE ala.articlelist_id = a.articlelist_id + ), 0) + WHEN a.articlelist_id IS NOT NULL THEN 0 + ELSE COALESCE(stock_calc.stock, 0) + END )), '[]') FROM warehouse w LEFT JOIN ( SELECT l.warehouse_id, SUM(l.amount) AS stock diff --git a/src/providers/sqlite3/queries/posterminal.js b/src/providers/sqlite3/queries/posterminal.js index b819d8b..2b2e433 100644 --- a/src/providers/sqlite3/queries/posterminal.js +++ b/src/providers/sqlite3/queries/posterminal.js @@ -8,6 +8,7 @@ export const { FIND_BY_PAIRINGPIN, PAIR, 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, } = queries; export const SET_ON = ` @@ -25,4 +26,157 @@ WITH RECURSIVE cats AS ( INSERT INTO posterminalcategory (posterminal_id, category_id) SELECT @posterminalId, id FROM cats WHERE 1 ON CONFLICT (posterminal_id, category_id) DO NOTHING -`; \ No newline at end of file +`; + +export const LIST_POS_CATEGORIES = ` +SELECT + c.id, + c.parent_id AS pid, + c.sort, + COALESCE(ct.name, '') AS name, + co.root_category_id AS rootCategoryId, + ( + SELECT p.hash + FROM categorypic cp + INNER JOIN pic p ON p.id = cp.pic_id + WHERE cp.category_id = c.id + ORDER BY cp.sort ASC, cp.id ASC + LIMIT 1 + ) AS imgHash, + CAST(strftime('%s', c.updated_at) AS INTEGER) AS lastChanged +FROM category c +INNER JOIN posterminalcategory pc ON pc.category_id = c.id +INNER JOIN posterminal pt ON pt.id = pc.posterminal_id +INNER JOIN company co ON co.id = pt.company_id +LEFT JOIN categorytext ct ON ct.category_id = c.id AND ct.language_id = co.default_language_id +WHERE pc.posterminal_id = @posterminalId + AND (co.root_category_id IS NULL OR c.id <> co.root_category_id) + AND CAST(strftime('%s', c.updated_at) AS INTEGER) > @cursor +ORDER BY lastChanged ASC, c.id ASC +LIMIT @limit +`; + +export const LIST_POS_PRODUCTS = ` +SELECT + a.id, + a.sku, + COALESCE(at.name, a.sku, '') AS name, + COALESCE(pr.net, 0) AS netPrice, + COALESCE(ptax.rate, ctax.rate, 0) AS taxRate, + a.created_at AS createdAt, + CAST(strftime('%s', a.updated_at) AS INTEGER) AS lastChanged, + COALESCE( + ( + SELECT p.hash + FROM articlepic ap + INNER JOIN pic p ON p.id = ap.pic_id + WHERE ap.article_id = a.id + ORDER BY ap.sort ASC, ap.id ASC + LIMIT 1 + ), + ( + SELECT p2.hash + FROM articlepic ap2 + INNER JOIN pic p2 ON p2.id = ap2.pic_id + WHERE a.fatherarticle_id IS NOT NULL AND ap2.article_id = a.fatherarticle_id + ORDER BY ap2.sort ASC, ap2.id ASC + LIMIT 1 + ) + ) AS imgHash, + ( + SELECT ae.ean + FROM articleean ae + WHERE ae.article_id = a.id + ORDER BY ae.id ASC + LIMIT 1 + ) AS barcode, + a.is_father AS isParent, + a.fatherarticle_id AS parentArticleId, + ( + SELECT fan.name || '|' || av.value || '|' || fan.sort || '|' || a.fathersort + FROM article fa + INNER JOIN attributename fan ON fan.id = fa.fatherattributename_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.fatherarticle_id IS NOT NULL AND fa.id = a.fatherarticle_id + LIMIT 1 + ) AS variantName, + CASE + WHEN a.articlelist_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM articlelistarticle ala WHERE ala.articlelist_id = a.articlelist_id + ) THEN '1' + ELSE '0' + END AS isCompositeProduct, + ( + SELECT COUNT(*) + FROM categoryarticle ca2 + INNER JOIN article a2 ON a2.id = ca2.article_id + LEFT JOIN articletext at2 ON at2.article_id = a2.id AND at2.language_id = co.default_language_id + WHERE ca2.category_id = ( + SELECT MIN(ca1.category_id) + FROM categoryarticle ca1 + WHERE ca1.article_id = a.id + ) + AND ( + COALESCE(at2.name, a2.sku, '') < COALESCE(at.name, a.sku, '') + OR (COALESCE(at2.name, a2.sku, '') = COALESCE(at.name, a.sku, '') AND a2.id < a.id) + ) + ) AS sort, + a.track_stock AS trackStock, + CASE + WHEN a.articlelist_id IS NOT NULL AND EXISTS ( + SELECT 1 FROM articlelistarticle ala WHERE ala.articlelist_id = a.articlelist_id + ) THEN COALESCE(( + SELECT CASE + WHEN MIN(COALESCE(comp_stock.stock, 0) / ala.count) < 0 THEN 0 + ELSE CAST(MIN(COALESCE(comp_stock.stock, 0) / ala.count) AS INTEGER) + END + FROM articlelistarticle ala + LEFT JOIN ( + SELECT ca.id AS comp_id, SUM(l.amount) AS stock + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + INNER JOIN article ca ON ( + ma.sku = ca.sku + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = ca.id) + ) + WHERE m.company_id = a.company_id + GROUP BY ca.id + ) comp_stock ON comp_stock.comp_id = ala.article_id + WHERE ala.articlelist_id = a.articlelist_id + ), 0) + WHEN a.articlelist_id IS NOT NULL THEN 0 + ELSE COALESCE(( + SELECT SUM(l.amount) + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + WHERE m.company_id = a.company_id + AND ( + ma.sku = a.sku + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = a.id) + ) + ), 0) + END AS fBestand, + co.id AS companyId, + co.root_category_id AS rootCategoryId, + co.poscustomergroup_id AS posCustomerGroupId +FROM article a +INNER JOIN posterminal pt ON pt.id = @posterminalId AND pt.company_id = a.company_id +INNER JOIN company co ON co.id = a.company_id +LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = co.default_language_id +LEFT JOIN price pr ON pr.article_id = a.id AND pr.customergroup_id = co.poscustomergroup_id AND pr.minmultiplier = 1 +LEFT JOIN tax ptax ON ptax.id = pr.tax_id +LEFT JOIN tax ctax ON ctax.id = co.taxrate +WHERE EXISTS ( + SELECT 1 + FROM categoryarticle ca + INNER JOIN posterminalcategory pc ON pc.category_id = ca.category_id + WHERE ca.article_id = a.id + AND pc.posterminal_id = @posterminalId +) +AND CAST(strftime('%s', a.updated_at) AS INTEGER) > @cursor +ORDER BY lastChanged ASC, a.id ASC +LIMIT @limit +`; diff --git a/src/store/posterminal/getCategoryList.js b/src/store/posterminal/getCategoryList.js new file mode 100644 index 0000000..0222f0e --- /dev/null +++ b/src/store/posterminal/getCategoryList.js @@ -0,0 +1,43 @@ +import { LIST_POS_CATEGORIES as MSSQL_SQL } from '../../providers/mssql/queries/posterminal.js'; +import { LIST_POS_CATEGORIES as SQLITE_SQL } from '../../providers/sqlite3/queries/posterminal.js'; +import makeRunner from '../runner.js'; + +function normalizeCursor(cursor) { + const n = Number(cursor) || 0; + if (n <= 0) return 0; + return n >= 1e11 ? Math.floor(n / 1000) : Math.floor(n); +} + +export default function getCategoryList(provider) { + const sql = provider?.dialect === 'sqlite3' ? SQLITE_SQL : MSSQL_SQL; + + /** + * Lists categories for a POS terminal modified after the cursor. + * + * @param {object} input + * @param {number} input.posterminalId - The POS terminal id. + * @param {number} [input.cursor=0] - Unix timestamp cursor. + * @param {number} [input.limit=20] - Max items to return. + * @param {object} [tx] - Optional transaction. + * @returns {Promise>} + */ + return async ({ posterminalId, cursor = 0, limit = 20 } = {}, tx) => { + const safeLimit = Number.isInteger(limit) && limit > 0 ? limit : 20; + const result = await makeRunner(provider, tx).query(sql, { + posterminalId: Number(posterminalId), + cursor: normalizeCursor(cursor), + limit: safeLimit, + }); + + return result.rows.map((row) => ({ + _id: String(row.id), + imghash: row.imgHash ?? null, + imgsrc: row.imgHash ?? null, + name: row.name ?? '', + pid: !row.pid || row.pid === row.rootCategoryId ? '0' : String(row.pid), + discounts: [], + sort: String(row.sort ?? 0), + lastChanged: String(row.lastChanged), + })); + }; +} diff --git a/src/store/posterminal/getProductList.js b/src/store/posterminal/getProductList.js new file mode 100644 index 0000000..8b36411 --- /dev/null +++ b/src/store/posterminal/getProductList.js @@ -0,0 +1,129 @@ +import { + LIST_POS_PRODUCTS as MSSQL_PRODUCTS, + LIST_POS_PRODUCT_CATEGORIES as MSSQL_CATEGORIES, + LIST_POS_CUSTOMER_GROUP_IDS as MSSQL_CG_IDS, + LIST_POS_PRODUCT_PRICES as MSSQL_PRICES, +} 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, +} from '../../providers/sqlite3/queries/posterminal.js'; +import makeRunner from '../runner.js'; + +function normalizeCursor(cursor) { + const n = Number(cursor) || 0; + if (n <= 0) return 0; + return n >= 1e11 ? Math.floor(n / 1000) : Math.floor(n); +} + +function formatDateTime(value) { + if (!value) return '0001-01-01 00:00:00'; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return '0001-01-01 00:00:00'; + const pad = (n) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +function grossPrice(netPrice, taxRate) { + return (Number(netPrice || 0) * (1 + Number(taxRate || 0) / 100)).toFixed(2); +} + +export default function getProductList(provider) { + const isSqlite = provider?.dialect === 'sqlite3'; + const productsSql = isSqlite ? SQLITE_PRODUCTS : MSSQL_PRODUCTS; + const categoriesSql = isSqlite ? SQLITE_CATEGORIES : MSSQL_CATEGORIES; + const cgIdsSql = isSqlite ? SQLITE_CG_IDS : MSSQL_CG_IDS; + const pricesSql = isSqlite ? SQLITE_PRICES : MSSQL_PRICES; + + /** + * Lists products for a POS terminal modified after the cursor. + * + * @param {object} input + * @param {number} input.posterminalId - The POS terminal id. + * @param {number} [input.cursor=0] - Unix timestamp cursor. + * @param {number} [input.limit=20] - Max items to return. + * @param {object} [tx] - Optional transaction. + * @returns {Promise>} + */ + return async ({ posterminalId, cursor = 0, limit = 20 } = {}, tx) => { + const safeLimit = Number.isInteger(limit) && limit > 0 ? limit : 20; + const runner = makeRunner(provider, tx); + const pid = Number(posterminalId); + + const [prodRes, catRes, cgRes, priceRes] = await Promise.all([ + runner.query(productsSql, { + posterminalId: pid, + cursor: normalizeCursor(cursor), + limit: safeLimit, + }), + runner.query(categoriesSql, { posterminalId: pid }), + runner.query(cgIdsSql, { posterminalId: pid }), + runner.query(pricesSql, { posterminalId: pid }), + ]); + + const categoriesByArticle = new Map(); + for (const row of catRes.rows) { + if (!categoriesByArticle.has(row.articleId)) { + categoriesByArticle.set(row.articleId, []); + } + categoriesByArticle.get(row.articleId).push(String(row.categoryId)); + } + + const overridesByArticle = new Map(); + for (const row of priceRes.rows) { + if (!overridesByArticle.has(row.articleId)) { + overridesByArticle.set(row.articleId, new Map()); + } + overridesByArticle.get(row.articleId).set(row.customerGroupId, row.netPrice); + } + + const customerGroupIds = cgRes.rows.map((row) => row.id); + + return prodRes.rows.map((product) => { + const overrides = overridesByArticle.get(product.id); + const basePrice = grossPrice(product.netPrice, product.taxRate); + + const prices = customerGroupIds.map((customerGroupId) => { + const overrideNetPrice = overrides?.get(customerGroupId); + const price = + overrideNetPrice !== undefined + ? grossPrice(overrideNetPrice, product.taxRate) + : basePrice; + return { + customerGroupId: String(customerGroupId), + customerId: '0', + price, + quantity: '0', + }; + }); + + const categoryIds = categoriesByArticle.get(product.id) ?? []; + + return { + _id: String(product.id), + imghash: product.imgHash ?? null, + imgsrc: product.imgHash ?? null, + sku: product.sku ?? '', + barcode: product.barcode ?? null, + name: product.name ?? '', + tax_rate: String(Math.round(Number(product.taxRate || 0))), + price: basePrice, + created_at: formatDateTime(product.createdAt), + lastChanged: String(product.lastChanged), + sort: String(product.sort ?? 0), + 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 ?? '', + isCompositeProduct: product.isCompositeProduct, + attributes: [], + use_stock: product.trackStock ? '1' : '0', + quantity: String(product.fBestand ?? 0), + }; + }); + }; +} diff --git a/src/store/stock/adjust.js b/src/store/stock/adjust.js index 5bcf416..af2544c 100644 --- a/src/store/stock/adjust.js +++ b/src/store/stock/adjust.js @@ -97,19 +97,61 @@ export default function adjust(provider) { const resolvedArticles = []; for (const item of normalizedLines) { const resolved = await resolveArticle(googleAuthId, item.sku, activeTx); - const artRes = await runner.query('SELECT is_father FROM article WHERE id = @articleId', { + const artRes = await runner.query('SELECT is_father, articlelist_id FROM article WHERE id = @articleId', { articleId: resolved.articleId, }); if (artRes.rows[0]?.is_father) { throw new Error(`A father article cannot have stock (${item.sku})`); } - const nameRes = await runner.query(GET_ARTICLE_NAME_SQL, { - articleId: resolved.articleId, - languageId: resolved.languageId, - }); - const articleName = nameRes.rows[0]?.name ?? item.sku; - resolvedArticles.push({ ...item, ...resolved, articleName }); + const articlelistId = artRes.rows[0]?.articlelist_id; + if (articlelistId != null) { + const compRes = await runner.query(` + SELECT + ala.article_id AS articleId, + ala.count, + a.sku, + at.name AS articleName + FROM articlelistarticle ala + INNER JOIN article a ON a.id = ala.article_id + LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId + WHERE ala.articlelist_id = @articlelistId + ORDER BY ala.sort ASC, ala.id ASC + `, { + articlelistId, + languageId: resolved.languageId, + }); + + if (compRes.rows.length === 0) { + throw new Error(`Stückliste has no items (${item.sku})`); + } + + for (const comp of compRes.rows) { + const compCount = Number(comp.count || 1); + const compAmount = item.amount * compCount; + const compText = item.text + ? `${item.text} (${item.sku})` + : (compAmount > 0 + ? `Manual stock addition (+${compAmount}) [${item.sku}]` + : `Manual stock removal (${compAmount}) [${item.sku}]`); + + resolvedArticles.push({ + sku: comp.sku, + warehouseId: item.warehouseId, + amount: compAmount, + text: compText, + articleId: comp.articleId, + articleName: comp.articleName || comp.sku, + }); + } + } else { + const nameRes = await runner.query(GET_ARTICLE_NAME_SQL, { + articleId: resolved.articleId, + languageId: resolved.languageId, + }); + const articleName = nameRes.rows[0]?.name ?? item.sku; + resolvedArticles.push({ ...item, ...resolved, articleName }); + } } // Allocate unique adjustment number using the dedicated adjustment number scheme