diff --git a/API.article.md b/API.article.md index d0e2281..aad1b36 100644 --- a/API.article.md +++ b/API.article.md @@ -320,6 +320,29 @@ const articles = await store.article.listExport(42, null); // => [{ id, sku, name, slug, unitSi, net, taxRate, eans, categoryBreadcrumbs, fullHashes, ... }] ``` +## `article.listHistory` + +*Source: `src/store/article/listHistory.js`* + +Lists article history entries for the user's selected company, supporting pagination, date cutoff via seekMonths, and search. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `provider` | `import('../../providers/provider.js').Provider` | | + +**Returns** + +`(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{history: Array<{id: number, date: string, sku: string, name: string|null, historytext: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}>` + +**Example** + +```js +const res = await store.article.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'hjg', seekMonths: -13 }); +// => { history: [...], total, unfilteredTotal, offset, limit } +``` + ## `article.listSearchIndex` *Source: `src/store/article/listSearchIndex.js`* diff --git a/API.ledger.md b/API.ledger.md index 4cddaab..079c073 100644 --- a/API.ledger.md +++ b/API.ledger.md @@ -84,3 +84,26 @@ Lists ledger entries for a given article SKU, optionally filtered by transaction const entries = await store.ledger.listBySku(googleAuthId, 'SKU-001', 'DELIVERY'); // => [{ id, manifestarticle_id, warehouse_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }] ``` + +## `ledger.listHistory` + +*Source: `src/store/ledger/listHistory.js`* + +Lists ledger entries for the user's selected company across all articles, supporting pagination, date cutoff via seekMonths, and search. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `provider` | `import('../../providers/provider.js').Provider` | | + +**Returns** + +`(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{ledger: Array<{id: number, date: string, sku: string|null, name: string|null, transaction_type: string, transaction_text: string, amount: number}>, total: number, unfilteredTotal: number, offset: number, limit: number}>` + +**Example** + +```js +const res = await store.ledger.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'kjh', seekMonths: -1 }); +// => { ledger: [...], total, unfilteredTotal, offset, limit } +``` diff --git a/API.md b/API.md index 8261482..2e4e0dc 100644 --- a/API.md +++ b/API.md @@ -116,6 +116,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `importRows(input: {googleAuthId: number, rows: Array}, tx?: object): Promise<{imported: Array, skipped: Array, errors: Array}>` - `listChildren(googleAuthId: number, parentSku: string, tx?: object): Promise>` - `listExport(googleAuthId: number, categoryId?: number|null, tx?: object): Promise>` + - `listHistory(provider: import('../../providers/provider.js').Provider): (googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{history: Array<{id: number, date: string, sku: string, name: string|null, historytext: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}>` - `listSearchIndex(googleAuthId: number, tx?: object): Promise, categoryIds: Array}>>` - `orderImages(googleAuthId: number, sku: string, input: {fullHashes: Array}, tx?: object): Promise<{fullHashes: Array}>` - `removeChild(googleAuthId: number, parentSku: string, childSku: string, options?: {deleteArticle?: boolean, cascade?: boolean}, tx?: object): Promise<{success: boolean, parentSku: string, childSku: string, deleted: boolean}>` @@ -238,6 +239,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `add(googleAuthId: number, input: {manifestarticle_id: number, warehouse_id?: number|null, transaction_type: string, amount: number, transaction_text: string}, tx?: object): Promise<{id: number, manifestarticle_id: number, warehouse_id: number|null, transaction_type: string, amount: number, transaction_text: string, hash_version: number, prev_hash: string, created_at: string}>` - `listByManifestarticleId(googleAuthId: number, manifestarticleId: number, optionsOrType?: string|object, tx?: object): Promise>` - `listBySku(googleAuthId: number, sku: string, optionsOrType?: string|object, tx?: object): Promise>` + - `listHistory(provider: import('../../providers/provider.js').Provider): (googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{ledger: Array<{id: number, date: string, sku: string|null, name: string|null, transaction_type: string, transaction_text: string, amount: number}>, total: number, unfilteredTotal: number, offset: number, limit: number}>` - [warehouse](API.warehouse.md) - `create(googleAuthId: number, input: {name: string, sort?: number}, tx?: object): Promise<{id: number, name: string, sort: number}>` - `delete(googleAuthId: number, id: number, tx?: object): Promise` diff --git a/src/index.js b/src/index.js index e1d7f48..f411429 100644 --- a/src/index.js +++ b/src/index.js @@ -25,6 +25,7 @@ import articleAddImage from './store/article/addImage.js'; import articleCreateUnderParent from './store/article/createUnderParent.js'; import articleGetBySku from './store/article/getBySku.js'; import articleGetHistoryBySku from './store/article/getHistoryBySku.js'; +import articleListHistory from './store/article/listHistory.js'; import articleListSearchIndex from './store/article/listSearchIndex.js'; import articleListExport from './store/article/listExport.js'; import articleUpdateBySku from './store/article/updateBySku.js'; @@ -159,6 +160,7 @@ import manifestattachmentOrder from './store/manifestattachment/order.js'; import ledgerAdd from './store/ledger/add.js'; import ledgerListByManifestarticleId from './store/ledger/listByManifestarticleId.js'; import ledgerListBySku from './store/ledger/listBySku.js'; +import ledgerListHistory from './store/ledger/listHistory.js'; import warehouseCreate from './store/warehouse/create.js'; import warehouseGetById from './store/warehouse/getById.js'; import warehouseList from './store/warehouse/list.js'; @@ -320,6 +322,7 @@ export default class LibDashStore { createUnderParent: articleCreateUnderParent(provider), getBySku: articleGetBySku(provider), getHistoryBySku: articleGetHistoryBySku(provider), + listHistory: articleListHistory(provider), listSearchIndex: articleListSearchIndex(provider), listExport: articleListExport(provider), updateBySku: articleUpdateBySku(provider), @@ -555,6 +558,7 @@ export default class LibDashStore { add: ledgerAdd(provider), listByManifestarticleId: ledgerListByManifestarticleId(provider), listBySku: ledgerListBySku(provider), + listHistory: ledgerListHistory(provider), }; this.warehouse = { diff --git a/src/store/article/listHistory.js b/src/store/article/listHistory.js new file mode 100644 index 0000000..4559deb --- /dev/null +++ b/src/store/article/listHistory.js @@ -0,0 +1,170 @@ +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +/** + * Lists article history entries for the user's selected company, + * supporting pagination, date cutoff via seekMonths, and search. + * + * @param {import('../../providers/provider.js').Provider} provider + * @returns {(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{history: Array<{id: number, date: string, sku: string, name: string|null, historytext: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}>} + * + * @example + * const res = await store.article.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'hjg', seekMonths: -13 }); + * // => { history: [...], total, unfilteredTotal, offset, limit } + */ +export default function listHistory(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + return async (googleAuthId, opts, tx) => { + let offset = 0; + let limit = 50; + let isPaged = true; + let resolvedTx = tx; + + if (opts != null && typeof opts === 'object' && !opts._isTx) { + if (opts.offset !== undefined) { + const parsedOffset = Number(opts.offset); + if (!Number.isInteger(parsedOffset) || parsedOffset < 0) { + throw new Error('Invalid offset'); + } + offset = parsedOffset; + } + if (opts.limit !== undefined) { + const parsedLimit = Number(opts.limit); + if (!Number.isInteger(parsedLimit) || parsedLimit < 1) { + throw new Error('Invalid limit'); + } + limit = parsedLimit; + } + } else if (opts != null) { + resolvedTx = opts; + } + + const runner = makeRunner(provider, resolvedTx); + const { companyId, languageId } = await access(googleAuthId, runner); + + const conditions = ['a.company_id = @companyId']; + const params = { + companyId, + languageId, + }; + + // Date cutoff via seekMonths (strictly integer) + if (opts?.seekMonths !== undefined) { + if (typeof opts.seekMonths !== 'number' || !Number.isInteger(opts.seekMonths)) { + throw new Error('Invalid seekMonths'); + } + const d = new Date(); + d.setMonth(d.getMonth() - Math.abs(opts.seekMonths)); + params.dateTo = d.toISOString(); + conditions.push(`ah.${isSqlite ? '"date"' : '[date]'} <= @dateTo`); + } + + // Free-text search + if (typeof opts?.search === 'string' && opts.search.trim() !== '') { + params.searchPattern = `%${opts.search.trim()}%`; + if (isSqlite) { + conditions.push(`( + a.sku LIKE @searchPattern + OR at.name LIKE @searchPattern + OR ah."text" LIKE @searchPattern + )`); + } else { + conditions.push(`( + a.sku LIKE @searchPattern + OR at.name LIKE @searchPattern + OR ah.[text] LIKE @searchPattern + )`); + } + } + + const whereClause = conditions.join('\n AND '); + + const sqlCount = ` + SELECT COUNT(*) AS total + FROM articlehistory ah + INNER JOIN article a ON a.id = ah.article_id + LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId + WHERE ${whereClause} + `; + + const sqlCountBase = ` + SELECT COUNT(*) AS total + FROM articlehistory ah + INNER JOIN article a ON a.id = ah.article_id + WHERE a.company_id = @companyId + `; + + let sqlList; + if (isSqlite) { + sqlList = ` + SELECT + ah.id, + ah."date", + a.sku, + at.name, + ah."text" AS historytext + FROM articlehistory ah + INNER JOIN article a ON a.id = ah.article_id + LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId + WHERE ${whereClause} + ORDER BY ah."date" DESC, ah.id DESC + ${isPaged ? 'LIMIT @limit OFFSET @offset' : ''} + `; + } else { + sqlList = ` + SELECT + ah.id, + ah.[date], + a.sku, + at.name, + ah.[text] AS historytext + FROM articlehistory ah + INNER JOIN article a ON a.id = ah.article_id + LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId + WHERE ${whereClause} + ORDER BY ah.[date] DESC, ah.id DESC + ${isPaged ? 'OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY' : ''} + `; + } + + const hasUserFilters = ( + opts?.seekMonths !== undefined || + (typeof opts?.search === 'string' && opts.search.trim() !== '') + ); + + params.offset = offset; + params.limit = limit; + + const [countResult, listResult, unfilteredCountResult] = await Promise.all([ + runner.query(sqlCount, params), + runner.query(sqlList, params), + hasUserFilters ? runner.query(sqlCountBase, { companyId }) : null, + ]); + + const total = Number(countResult.rows[0]?.total ?? 0); + const unfilteredTotal = hasUserFilters + ? Number(unfilteredCountResult?.rows[0]?.total ?? 0) + : total; + + return { + history: listResult.rows.map((row) => ({ + id: row.id, + date: + row.date instanceof Date + ? row.date.toISOString() + : row.date + ? new Date(row.date).toISOString() + : null, + sku: row.sku, + name: row.name ?? null, + historytext: row.historytext, + })), + total, + unfilteredTotal, + offset, + limit, + }; + }; +} diff --git a/src/store/ledger/listHistory.js b/src/store/ledger/listHistory.js new file mode 100644 index 0000000..579271d --- /dev/null +++ b/src/store/ledger/listHistory.js @@ -0,0 +1,180 @@ +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +/** + * Lists ledger entries for the user's selected company across all articles, + * supporting pagination, date cutoff via seekMonths, and search. + * + * @param {import('../../providers/provider.js').Provider} provider + * @returns {(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object) => Promise<{ledger: Array<{id: number, date: string, sku: string|null, name: string|null, transaction_type: string, transaction_text: string, amount: number}>, total: number, unfilteredTotal: number, offset: number, limit: number}>} + * + * @example + * const res = await store.ledger.listHistory(googleAuthId, { offset: 0, limit: 50, search: 'kjh', seekMonths: -1 }); + * // => { ledger: [...], total, unfilteredTotal, offset, limit } + */ +export default function listHistory(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + return async (googleAuthId, opts, tx) => { + let offset = 0; + let limit = 50; + let isPaged = true; + let resolvedTx = tx; + + if (opts != null && typeof opts === 'object' && !opts._isTx) { + if (opts.offset !== undefined) { + const parsedOffset = Number(opts.offset); + if (!Number.isInteger(parsedOffset) || parsedOffset < 0) { + throw new Error('Invalid offset'); + } + offset = parsedOffset; + } + if (opts.limit !== undefined) { + const parsedLimit = Number(opts.limit); + if (!Number.isInteger(parsedLimit) || parsedLimit < 1) { + throw new Error('Invalid limit'); + } + limit = parsedLimit; + } + } else if (opts != null) { + resolvedTx = opts; + } + + const runner = makeRunner(provider, resolvedTx); + const { companyId, languageId } = await access(googleAuthId, runner); + + const conditions = ['m.company_id = @companyId']; + const params = { + companyId, + languageId, + }; + + // Date cutoff via seekMonths (strictly integer) + if (opts?.seekMonths !== undefined) { + if (typeof opts.seekMonths !== 'number' || !Number.isInteger(opts.seekMonths)) { + throw new Error('Invalid seekMonths'); + } + const d = new Date(); + d.setMonth(d.getMonth() - Math.abs(opts.seekMonths)); + params.dateTo = d.toISOString(); + conditions.push(`l.created_at <= @dateTo`); + } + + // Free-text search + if (typeof opts?.search === 'string' && opts.search.trim() !== '') { + params.searchPattern = `%${opts.search.trim()}%`; + const numCol = isSqlite ? 'm."number"' : 'm.[number]'; + conditions.push(`( + ma.sku LIKE @searchPattern + OR ma.name LIKE @searchPattern + OR at.name LIKE @searchPattern + OR ${numCol} LIKE @searchPattern + OR m.external_number LIKE @searchPattern + OR l.transaction_type LIKE @searchPattern + OR l.transaction_text LIKE @searchPattern + )`); + } + + const whereClause = conditions.join('\n AND '); + + const sqlCount = ` + SELECT COUNT(*) AS total + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + LEFT JOIN article a ON a.sku = ma.sku AND a.company_id = m.company_id + LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId + WHERE ${whereClause} + `; + + const sqlCountBase = ` + SELECT COUNT(*) AS total + 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 = @companyId + `; + + let sqlList; + if (isSqlite) { + sqlList = ` + SELECT + l.id, + l.created_at AS "date", + ma.sku, + COALESCE(at.name, ma.name) AS name, + l.transaction_type, + l.transaction_text, + 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 + LEFT JOIN article a ON a.sku = ma.sku AND a.company_id = m.company_id + LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId + WHERE ${whereClause} + ORDER BY l.created_at DESC, l.id DESC + ${isPaged ? 'LIMIT @limit OFFSET @offset' : ''} + `; + } else { + sqlList = ` + SELECT + l.id, + l.created_at AS [date], + ma.sku, + COALESCE(at.name, ma.name) AS name, + l.transaction_type, + l.transaction_text, + 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 + LEFT JOIN article a ON a.sku = ma.sku AND a.company_id = m.company_id + LEFT JOIN articletext at ON at.article_id = a.id AND at.language_id = @languageId + WHERE ${whereClause} + ORDER BY l.created_at DESC, l.id DESC + ${isPaged ? 'OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY' : ''} + `; + } + + const hasUserFilters = ( + opts?.seekMonths !== undefined || + (typeof opts?.search === 'string' && opts.search.trim() !== '') + ); + + params.offset = offset; + params.limit = limit; + + const [countResult, listResult, unfilteredCountResult] = await Promise.all([ + runner.query(sqlCount, params), + runner.query(sqlList, params), + hasUserFilters ? runner.query(sqlCountBase, { companyId }) : null, + ]); + + const total = Number(countResult.rows[0]?.total ?? 0); + const unfilteredTotal = hasUserFilters + ? Number(unfilteredCountResult?.rows[0]?.total ?? 0) + : total; + + return { + ledger: listResult.rows.map((row) => ({ + id: row.id, + date: + row.date instanceof Date + ? row.date.toISOString() + : row.date + ? new Date(row.date).toISOString() + : null, + sku: row.sku ?? null, + name: row.name ?? null, + transaction_type: row.transaction_type, + transaction_text: row.transaction_text, + amount: row.amount, + })), + total, + unfilteredTotal, + offset, + limit, + }; + }; +}