u
This commit is contained in:
4
API.md
4
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}>}>`
|
- `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<void>`
|
- `delete(googleAuthId: number, id: number, tx?: object): Promise<void>`
|
||||||
- `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}>}>`
|
- `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<Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string}>>`
|
- `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<object>>`
|
||||||
|
- `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}>}>`
|
- `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)
|
- [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}>}>`
|
- `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}>}>`
|
||||||
|
|||||||
30
API.quote.md
30
API.quote.md
@@ -104,19 +104,45 @@ Lists the quotes of the user's selected company.
|
|||||||
| Name | Type | Description |
|
| Name | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `googleAuthId` | `number` | The id of the Google auth user. |
|
| `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. |
|
| `[tx]` | `object` | Optional transaction. |
|
||||||
|
|
||||||
**Returns**
|
**Returns**
|
||||||
|
|
||||||
`Promise<Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string}>>` — 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<object>>` — The quotes.
|
||||||
|
|
||||||
**Example**
|
**Example**
|
||||||
|
|
||||||
```js
|
```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);
|
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`
|
## `quote.update`
|
||||||
|
|
||||||
*Source: `src/store/quote/update.js`*
|
*Source: `src/store/quote/update.js`*
|
||||||
|
|||||||
142
scripts/test-quote-summary.js
Normal file
142
scripts/test-quote-summary.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -93,6 +93,7 @@ import quoteGetById from './store/quote/getById.js';
|
|||||||
import quoteCreate from './store/quote/create.js';
|
import quoteCreate from './store/quote/create.js';
|
||||||
import quoteUpdate from './store/quote/update.js';
|
import quoteUpdate from './store/quote/update.js';
|
||||||
import quoteDelete from './store/quote/delete.js';
|
import quoteDelete from './store/quote/delete.js';
|
||||||
|
import quoteRebuildSummaries from './store/quote/rebuildSummaries.js';
|
||||||
import orderList from './store/order/list.js';
|
import orderList from './store/order/list.js';
|
||||||
import orderGetById from './store/order/getById.js';
|
import orderGetById from './store/order/getById.js';
|
||||||
import orderCreate from './store/order/create.js';
|
import orderCreate from './store/order/create.js';
|
||||||
@@ -420,6 +421,7 @@ export default class LibDashStore {
|
|||||||
create: quoteCreate(provider),
|
create: quoteCreate(provider),
|
||||||
update: quoteUpdate(provider),
|
update: quoteUpdate(provider),
|
||||||
delete: quoteDelete(provider),
|
delete: quoteDelete(provider),
|
||||||
|
rebuildSummaries: quoteRebuildSummaries(provider),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.order = {
|
this.order = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Drop order = reverse of schema create dependency order. */
|
/** Drop order = reverse of schema create dependency order. */
|
||||||
export const TABLES = [
|
export const TABLES = [
|
||||||
|
'quotesummary',
|
||||||
'ordersummary',
|
'ordersummary',
|
||||||
'package',
|
'package',
|
||||||
'shipping',
|
'shipping',
|
||||||
|
|||||||
@@ -1215,6 +1215,34 @@ BEGIN
|
|||||||
END
|
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 = `
|
export const INIT_MCP_CLIENT = `
|
||||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client')
|
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client')
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -1338,6 +1366,7 @@ export const SCHEMA_SCRIPTS = [
|
|||||||
INIT_SHIPPING,
|
INIT_SHIPPING,
|
||||||
INIT_PACKAGE,
|
INIT_PACKAGE,
|
||||||
INIT_ORDERSUMMARY,
|
INIT_ORDERSUMMARY,
|
||||||
|
INIT_QUOTESUMMARY,
|
||||||
INIT_MCP_CLIENT,
|
INIT_MCP_CLIENT,
|
||||||
INIT_MCP_AUTH_CODE,
|
INIT_MCP_AUTH_CODE,
|
||||||
INIT_MCP_TOKEN,
|
INIT_MCP_TOKEN,
|
||||||
|
|||||||
@@ -876,6 +876,29 @@ CREATE INDEX IF NOT EXISTS IX_ordersummary_company_number
|
|||||||
ON ordersummary (company_id, order_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 = `
|
export const INIT_MCP_CLIENT = `
|
||||||
CREATE TABLE IF NOT EXISTS mcp_client (
|
CREATE TABLE IF NOT EXISTS mcp_client (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -985,6 +1008,7 @@ export const SCHEMA_SCRIPTS = [
|
|||||||
INIT_SHIPPING,
|
INIT_SHIPPING,
|
||||||
INIT_PACKAGE,
|
INIT_PACKAGE,
|
||||||
INIT_ORDERSUMMARY,
|
INIT_ORDERSUMMARY,
|
||||||
|
INIT_QUOTESUMMARY,
|
||||||
INIT_MCP_CLIENT,
|
INIT_MCP_CLIENT,
|
||||||
INIT_MCP_AUTH_CODE,
|
INIT_MCP_AUTH_CODE,
|
||||||
INIT_MCP_TOKEN,
|
INIT_MCP_TOKEN,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA
|
|||||||
import taxEnsure from '../tax/ensure.js';
|
import taxEnsure from '../tax/ensure.js';
|
||||||
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
|
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
|
||||||
import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js';
|
import { assertCustomerEntity, loadDoc } from '../manifestDoc/helpers.js';
|
||||||
|
import { syncQuoteSummary } from './syncSummary.js';
|
||||||
import makeRunner from '../runner.js';
|
import makeRunner from '../runner.js';
|
||||||
|
|
||||||
export default function create(provider) {
|
export default function create(provider) {
|
||||||
@@ -87,6 +88,8 @@ export default function create(provider) {
|
|||||||
id: manifestId,
|
id: manifestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await syncQuoteSummary(runner, { companyId, manifestId });
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,247 @@
|
|||||||
import {
|
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,
|
QUOTE_TYPE as MSSQL_QUOTE_TYPE,
|
||||||
} from '../../providers/mssql/queries/manifestDoc.js';
|
} from '../../providers/mssql/queries/manifestDoc.js';
|
||||||
import {
|
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,
|
QUOTE_TYPE as SQLITE_QUOTE_TYPE,
|
||||||
} from '../../providers/sqlite3/queries/manifestDoc.js';
|
} from '../../providers/sqlite3/queries/manifestDoc.js';
|
||||||
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
|
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
|
||||||
import { shapeSummary } from '../manifestDoc/helpers.js';
|
import { shapeSummary } from '../manifestDoc/helpers.js';
|
||||||
|
import { shapeLine } from '../manifestLines.js';
|
||||||
import makeRunner from '../runner.js';
|
import makeRunner from '../runner.js';
|
||||||
|
|
||||||
export default function list(provider) {
|
export default function list(provider) {
|
||||||
const access = resolveSelectedCompanyAccess(provider);
|
const access = resolveSelectedCompanyAccess(provider);
|
||||||
const isSqlite = provider?.dialect === 'sqlite3';
|
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 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.
|
* Lists the quotes of the user's selected company.
|
||||||
*
|
*
|
||||||
* @param {number} googleAuthId - The id of the Google auth user.
|
* @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.
|
* @param {object} [tx] - Optional transaction.
|
||||||
* @returns {Promise<Array<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string}>>} 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<object>>} The quotes.
|
||||||
*
|
*
|
||||||
* @example
|
* @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);
|
* 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) => {
|
return async (googleAuthId, opts, tx) => {
|
||||||
const runner = makeRunner(provider, 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 { 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,
|
companyId,
|
||||||
type: quoteType,
|
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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
src/store/quote/rebuildSummaries.js
Normal file
19
src/store/quote/rebuildSummaries.js
Normal file
@@ -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 };
|
||||||
|
};
|
||||||
|
}
|
||||||
245
src/store/quote/syncSummary.js
Normal file
245
src/store/quote/syncSummary.js
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyA
|
|||||||
import taxEnsure from '../tax/ensure.js';
|
import taxEnsure from '../tax/ensure.js';
|
||||||
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
|
import { insertLines, normalizeLines, parseDate } from '../manifestLines.js';
|
||||||
import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js';
|
import { assertCustomerEntity, loadDoc, shapeSummary } from '../manifestDoc/helpers.js';
|
||||||
|
import { syncQuoteSummary } from './syncSummary.js';
|
||||||
import makeRunner from '../runner.js';
|
import makeRunner from '../runner.js';
|
||||||
|
|
||||||
export default function update(provider) {
|
export default function update(provider) {
|
||||||
@@ -146,6 +147,7 @@ export default function update(provider) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const doc = await loadDoc(runner, { type: quoteType, companyId, id });
|
const doc = await loadDoc(runner, { type: quoteType, companyId, id });
|
||||||
|
await syncQuoteSummary(runner, { companyId, manifestId: id });
|
||||||
return doc;
|
return doc;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user