diff --git a/API.md b/API.md index 6cfdb00..ac95e4c 100644 --- a/API.md +++ b/API.md @@ -178,7 +178,9 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `create(googleAuthId: number, body: {number: string, date: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` - `delete(googleAuthId: number, id: number, tx?: object): Promise` - `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` - - `list(googleAuthId: number, tx?: object): Promise>` + - `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, seekMonths?: number}, tx?: object): Promise<{quotes: Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, createdAt: string|null, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>, positions?: number, grossTotal?: number, totalQuantity?: number, customerName?: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>` + - `rebuildSummaries(provider: import('../../providers/provider.js').DatabaseProvider): void` + - `syncSummary(): void` - `update(googleAuthId: number, id: number, patch: {number?: string, date?: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` - [order](API.order.md) - `create(googleAuthId: number, body: {number: string, date: string, entityId?: number, externalNumber?: string, lines?: Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>}, tx?: object): Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>` diff --git a/API.quote.md b/API.quote.md index cbcdcc6..805106c 100644 --- a/API.quote.md +++ b/API.quote.md @@ -104,19 +104,45 @@ Lists the quotes of the user's selected company. | Name | Type | Description | | --- | --- | --- | | `googleAuthId` | `number` | The id of the Google auth user. | +| `[opts]` | `object` | Optional pagination and filter options. | +| `[opts.offset=0]` | `number` | Number of quotes to skip. | +| `[opts.limit=50]` | `number` | Number of quotes to return. | +| `[opts.search]` | `string` | Free-text search matching quote number, external number, or customer name. | +| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). | | `[tx]` | `object` | Optional transaction. | **Returns** -`Promise>` — The quotes. +`Promise<{quotes: Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, createdAt: string|null, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>, positions?: number, grossTotal?: number, totalQuantity?: number, customerName?: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>` — The quotes. **Example** ```js +const result = await store.quote.list(42, { offset: 0, limit: 50, search: 'A-1002' }); +// => { quotes: [...], total: 10, unfilteredTotal: 42, offset: 0, limit: 50 } + const quotes = await store.quote.list(42); -// => [{ id, number, type, entityId, externalNumber, date }] +// => [{ id, number, type, entityId, externalNumber, date, createdAt, lines: [...] }] ``` +## `quote.rebuildSummaries` + +*Source: `src/store/quote/rebuildSummaries.js`* + +Rebuilds the `quotesummary` table for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `provider` | `import('../../providers/provider.js').DatabaseProvider` | | + +## `quote.syncSummary` + +*Source: `src/store/quote/syncSummary.js`* + +Centralized sync and rebuild logic for the materialized `quotesummary` table. + ## `quote.update` *Source: `src/store/quote/update.js`* diff --git a/scripts/test-quote-summary.js b/scripts/test-quote-summary.js new file mode 100644 index 0000000..a2e5625 --- /dev/null +++ b/scripts/test-quote-summary.js @@ -0,0 +1,142 @@ +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 Quote Summary & Filtering in dashdbschema ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-quote-summary-')); + 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('quote-summary-test@example.com'); + const company = await store.company.create({ + name: 'Quote Test Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + const groups = await store.customergroup.list(googleAuthId); + const customerAlice = await store.entity.create(googleAuthId, { + customergroupId: groups[0].id, + firstName: 'Alice', + name: 'Wonderland', + }); + const customerBob = await store.entity.create(googleAuthId, { + customergroupId: groups[0].id, + firstName: 'Bob', + name: 'Builder', + }); + + const art = await store.article.createUnderParent({ + googleAuthId, + parentId: company.rootCategoryId, + name: 'Gadget', + track_stock: 0, + }); + + // 1. Create quote 1: Alice, Q-1001, 2 items of 100 net @ 19% tax => 238 gross, 2 total quantity + const quote1 = await store.quote.create(googleAuthId, { + number: 'Q-1001', + date: '2026-08-01T10:00:00Z', + entityId: customerAlice.id, + lines: [{ sku: art.sku, name: 'Gadget', amount: 2, net: 100, taxrate: 19 }], + }); + assert.equal(quote1.number, 'Q-1001'); + + // Verify row in quotesummary table directly + const directRows1 = provider.db.prepare('SELECT * FROM quotesummary WHERE manifest_id = ?').all(quote1.id); + assert.equal(directRows1.length, 1); + assert.equal(directRows1[0].quote_number, 'Q-1001'); + assert.equal(directRows1[0].customer_name, 'Alice Wonderland'); + assert.equal(directRows1[0].positions, 1); + assert.equal(directRows1[0].gross_total, 238); + assert.equal(directRows1[0].total_quantity, 2); + + // 2. Create quote 2: Bob, Q-1002, 3 items of 50 net @ 19% tax => 178.50 gross, 3 total quantity + const quote2 = await store.quote.create(googleAuthId, { + number: 'Q-1002', + date: '2026-10-01T10:00:00Z', + entityId: customerBob.id, + lines: [{ sku: art.sku, name: 'Gadget', amount: 3, net: 50, taxrate: 19 }], + }); + + // Test quote.list unpaged + const unpaged = await store.quote.list(googleAuthId); + assert.equal(unpaged.length, 2); + const q1 = unpaged.find((q) => q.id === quote1.id); + assert.equal(q1.positions, 1); + assert.equal(q1.grossTotal, 238); + assert.equal(q1.totalQuantity, 2); + assert.equal(q1.customerName, 'Alice Wonderland'); + + // Test quote.list paged with search + const searchAlice = await store.quote.list(googleAuthId, { search: 'Alice' }); + assert.equal(searchAlice.total, 1); + assert.equal(searchAlice.unfilteredTotal, 2); + assert.equal(searchAlice.quotes[0].number, 'Q-1001'); + + const searchNumber = await store.quote.list(googleAuthId, { search: '1002' }); + assert.equal(searchNumber.total, 1); + assert.equal(searchNumber.quotes[0].number, 'Q-1002'); + + // Test quote.update updates summary + await store.quote.update(googleAuthId, quote1.id, { + lines: [ + { sku: art.sku, name: 'Gadget', amount: 5, net: 100, taxrate: 19 }, + { sku: art.sku, name: 'Gadget 2', amount: 1, net: 10, taxrate: 19 }, + ], + }); + const updatedSummary = provider.db.prepare('SELECT * FROM quotesummary WHERE manifest_id = ?').get(quote1.id); + assert.equal(updatedSummary.positions, 2); + assert.equal(updatedSummary.total_quantity, 6); + // (5 * 100 * 1.19) + (1 * 10 * 1.19) = 595 + 11.9 = 606.90 + assert.equal(updatedSummary.gross_total, 606.9); + + // Test rebuildSummaries + provider.db.prepare('DELETE FROM quotesummary').run(); + const countBeforeRebuild = provider.db.prepare('SELECT COUNT(*) as cnt FROM quotesummary').get().cnt; + assert.equal(countBeforeRebuild, 0); + + const rebuildRes = await store.quote.rebuildSummaries(googleAuthId); + assert.equal(rebuildRes.ok, true); + const countAfterRebuild = provider.db.prepare('SELECT COUNT(*) as cnt FROM quotesummary').get().cnt; + assert.equal(countAfterRebuild, 2); + + const rebuiltQ1 = provider.db.prepare('SELECT * FROM quotesummary WHERE manifest_id = ?').get(quote1.id); + assert.equal(rebuiltQ1.positions, 2); + assert.equal(rebuiltQ1.total_quantity, 6); + assert.equal(rebuiltQ1.gross_total, 606.9); + + // Test delete cascade + await store.quote.delete(googleAuthId, quote1.id); + const countAfterDelete = provider.db.prepare('SELECT COUNT(*) as cnt FROM quotesummary WHERE manifest_id = ?').get(quote1.id); + assert.equal(countAfterDelete.cnt, 0); + + console.log('--- ALL QUOTE SUMMARY 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/index.js b/src/index.js index ddd7ce1..c1d6088 100644 --- a/src/index.js +++ b/src/index.js @@ -93,6 +93,7 @@ import quoteGetById from './store/quote/getById.js'; import quoteCreate from './store/quote/create.js'; import quoteUpdate from './store/quote/update.js'; import quoteDelete from './store/quote/delete.js'; +import quoteRebuildSummaries from './store/quote/rebuildSummaries.js'; import orderList from './store/order/list.js'; import orderGetById from './store/order/getById.js'; import orderCreate from './store/order/create.js'; @@ -420,6 +421,7 @@ export default class LibDashStore { create: quoteCreate(provider), update: quoteUpdate(provider), delete: quoteDelete(provider), + rebuildSummaries: quoteRebuildSummaries(provider), }; this.order = { diff --git a/src/providers/mssql/purgeSql.js b/src/providers/mssql/purgeSql.js index 1c97f67..71a10dc 100644 --- a/src/providers/mssql/purgeSql.js +++ b/src/providers/mssql/purgeSql.js @@ -1,5 +1,6 @@ /** Drop order = reverse of schema create dependency order. */ export const TABLES = [ + 'quotesummary', 'ordersummary', 'package', 'shipping', diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index 3a943e8..75dcc93 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -1215,6 +1215,34 @@ BEGIN END `; +export const INIT_QUOTESUMMARY = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'quotesummary') +BEGIN + CREATE TABLE quotesummary ( + id INT IDENTITY(1,1) PRIMARY KEY, + company_id INT NOT NULL, + manifest_id INT NOT NULL CONSTRAINT UQ_quotesummary_manifest UNIQUE, + quote_number NVARCHAR(100) NOT NULL, + quote_date DATETIME2 NOT NULL, + created_at DATETIME2 NOT NULL CONSTRAINT DF_quotesummary_created_at DEFAULT SYSUTCDATETIME(), + entity_id INT NULL, + customer_name NVARCHAR(255) NULL, + positions INT NOT NULL CONSTRAINT DF_quotesummary_positions DEFAULT 0, + gross_total FLOAT NOT NULL CONSTRAINT DF_quotesummary_gross_total DEFAULT 0, + total_quantity FLOAT NOT NULL CONSTRAINT DF_quotesummary_total_quantity DEFAULT 0, + CONSTRAINT FK_quotesummary_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION, + CONSTRAINT FK_quotesummary_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE, + CONSTRAINT FK_quotesummary_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL + ); + + CREATE INDEX IX_quotesummary_company_date + ON quotesummary (company_id, quote_date DESC, manifest_id DESC); + + CREATE INDEX IX_quotesummary_company_number + ON quotesummary (company_id, quote_number); +END +`; + export const INIT_MCP_CLIENT = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client') BEGIN @@ -1338,6 +1366,7 @@ export const SCHEMA_SCRIPTS = [ INIT_SHIPPING, INIT_PACKAGE, INIT_ORDERSUMMARY, + INIT_QUOTESUMMARY, INIT_MCP_CLIENT, INIT_MCP_AUTH_CODE, INIT_MCP_TOKEN, diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index 1cc6975..d292e96 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -876,6 +876,29 @@ CREATE INDEX IF NOT EXISTS IX_ordersummary_company_number ON ordersummary (company_id, order_number); `; +export const INIT_QUOTESUMMARY = ` +CREATE TABLE IF NOT EXISTS quotesummary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL, + manifest_id INTEGER NOT NULL UNIQUE, + quote_number TEXT NOT NULL, + quote_date TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (${UTC_NOW}), + entity_id INTEGER NULL, + customer_name TEXT NULL, + positions INTEGER NOT NULL DEFAULT 0, + gross_total REAL NOT NULL DEFAULT 0, + total_quantity REAL NOT NULL DEFAULT 0, + FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION, + FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE, + FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS IX_quotesummary_company_date + ON quotesummary (company_id, quote_date DESC, manifest_id DESC); +CREATE INDEX IF NOT EXISTS IX_quotesummary_company_number + ON quotesummary (company_id, quote_number); +`; + export const INIT_MCP_CLIENT = ` CREATE TABLE IF NOT EXISTS mcp_client ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -985,6 +1008,7 @@ export const SCHEMA_SCRIPTS = [ INIT_SHIPPING, INIT_PACKAGE, INIT_ORDERSUMMARY, + INIT_QUOTESUMMARY, INIT_MCP_CLIENT, INIT_MCP_AUTH_CODE, INIT_MCP_TOKEN, diff --git a/src/store/quote/create.js b/src/store/quote/create.js index 578d4f7..5289f98 100644 --- a/src/store/quote/create.js +++ b/src/store/quote/create.js @@ -10,6 +10,7 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js'; +import { syncQuoteSummary } from './syncSummary.js'; import makeRunner from '../runner.js'; export default function create(provider) { @@ -87,6 +88,8 @@ export default function create(provider) { id: manifestId, }); + await syncQuoteSummary(runner, { companyId, manifestId }); + return doc; }; diff --git a/src/store/quote/list.js b/src/store/quote/list.js index a52f30c..9bb8797 100644 --- a/src/store/quote/list.js +++ b/src/store/quote/list.js @@ -1,42 +1,247 @@ import { - LIST_MANIFESTS as MSSQL_LIST, + COUNT_MANIFESTS as MSSQL_COUNT_MANIFESTS, + GET_MANIFEST_LINES_BY_MANIFEST_IDS as MSSQL_LINES_BY_MANIFEST_IDS, QUOTE_TYPE as MSSQL_QUOTE_TYPE, } from '../../providers/mssql/queries/manifestDoc.js'; import { - LIST_MANIFESTS as SQLITE_LIST, + COUNT_MANIFESTS as SQLITE_COUNT_MANIFESTS, + GET_MANIFEST_LINES_BY_MANIFEST_IDS as SQLITE_LINES_BY_MANIFEST_IDS, QUOTE_TYPE as SQLITE_QUOTE_TYPE, } from '../../providers/sqlite3/queries/manifestDoc.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { shapeSummary } from '../manifestDoc/helpers.js'; +import { shapeLine } from '../manifestLines.js'; import makeRunner from '../runner.js'; export default function list(provider) { const access = resolveSelectedCompanyAccess(provider); const isSqlite = provider?.dialect === 'sqlite3'; - const sql = isSqlite ? SQLITE_LIST : MSSQL_LIST; + const sqlLinesByManifestIds = isSqlite + ? SQLITE_LINES_BY_MANIFEST_IDS + : MSSQL_LINES_BY_MANIFEST_IDS; const quoteType = isSqlite ? SQLITE_QUOTE_TYPE : MSSQL_QUOTE_TYPE; + const sqlCountManifests = isSqlite + ? SQLITE_COUNT_MANIFESTS + : MSSQL_COUNT_MANIFESTS; /** * Lists the quotes of the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [opts] - Optional pagination and filter options. + * @param {number} [opts.offset=0] - Number of quotes to skip. + * @param {number} [opts.limit=50] - Number of quotes to return. + * @param {string} [opts.search] - Free-text search matching quote number, external number, or customer name. + * @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1). * @param {object} [tx] - Optional transaction. - * @returns {Promise>} The quotes. + * @returns {Promise<{quotes: Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, createdAt: string|null, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>, positions?: number, grossTotal?: number, totalQuantity?: number, customerName?: string}>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>} The quotes. * * @example + * const result = await store.quote.list(42, { offset: 0, limit: 50, search: 'A-1002' }); + * // => { quotes: [...], total: 10, unfilteredTotal: 42, offset: 0, limit: 50 } + * * const quotes = await store.quote.list(42); - * // => [{ id, number, type, entityId, externalNumber, date }] + * // => [{ id, number, type, entityId, externalNumber, date, createdAt, lines: [...] }] */ - return async (googleAuthId, tx) => { - const runner = makeRunner(provider, tx); + return async (googleAuthId, opts, tx) => { + let offset = null; + let limit = null; + let isPaged = false; + let resolvedTx = tx; + + const hasFilter = opts != null && typeof opts === 'object' && !opts._isTx && ( + opts.offset !== undefined || + opts.limit !== undefined || + (typeof opts.search === 'string' && opts.search.trim() !== '') || + opts.seekMonths !== undefined + ); + + if (opts != null && typeof opts === 'object' && !opts._isTx) { + if (hasFilter || opts.offset !== undefined || opts.limit !== undefined) { + isPaged = true; + const parsedOffset = Number(opts.offset ?? 0); + const parsedLimit = Number(opts.limit ?? 50); + if (!Number.isInteger(parsedOffset) || parsedOffset < 0) { + throw new Error('Invalid offset'); + } + if (!Number.isInteger(parsedLimit) || parsedLimit < 1) { + throw new Error('Invalid limit'); + } + offset = parsedOffset; + limit = parsedLimit; + } + } else if (opts != null) { + resolvedTx = opts; + } + + const runner = makeRunner(provider, resolvedTx); const { companyId } = await access(googleAuthId, runner); - const result = await runner.query(sql, { + const conditions = ['m.company_id = @companyId', `m.${isSqlite ? '"type"' : '[type]'} = @type`]; + const params = { companyId, type: quoteType, - }); + }; - return result.rows.map((row) => shapeSummary(quoteType, row)); + // 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(`m.${isSqlite ? '"date"' : '[date]'} <= @dateTo`); + } + + // Free-text search (strictly string) + if (typeof opts?.search === 'string' && opts.search.trim() !== '') { + params.searchPattern = `%${opts.search.trim()}%`; + if (isSqlite) { + conditions.push(`( + m."number" LIKE @searchPattern + OR m.external_number LIKE @searchPattern + OR e.name LIKE @searchPattern + OR e.first_name LIKE @searchPattern + OR (IFNULL(e.first_name, '') || ' ' || IFNULL(e.name, '')) LIKE @searchPattern + OR (IFNULL(e.name, '') || ' ' || IFNULL(e.first_name, '')) LIKE @searchPattern + )`); + } else { + conditions.push(`( + m.[number] LIKE @searchPattern + OR m.external_number LIKE @searchPattern + OR e.name LIKE @searchPattern + OR e.first_name LIKE @searchPattern + OR (ISNULL(e.first_name, '') + ' ' + ISNULL(e.name, '')) LIKE @searchPattern + OR (ISNULL(e.name, '') + ' ' + ISNULL(e.first_name, '')) LIKE @searchPattern + )`); + } + } + + const whereClause = conditions.join('\n AND '); + const sqlCount = ` + SELECT COUNT(*) AS total + FROM manifest m + LEFT JOIN quotesummary qs ON qs.manifest_id = m.id + LEFT JOIN entity e ON e.id = m.entity_id + WHERE ${whereClause} + `; + + let sqlList; + if (isSqlite) { + sqlList = ` + SELECT + m.id, + m."number", + m."type", + m.entity_id AS entityId, + m.external_number AS externalNumber, + m."date", + m.created_at AS createdAt, + qs.positions, + qs.gross_total AS grossTotal, + qs.total_quantity AS totalQuantity, + qs.customer_name AS customerName + FROM manifest m + LEFT JOIN quotesummary qs ON qs.manifest_id = m.id + LEFT JOIN entity e ON e.id = m.entity_id + WHERE ${whereClause} + ORDER BY m."date" DESC, m.id DESC + ${isPaged ? 'LIMIT @limit OFFSET @offset' : ''} + `; + } else { + sqlList = ` + SELECT + m.id, + m.[number], + m.[type], + m.entity_id AS entityId, + m.external_number AS externalNumber, + m.[date], + m.created_at AS createdAt, + qs.positions, + qs.gross_total AS grossTotal, + qs.total_quantity AS totalQuantity, + qs.customer_name AS customerName + FROM manifest m + LEFT JOIN quotesummary qs ON qs.manifest_id = m.id + LEFT JOIN entity e ON e.id = m.entity_id + WHERE ${whereClause} + ORDER BY m.[date] DESC, m.id DESC + ${isPaged ? 'OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY' : ''} + `; + } + + const hasUserFilters = ( + opts?.seekMonths !== undefined || + (typeof opts?.search === 'string' && opts.search.trim() !== '') + ); + + let manifestRows; + let total = 0; + let unfilteredTotal = 0; + + if (isPaged) { + params.offset = offset; + params.limit = limit; + const [countResult, listResult, unfilteredCountResult] = await Promise.all([ + runner.query(sqlCount, params), + runner.query(sqlList, params), + hasUserFilters ? runner.query(sqlCountManifests, { companyId, type: quoteType }) : null, + ]); + total = Number(countResult.rows[0]?.total ?? 0); + unfilteredTotal = hasUserFilters + ? Number(unfilteredCountResult?.rows[0]?.total ?? 0) + : total; + manifestRows = listResult.rows; + } else { + const result = await runner.query(sqlList, params); + manifestRows = result.rows; + total = manifestRows.length; + if (hasUserFilters) { + const countRes = await runner.query(sqlCountManifests, { companyId, type: quoteType }); + unfilteredTotal = Number(countRes.rows[0]?.total ?? 0); + } else { + unfilteredTotal = total; + } + } + + const manifestIds = manifestRows.map((r) => r.id); + const linesByManifestId = new Map(); + + if (manifestIds.length > 0) { + const linesResult = await runner.query(sqlLinesByManifestIds, { + manifestIdsJson: JSON.stringify(manifestIds), + }); + for (const row of linesResult.rows) { + let lines = linesByManifestId.get(row.manifestId); + if (!lines) { + lines = []; + linesByManifestId.set(row.manifestId, lines); + } + lines.push(shapeLine(row)); + } + } + + const quotes = manifestRows.map((row) => ({ + ...shapeSummary(quoteType, row), + lines: linesByManifestId.get(row.id) ?? [], + positions: row.positions !== undefined && row.positions !== null ? Number(row.positions) : undefined, + grossTotal: row.grossTotal !== undefined && row.grossTotal !== null ? Number(row.grossTotal) : undefined, + totalQuantity: row.totalQuantity !== undefined && row.totalQuantity !== null ? Number(row.totalQuantity) : undefined, + customerName: row.customerName ?? undefined, + })); + + if (isPaged) { + return { + quotes, + total, + unfilteredTotal, + offset, + limit, + }; + } + + return quotes; }; } - diff --git a/src/store/quote/rebuildSummaries.js b/src/store/quote/rebuildSummaries.js new file mode 100644 index 0000000..6b5c309 --- /dev/null +++ b/src/store/quote/rebuildSummaries.js @@ -0,0 +1,19 @@ +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; +import { rebuildQuoteSummaries } from './syncSummary.js'; + +/** + * Rebuilds the `quotesummary` table for the user's selected company. + * + * @param {import('../../providers/provider.js').DatabaseProvider} provider + */ +export default function rebuildSummaries(provider) { + const access = resolveSelectedCompanyAccess(provider); + + return async (googleAuthId, tx) => { + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + await rebuildQuoteSummaries(runner, companyId); + return { ok: true, companyId }; + }; +} diff --git a/src/store/quote/syncSummary.js b/src/store/quote/syncSummary.js new file mode 100644 index 0000000..0deb06e --- /dev/null +++ b/src/store/quote/syncSummary.js @@ -0,0 +1,245 @@ +/** + * Centralized sync and rebuild logic for the materialized `quotesummary` table. + */ + +const SQL_GET_QUOTE_BY_ID_SQLITE = ` + SELECT m.id, m.company_id, m."number", m."date", m.created_at, m.entity_id + FROM manifest m + WHERE m.id = @manifestId AND m.company_id = @companyId AND m."type" = 'quote' +`; + +const SQL_GET_QUOTE_BY_ID_MSSQL = ` + SELECT m.id, m.company_id, m.[number], m.[date], m.created_at, m.entity_id + FROM manifest m + WHERE m.id = @manifestId AND m.company_id = @companyId AND m.[type] = 'quote' +`; + +const SQL_GET_QUOTE_BY_NUMBER_SQLITE = ` + SELECT m.id, m.company_id, m."number", m."date", m.created_at, m.entity_id + FROM manifest m + WHERE m."number" = @number AND m.company_id = @companyId AND m."type" = 'quote' +`; + +const SQL_GET_QUOTE_BY_NUMBER_MSSQL = ` + SELECT m.id, m.company_id, m.[number], m.[date], m.created_at, m.entity_id + FROM manifest m + WHERE m.[number] = @number AND m.company_id = @companyId AND m.[type] = 'quote' +`; + +const SQL_LINE_TOTALS = ` + SELECT + COUNT(*) AS positions, + COALESCE(SUM(amount), 0) AS total_quantity, + COALESCE(SUM(amount * (net * (1.0 - rebate / 100.0)) * (1.0 + taxrate / 100.0)), 0) AS gross_total + FROM manifestarticle + WHERE manifest_id = @manifestId +`; + +const SQL_CUSTOMER_NAME_SQLITE = ` + SELECT TRIM(COALESCE(first_name || ' ', '') || COALESCE(name, '')) AS customer_name + FROM entity + WHERE id = @entityId +`; + +const SQL_CUSTOMER_NAME_MSSQL = ` + SELECT LTRIM(RTRIM(ISNULL(first_name + ' ', '') + ISNULL(name, ''))) AS customer_name + FROM entity + WHERE id = @entityId +`; + +const SQL_UPSERT_SUMMARY_SQLITE = ` + INSERT INTO quotesummary ( + company_id, manifest_id, quote_number, quote_date, created_at, + entity_id, customer_name, positions, gross_total, total_quantity + ) VALUES ( + @companyId, @manifestId, @quoteNumber, @quoteDate, @createdAt, + @entityId, @customerName, @positions, @grossTotal, @totalQuantity + ) + ON CONFLICT(manifest_id) DO UPDATE SET + quote_number = excluded.quote_number, + quote_date = excluded.quote_date, + created_at = excluded.created_at, + entity_id = excluded.entity_id, + customer_name = excluded.customer_name, + positions = excluded.positions, + gross_total = excluded.gross_total, + total_quantity = excluded.total_quantity +`; + +const SQL_UPSERT_SUMMARY_MSSQL = ` + MERGE INTO quotesummary AS target + USING (SELECT @manifestId AS manifest_id) AS source + ON (target.manifest_id = source.manifest_id) + WHEN MATCHED THEN + UPDATE SET + quote_number = @quoteNumber, + quote_date = @quoteDate, + created_at = @createdAt, + entity_id = @entityId, + customer_name = @customerName, + positions = @positions, + gross_total = @grossTotal, + total_quantity = @totalQuantity + WHEN NOT MATCHED THEN + INSERT ( + company_id, manifest_id, quote_number, quote_date, created_at, + entity_id, customer_name, positions, gross_total, total_quantity + ) VALUES ( + @companyId, @manifestId, @quoteNumber, @quoteDate, @createdAt, + @entityId, @customerName, @positions, @grossTotal, @totalQuantity + ); +`; + +/** + * Recalculates and upserts a single quote summary into `quotesummary`. + * + * @param {object} runner - Database runner. + * @param {object} opts + * @param {number} opts.companyId + * @param {number} [opts.manifestId] - Quote manifest id. + * @param {string} [opts.quoteNumber] - Quote number (if manifestId not given). + */ +export async function syncQuoteSummary(runner, { companyId, manifestId, quoteNumber }) { + const isSqlite = runner.dialect === 'sqlite3'; + + let quoteRow; + if (manifestId) { + const res = await runner.query( + isSqlite ? SQL_GET_QUOTE_BY_ID_SQLITE : SQL_GET_QUOTE_BY_ID_MSSQL, + { manifestId, companyId }, + ); + quoteRow = res.rows[0]; + } else if (quoteNumber) { + const res = await runner.query( + isSqlite ? SQL_GET_QUOTE_BY_NUMBER_SQLITE : SQL_GET_QUOTE_BY_NUMBER_MSSQL, + { number: quoteNumber, companyId }, + ); + quoteRow = res.rows[0]; + } + + if (!quoteRow) { + if (manifestId) { + await runner.query('DELETE FROM quotesummary WHERE manifest_id = @manifestId', { manifestId }); + } + return; + } + + const quoteId = quoteRow.id; + const num = quoteRow.number; + + const lineRes = await runner.query(SQL_LINE_TOTALS, { manifestId: quoteId }); + + let customerName = null; + if (quoteRow.entity_id) { + const custRes = await runner.query( + isSqlite ? SQL_CUSTOMER_NAME_SQLITE : SQL_CUSTOMER_NAME_MSSQL, + { entityId: quoteRow.entity_id }, + ); + customerName = custRes.rows[0]?.customer_name || null; + } + + const positions = Number(lineRes.rows[0]?.positions || 0); + const totalQuantity = Math.round(Number(lineRes.rows[0]?.total_quantity || 0) * 1000) / 1000; + const grossTotal = Math.round(Number(lineRes.rows[0]?.gross_total || 0) * 100) / 100; + + await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, { + companyId, + manifestId: quoteId, + quoteNumber: num, + quoteDate: quoteRow.date, + createdAt: quoteRow.created_at, + entityId: quoteRow.entity_id ?? null, + customerName, + positions, + grossTotal, + totalQuantity, + }); +} + +/** + * Full rebuild of all quotes in `quotesummary` for a given company. + * + * @param {object} runner + * @param {number} companyId + */ +export async function rebuildQuoteSummaries(runner, companyId) { + const isSqlite = runner.dialect === 'sqlite3'; + + if (isSqlite) { + const sqlRebuildSqlite = ` + WITH + quote_articles AS ( + SELECT + manifest_id, + COUNT(*) AS positions, + SUM(amount) AS total_quantity, + SUM(amount * (net * (1.0 - rebate / 100.0)) * (1.0 + taxrate / 100.0)) AS gross_total + FROM manifestarticle + GROUP BY manifest_id + ) + INSERT INTO quotesummary ( + company_id, manifest_id, quote_number, quote_date, created_at, + entity_id, customer_name, positions, gross_total, total_quantity + ) + SELECT + m.company_id, + m.id AS manifest_id, + m."number" AS quote_number, + m."date" AS quote_date, + m.created_at, + m.entity_id, + TRIM(COALESCE(e.first_name || ' ', '') || COALESCE(e.name, '')) AS customer_name, + COALESCE(qa.positions, 0) AS positions, + ROUND(COALESCE(qa.gross_total, 0), 2) AS gross_total, + ROUND(COALESCE(qa.total_quantity, 0), 3) AS total_quantity + FROM manifest m + LEFT JOIN entity e ON e.id = m.entity_id + LEFT JOIN quote_articles qa ON qa.manifest_id = m.id + WHERE m.company_id = @companyId AND m."type" = 'quote' + ON CONFLICT(manifest_id) DO UPDATE SET + quote_number = excluded.quote_number, + quote_date = excluded.quote_date, + created_at = excluded.created_at, + entity_id = excluded.entity_id, + customer_name = excluded.customer_name, + positions = excluded.positions, + gross_total = excluded.gross_total, + total_quantity = excluded.total_quantity; + `; + await runner.query(sqlRebuildSqlite, { companyId }); + } else { + await runner.query('DELETE FROM quotesummary WHERE company_id = @companyId', { companyId }); + const sqlRebuildMssql = ` + WITH + quote_articles AS ( + SELECT + manifest_id, + COUNT(*) AS positions, + SUM(amount) AS total_quantity, + SUM(amount * (net * (1.0 - rebate / 100.0)) * (1.0 + taxrate / 100.0)) AS gross_total + FROM manifestarticle + GROUP BY manifest_id + ) + INSERT INTO quotesummary ( + company_id, manifest_id, quote_number, quote_date, created_at, + entity_id, customer_name, positions, gross_total, total_quantity + ) + SELECT + m.company_id, + m.id AS manifest_id, + m.[number] AS quote_number, + m.[date] AS quote_date, + m.created_at, + m.entity_id, + LTRIM(RTRIM(ISNULL(e.first_name + ' ', '') + ISNULL(e.name, ''))) AS customer_name, + COALESCE(qa.positions, 0) AS positions, + ROUND(COALESCE(qa.gross_total, 0), 2) AS gross_total, + ROUND(COALESCE(qa.total_quantity, 0), 3) AS total_quantity + FROM manifest m + LEFT JOIN entity e ON e.id = m.entity_id + LEFT JOIN quote_articles qa ON qa.manifest_id = m.id + WHERE m.company_id = @companyId AND m.[type] = 'quote'; + `; + await runner.query(sqlRebuildMssql, { companyId }); + } +} diff --git a/src/store/quote/update.js b/src/store/quote/update.js index e54738e..61c9a42 100644 --- a/src/store/quote/update.js +++ b/src/store/quote/update.js @@ -14,6 +14,7 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA import taxEnsure from '../tax/ensure.js'; import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js'; +import { syncQuoteSummary } from './syncSummary.js'; import makeRunner from '../runner.js'; export default function update(provider) { @@ -146,6 +147,7 @@ export default function update(provider) { } const doc = await loadDoc(runner, { type: quoteType, companyId, id }); + await syncQuoteSummary(runner, { companyId, manifestId: id }); return doc; };