diff --git a/API.md b/API.md index bc575df..359f8df 100644 --- a/API.md +++ b/API.md @@ -401,6 +401,21 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `create(googleAuthId: number, input: {accountId: number, manifestId: number, type: string, amount: number}, tx?: object): Promise<{id: number, accountId: number, manifestId: number, type: string, amount: number}>` - `delete(googleAuthId: number, id: number, tx?: object): Promise` - `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise>` +- [retoure](API.retoure.md) + - `create(googleAuthId: number, body: {number?: string, date: string, entityId?: number, externalNumber?: string, lines?: Array}, tx?: object): Promise` + - `createShipment(googleAuthId: number, retoureId: number, input: object, tx?: object): Promise` + - `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, opts?: {offset?: number, limit?: number, search?: string, onlyUnpaid?: boolean, onlyUndelivered?: boolean, seekMonths?: number}, tx?: object): Promise<{retoures: Array, 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}, tx?: object): Promise` +- [return](API.return.md) + - `book(provider: object): Function` + - `getById(googleAuthId: number, id: number, tx?: object): Promise` + - `list(googleAuthId: number, opts?: {offset?: number, limit?: number, search?: string, onlyUnbooked?: boolean, supplierId?: number, seekMonths?: number}, tx?: object): Promise<{returns: Array, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>` + - `rebuildSummaries(provider: import('../../providers/provider.js').DatabaseProvider): void` + - `syncSummary(): void` - [subagentschedule](API.subagentschedule.md) - `create(googleAuthId: number, input: {prompt: string, cron: string, desc?: string, skills?: string|Array, lastagentnote?: string, max_itr?: number}, tx?: object): Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>` - `delete(googleAuthId: number, id: number, tx?: object): Promise` diff --git a/API.retoure.md b/API.retoure.md new file mode 100644 index 0000000..3f380a0 --- /dev/null +++ b/API.retoure.md @@ -0,0 +1,155 @@ +# retoure + +This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. + +## `retoure.create` + +*Source: `src/store/retoure/create.js`* + +Creates a new retoure for the user's selected company with its lines. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `body` | `object` | The retoure input. | +| `[body.number]` | `string` | The retoure number. | +| `body.date` | `string` | The retoure date. | +| `[body.entityId]` | `number` | The id of the customer entity. | +| `[body.externalNumber]` | `string` | An external reference number. | +| `[body.lines]` | `Array` | The retoure lines. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise` — The created retoure. + +## `retoure.createShipment` + +*Source: `src/store/retoure/createShipment.js`* + +Creates a shipment manifest for a retoure. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `retoureId` | `number` | The parent retoure manifest id. | +| `input` | `object` | Shipment input. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise` — The created shipment. + +## `retoure.delete` + +*Source: `src/store/retoure/delete.js`* + +Deletes a retoure by its id within the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The id of the retoure to delete. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise` + +**Throws** + +`Error` — If the retoure is not found. + +## `retoure.getById` + +*Source: `src/store/retoure/getById.js`* + +Returns a single retoure by its id within the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The id of the retoure. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`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}>}>` — The retoure with its lines. + +**Throws** + +`Error` — If the retoure is not found. + +## `retoure.list` + +*Source: `src/store/retoure/list.js`* + +Lists the retoures of the user's selected company. + +**Parameters** + +| 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 retoures to skip. | +| `[opts.limit=50]` | `number` | Number of retoures to return. | +| `[opts.search]` | `string` | Free-text search matching retoure number, external number, or customer name. | +| `[opts.onlyUnpaid=false]` | `boolean` | When true, returns only unpaid retoures. | +| `[opts.onlyUndelivered=false]` | `boolean` | When true, returns only undelivered retoures. | +| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago (e.g. -1). | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise<{retoures: Array, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>` + +## `retoure.rebuildSummaries` + +*Source: `src/store/retoure/rebuildSummaries.js`* + +Rebuilds the `retouresummary` table for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `provider` | `import('../../providers/provider.js').DatabaseProvider` | | + +## `retoure.syncSummary` + +*Source: `src/store/retoure/syncSummary.js`* + +Centralized sync and rebuild logic for the materialized `retouresummary` table. + +## `retoure.update` + +*Source: `src/store/retoure/update.js`* + +Updates a retoure's header and/or lines. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The id of the retoure to update. | +| `patch` | `object` | The update patch. | +| `[patch.number]` | `string` | The new retoure number. | +| `[patch.date]` | `string` | The new retoure date. | +| `[patch.entityId]` | `number` | The new customer entity id. | +| `[patch.externalNumber]` | `string` | The new external reference number. | +| `[patch.lines]` | `Array` | The new retoure lines. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise` — The updated retoure. diff --git a/API.return.md b/API.return.md new file mode 100644 index 0000000..a35f635 --- /dev/null +++ b/API.return.md @@ -0,0 +1,83 @@ +# return + +This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. + +## `return.book` + +*Source: `src/store/return/book.js`* + +Books all positions of a return manifest to the ledger. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `provider` | `object` | Storage provider instance. | + +**Returns** + +`Function` — `(googleAuthId, input, tx) => Promise` + +## `return.getById` + +*Source: `src/store/return/getById.js`* + +Returns a return manifest by its id within the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The id of the return manifest. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise` — The return manifest with lines and summary. + +**Throws** + +`Error` — If the return is not found. + +## `return.list` + +*Source: `src/store/return/list.js`* + +Lists the returns of the user's selected company. + +**Parameters** + +| 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 returns to skip. | +| `[opts.limit=50]` | `number` | Number of returns to return. | +| `[opts.search]` | `string` | Free-text search matching return number, external number, or supplier name. | +| `[opts.onlyUnbooked=false]` | `boolean` | When true, returns only returns with unbooked stock items. | +| `[opts.supplierId]` | `number` | Filter by supplier entity id. | +| `[opts.seekMonths]` | `number` | Negative integer for cutoff months ago. | +| `[tx]` | `object` | Optional transaction. | + +**Returns** + +`Promise<{returns: Array, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>` + +## `return.rebuildSummaries` + +*Source: `src/store/return/rebuildSummaries.js`* + +Rebuilds the `returnsummary` table for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `provider` | `import('../../providers/provider.js').DatabaseProvider` | | + +## `return.syncSummary` + +*Source: `src/store/return/syncSummary.js`* + +Centralized sync and rebuild logic for the materialized `returnsummary` table. Keeps `returnsummary` in sync with manifest (return), manifestarticle, article (track_stock), and ledger (RETURN transactions). diff --git a/src/index.js b/src/index.js index f6df0ae..ba150c9 100644 --- a/src/index.js +++ b/src/index.js @@ -108,6 +108,20 @@ import orderCreateShipment from './store/order/createShipment.js'; import orderUpdate from './store/order/update.js'; import orderDelete from './store/order/delete.js'; import orderRebuildSummaries from './store/order/rebuildSummaries.js'; +import returnList from './store/return/list.js'; +import returnGetById from './store/return/getById.js'; +import returnCreate from './store/return/create.js'; +import returnUpdate from './store/return/update.js'; +import returnDelete from './store/return/delete.js'; +import returnBook from './store/return/book.js'; +import returnRebuildSummaries from './store/return/rebuildSummaries.js'; +import retoureList from './store/retoure/list.js'; +import retoureGetById from './store/retoure/getById.js'; +import retoureCreate from './store/retoure/create.js'; +import retoureCreateShipment from './store/retoure/createShipment.js'; +import retoureUpdate from './store/retoure/update.js'; +import retoureDelete from './store/retoure/delete.js'; +import retoureRebuildSummaries from './store/retoure/rebuildSummaries.js'; import posterminalList from './store/posterminal/list.js'; import posterminalUpsert from './store/posterminal/upsert.js'; import posterminalDelete from './store/posterminal/delete.js'; @@ -472,6 +486,26 @@ export default class LibDashStore { rebuildSummaries: orderRebuildSummaries(provider), }; + this.return = { + list: returnList(provider), + getById: returnGetById(provider), + create: returnCreate(provider), + update: returnUpdate(provider), + delete: returnDelete(provider), + book: returnBook(provider), + rebuildSummaries: returnRebuildSummaries(provider), + }; + + this.retoure = { + list: retoureList(provider), + getById: retoureGetById(provider), + create: retoureCreate(provider), + createShipment: retoureCreateShipment(provider), + update: retoureUpdate(provider), + delete: retoureDelete(provider), + rebuildSummaries: retoureRebuildSummaries(provider), + }; + this.posterminal = { list: posterminalList(provider), upsert: posterminalUpsert(provider), diff --git a/src/providers/mssql/queries/manifestDoc.js b/src/providers/mssql/queries/manifestDoc.js index 92fae88..7b33f08 100644 --- a/src/providers/mssql/queries/manifestDoc.js +++ b/src/providers/mssql/queries/manifestDoc.js @@ -5,6 +5,8 @@ export const QUOTE_TYPE = 'quote'; export const ORDER_TYPE = 'order'; +export const RETOURE_TYPE = 'retoure'; +export const RETURN_TYPE = 'return'; export const LIST_MANIFESTS = ` SELECT diff --git a/src/providers/mssql/queries/retoure.js b/src/providers/mssql/queries/retoure.js new file mode 100644 index 0000000..e3f93a3 --- /dev/null +++ b/src/providers/mssql/queries/retoure.js @@ -0,0 +1,25 @@ +export const RETOURE_TYPE = 'retoure'; + +export const GET_RETOURE_HEADER = ` +SELECT + m.id, + m.[number], + m.[type], + m.entity_id AS entityId, + m.external_number AS externalNumber, + m.[date], + m.created_at AS createdAt, + rs.positions, + rs.gross_total AS grossTotal, + rs.paid_total AS paidTotal, + rs.ordered_quantity AS orderedQuantity, + rs.shipped_quantity AS shippedQuantity, + rs.is_paid AS isPaid, + rs.is_shipped AS isShipped, + rs.customer_name AS customerName +FROM manifest m +LEFT JOIN retouresummary rs ON rs.manifest_id = m.id +WHERE m.id = @id + AND m.company_id = @companyId + AND m.[type] = @type +`; diff --git a/src/providers/mssql/queries/return.js b/src/providers/mssql/queries/return.js new file mode 100644 index 0000000..2b60b34 --- /dev/null +++ b/src/providers/mssql/queries/return.js @@ -0,0 +1,79 @@ +export const RETURN_TYPE = 'return'; + +export const GET_RETURN_HEADER = ` +SELECT + m.id, + m.[number], + m.[type], + m.entity_id AS supplierId, + m.external_number AS externalNumber, + m.[date], + m.created_at AS createdAt, + rs.positions, + rs.gross_total AS grossTotal, + rs.net_total AS netTotal, + rs.total_quantity AS totalQuantity, + rs.stock_positions AS stockPositions, + rs.booked_positions AS bookedPositions, + rs.unbooked_positions AS unbookedPositions, + rs.stock_quantity AS stockQuantity, + rs.booked_quantity AS bookedQuantity, + rs.unbooked_quantity AS unbookedQuantity, + rs.is_booked AS isBooked, + rs.supplier_name AS supplierName +FROM manifest m +LEFT JOIN returnsummary rs ON rs.manifest_id = m.id +WHERE m.id = @id + AND m.company_id = @companyId + AND m.[type] = @type +`; + +export const GET_RETURN_LINES = ` +SELECT + ma.id, + ma.sku, + ma.name, + ma.pu, + ma.amount, + ma.net, + ma.rebate, + ma.taxrate, + ma.comment +FROM manifestarticle ma +WHERE ma.manifest_id = @manifestId +ORDER BY ma.id +`; + +export const LIST_RETURNS = ` +SELECT + m.id, + m.[number], + m.[type], + m.entity_id AS supplierId, + m.external_number AS externalNumber, + m.[date], + m.created_at AS createdAt, + COUNT(ma.id) AS totalPositions, + COUNT(CASE WHEN ma.id IS NOT NULL AND ( + (ma.amount > 0 AND COALESCE(ma_ledger.booked_amount, 0) >= ma.amount) OR + (ma.amount <= 0 AND COALESCE(ma_ledger.booked_amount, 0) > 0) + ) THEN 1 END) AS completelyOnLedgerPositions +FROM manifest m +LEFT JOIN manifestarticle ma ON ma.manifest_id = m.id +LEFT JOIN ( + SELECT manifestarticle_id, SUM(amount) AS booked_amount + FROM ledger + GROUP BY manifestarticle_id +) ma_ledger ON ma_ledger.manifestarticle_id = ma.id +WHERE m.company_id = @companyId + AND m.[type] = @type +GROUP BY + m.id, + m.[number], + m.[type], + m.entity_id, + m.external_number, + m.[date], + m.created_at +ORDER BY m.[date] DESC, m.id DESC +`; diff --git a/src/providers/mssql/queries/shipment.js b/src/providers/mssql/queries/shipment.js index eb84e45..9338ae3 100644 --- a/src/providers/mssql/queries/shipment.js +++ b/src/providers/mssql/queries/shipment.js @@ -3,7 +3,7 @@ export const SHIPMENT_TYPE = 'shipment'; export const GET_ORDER_HEADER = ` SELECT id, company_id AS companyId, [number], [type], entity_id AS entityId, external_number AS externalNumber, [date] FROM manifest -WHERE id = @orderId AND company_id = @companyId AND [type] = 'order'; +WHERE id = @orderId AND company_id = @companyId AND [type] IN ('order', 'retoure'); `; export const GET_ORDER_LINES = ` diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index e31697c..2ba11f0 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -1398,6 +1398,87 @@ BEGIN END `; +export const INIT_RETURNSUMMARY = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'returnsummary') +BEGIN + CREATE TABLE returnsummary ( + id INT IDENTITY(1,1) PRIMARY KEY, + company_id INT NOT NULL, + manifest_id INT NOT NULL CONSTRAINT UQ_returnsummary_manifest UNIQUE, + return_number NVARCHAR(100) NOT NULL, + external_number NVARCHAR(100) NULL, + return_date DATETIME2 NOT NULL, + created_at DATETIME2 NOT NULL CONSTRAINT DF_returnsummary_created_at DEFAULT SYSUTCDATETIME(), + supplier_id INT NULL, + supplier_name NVARCHAR(255) NULL, + positions INT NOT NULL CONSTRAINT DF_returnsummary_positions DEFAULT 0, + gross_total FLOAT NOT NULL CONSTRAINT DF_returnsummary_gross_total DEFAULT 0, + net_total FLOAT NOT NULL CONSTRAINT DF_returnsummary_net_total DEFAULT 0, + total_quantity FLOAT NOT NULL CONSTRAINT DF_returnsummary_total_quantity DEFAULT 0, + stock_positions INT NOT NULL CONSTRAINT DF_returnsummary_stock_positions DEFAULT 0, + booked_positions INT NOT NULL CONSTRAINT DF_returnsummary_booked_positions DEFAULT 0, + unbooked_positions INT NOT NULL CONSTRAINT DF_returnsummary_unbooked_positions DEFAULT 0, + stock_quantity FLOAT NOT NULL CONSTRAINT DF_returnsummary_stock_quantity DEFAULT 0, + booked_quantity FLOAT NOT NULL CONSTRAINT DF_returnsummary_booked_quantity DEFAULT 0, + unbooked_quantity FLOAT NOT NULL CONSTRAINT DF_returnsummary_unbooked_quantity DEFAULT 0, + is_booked TINYINT NOT NULL CONSTRAINT DF_returnsummary_is_booked DEFAULT 0, + CONSTRAINT FK_returnsummary_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION, + CONSTRAINT FK_returnsummary_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE, + CONSTRAINT FK_returnsummary_supplier FOREIGN KEY (supplier_id) REFERENCES entity(id) ON DELETE SET NULL + ); + + CREATE INDEX IX_returnsummary_company_date + ON returnsummary (company_id, return_date DESC, manifest_id DESC); + + CREATE INDEX IX_returnsummary_company_number + ON returnsummary (company_id, return_number); + + CREATE INDEX IX_returnsummary_company_booked + ON returnsummary (company_id, is_booked); + + CREATE INDEX IX_returnsummary_company_supplier + ON returnsummary (company_id, supplier_id); +END +`; + +export const INIT_RETOURESUMMARY = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'retouresummary') +BEGIN + CREATE TABLE retouresummary ( + id INT IDENTITY(1,1) PRIMARY KEY, + company_id INT NOT NULL, + manifest_id INT NOT NULL CONSTRAINT UQ_retouresummary_manifest UNIQUE, + retoure_number NVARCHAR(100) NOT NULL, + retoure_date DATETIME2 NOT NULL, + created_at DATETIME2 NOT NULL CONSTRAINT DF_retouresummary_created_at DEFAULT SYSUTCDATETIME(), + entity_id INT NULL, + customer_name NVARCHAR(255) NULL, + positions INT NOT NULL CONSTRAINT DF_retouresummary_positions DEFAULT 0, + gross_total FLOAT NOT NULL CONSTRAINT DF_retouresummary_gross_total DEFAULT 0, + paid_total FLOAT NOT NULL CONSTRAINT DF_retouresummary_paid_total DEFAULT 0, + ordered_quantity FLOAT NOT NULL CONSTRAINT DF_retouresummary_ordered_quantity DEFAULT 0, + shipped_quantity FLOAT NOT NULL CONSTRAINT DF_retouresummary_shipped_quantity DEFAULT 0, + is_paid TINYINT NOT NULL CONSTRAINT DF_retouresummary_is_paid DEFAULT 0, + is_shipped TINYINT NOT NULL CONSTRAINT DF_retouresummary_is_shipped DEFAULT 0, + CONSTRAINT FK_retouresummary_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION, + CONSTRAINT FK_retouresummary_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE, + CONSTRAINT FK_retouresummary_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL + ); + + CREATE INDEX IX_retouresummary_company_date + ON retouresummary (company_id, retoure_date DESC, manifest_id DESC); + + CREATE INDEX IX_retouresummary_company_shipped + ON retouresummary (company_id, is_shipped); + + CREATE INDEX IX_retouresummary_company_paid + ON retouresummary (company_id, is_paid); + + CREATE INDEX IX_retouresummary_company_number + ON retouresummary (company_id, retoure_number); +END +`; + export const INIT_ARTICLEWAREHOUSESUMMARY = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'articlewarehousesummary') BEGIN @@ -1575,6 +1656,8 @@ export const SCHEMA_SCRIPTS = [ INIT_QUOTESUMMARY, INIT_SHIPMENTSUMMARY, INIT_DELIVERYSUMMARY, + INIT_RETURNSUMMARY, + INIT_RETOURESUMMARY, INIT_ARTICLEWAREHOUSESUMMARY, INIT_MCP_CLIENT, INIT_MCP_AUTH_CODE, diff --git a/src/providers/sqlite3/queries/manifestDoc.js b/src/providers/sqlite3/queries/manifestDoc.js index d71d239..df84fb6 100644 --- a/src/providers/sqlite3/queries/manifestDoc.js +++ b/src/providers/sqlite3/queries/manifestDoc.js @@ -5,7 +5,7 @@ const queries = translateQueries(source, 'manifestDoc'); export const { CHECK_ARTICLE_IS_PARENT, COUNT_MANIFESTS, CREATE_LINE, CREATE_MANIFEST, DELETE_LINES_BY_MANIFEST, DELETE_MANIFEST, GET_ARTICLE_SUPPLIER_LINK, GET_MANIFEST_CONTEXT, GET_MANIFEST_HEADER, - GET_MANIFEST_LINES, LIST_MANIFESTS, ORDER_TYPE, QUOTE_TYPE, UPDATE_ARTICLE_SUPPLIER_LINK, UPDATE_MANIFEST, + GET_MANIFEST_LINES, LIST_MANIFESTS, ORDER_TYPE, QUOTE_TYPE, RETOURE_TYPE, RETURN_TYPE, UPDATE_ARTICLE_SUPPLIER_LINK, UPDATE_MANIFEST, } = queries; export const LIST_MANIFESTS_PAGED = ` diff --git a/src/providers/sqlite3/queries/retoure.js b/src/providers/sqlite3/queries/retoure.js new file mode 100644 index 0000000..46bfeae --- /dev/null +++ b/src/providers/sqlite3/queries/retoure.js @@ -0,0 +1,8 @@ +import * as source from '../../mssql/queries/retoure.js'; +import { translateQueries } from './translate.js'; + +const queries = translateQueries(source, 'retoure'); +export const { + GET_RETOURE_HEADER, + RETOURE_TYPE, +} = queries; diff --git a/src/providers/sqlite3/queries/return.js b/src/providers/sqlite3/queries/return.js new file mode 100644 index 0000000..82656a4 --- /dev/null +++ b/src/providers/sqlite3/queries/return.js @@ -0,0 +1,10 @@ +import * as source from '../../mssql/queries/return.js'; +import { translateQueries } from './translate.js'; + +const queries = translateQueries(source, 'return'); +export const { + GET_RETURN_HEADER, + GET_RETURN_LINES, + LIST_RETURNS, + RETURN_TYPE, +} = queries; diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index 2ea2c8e..d501411 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -1000,6 +1000,73 @@ CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_supplier ON deliverysummary (company_id, supplier_id); `; +export const INIT_RETURNSUMMARY = ` +CREATE TABLE IF NOT EXISTS returnsummary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL, + manifest_id INTEGER NOT NULL UNIQUE, + return_number TEXT NOT NULL, + external_number TEXT, + return_date TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (${UTC_NOW}), + supplier_id INTEGER NULL, + supplier_name TEXT NULL, + positions INTEGER NOT NULL DEFAULT 0, + gross_total REAL NOT NULL DEFAULT 0, + net_total REAL NOT NULL DEFAULT 0, + total_quantity REAL NOT NULL DEFAULT 0, + stock_positions INTEGER NOT NULL DEFAULT 0, + booked_positions INTEGER NOT NULL DEFAULT 0, + unbooked_positions INTEGER NOT NULL DEFAULT 0, + stock_quantity REAL NOT NULL DEFAULT 0, + booked_quantity REAL NOT NULL DEFAULT 0, + unbooked_quantity REAL NOT NULL DEFAULT 0, + is_booked INTEGER 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 (supplier_id) REFERENCES entity(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS IX_returnsummary_company_date + ON returnsummary (company_id, return_date DESC, manifest_id DESC); +CREATE INDEX IF NOT EXISTS IX_returnsummary_company_number + ON returnsummary (company_id, return_number); +CREATE INDEX IF NOT EXISTS IX_returnsummary_company_booked + ON returnsummary (company_id, is_booked); +CREATE INDEX IF NOT EXISTS IX_returnsummary_company_supplier + ON returnsummary (company_id, supplier_id); +`; + +export const INIT_RETOURESUMMARY = ` +CREATE TABLE IF NOT EXISTS retouresummary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL, + manifest_id INTEGER NOT NULL UNIQUE, + retoure_number TEXT NOT NULL, + retoure_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, + paid_total REAL NOT NULL DEFAULT 0, + ordered_quantity REAL NOT NULL DEFAULT 0, + shipped_quantity REAL NOT NULL DEFAULT 0, + is_paid INTEGER NOT NULL DEFAULT 0, + is_shipped INTEGER 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_retouresummary_company_date + ON retouresummary (company_id, retoure_date DESC, manifest_id DESC); +CREATE INDEX IF NOT EXISTS IX_retouresummary_company_shipped + ON retouresummary (company_id, is_shipped); +CREATE INDEX IF NOT EXISTS IX_retouresummary_company_paid + ON retouresummary (company_id, is_paid); +CREATE INDEX IF NOT EXISTS IX_retouresummary_company_number + ON retouresummary (company_id, retoure_number); +`; + export const INIT_ARTICLEWAREHOUSESUMMARY = ` CREATE TABLE IF NOT EXISTS articlewarehousesummary ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1153,6 +1220,8 @@ export const SCHEMA_SCRIPTS = [ INIT_QUOTESUMMARY, INIT_SHIPMENTSUMMARY, INIT_DELIVERYSUMMARY, + INIT_RETURNSUMMARY, + INIT_RETOURESUMMARY, INIT_ARTICLEWAREHOUSESUMMARY, INIT_MCP_CLIENT, INIT_MCP_AUTH_CODE, diff --git a/src/store/numbers/ensureType.js b/src/store/numbers/ensureType.js index 5d7666e..bc96405 100644 --- a/src/store/numbers/ensureType.js +++ b/src/store/numbers/ensureType.js @@ -67,13 +67,17 @@ export async function ensureNumberType(runner, companyId, type) { } } else if (type === 'adjustment') { prefix = prefixStyle.endsWith('-') ? 'KORR-' : ''; + } else if (type === 'return') { + prefix = prefixStyle.endsWith('-') ? 'RTN-' : 'RTN'; + } else if (type === 'retoure') { + prefix = prefixStyle.endsWith('-') ? 'RET-' : 'RET'; } - const padding = (type === 'order' || type === 'quote') ? (pad || 6) : (type === 'adjustment' ? 5 : (refSeq?.padding ?? 0)); + const padding = (type === 'order' || type === 'quote' || type === 'return' || type === 'retoure') ? (pad || 6) : (type === 'adjustment' ? 5 : (refSeq?.padding ?? 0)); // Determine next counter by scanning manifest for existing documents let next = 1; - if (type === 'order' || type === 'quote') { + if (type === 'order' || type === 'quote' || type === 'return' || type === 'retoure') { const manifestSql = isSqlite ? 'SELECT "number" FROM manifest WHERE company_id = @companyId AND "type" = @type' : 'SELECT [number] FROM manifest WHERE company_id = @companyId AND [type] = @type'; diff --git a/src/store/payment/create.js b/src/store/payment/create.js index dc77cf7..ba27d35 100644 --- a/src/store/payment/create.js +++ b/src/store/payment/create.js @@ -2,6 +2,7 @@ import { queriesFor, requireId, requireText } from '../logistics/helpers.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveManifestAccess from '../manifestattachment/resolveManifestAccess.js'; import { syncOrderSummary } from '../order/syncSummary.js'; +import { syncRetoureSummary } from '../retoure/syncSummary.js'; import makeRunner from '../runner.js'; function shape(row) { @@ -67,7 +68,17 @@ export default function create(provider) { type, amount: input.amount, }; - await syncOrderSummary(runner, { companyId, manifestId }); + const mTypeRes = await runner.query( + runner.dialect === 'sqlite3' + ? 'SELECT "type" FROM manifest WHERE id = @manifestId' + : 'SELECT [type] FROM manifest WHERE id = @manifestId', + { manifestId }, + ); + if (mTypeRes.rows[0]?.type === 'retoure') { + await syncRetoureSummary(runner, { companyId, manifestId }); + } else { + await syncOrderSummary(runner, { companyId, manifestId }); + } return shape(row); }; diff --git a/src/store/retoure/create.js b/src/store/retoure/create.js new file mode 100644 index 0000000..5da6b71 --- /dev/null +++ b/src/store/retoure/create.js @@ -0,0 +1,129 @@ +import { + CREATE_MANIFEST as MSSQL_CREATE_MANIFEST, + RETOURE_TYPE as MSSQL_RETOURE_TYPE, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + CREATE_MANIFEST as SQLITE_CREATE_MANIFEST, + RETOURE_TYPE as SQLITE_RETOURE_TYPE, +} from '../../providers/sqlite3/queries/manifestDoc.js'; +import { + FIND_ARTICLES_FOR_HISTORY as MSSQL_FIND_ARTICLES, +} from '../../providers/mssql/queries/ledger.js'; +import { + FIND_ARTICLES_FOR_HISTORY as SQLITE_FIND_ARTICLES, +} from '../../providers/sqlite3/queries/ledger.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import taxEnsure from '../tax/ensure.js'; +import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; +import { assertCustomerEntity, assertUniqueManifestNumber, loadDoc } from '../manifestDoc/helpers.js'; +import { syncRetoureSummary } from './syncSummary.js'; +import { ensureNumberType } from '../numbers/ensureType.js'; +import makeRunner from '../runner.js'; + +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const createManifestSql = isSqlite ? SQLITE_CREATE_MANIFEST : MSSQL_CREATE_MANIFEST; + const retoureType = isSqlite ? SQLITE_RETOURE_TYPE : MSSQL_RETOURE_TYPE; + const findArticlesSql = isSqlite ? SQLITE_FIND_ARTICLES : MSSQL_FIND_ARTICLES; + + /** + * Creates a new retoure for the user's selected company with its lines. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} body - The retoure input. + * @param {string} [body.number] - The retoure number. + * @param {string} body.date - The retoure date. + * @param {number} [body.entityId] - The id of the customer entity. + * @param {string} [body.externalNumber] - An external reference number. + * @param {Array} [body.lines] - The retoure lines. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise} The created retoure. + */ + return async (googleAuthId, body = {}, tx) => { + const date = parseDate(body.date); + if (!date) { + throw new Error('Invalid retoure'); + } + const createdAt = body.createdAt ? parseDate(body.createdAt) : new Date(); + if (body.createdAt && !createdAt) { + throw new Error('Invalid retoure'); + } + const externalNumber = body.externalNumber?.trim?.() + ? body.externalNumber.trim() + : null; + + const lines = normalizeLines(body.lines ?? []); + + const runCreate = async (runner) => { + const { companyId, companyuserId } = await access(googleAuthId, runner); + + let entityId = null; + if (body.entityId != null) { + entityId = await assertCustomerEntity( + runner, + companyId, + body.entityId, + ); + } + + let number = body.number?.trim?.() ? body.number.trim() : null; + if (!number) { + const seq = await ensureNumberType(runner, companyId, 'retoure'); + const numVal = seq.next ?? 1; + number = (seq.prefix || 'RET-') + (seq.padding > 0 ? String(numVal).padStart(seq.padding, '0') : String(numVal)); + await runner.query( + isSqlite + ? 'UPDATE numbers SET "next" = "next" + 1 WHERE id = @id' + : 'UPDATE numbers SET [next] = [next] + 1 WHERE id = @id', + { id: seq.id }, + ); + } + + await assertUniqueManifestNumber(runner, { + companyId, + type: retoureType, + number, + errorMessage: 'Retoure number already exists', + }); + + const created = await runner.query(createManifestSql, { + companyId, + number, + type: retoureType, + entityId, + externalNumber, + date, + createdAt, + }); + + const manifestId = created.rows[0]?.id ?? created.insertId; + + await insertLines(runner, { + manifestId, + lines, + ensureTax, + }); + + const doc = await loadDoc(runner, { + type: retoureType, + companyId, + id: manifestId, + }); + + await syncRetoureSummary(runner, { companyId, manifestId }); + + return doc; + }; + + if (tx) { + return runCreate(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runCreate); + } + return runCreate(makeRunner(provider)); + }; +} diff --git a/src/store/retoure/createShipment.js b/src/store/retoure/createShipment.js new file mode 100644 index 0000000..48ea185 --- /dev/null +++ b/src/store/retoure/createShipment.js @@ -0,0 +1,18 @@ +import shipmentCreate from '../shipment/create.js'; + +export default function createShipment(provider) { + const create = shipmentCreate(provider); + + /** + * Creates a shipment manifest for a retoure. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} retoureId - The parent retoure manifest id. + * @param {object} input - Shipment input. + * @param {object} [tx] - Optional transaction. + * @returns {Promise} The created shipment. + */ + return async (googleAuthId, retoureId, input = {}, tx) => { + return create(googleAuthId, { ...input, orderId: retoureId }, tx); + }; +} diff --git a/src/store/retoure/delete.js b/src/store/retoure/delete.js new file mode 100644 index 0000000..1472c68 --- /dev/null +++ b/src/store/retoure/delete.js @@ -0,0 +1,75 @@ +import { + DELETE_MANIFEST as MSSQL_DELETE, + GET_MANIFEST_HEADER as MSSQL_GET_HEADER, + RETOURE_TYPE as MSSQL_RETOURE_TYPE, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + DELETE_MANIFEST as SQLITE_DELETE, + GET_MANIFEST_HEADER as SQLITE_GET_HEADER, + RETOURE_TYPE as SQLITE_RETOURE_TYPE, +} from '../../providers/sqlite3/queries/manifestDoc.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + const getHeaderSql = isSqlite ? SQLITE_GET_HEADER : MSSQL_GET_HEADER; + const retoureType = isSqlite ? SQLITE_RETOURE_TYPE : MSSQL_RETOURE_TYPE; + + /** + * Deletes a retoure by its id within the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The id of the retoure to delete. + * @param {object} [tx] - Optional transaction. + * @returns {Promise} + * @throws {Error} If the retoure is not found. + */ + return async (googleAuthId, id, tx) => { + if (!Number.isFinite(id)) { + throw new Error('Retoure not found'); + } + + const run = async (runner) => { + const { companyId } = await access(googleAuthId, runner); + + const headerRes = await runner.query(getHeaderSql, { + id, + companyId, + type: retoureType, + }); + + const header = headerRes.rows[0]; + if (!header) { + throw new Error('Retoure not found'); + } + + await runner.query( + isSqlite + ? 'DELETE FROM retouresummary WHERE manifest_id = @id' + : 'DELETE FROM retouresummary WHERE manifest_id = @id', + { id }, + ); + + const result = await runner.query(deleteSql, { + id, + companyId, + type: retoureType, + }); + + if (!result.rowsAffected) { + throw new Error('Retoure not found'); + } + }; + + if (tx) { + return run(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(run); + } + return run(makeRunner(provider)); + }; +} diff --git a/src/store/retoure/getById.js b/src/store/retoure/getById.js new file mode 100644 index 0000000..244b713 --- /dev/null +++ b/src/store/retoure/getById.js @@ -0,0 +1,35 @@ +import { RETOURE_TYPE as MSSQL_RETOURE_TYPE } from '../../providers/mssql/queries/manifestDoc.js'; +import { RETOURE_TYPE as SQLITE_RETOURE_TYPE } from '../../providers/sqlite3/queries/manifestDoc.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import { loadDoc } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; + +export default function getById(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const retoureType = isSqlite ? SQLITE_RETOURE_TYPE : MSSQL_RETOURE_TYPE; + + /** + * Returns a single retoure by its id within the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The id of the retoure. + * @param {object} [tx] - Optional transaction. + * @returns {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}>}>} The retoure with its lines. + * @throws {Error} If the retoure is not found. + */ + return async (googleAuthId, id, tx) => { + if (!Number.isFinite(id)) { + throw new Error('Retoure not found'); + } + + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + const doc = await loadDoc(runner, { type: retoureType, companyId, id }); + if (!doc) { + throw new Error('Retoure not found'); + } + return doc; + }; +} diff --git a/src/store/retoure/list.js b/src/store/retoure/list.js new file mode 100644 index 0000000..288596f --- /dev/null +++ b/src/store/retoure/list.js @@ -0,0 +1,274 @@ +import { + COUNT_MANIFESTS as MSSQL_COUNT_MANIFESTS, + GET_MANIFEST_LINES_BY_MANIFEST_IDS as MSSQL_LINES_BY_MANIFEST_IDS, + RETOURE_TYPE as MSSQL_RETOURE_TYPE, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + COUNT_MANIFESTS as SQLITE_COUNT_MANIFESTS, + GET_MANIFEST_LINES_BY_MANIFEST_IDS as SQLITE_LINES_BY_MANIFEST_IDS, + RETOURE_TYPE as SQLITE_RETOURE_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 sqlLinesByManifestIds = isSqlite + ? SQLITE_LINES_BY_MANIFEST_IDS + : MSSQL_LINES_BY_MANIFEST_IDS; + const retoureType = isSqlite ? SQLITE_RETOURE_TYPE : MSSQL_RETOURE_TYPE; + const sqlCountManifests = isSqlite + ? SQLITE_COUNT_MANIFESTS + : MSSQL_COUNT_MANIFESTS; + + /** + * Lists the retoures 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 retoures to skip. + * @param {number} [opts.limit=50] - Number of retoures to return. + * @param {string} [opts.search] - Free-text search matching retoure number, external number, or customer name. + * @param {boolean} [opts.onlyUnpaid=false] - When true, returns only unpaid retoures. + * @param {boolean} [opts.onlyUndelivered=false] - When true, returns only undelivered retoures. + * @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1). + * @param {object} [tx] - Optional transaction. + * @returns {Promise<{retoures: Array, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>} + */ + 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.onlyUnpaid === true || + opts.onlyUndelivered === true || + opts.entityId !== undefined || + 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 conditions = ['m.company_id = @companyId', `m.${isSqlite ? '"type"' : '[type]'} = @type`]; + const params = { + companyId, + type: retoureType, + }; + + if (opts?.entityId !== undefined && opts.entityId !== null) { + const entityId = Number(opts.entityId); + if (!Number.isInteger(entityId) || entityId <= 0) { + throw new Error('Invalid entityId'); + } + params.entityId = entityId; + conditions.push('m.entity_id = @entityId'); + } + + 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`); + } + + 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 + )`); + } + } + + if (opts?.onlyUnpaid === true) { + conditions.push('COALESCE(rs.is_paid, 0) = 0'); + } + + if (opts?.onlyUndelivered === true) { + conditions.push('COALESCE(rs.is_shipped, 0) = 0'); + } + + const whereClause = conditions.join('\n AND '); + const sqlCount = ` + SELECT COUNT(*) AS total + FROM manifest m + LEFT JOIN retouresummary rs ON rs.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, + rs.positions, + rs.gross_total AS grossTotal, + rs.paid_total AS paidTotal, + rs.ordered_quantity AS orderedQuantity, + rs.shipped_quantity AS shippedQuantity, + rs.is_paid AS isPaid, + rs.is_shipped AS isShipped, + rs.customer_name AS customerName + FROM manifest m + LEFT JOIN retouresummary rs ON rs.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, + rs.positions, + rs.gross_total AS grossTotal, + rs.paid_total AS paidTotal, + rs.ordered_quantity AS orderedQuantity, + rs.shipped_quantity AS shippedQuantity, + rs.is_paid AS isPaid, + rs.is_shipped AS isShipped, + rs.customer_name AS customerName + FROM manifest m + LEFT JOIN retouresummary rs ON rs.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() !== '') || + opts?.onlyUnpaid === true || + opts?.onlyUndelivered === true + ); + + 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: retoureType }) : 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: retoureType }); + 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 retoures = manifestRows.map((row) => ({ + ...shapeSummary(retoureType, 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, + paidTotal: row.paidTotal !== undefined && row.paidTotal !== null ? Number(row.paidTotal) : undefined, + orderedQuantity: row.orderedQuantity !== undefined && row.orderedQuantity !== null ? Number(row.orderedQuantity) : undefined, + shippedQuantity: row.shippedQuantity !== undefined && row.shippedQuantity !== null ? Number(row.shippedQuantity) : undefined, + isPaid: row.isPaid !== undefined && row.isPaid !== null ? Boolean(row.isPaid) : undefined, + isShipped: row.isShipped !== undefined && row.isShipped !== null ? Boolean(row.isShipped) : undefined, + customerName: row.customerName ?? undefined, + })); + + if (isPaged) { + return { + retoures, + total, + unfilteredTotal, + offset, + limit, + }; + } + + return retoures; + }; +} diff --git a/src/store/retoure/rebuildSummaries.js b/src/store/retoure/rebuildSummaries.js new file mode 100644 index 0000000..1d9bb2b --- /dev/null +++ b/src/store/retoure/rebuildSummaries.js @@ -0,0 +1,19 @@ +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; +import { rebuildRetoureSummaries } from './syncSummary.js'; + +/** + * Rebuilds the `retouresummary` 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 rebuildRetoureSummaries(runner, { companyId }); + return { ok: true, companyId }; + }; +} diff --git a/src/store/retoure/syncSummary.js b/src/store/retoure/syncSummary.js new file mode 100644 index 0000000..c4904c8 --- /dev/null +++ b/src/store/retoure/syncSummary.js @@ -0,0 +1,368 @@ +/** + * Centralized sync and rebuild logic for the materialized `retouresummary` table. + */ + +const SQL_GET_SHIPMENT_PREFIX = ` + SELECT prefix FROM numbers WHERE company_id = @companyId AND [type] = 'shipment' +`; + +const SQL_GET_RETOURE_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" = 'retoure' +`; + +const SQL_GET_RETOURE_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] = 'retoure' +`; + +const SQL_GET_RETOURE_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" = 'retoure' +`; + +const SQL_GET_RETOURE_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] = 'retoure' +`; + +const SQL_LINE_TOTALS = ` + SELECT + COUNT(*) AS positions, + COALESCE(SUM(CASE WHEN shippable = 1 THEN amount ELSE 0 END), 0) AS ordered_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_PAYMENT_TOTAL = ` + SELECT COALESCE(SUM(amount), 0) AS paid_total + FROM payment + WHERE manifest_id = @manifestId +`; + +const SQL_SHIPPED_QTY_SQLITE = ` + SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity + FROM manifest m_s + JOIN manifestarticle ma_s ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss ON ss.manifest_id = m_s.id + WHERE m_s.company_id = @companyId + AND m_s."type" = 'shipment' + AND COALESCE(ss.state, 'new') != 'cancelled' + AND m_s."number" NOT LIKE '%#cancelled#%' + AND ( + ss.order_id = @manifestId + OR ss.order_number = @retoureNumber + OR m_s."number" LIKE @retoureNumber || @shipmentPrefix || '%' + ) +`; + +const SQL_SHIPPED_QTY_MSSQL = ` + SELECT COALESCE(SUM(ma_s.amount), 0) AS shipped_quantity + FROM manifest m_s WITH (NOLOCK) + JOIN manifestarticle ma_s WITH (NOLOCK) ON ma_s.manifest_id = m_s.id + LEFT JOIN shipmentsummary ss WITH (NOLOCK) ON ss.manifest_id = m_s.id + WHERE m_s.company_id = @companyId + AND m_s.[type] = 'shipment' + AND ISNULL(ss.state, 'new') != 'cancelled' + AND m_s.[number] NOT LIKE '%#cancelled#%' + AND ( + ss.order_id = @manifestId + OR ss.order_number = @retoureNumber + OR m_s.[number] LIKE @retoureNumber + @shipmentPrefix + '%' + ) +`; + +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 retouresummary ( + company_id, manifest_id, retoure_number, retoure_date, created_at, + entity_id, customer_name, positions, gross_total, paid_total, + ordered_quantity, shipped_quantity, is_paid, is_shipped + ) VALUES ( + @companyId, @manifestId, @retoureNumber, @retoureDate, @createdAt, + @entityId, @customerName, @positions, @grossTotal, @paidTotal, + @orderedQuantity, @shippedQuantity, @isPaid, @isShipped + ) + ON CONFLICT(manifest_id) DO UPDATE SET + retoure_number = excluded.retoure_number, + retoure_date = excluded.retoure_date, + created_at = excluded.created_at, + entity_id = excluded.entity_id, + customer_name = excluded.customer_name, + positions = excluded.positions, + gross_total = excluded.gross_total, + paid_total = excluded.paid_total, + ordered_quantity = excluded.ordered_quantity, + shipped_quantity = excluded.shipped_quantity, + is_paid = excluded.is_paid, + is_shipped = excluded.is_shipped +`; + +const SQL_UPSERT_SUMMARY_MSSQL = ` + MERGE INTO retouresummary AS target + USING (SELECT @manifestId AS manifest_id) AS source + ON (target.manifest_id = source.manifest_id) + WHEN MATCHED THEN + UPDATE SET + retoure_number = @retoureNumber, + retoure_date = @retoureDate, + created_at = @createdAt, + entity_id = @entityId, + customer_name = @customerName, + positions = @positions, + gross_total = @grossTotal, + paid_total = @paidTotal, + ordered_quantity = @orderedQuantity, + shipped_quantity = @shippedQuantity, + is_paid = @isPaid, + is_shipped = @isShipped + WHEN NOT MATCHED THEN + INSERT ( + company_id, manifest_id, retoure_number, retoure_date, created_at, + entity_id, customer_name, positions, gross_total, paid_total, + ordered_quantity, shipped_quantity, is_paid, is_shipped + ) VALUES ( + @companyId, @manifestId, @retoureNumber, @retoureDate, @createdAt, + @entityId, @customerName, @positions, @grossTotal, @paidTotal, + @orderedQuantity, @shippedQuantity, @isPaid, @isShipped + ); +`; + +/** + * Recalculates and upserts a single retoure summary into `retouresummary`. + * + * @param {object} runner - Database runner. + * @param {object} opts + * @param {number} opts.companyId + * @param {number} [opts.manifestId] - Retoure manifest id. + * @param {string} [opts.retoureNumber] - Retoure number (if manifestId not given). + */ +export async function syncRetoureSummary(runner, { companyId, manifestId, retoureNumber }) { + const isSqlite = runner.dialect === 'sqlite3'; + + let retoureRow; + if (manifestId) { + const res = await runner.query( + isSqlite ? SQL_GET_RETOURE_BY_ID_SQLITE : SQL_GET_RETOURE_BY_ID_MSSQL, + { manifestId, companyId }, + ); + retoureRow = res.rows[0]; + } else if (retoureNumber) { + const res = await runner.query( + isSqlite ? SQL_GET_RETOURE_BY_NUMBER_SQLITE : SQL_GET_RETOURE_BY_NUMBER_MSSQL, + { number: retoureNumber, companyId }, + ); + retoureRow = res.rows[0]; + } + + if (!retoureRow) { + if (manifestId) { + await runner.query('DELETE FROM retouresummary WHERE manifest_id = @manifestId', { manifestId }); + } + return; + } + + const retoureId = retoureRow.id; + const num = retoureRow.number; + + const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId }); + const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-'; + + const lineRes = await runner.query(SQL_LINE_TOTALS, { manifestId: retoureId }); + const payRes = await runner.query(SQL_PAYMENT_TOTAL, { manifestId: retoureId }); + const shipRes = await runner.query(isSqlite ? SQL_SHIPPED_QTY_SQLITE : SQL_SHIPPED_QTY_MSSQL, { + companyId, + manifestId: retoureId, + retoureNumber: num, + shipmentPrefix, + }); + + let customerName = null; + if (retoureRow.entity_id) { + const custRes = await runner.query( + isSqlite ? SQL_CUSTOMER_NAME_SQLITE : SQL_CUSTOMER_NAME_MSSQL, + { entityId: retoureRow.entity_id }, + ); + customerName = custRes.rows[0]?.customer_name || null; + } + + const positions = Number(lineRes.rows[0]?.positions || 0); + const orderedQuantity = Math.round(Number(lineRes.rows[0]?.ordered_quantity || 0) * 1000) / 1000; + const grossTotal = Math.round(Number(lineRes.rows[0]?.gross_total || 0) * 100) / 100; + const paidTotal = Math.round(Number(payRes.rows[0]?.paid_total || 0) * 100) / 100; + const shippedQuantity = Math.round(Number(shipRes.rows[0]?.shipped_quantity || 0) * 1000) / 1000; + + const isPaid = Math.abs(grossTotal) < 0.01 || Math.abs(paidTotal - grossTotal) < 0.01 ? 1 : 0; + const isShipped = orderedQuantity !== 0 && Math.abs(shippedQuantity - orderedQuantity) < 0.001 ? 1 : 0; + + await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, { + companyId, + manifestId: retoureId, + retoureNumber: num, + retoureDate: retoureRow.date, + createdAt: retoureRow.created_at, + entityId: retoureRow.entity_id ?? null, + customerName, + positions, + grossTotal, + paidTotal, + orderedQuantity, + shippedQuantity, + isPaid, + isShipped, + }); +} + +/** + * Full rebuild of all retoures in `retouresummary` for a given company. + * + * @param {object} runner + * @param {object} opts + * @param {number} opts.companyId + */ +export async function rebuildRetoureSummaries(runner, { companyId }) { + const isSqlite = runner.dialect === 'sqlite3'; + + const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId }); + const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-'; + + if (isSqlite) { + await runner.query('DELETE FROM retouresummary WHERE company_id = @companyId', { companyId }); + const sqlRebuildSqlite = ` + WITH + retoure_articles AS ( + SELECT + ma.manifest_id, + COUNT(*) AS positions, + SUM(CASE WHEN ma.shippable = 1 THEN ma.amount ELSE 0 END) AS ordered_quantity, + SUM(ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0)) AS gross_total + FROM manifestarticle ma + JOIN manifest m ON m.id = ma.manifest_id AND m.company_id = @companyId AND m."type" = 'retoure' + GROUP BY ma.manifest_id + ), + retoure_payments AS ( + SELECT + p.manifest_id, + SUM(p.amount) AS paid_total + FROM payment p + JOIN manifest m ON m.id = p.manifest_id AND m.company_id = @companyId AND m."type" = 'retoure' + GROUP BY p.manifest_id + ), + shipment_totals AS ( + SELECT + ss.order_id, + SUM(ma_s.amount) AS shipped_quantity + FROM shipmentsummary ss + JOIN manifestarticle ma_s ON ma_s.manifest_id = ss.manifest_id + WHERE ss.company_id = @companyId + AND COALESCE(ss.state, 'new') != 'cancelled' + AND ss.order_id IS NOT NULL + GROUP BY ss.order_id + ) + INSERT INTO retouresummary ( + company_id, manifest_id, retoure_number, retoure_date, created_at, + entity_id, customer_name, positions, gross_total, paid_total, + ordered_quantity, shipped_quantity, is_paid, is_shipped + ) + SELECT + m.company_id, + m.id AS manifest_id, + m."number" AS retoure_number, + m."date" AS retoure_date, + m.created_at, + m.entity_id, + TRIM(COALESCE(e.first_name || ' ', '') || COALESCE(e.name, '')) AS customer_name, + COALESCE(oa.positions, 0) AS positions, + ROUND(COALESCE(oa.gross_total, 0), 2) AS gross_total, + ROUND(COALESCE(op.paid_total, 0), 2) AS paid_total, + ROUND(COALESCE(oa.ordered_quantity, 0), 3) AS ordered_quantity, + ROUND(COALESCE(st.shipped_quantity, 0), 3) AS shipped_quantity, + CASE WHEN ABS(COALESCE(oa.gross_total, 0)) < 0.01 OR ABS(COALESCE(op.paid_total, 0) - oa.gross_total) < 0.01 THEN 1 ELSE 0 END AS is_paid, + CASE WHEN COALESCE(oa.ordered_quantity, 0) != 0 AND ABS(COALESCE(st.shipped_quantity, 0) - oa.ordered_quantity) < 0.001 THEN 1 ELSE 0 END AS is_shipped + FROM manifest m + LEFT JOIN entity e ON e.id = m.entity_id + LEFT JOIN retoure_articles oa ON oa.manifest_id = m.id + LEFT JOIN retoure_payments op ON op.manifest_id = m.id + LEFT JOIN shipment_totals st ON st.order_id = m.id + WHERE m.company_id = @companyId AND m."type" = 'retoure'; + `; + await runner.query(sqlRebuildSqlite, { companyId, shipmentPrefix }); + } else { + await runner.query('DELETE FROM retouresummary WHERE company_id = @companyId', { companyId }); + const sqlRebuildMssql = ` + WITH + retoure_articles AS ( + SELECT + ma.manifest_id, + COUNT(*) AS positions, + SUM(CASE WHEN ma.shippable = 1 THEN ma.amount ELSE 0 END) AS ordered_quantity, + SUM(ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0)) AS gross_total + FROM manifestarticle ma WITH (NOLOCK) + JOIN manifest m WITH (NOLOCK) ON m.id = ma.manifest_id AND m.company_id = @companyId AND m.[type] = 'retoure' + GROUP BY ma.manifest_id + ), + retoure_payments AS ( + SELECT + p.manifest_id, + SUM(p.amount) AS paid_total + FROM payment p WITH (NOLOCK) + JOIN manifest m WITH (NOLOCK) ON m.id = p.manifest_id AND m.company_id = @companyId AND m.[type] = 'retoure' + GROUP BY p.manifest_id + ), + shipment_totals AS ( + SELECT + ss.order_id, + SUM(ma_s.amount) AS shipped_quantity + FROM shipmentsummary ss WITH (NOLOCK) + JOIN manifestarticle ma_s WITH (NOLOCK) ON ma_s.manifest_id = ss.manifest_id + WHERE ss.company_id = @companyId + AND ISNULL(ss.state, 'new') != 'cancelled' + AND ss.order_id IS NOT NULL + GROUP BY ss.order_id + ) + INSERT INTO retouresummary ( + company_id, manifest_id, retoure_number, retoure_date, created_at, + entity_id, customer_name, positions, gross_total, paid_total, + ordered_quantity, shipped_quantity, is_paid, is_shipped + ) + SELECT + m.company_id, + m.id AS manifest_id, + m.[number] AS retoure_number, + m.[date] AS retoure_date, + m.created_at, + m.entity_id, + LTRIM(RTRIM(ISNULL(e.first_name + ' ', '') + ISNULL(e.name, ''))) AS customer_name, + COALESCE(oa.positions, 0) AS positions, + ROUND(COALESCE(oa.gross_total, 0), 2) AS gross_total, + ROUND(COALESCE(op.paid_total, 0), 2) AS paid_total, + ROUND(COALESCE(oa.ordered_quantity, 0), 3) AS ordered_quantity, + ROUND(COALESCE(st.shipped_quantity, 0), 3) AS shipped_quantity, + CASE WHEN ABS(COALESCE(oa.gross_total, 0)) < 0.01 OR ABS(COALESCE(op.paid_total, 0) - oa.gross_total) < 0.01 THEN 1 ELSE 0 END AS is_paid, + CASE WHEN COALESCE(oa.ordered_quantity, 0) != 0 AND ABS(COALESCE(st.shipped_quantity, 0) - oa.ordered_quantity) < 0.001 THEN 1 ELSE 0 END AS is_shipped + FROM manifest m WITH (NOLOCK) + LEFT JOIN entity e WITH (NOLOCK) ON e.id = m.entity_id + LEFT JOIN retoure_articles oa ON oa.manifest_id = m.id + LEFT JOIN retoure_payments op ON op.manifest_id = m.id + LEFT JOIN shipment_totals st ON st.order_id = m.id + WHERE m.company_id = @companyId AND m.[type] = 'retoure'; + `; + await runner.query(sqlRebuildMssql, { companyId, shipmentPrefix }); + } +} diff --git a/src/store/retoure/update.js b/src/store/retoure/update.js new file mode 100644 index 0000000..1ca3e22 --- /dev/null +++ b/src/store/retoure/update.js @@ -0,0 +1,144 @@ +import { + DELETE_LINES_BY_MANIFEST as MSSQL_DELETE_LINES, + GET_MANIFEST_HEADER as MSSQL_GET_HEADER, + RETOURE_TYPE as MSSQL_RETOURE_TYPE, + UPDATE_MANIFEST as MSSQL_UPDATE_MANIFEST, +} from '../../providers/mssql/queries/manifestDoc.js'; +import { + DELETE_LINES_BY_MANIFEST as SQLITE_DELETE_LINES, + GET_MANIFEST_HEADER as SQLITE_GET_HEADER, + RETOURE_TYPE as SQLITE_RETOURE_TYPE, + UPDATE_MANIFEST as SQLITE_UPDATE_MANIFEST, +} from '../../providers/sqlite3/queries/manifestDoc.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import taxEnsure from '../tax/ensure.js'; +import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; +import { assertCustomerEntity, assertUniqueManifestNumber, loadDoc } from '../manifestDoc/helpers.js'; +import { syncRetoureSummary } from './syncSummary.js'; +import makeRunner from '../runner.js'; + +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + + const deleteLinesSql = isSqlite ? SQLITE_DELETE_LINES : MSSQL_DELETE_LINES; + const getHeaderSql = isSqlite ? SQLITE_GET_HEADER : MSSQL_GET_HEADER; + const retoureType = isSqlite ? SQLITE_RETOURE_TYPE : MSSQL_RETOURE_TYPE; + const updateManifestSql = isSqlite ? SQLITE_UPDATE_MANIFEST : MSSQL_UPDATE_MANIFEST; + + /** + * Updates a retoure's header and/or lines. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The id of the retoure to update. + * @param {object} patch - The update patch. + * @param {string} [patch.number] - The new retoure number. + * @param {string} [patch.date] - The new retoure date. + * @param {number} [patch.entityId] - The new customer entity id. + * @param {string} [patch.externalNumber] - The new external reference number. + * @param {Array} [patch.lines] - The new retoure lines. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise} The updated retoure. + */ + return async (googleAuthId, id, patch = {}, tx) => { + if (!Number.isFinite(id)) { + throw new Error('Retoure not found'); + } + + const runUpdate = async (runner) => { + const { companyId } = await access(googleAuthId, runner); + + const current = await runner.query(getHeaderSql, { + id, + companyId, + type: retoureType, + }); + + if (!current.rows[0]) { + throw new Error('Retoure not found'); + } + + const existing = current.rows[0]; + + let number = existing.number; + if (patch.number !== undefined) { + const trimmed = patch.number?.trim?.() ? patch.number.trim() : null; + if (!trimmed) { + throw new Error('Invalid retoure'); + } + number = trimmed; + if (number !== existing.number) { + await assertUniqueManifestNumber(runner, { + companyId, + type: retoureType, + number, + errorMessage: 'Retoure number already exists', + }); + } + } + + let date = existing.date; + if (patch.date !== undefined) { + const parsed = parseDate(patch.date); + if (!parsed) { + throw new Error('Invalid retoure'); + } + date = parsed; + } + + let entityId = existing.entityId; + if (patch.entityId !== undefined) { + entityId = patch.entityId == null + ? null + : await assertCustomerEntity(runner, companyId, patch.entityId); + } + + let externalNumber = existing.externalNumber; + if (patch.externalNumber !== undefined) { + externalNumber = patch.externalNumber?.trim?.() + ? patch.externalNumber.trim() + : null; + } + + await runner.query(updateManifestSql, { + id, + companyId, + type: retoureType, + number, + entityId, + externalNumber, + date, + createdAt: null, + }); + + if (Array.isArray(patch.lines)) { + await runner.query(deleteLinesSql, { manifestId: id }); + const lines = normalizeLines(patch.lines); + await insertLines(runner, { + manifestId: id, + lines, + ensureTax, + }); + } + + const doc = await loadDoc(runner, { + type: retoureType, + companyId, + id, + }); + + await syncRetoureSummary(runner, { companyId, manifestId: id }); + + return doc; + }; + + if (tx) { + return runUpdate(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runUpdate); + } + return runUpdate(makeRunner(provider)); + }; +} diff --git a/src/store/return/book.js b/src/store/return/book.js new file mode 100644 index 0000000..61c7d45 --- /dev/null +++ b/src/store/return/book.js @@ -0,0 +1,49 @@ +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import stockAccept from '../stock/accept.js'; +import { loadReturn } from './helpers.js'; +import { syncReturnSummary } from './syncSummary.js'; +import makeRunner from '../runner.js'; + +/** + * Books all positions of a return manifest to the ledger. + * + * @param {object} provider - Storage provider instance. + * @returns {Function} `(googleAuthId, input, tx) => Promise` + */ +export default function book(provider) { + const access = resolveSelectedCompanyAccess(provider); + const accept = stockAccept(provider); + + return async (googleAuthId, input = {}, tx) => { + const rawId = typeof input === 'number' ? input : (input?.id ?? input?.returnId); + const id = Number(rawId); + if (!Number.isInteger(id) || id <= 0) { + throw new Error('Return not found'); + } + + const warehouseId = input?.warehouseId ?? input?.warehouse_id ?? null; + + const doBook = async (runner, activeTx) => { + const { companyId } = await access(googleAuthId, runner); + const returnDoc = await loadReturn(provider, activeTx, companyId, id); + if (!returnDoc) { + throw new Error('Return not found'); + } + if (!returnDoc.lines || returnDoc.lines.length === 0) { + throw new Error('Return has no lines'); + } + + await accept(googleAuthId, { deliveryId: id, warehouseId }, activeTx); + await syncReturnSummary(runner, { companyId, manifestId: id }); + return loadReturn(provider, activeTx, companyId, id); + }; + + if (tx) { + return doBook(makeRunner(provider, tx), tx); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(doBook); + } + return doBook(makeRunner(provider)); + }; +} diff --git a/src/store/return/create.js b/src/store/return/create.js new file mode 100644 index 0000000..a193025 --- /dev/null +++ b/src/store/return/create.js @@ -0,0 +1,89 @@ +import { syncReturnSummary } from './syncSummary.js'; +import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import nextNumber from '../numbers/nextNumber.js'; +import taxEnsure from '../tax/ensure.js'; +import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; +import { loadReturn, RETURN_TYPE } from './helpers.js'; +import { assertSupplier } from '../delivery/writeLines.js'; +import { assertUniqueManifestNumber } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; + +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const allocateNumber = nextNumber(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; + + return async (googleAuthId, body = {}) => { + const date = parseDate(body.date); + if (!date) { + throw new Error('Invalid return'); + } + const createdAt = body.createdAt ? parseDate(body.createdAt) : new Date(); + if (body.createdAt && !createdAt) { + throw new Error('Invalid return'); + } + const externalNumber = body.externalNumber?.trim?.() + ? body.externalNumber.trim() + : null; + + const lines = normalizeLines(body.lines ?? []); + + const { companyId, companyuserId } = await access(googleAuthId); + + const run = async (runner, tx) => { + const targetSupplierId = body.supplierId !== undefined ? body.supplierId : body.entityId; + if (targetSupplierId == null) { + throw new Error('No supplier selected'); + } + + const supplierId = await assertSupplier( + provider, + tx, + companyId, + targetSupplierId, + ); + + const number = body.number?.trim?.() + ? body.number.trim() + : await allocateNumber(companyId, RETURN_TYPE, tx); + + await assertUniqueManifestNumber(provider, tx, { + companyId, + type: RETURN_TYPE, + number, + errorMessage: 'Return number already exists', + }); + + const created = await runner.query(queries.CREATE_MANIFEST, { + companyId, + number, + type: RETURN_TYPE, + entityId: supplierId, + externalNumber, + date, + createdAt, + }); + + const manifestId = created.rows[0]?.id ?? created.insertId; + + await insertLines(provider, tx, { + manifestId, + lines, + ensureTax, + companyuserId, + }); + + await syncReturnSummary(runner, { companyId, manifestId }); + return loadReturn(provider, tx, companyId, manifestId); + }; + + if (typeof provider?.transaction === 'function') { + return provider.transaction((runner, tx) => run(runner, tx)); + } + return run(makeRunner(provider)); + }; +} diff --git a/src/store/return/delete.js b/src/store/return/delete.js new file mode 100644 index 0000000..012c55d --- /dev/null +++ b/src/store/return/delete.js @@ -0,0 +1,40 @@ +import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; +import { isForeignKeyConstraintError } from '../../providers/provider.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import { RETURN_TYPE } from './helpers.js'; +import makeRunner from '../runner.js'; + +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; + + return async (googleAuthId, id) => { + if (!Number.isFinite(id)) { + throw new Error('Return not found'); + } + + const { companyId } = await access(googleAuthId); + const runner = makeRunner(provider); + + try { + await runner.query('DELETE FROM returnsummary WHERE manifest_id = @id AND company_id = @companyId', { id, companyId }); + const result = await runner.query(queries.DELETE_MANIFEST, { + id, + companyId, + type: RETURN_TYPE, + }); + + const rowsAffected = result.rowsAffected?.[0] ?? (typeof result.rowsAffected === 'number' ? result.rowsAffected : 0); + if (!rowsAffected) { + throw new Error('Return not found'); + } + } catch (err) { + if (isForeignKeyConstraintError(err)) { + throw new Error('Return in use in ledger'); + } + throw err; + } + }; +} diff --git a/src/store/return/getById.js b/src/store/return/getById.js new file mode 100644 index 0000000..1bccd7a --- /dev/null +++ b/src/store/return/getById.js @@ -0,0 +1,28 @@ +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import { loadReturn } from './helpers.js'; + +export default function getById(provider) { + const access = resolveSelectedCompanyAccess(provider); + + /** + * Returns a return manifest by its id within the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The id of the return manifest. + * @param {object} [tx] - Optional transaction. + * @returns {Promise} The return manifest with lines and summary. + * @throws {Error} If the return is not found. + */ + return async (googleAuthId, id, tx) => { + if (!Number.isFinite(id)) { + throw new Error('Return not found'); + } + + const { companyId } = await access(googleAuthId, tx); + const result = await loadReturn(provider, tx, companyId, id); + if (!result) { + throw new Error('Return not found'); + } + return result; + }; +} diff --git a/src/store/return/helpers.js b/src/store/return/helpers.js new file mode 100644 index 0000000..33d82d9 --- /dev/null +++ b/src/store/return/helpers.js @@ -0,0 +1,90 @@ +import * as mssqlQueries from '../../providers/mssql/queries/return.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/return.js'; +import makeRunner from '../runner.js'; +import { shapeLine } from '../manifestLines.js'; +import { syncReturnSummary } from './syncSummary.js'; + +export const RETURN_TYPE = mssqlQueries.RETURN_TYPE; + +export function shapeSummary(row) { + const stockPositions = Number(row.stockPositions ?? 0); + const totalPositions = Number(row.stockPositions ?? row.totalPositions ?? row.positions ?? 0); + const bookedPositions = Number(row.bookedPositions ?? row.completelyOnLedgerPositions ?? 0); + const unbookedPositions = Number(row.unbookedPositions ?? Math.max(0, totalPositions - bookedPositions)); + + const totalQuantity = Number(row.totalQuantity ?? row.stockQuantity ?? 0); + const stockQuantity = Number(row.stockQuantity ?? 0); + const bookedQuantity = Number(row.bookedQuantity ?? 0); + const unbookedQuantity = Number(row.unbookedQuantity ?? Math.max(0, (stockQuantity || totalQuantity) - bookedQuantity)); + const isBooked = row.isBooked !== undefined && row.isBooked !== null + ? Boolean(row.isBooked) + : (totalPositions > 0 && bookedPositions >= totalPositions); + + return { + id: row.id, + number: row.number, + type: RETURN_TYPE, + supplierId: row.supplierId ?? null, + supplierName: row.supplierName ?? undefined, + externalNumber: row.externalNumber ?? null, + date: row.date, + createdAt: row.createdAt ?? row.created_at ?? null, + positions: Number(row.positions ?? totalPositions), + grossTotal: row.grossTotal !== undefined && row.grossTotal !== null ? Number(row.grossTotal) : 0, + netTotal: row.netTotal !== undefined && row.netTotal !== null ? Number(row.netTotal) : 0, + totalQuantity, + stockPositions, + bookedPositions, + unbookedPositions, + stockQuantity, + bookedQuantity, + unbookedQuantity, + isBooked, + ledger: { + totalPositions, + completelyOnLedgerPositions: bookedPositions, + bookedPositions, + unbookedPositions, + totalQuantity: stockQuantity || totalQuantity, + bookedQuantity, + unbookedQuantity, + isBooked, + }, + }; +} + +export async function loadReturn(provider, tx, companyId, id) { + const runner = makeRunner(provider, tx); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; + + let header = await runner.query(queries.GET_RETURN_HEADER, { + id, + companyId, + type: RETURN_TYPE, + }); + + let row = header.rows[0]; + if (!row) { + return null; + } + + if (row.positions === null || row.positions === undefined) { + await syncReturnSummary(runner, { companyId, manifestId: id }); + header = await runner.query(queries.GET_RETURN_HEADER, { + id, + companyId, + type: RETURN_TYPE, + }); + row = header.rows[0]; + } + + const lines = await runner.query(queries.GET_RETURN_LINES, { + manifestId: id, + }); + + return { + ...shapeSummary(row), + lines: lines.rows.map(shapeLine), + }; +} diff --git a/src/store/return/list.js b/src/store/return/list.js new file mode 100644 index 0000000..260acb2 --- /dev/null +++ b/src/store/return/list.js @@ -0,0 +1,237 @@ +import * as mssqlQueries from '../../providers/mssql/queries/return.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/return.js'; +import makeRunner from '../runner.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import { shapeSummary } from './helpers.js'; + +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; + + const sqlCountManifests = isSqlite + ? 'SELECT COUNT(*) AS total FROM manifest WHERE company_id = @companyId AND "type" = @type' + : 'SELECT COUNT(*) AS total FROM manifest WHERE company_id = @companyId AND [type] = @type'; + + /** + * Lists the returns 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 returns to skip. + * @param {number} [opts.limit=50] - Number of returns to return. + * @param {string} [opts.search] - Free-text search matching return number, external number, or supplier name. + * @param {boolean} [opts.onlyUnbooked=false] - When true, returns only returns with unbooked stock items. + * @param {number} [opts.supplierId] - Filter by supplier entity id. + * @param {number} [opts.seekMonths] - Negative integer for cutoff months ago. + * @param {object} [tx] - Optional transaction. + * @returns {Promise<{returns: Array, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>} + */ + 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.onlyUnbooked === true || + opts.supplierId !== undefined || + 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 conditions = ['m.company_id = @companyId', `m.${isSqlite ? '"type"' : '[type]'} = @type`]; + const params = { + companyId, + type: queries.RETURN_TYPE, + }; + + if (opts?.supplierId !== undefined && opts.supplierId !== null) { + const supplierId = Number(opts.supplierId); + if (!Number.isInteger(supplierId) || supplierId <= 0) { + throw new Error('Invalid supplierId'); + } + params.supplierId = supplierId; + conditions.push('m.entity_id = @supplierId'); + } + + 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`); + } + + 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 + )`); + } + } + + if (opts?.onlyUnbooked === true) { + conditions.push('COALESCE(rs.is_booked, 0) = 0'); + } + + const whereClause = conditions.join('\n AND '); + const sqlCount = ` + SELECT COUNT(*) AS total + FROM manifest m + LEFT JOIN returnsummary rs ON rs.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 supplierId, + m.external_number AS externalNumber, + m."date", + m.created_at AS createdAt, + rs.positions, + rs.gross_total AS grossTotal, + rs.net_total AS netTotal, + rs.total_quantity AS totalQuantity, + rs.stock_positions AS stockPositions, + rs.booked_positions AS bookedPositions, + rs.unbooked_positions AS unbookedPositions, + rs.stock_quantity AS stockQuantity, + rs.booked_quantity AS bookedQuantity, + rs.unbooked_quantity AS unbookedQuantity, + rs.is_booked AS isBooked, + rs.supplier_name AS supplierName + FROM manifest m + LEFT JOIN returnsummary rs ON rs.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 supplierId, + m.external_number AS externalNumber, + m.[date], + m.created_at AS createdAt, + rs.positions, + rs.gross_total AS grossTotal, + rs.net_total AS netTotal, + rs.total_quantity AS totalQuantity, + rs.stock_positions AS stockPositions, + rs.booked_positions AS bookedPositions, + rs.unbooked_positions AS unbookedPositions, + rs.stock_quantity AS stockQuantity, + rs.booked_quantity AS bookedQuantity, + rs.unbooked_quantity AS unbookedQuantity, + rs.is_booked AS isBooked, + rs.supplier_name AS supplierName + FROM manifest m + LEFT JOIN returnsummary rs ON rs.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() !== '') || + opts?.onlyUnbooked === true + ); + + let rows; + 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: queries.RETURN_TYPE }) : null, + ]); + total = Number(countResult.rows[0]?.total ?? 0); + unfilteredTotal = hasUserFilters + ? Number(unfilteredCountResult?.rows[0]?.total ?? 0) + : total; + rows = listResult.rows; + } else { + const result = await runner.query(sqlList, params); + rows = result.rows; + total = rows.length; + if (hasUserFilters) { + const countRes = await runner.query(sqlCountManifests, { companyId, type: queries.RETURN_TYPE }); + unfilteredTotal = Number(countRes.rows[0]?.total ?? 0); + } else { + unfilteredTotal = total; + } + } + + const returns = rows.map(shapeSummary); + + if (isPaged) { + return { + returns, + total, + unfilteredTotal, + offset, + limit, + }; + } + + return returns; + }; +} diff --git a/src/store/return/rebuildSummaries.js b/src/store/return/rebuildSummaries.js new file mode 100644 index 0000000..f06c18a --- /dev/null +++ b/src/store/return/rebuildSummaries.js @@ -0,0 +1,19 @@ +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; +import { rebuildReturnSummaries } from './syncSummary.js'; + +/** + * Rebuilds the `returnsummary` 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 rebuildReturnSummaries(runner, { companyId }); + return { ok: true, companyId }; + }; +} diff --git a/src/store/return/syncSummary.js b/src/store/return/syncSummary.js new file mode 100644 index 0000000..d2cba31 --- /dev/null +++ b/src/store/return/syncSummary.js @@ -0,0 +1,308 @@ +/** + * Centralized sync and rebuild logic for the materialized `returnsummary` table. + * + * Keeps `returnsummary` in sync with manifest (return), manifestarticle, + * article (track_stock), and ledger (RETURN transactions). + */ + +const SQL_GET_RETURN_BY_ID_SQLITE = ` + SELECT m.id, m.company_id, m."number", m.external_number, m."date", m.created_at, m.entity_id + FROM manifest m + WHERE m.id = @manifestId AND m.company_id = @companyId AND m."type" = 'return' +`; + +const SQL_GET_RETURN_BY_ID_MSSQL = ` + SELECT m.id, m.company_id, m.[number], m.external_number, m.[date], m.created_at, m.entity_id + FROM manifest m + WHERE m.id = @manifestId AND m.company_id = @companyId AND m.[type] = 'return' +`; + +const SQL_GET_RETURN_BY_NUMBER_SQLITE = ` + SELECT m.id, m.company_id, m."number", m.external_number, m."date", m.created_at, m.entity_id + FROM manifest m + WHERE m."number" = @number AND m.company_id = @companyId AND m."type" = 'return' +`; + +const SQL_GET_RETURN_BY_NUMBER_MSSQL = ` + SELECT m.id, m.company_id, m.[number], m.external_number, m.[date], m.created_at, m.entity_id + FROM manifest m + WHERE m.[number] = @number AND m.company_id = @companyId AND m.[type] = 'return' +`; + +const SQL_SUPPLIER_NAME_SQLITE = ` + SELECT TRIM(COALESCE(first_name || ' ', '') || COALESCE(name, '')) AS supplier_name + FROM entity + WHERE id = @entityId +`; + +const SQL_SUPPLIER_NAME_MSSQL = ` + SELECT LTRIM(RTRIM(ISNULL(first_name + ' ', '') + ISNULL(name, ''))) AS supplier_name + FROM entity + WHERE id = @entityId +`; + +const SQL_RETURN_TOTALS_SQLITE = ` + WITH line_ledger AS ( + SELECT + manifestarticle_id, + SUM(amount) AS booked_amount + FROM ledger + WHERE warehouse_id IS NULL + AND UPPER(transaction_type) IN ('RETURN', 'ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT') + GROUP BY manifestarticle_id + ), + line_data AS ( + SELECT + ma.id, + ma.amount, + ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross, + ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net, + CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock, + COALESCE(ll.booked_amount, 0) AS booked_amount + FROM manifestarticle ma + LEFT JOIN article a ON a.company_id = @companyId AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku)) + LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id + WHERE ma.manifest_id = @manifestId + ) + SELECT + COUNT(*) AS positions, + COALESCE(SUM(line_gross), 0) AS gross_total, + COALESCE(SUM(line_net), 0) AS net_total, + COALESCE(SUM(amount), 0) AS total_quantity, + COALESCE(SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END), 0) AS stock_positions, + COALESCE(SUM(CASE WHEN track_stock = 1 AND ((amount > 0 AND booked_amount >= amount) OR (amount <= 0 AND booked_amount > 0)) THEN 1 ELSE 0 END), 0) AS stock_booked_positions, + COALESCE(SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END), 0) AS stock_quantity, + COALESCE(SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END), 0) AS stock_booked_quantity, + COALESCE(SUM(CASE WHEN (amount > 0 AND booked_amount >= amount) OR (amount <= 0 AND booked_amount > 0) THEN 1 ELSE 0 END), 0) AS all_booked_positions, + COALESCE(SUM(booked_amount), 0) AS all_booked_quantity + FROM line_data +`; + +const SQL_RETURN_TOTALS_MSSQL = ` + WITH line_ledger AS ( + SELECT + manifestarticle_id, + SUM(amount) AS booked_amount + FROM ledger + WHERE warehouse_id IS NULL + AND UPPER(transaction_type) IN ('RETURN', 'ACCEPTANCE', 'DELIVERY', 'GOODS_RECEIPT') + GROUP BY manifestarticle_id + ), + line_data AS ( + SELECT + ma.id, + ma.amount, + ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) * (1.0 + ma.taxrate / 100.0) AS line_gross, + ma.amount * (ma.net * (1.0 - ma.rebate / 100.0)) AS line_net, + CASE WHEN COALESCE(a.track_stock, 1) = 0 THEN 0 ELSE 1 END AS track_stock, + COALESCE(ll.booked_amount, 0) AS booked_amount + FROM manifestarticle ma + LEFT JOIN article a ON a.company_id = @companyId AND (a.sku = ma.sku OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku)) + LEFT JOIN line_ledger ll ON ll.manifestarticle_id = ma.id + WHERE ma.manifest_id = @manifestId + ) + SELECT + COUNT(*) AS positions, + COALESCE(SUM(line_gross), 0) AS gross_total, + COALESCE(SUM(line_net), 0) AS net_total, + COALESCE(SUM(amount), 0) AS total_quantity, + COALESCE(SUM(CASE WHEN track_stock = 1 THEN 1 ELSE 0 END), 0) AS stock_positions, + COALESCE(SUM(CASE WHEN track_stock = 1 AND ((amount > 0 AND booked_amount >= amount) OR (amount <= 0 AND booked_amount > 0)) THEN 1 ELSE 0 END), 0) AS stock_booked_positions, + COALESCE(SUM(CASE WHEN track_stock = 1 THEN amount ELSE 0 END), 0) AS stock_quantity, + COALESCE(SUM(CASE WHEN track_stock = 1 THEN booked_amount ELSE 0 END), 0) AS stock_booked_quantity, + COALESCE(SUM(CASE WHEN (amount > 0 AND booked_amount >= amount) OR (amount <= 0 AND booked_amount > 0) THEN 1 ELSE 0 END), 0) AS all_booked_positions, + COALESCE(SUM(booked_amount), 0) AS all_booked_quantity + FROM line_data +`; + +const SQL_UPSERT_SUMMARY_SQLITE = ` + INSERT INTO returnsummary ( + company_id, manifest_id, return_number, external_number, return_date, created_at, + supplier_id, supplier_name, positions, gross_total, net_total, total_quantity, + stock_positions, booked_positions, unbooked_positions, + stock_quantity, booked_quantity, unbooked_quantity, is_booked + ) VALUES ( + @companyId, @manifestId, @returnNumber, @externalNumber, @returnDate, @createdAt, + @supplierId, @supplierName, @positions, @grossTotal, @netTotal, @totalQuantity, + @stockPositions, @bookedPositions, @unbookedPositions, + @stockQuantity, @bookedQuantity, @unbookedQuantity, @isBooked + ) + ON CONFLICT(manifest_id) DO UPDATE SET + return_number = excluded.return_number, + external_number = excluded.external_number, + return_date = excluded.return_date, + created_at = excluded.created_at, + supplier_id = excluded.supplier_id, + supplier_name = excluded.supplier_name, + positions = excluded.positions, + gross_total = excluded.gross_total, + net_total = excluded.net_total, + total_quantity = excluded.total_quantity, + stock_positions = excluded.stock_positions, + booked_positions = excluded.booked_positions, + unbooked_positions = excluded.unbooked_positions, + stock_quantity = excluded.stock_quantity, + booked_quantity = excluded.booked_quantity, + unbooked_quantity = excluded.unbooked_quantity, + is_booked = excluded.is_booked +`; + +const SQL_UPSERT_SUMMARY_MSSQL = ` + MERGE INTO returnsummary AS target + USING (SELECT @manifestId AS manifest_id) AS source + ON (target.manifest_id = source.manifest_id) + WHEN MATCHED THEN + UPDATE SET + return_number = @returnNumber, + external_number = @externalNumber, + return_date = @returnDate, + created_at = @createdAt, + supplier_id = @supplierId, + supplier_name = @supplierName, + positions = @positions, + gross_total = @grossTotal, + net_total = @netTotal, + total_quantity = @totalQuantity, + stock_positions = @stockPositions, + booked_positions = @bookedPositions, + unbooked_positions = @unbookedPositions, + stock_quantity = @stockQuantity, + booked_quantity = @bookedQuantity, + unbooked_quantity = @unbookedQuantity, + is_booked = @isBooked + WHEN NOT MATCHED THEN + INSERT ( + company_id, manifest_id, return_number, external_number, return_date, created_at, + supplier_id, supplier_name, positions, gross_total, net_total, total_quantity, + stock_positions, booked_positions, unbooked_positions, + stock_quantity, booked_quantity, unbooked_quantity, is_booked + ) VALUES ( + @companyId, @manifestId, @returnNumber, @externalNumber, @returnDate, @createdAt, + @supplierId, @supplierName, @positions, @grossTotal, @netTotal, @totalQuantity, + @stockPositions, @bookedPositions, @unbookedPositions, + @stockQuantity, @bookedQuantity, @unbookedQuantity, @isBooked + ); +`; + +/** + * Recalculates and upserts a single return summary into `returnsummary`. + * + * @param {object} runner - Database runner. + * @param {object} opts + * @param {number} opts.companyId + * @param {number} [opts.manifestId] - Return manifest id. + * @param {string} [opts.returnNumber] - Return number (if manifestId not given). + */ +export async function syncReturnSummary(runner, { companyId, manifestId, returnNumber }) { + const isSqlite = runner.dialect === 'sqlite3'; + + let returnRow; + if (manifestId) { + const res = await runner.query( + isSqlite ? SQL_GET_RETURN_BY_ID_SQLITE : SQL_GET_RETURN_BY_ID_MSSQL, + { manifestId, companyId }, + ); + returnRow = res.rows[0]; + } else if (returnNumber) { + const res = await runner.query( + isSqlite ? SQL_GET_RETURN_BY_NUMBER_SQLITE : SQL_GET_RETURN_BY_NUMBER_MSSQL, + { number: returnNumber, companyId }, + ); + returnRow = res.rows[0]; + } + + if (!returnRow) { + if (manifestId) { + await runner.query('DELETE FROM returnsummary WHERE manifest_id = @manifestId', { manifestId }); + } + return; + } + + const returnId = returnRow.id; + const num = returnRow.number; + + const totalsRes = await runner.query( + isSqlite ? SQL_RETURN_TOTALS_SQLITE : SQL_RETURN_TOTALS_MSSQL, + { manifestId: returnId, companyId }, + ); + const totals = totalsRes.rows[0] ?? {}; + + let supplierName = null; + if (returnRow.entity_id) { + const suppRes = await runner.query( + isSqlite ? SQL_SUPPLIER_NAME_SQLITE : SQL_SUPPLIER_NAME_MSSQL, + { entityId: returnRow.entity_id }, + ); + supplierName = suppRes.rows[0]?.supplier_name || null; + } + + const positions = Number(totals.positions || 0); + const grossTotal = Math.round(Number(totals.gross_total || 0) * 100) / 100; + const netTotal = Math.round(Number(totals.net_total || 0) * 100) / 100; + const totalQuantity = Math.round(Number(totals.total_quantity || 0) * 1000) / 1000; + + const stockPositions = Number(totals.stock_positions || 0); + const stockQuantity = Math.round(Number(totals.stock_quantity || 0) * 1000) / 1000; + + let bookedPositions; + let bookedQuantity; + let unbookedPositions; + let unbookedQuantity; + + if (stockPositions > 0) { + bookedPositions = Number(totals.stock_booked_positions || 0); + bookedQuantity = Math.round(Number(totals.stock_booked_quantity || 0) * 1000) / 1000; + unbookedPositions = Math.max(0, stockPositions - bookedPositions); + unbookedQuantity = Math.round(Math.max(0, stockQuantity - bookedQuantity) * 1000) / 1000; + } else { + bookedPositions = Number(totals.all_booked_positions || 0); + bookedQuantity = Math.round(Number(totals.all_booked_quantity || 0) * 1000) / 1000; + unbookedPositions = Math.max(0, positions - bookedPositions); + unbookedQuantity = Math.round(Math.max(0, totalQuantity - bookedQuantity) * 1000) / 1000; + } + + const isBooked = positions > 0 && unbookedPositions === 0 ? 1 : 0; + + await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, { + companyId, + manifestId: returnId, + returnNumber: num, + externalNumber: returnRow.external_number ?? null, + returnDate: returnRow.date, + createdAt: returnRow.created_at, + supplierId: returnRow.entity_id ?? null, + supplierName, + positions, + grossTotal, + netTotal, + totalQuantity, + stockPositions, + bookedPositions, + unbookedPositions, + stockQuantity, + bookedQuantity, + unbookedQuantity, + isBooked, + }); +} + +/** + * Full rebuild of all returns in `returnsummary` for a given company. + * + * @param {object} runner + * @param {object} opts + * @param {number} opts.companyId + */ +export async function rebuildReturnSummaries(runner, { companyId }) { + const isSqlite = runner.dialect === 'sqlite3'; + await runner.query('DELETE FROM returnsummary WHERE company_id = @companyId', { companyId }); + + const listReturnsSql = isSqlite + ? 'SELECT id FROM manifest WHERE company_id = @companyId AND "type" = \'return\'' + : 'SELECT id FROM manifest WHERE company_id = @companyId AND [type] = \'return\''; + + const returns = (await runner.query(listReturnsSql, { companyId })).rows; + for (const r of returns) { + await syncReturnSummary(runner, { companyId, manifestId: r.id }); + } +} diff --git a/src/store/return/update.js b/src/store/return/update.js new file mode 100644 index 0000000..0406b4a --- /dev/null +++ b/src/store/return/update.js @@ -0,0 +1,148 @@ +import { syncReturnSummary } from './syncSummary.js'; +import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; +import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; +import { isForeignKeyConstraintError } from '../../providers/provider.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import taxEnsure from '../tax/ensure.js'; +import { insertLines, normalizeLines, parseDate } from '../manifestLines.js'; +import { loadReturn, shapeSummary, RETURN_TYPE } from './helpers.js'; +import { assertSupplier } from '../delivery/writeLines.js'; +import { assertUniqueManifestNumber } from '../manifestDoc/helpers.js'; +import makeRunner from '../runner.js'; + +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const ensureTax = taxEnsure(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const queries = isSqlite ? sqliteQueries : mssqlQueries; + + return async (googleAuthId, id, patch = {}) => { + if (!Number.isFinite(id)) { + throw new Error('Return not found'); + } + + const hasNumber = Object.prototype.hasOwnProperty.call(patch, 'number'); + const hasDate = Object.prototype.hasOwnProperty.call(patch, 'date'); + const hasCreatedAt = Object.prototype.hasOwnProperty.call(patch, 'createdAt'); + const hasSupplierId = Object.prototype.hasOwnProperty.call(patch, 'supplierId'); + const hasEntityId = Object.prototype.hasOwnProperty.call(patch, 'entityId'); + const hasExternalNumber = Object.prototype.hasOwnProperty.call(patch, 'externalNumber'); + const hasLines = Object.prototype.hasOwnProperty.call(patch, 'lines'); + + if (!hasNumber && !hasDate && !hasCreatedAt && !hasSupplierId && !hasEntityId && !hasExternalNumber && !hasLines) { + throw new Error('Invalid return'); + } + + let normalizedLines; + if (hasLines) { + normalizedLines = normalizeLines(patch.lines); + } + + const { companyId, companyuserId } = await access(googleAuthId); + + const run = async (runner, tx) => { + const existing = await runner.query( + isSqlite + ? 'SELECT m.id, m."number", m.external_number AS externalNumber, m."date", m.created_at AS createdAt, m.entity_id AS supplierId FROM manifest m WHERE m.id = @id AND m.company_id = @companyId AND m."type" = @type' + : 'SELECT m.id, m.[number], m.external_number AS externalNumber, m.[date], m.created_at AS createdAt, m.entity_id AS supplierId FROM manifest m WHERE m.id = @id AND m.company_id = @companyId AND m.[type] = @type', + { id, companyId, type: RETURN_TYPE }, + ); + + const current = existing.rows[0]; + if (!current) { + throw new Error('Return not found'); + } + + let number = current.number; + if (hasNumber) { + number = patch.number?.trim?.() ? patch.number.trim() : null; + if (!number) { + throw new Error('Invalid return'); + } + await assertUniqueManifestNumber(provider, tx, { + companyId, + type: RETURN_TYPE, + number, + excludeId: id, + errorMessage: 'Return number already exists', + }); + } + + let date = current.date; + if (hasDate) { + date = parseDate(patch.date); + if (!date) { + throw new Error('Invalid return'); + } + } + + let createdAt = current.createdAt ? parseDate(current.createdAt) : null; + if (hasCreatedAt) { + createdAt = parseDate(patch.createdAt); + if (!createdAt) { + throw new Error('Invalid return'); + } + } + + let supplierId = current.supplierId; + if (hasSupplierId || hasEntityId) { + const val = hasSupplierId ? patch.supplierId : patch.entityId; + if (val == null) { + throw new Error('No supplier selected'); + } + supplierId = await assertSupplier( + provider, + tx, + companyId, + val, + ); + } + + let externalNumber = current.externalNumber; + if (hasExternalNumber) { + externalNumber = patch.externalNumber?.trim?.() + ? patch.externalNumber.trim() + : null; + } + + await runner.query(queries.UPDATE_MANIFEST, { + id, + companyId, + type: RETURN_TYPE, + number, + entityId: supplierId, + externalNumber, + date, + createdAt, + }); + + if (hasLines) { + try { + await runner.query(queries.DELETE_LINES_BY_MANIFEST, { + manifestId: id, + }); + } catch (err) { + if (isForeignKeyConstraintError(err)) { + throw new Error('Return lines already booked on ledger'); + } + throw err; + } + + await insertLines(provider, tx, { + manifestId: id, + lines: normalizedLines, + ensureTax, + companyuserId, + }); + } + + await syncReturnSummary(runner, { companyId, manifestId: id }); + return loadReturn(provider, tx, companyId, id); + }; + + if (typeof provider?.transaction === 'function') { + return provider.transaction((runner, tx) => run(runner, tx)); + } + return run(makeRunner(provider)); + }; +} diff --git a/src/store/shipment/create.js b/src/store/shipment/create.js index 404a879..f0b30fa 100644 --- a/src/store/shipment/create.js +++ b/src/store/shipment/create.js @@ -6,6 +6,7 @@ import makeRunner from '../runner.js'; import shippingCreate from '../shipping/create.js'; import { loadShipment, queriesFor } from './helpers.js'; import { syncOrderSummary } from '../order/syncSummary.js'; +import { syncRetoureSummary } from '../retoure/syncSummary.js'; import { syncShipmentSummary } from './syncSummary.js'; function requireId(val, name) { @@ -293,8 +294,12 @@ export default function create(provider) { // summary state ('delivered') is written, as that would block shipping creation. await createShipping(googleAuthId, { ...input.shipping, manifestId }, innerTx); - // 9. Synchronize order summary row and shipment summary row - await syncOrderSummary(runner, { companyId, manifestId: orderId }); + // 9. Synchronize order/retoure summary row and shipment summary row + if (order.type === 'retoure') { + await syncRetoureSummary(runner, { companyId, manifestId: orderId }); + } else { + await syncOrderSummary(runner, { companyId, manifestId: orderId }); + } const validState = input.state && ['new', 'delivered'].includes(String(input.state).trim().toLowerCase()) ? String(input.state).trim().toLowerCase() : 'new';