From 688fa4f3fd9c0d394b7519e1813c89323eb93977 Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 17 Oct 2026 23:31:37 +0200 Subject: [PATCH] u --- generate-api-doc.js | 1 + generate-schema-graph.js | 1 + src/index.js | 2 + src/providers/mssql/purgeSql.js | 1 + src/providers/mssql/queries/entity.js | 11 + src/providers/mssql/schema.js | 23 +- src/providers/sqlite3/queries/entity.js | 12 + src/providers/sqlite3/schema.js | 19 +- src/store/article/addChild.js | 5 + src/store/article/createUnderParent.js | 5 + src/store/article/setTrackStockBySku.js | 10 + src/store/article/toggleParent.js | 2 + .../articlewarehousesummary/syncSummary.js | 266 ++++++++++++++++++ src/store/entity/delete.js | 46 ++- src/store/ledger/add.js | 19 ++ src/store/stock/adjust.js | 7 + src/store/stock/rebuildSummaries.js | 19 ++ src/store/supplier/delete.js | 40 ++- src/store/warehouse/create.js | 4 + 19 files changed, 475 insertions(+), 18 deletions(-) create mode 100644 src/store/articlewarehousesummary/syncSummary.js create mode 100644 src/store/stock/rebuildSummaries.js diff --git a/generate-api-doc.js b/generate-api-doc.js index 479da89..727a362 100644 --- a/generate-api-doc.js +++ b/generate-api-doc.js @@ -351,6 +351,7 @@ const SCHEMA_TABLES = [ ['companysetting', 'id PK, company_id->company, key:s(255), value:txt, frontend:b=0, writeonly:b=0, UQ(company_id, key)'], ['companyuser', 'id PK, google_auth_id->google_auth, company_id->company, UQ(google_auth_id, company_id)'], ['customergroup', 'id PK, name:s(255), sort:i=0, company_id?->company'], + ['articlewarehousesummary', 'id PK, article_id->article, warehouse_id->warehouse, stock:f=0, UQ(article_id, warehouse_id)'], ['deliverysummary', 'id PK, company_id->company, manifest_id->manifest UQ, delivery_number:s(100), external_number?:s(100), delivery_date:dt, created_at:dt, supplier_id?->entity, supplier_name?:s(255), positions:i=0, gross_total:f=0, net_total:f=0, total_quantity:f=0, stock_positions:i=0, booked_positions:i=0, unbooked_positions:i=0, stock_quantity:f=0, booked_quantity:f=0, unbooked_quantity:f=0, is_booked:b=0'], ['entity', 'id PK, customergroup_id->customergroup, first_name?:s(255), name:s(255), number?:s(100), created_at:dt, updated_at:dt, UQ(company_id, number)'], ['entityaddress', 'id PK, entity_id->entity, first_name?:s(255), name?:s(255), street?:s(255), streetnumber?:s(50), zip?:s(50), city?:s(255), county?:s(255), addresstype:s(50), sort:i=0'], diff --git a/generate-schema-graph.js b/generate-schema-graph.js index 9a45186..36e3722 100644 --- a/generate-schema-graph.js +++ b/generate-schema-graph.js @@ -374,6 +374,7 @@ const CLUSTERS = [ 'manifest', 'manifestarticle', 'manifestattachment', + 'articlewarehousesummary', 'deliverysummary', 'ordersummary', 'quotesummary', diff --git a/src/index.js b/src/index.js index f014704..e1d7f48 100644 --- a/src/index.js +++ b/src/index.js @@ -223,6 +223,7 @@ import labeltemplateDelete from './store/labeltemplate/delete.js'; import stockAdjust from './store/stock/adjust.js'; import stockAccept from './store/stock/accept.js'; import stockShip from './store/stock/ship.js'; +import stockRebuildSummaries from './store/stock/rebuildSummaries.js'; import propertynameList from './store/propertyname/list.js'; import propertynameCreate from './store/propertyname/create.js'; import propertynameUpdate from './store/propertyname/update.js'; @@ -624,6 +625,7 @@ export default class LibDashStore { adjust: stockAdjust(provider), accept: stockAccept(provider), ship: stockShip(provider), + rebuildSummaries: stockRebuildSummaries(provider), }; this.adjustment = this.stock; diff --git a/src/providers/mssql/purgeSql.js b/src/providers/mssql/purgeSql.js index 2aaf5c1..9b10cf6 100644 --- a/src/providers/mssql/purgeSql.js +++ b/src/providers/mssql/purgeSql.js @@ -1,6 +1,7 @@ /** Drop order = reverse of schema create dependency order. */ export const TABLES = [ + 'articlewarehousesummary', 'deliverysummary', 'shipmentsummary', 'quotesummary', diff --git a/src/providers/mssql/queries/entity.js b/src/providers/mssql/queries/entity.js index 04aef09..deba50d 100644 --- a/src/providers/mssql/queries/entity.js +++ b/src/providers/mssql/queries/entity.js @@ -161,3 +161,14 @@ WHERE e.id = @id AND cg.company_id = @companyId AND cg.name = @groupName `; + +export const CHECK_SUPPLIER_IN_USE = ` +SELECT TOP 1 1 AS inUse +FROM manifest m +WHERE m.entity_id = @id AND m.company_id = @companyId +UNION ALL +SELECT TOP 1 1 AS inUse +FROM articlesupplier asup +INNER JOIN article a ON a.id = asup.article_id +WHERE asup.entity_id = @id AND a.company_id = @companyId; +`; diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index eb22a2c..ef31e2d 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -619,7 +619,7 @@ BEGIN pu FLOAT NOT NULL CONSTRAINT DF_articlesupplier_pu DEFAULT 1, sort INT NOT NULL CONSTRAINT DF_articlesupplier_sort DEFAULT 0, CONSTRAINT FK_articlesupplier_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE, - CONSTRAINT FK_articlesupplier_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE, + CONSTRAINT FK_articlesupplier_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION, CONSTRAINT FK_articlesupplier_tax FOREIGN KEY (tax_id) REFERENCES tax(id) ON DELETE NO ACTION, CONSTRAINT UQ_articlesupplier_article_entity UNIQUE (article_id, entity_id), CONSTRAINT UQ_articlesupplier_article_sort UNIQUE (article_id, sort) @@ -643,7 +643,7 @@ BEGIN [date] DATETIME2 NOT NULL CONSTRAINT DF_manifest_date DEFAULT SYSUTCDATETIME(), created_at DATETIME2 NOT NULL CONSTRAINT DF_manifest_created_at DEFAULT SYSUTCDATETIME(), CONSTRAINT FK_manifest_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION, - CONSTRAINT FK_manifest_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL + CONSTRAINT FK_manifest_entity FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION ); END `; @@ -1338,6 +1338,24 @@ BEGIN END `; +export const INIT_ARTICLEWAREHOUSESUMMARY = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'articlewarehousesummary') +BEGIN + CREATE TABLE articlewarehousesummary ( + id INT IDENTITY(1,1) PRIMARY KEY, + article_id INT NOT NULL, + warehouse_id INT NOT NULL, + stock FLOAT NOT NULL CONSTRAINT DF_articlewarehousesummary_stock DEFAULT 0, + CONSTRAINT FK_articlewarehousesummary_article FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE, + CONSTRAINT FK_articlewarehousesummary_warehouse FOREIGN KEY (warehouse_id) REFERENCES warehouse(id) ON DELETE CASCADE, + CONSTRAINT UQ_articlewarehousesummary_article_warehouse UNIQUE (article_id, warehouse_id) + ); + + CREATE INDEX IX_articlewarehousesummary_warehouse + ON articlewarehousesummary (warehouse_id); +END +`; + export const INIT_MCP_CLIENT = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'mcp_client') BEGIN @@ -1485,6 +1503,7 @@ export const SCHEMA_SCRIPTS = [ INIT_QUOTESUMMARY, INIT_SHIPMENTSUMMARY, INIT_DELIVERYSUMMARY, + INIT_ARTICLEWAREHOUSESUMMARY, INIT_MCP_CLIENT, INIT_MCP_AUTH_CODE, INIT_MCP_TOKEN, diff --git a/src/providers/sqlite3/queries/entity.js b/src/providers/sqlite3/queries/entity.js index 426a446..ca142e5 100644 --- a/src/providers/sqlite3/queries/entity.js +++ b/src/providers/sqlite3/queries/entity.js @@ -23,4 +23,16 @@ WHERE id = @id AND customergroup_id IN ( SELECT id FROM customergroup WHERE company_id = @companyId AND name = @groupName ) +`; + +export const CHECK_SUPPLIER_IN_USE = ` +SELECT 1 AS inUse +FROM manifest m +WHERE m.entity_id = @id AND m.company_id = @companyId +UNION ALL +SELECT 1 AS inUse +FROM articlesupplier asup +INNER JOIN article a ON a.id = asup.article_id +WHERE asup.entity_id = @id AND a.company_id = @companyId +LIMIT 1; `; \ No newline at end of file diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index 07daa52..c637bec 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -412,7 +412,7 @@ CREATE TABLE IF NOT EXISTS articlesupplier ( UNIQUE (article_id, entity_id), UNIQUE (article_id, sort), FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE, - FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE CASCADE, + FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION, FOREIGN KEY (tax_id) REFERENCES tax(id) ON DELETE NO ACTION ); CREATE INDEX IF NOT EXISTS IX_articlesupplier_article_entity @@ -430,7 +430,7 @@ CREATE TABLE IF NOT EXISTS manifest ( "date" TEXT NOT NULL DEFAULT (${UTC_NOW}), created_at TEXT NOT NULL DEFAULT (${UTC_NOW}), FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE NO ACTION, - FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE SET NULL + FOREIGN KEY (entity_id) REFERENCES entity(id) ON DELETE NO ACTION ); `; @@ -981,6 +981,20 @@ CREATE INDEX IF NOT EXISTS IX_deliverysummary_company_supplier ON deliverysummary (company_id, supplier_id); `; +export const INIT_ARTICLEWAREHOUSESUMMARY = ` +CREATE TABLE IF NOT EXISTS articlewarehousesummary ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + article_id INTEGER NOT NULL, + warehouse_id INTEGER NOT NULL, + stock REAL NOT NULL DEFAULT 0, + UNIQUE (article_id, warehouse_id), + FOREIGN KEY (article_id) REFERENCES article(id) ON DELETE CASCADE, + FOREIGN KEY (warehouse_id) REFERENCES warehouse(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS IX_articlewarehousesummary_warehouse + ON articlewarehousesummary (warehouse_id); +`; + export const INIT_MCP_CLIENT = ` CREATE TABLE IF NOT EXISTS mcp_client ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -1111,6 +1125,7 @@ export const SCHEMA_SCRIPTS = [ INIT_QUOTESUMMARY, INIT_SHIPMENTSUMMARY, INIT_DELIVERYSUMMARY, + INIT_ARTICLEWAREHOUSESUMMARY, INIT_MCP_CLIENT, INIT_MCP_AUTH_CODE, INIT_MCP_TOKEN, diff --git a/src/store/article/addChild.js b/src/store/article/addChild.js index 1959f5f..54fd21a 100644 --- a/src/store/article/addChild.js +++ b/src/store/article/addChild.js @@ -19,6 +19,7 @@ import { recordChildAssignedToParentHistory, } from '../articlehistory/record.js'; import attachChild from './attachChild.js'; +import { initArticleWarehouseSummary } from '../articlewarehousesummary/syncSummary.js'; export default function addChild(provider) { const resolve = resolveArticleBySku(provider); @@ -266,6 +267,10 @@ export default function addChild(provider) { childSku, }); + if (finalTrackStock === 1) { + await initArticleWarehouseSummary(runner, { companyId, articleId: childId }); + } + return load(googleAuthId, childSku, runner); }; diff --git a/src/store/article/createUnderParent.js b/src/store/article/createUnderParent.js index d879dda..4090588 100644 --- a/src/store/article/createUnderParent.js +++ b/src/store/article/createUnderParent.js @@ -17,6 +17,7 @@ import nextSku from '../numbers/nextSku.js'; import makeRunner from '../runner.js'; import resolveUniqueSlug from './resolveUniqueSlug.js'; import { recordArticleCreatedHistory } from '../articlehistory/record.js'; +import { initArticleWarehouseSummary } from '../articlewarehousesummary/syncSummary.js'; export default function createUnderParent(provider) { const access = resolveCategoryAccess(provider); @@ -113,6 +114,10 @@ export default function createUnderParent(provider) { name, }); + if (finalTrackStock === 1) { + await initArticleWarehouseSummary(runner, { companyId, articleId: id }); + } + return { id, parentId, diff --git a/src/store/article/setTrackStockBySku.js b/src/store/article/setTrackStockBySku.js index bcbe66d..f07d970 100644 --- a/src/store/article/setTrackStockBySku.js +++ b/src/store/article/setTrackStockBySku.js @@ -13,6 +13,10 @@ import resolveArticleBySku from './resolveArticleBySku.js'; import { recordTrackStockChangeHistory } from '../articlehistory/record.js'; import stockAdjust from '../stock/adjust.js'; import makeRunner from '../runner.js'; +import { + initArticleWarehouseSummary, + removeArticleWarehouseSummary, +} from '../articlewarehousesummary/syncSummary.js'; export default function setTrackStockBySku(provider) { const resolve = resolveArticleBySku(provider); @@ -94,6 +98,12 @@ export default function setTrackStockBySku(provider) { // Write history if track_stock changed if (oldTrack !== newTrack) { + if (!newTrack) { + await removeArticleWarehouseSummary(runner, { articleId }); + } else { + await initArticleWarehouseSummary(runner, { companyId, articleId }); + } + await recordTrackStockChangeHistory(runner, { articleId, companyuserId, diff --git a/src/store/article/toggleParent.js b/src/store/article/toggleParent.js index cadc88b..6124b40 100644 --- a/src/store/article/toggleParent.js +++ b/src/store/article/toggleParent.js @@ -11,6 +11,7 @@ import { recordParentDeactivatedHistory, } from '../articlehistory/record.js'; import makeRunner from '../runner.js'; +import { removeArticleWarehouseSummary } from '../articlewarehousesummary/syncSummary.js'; export default function toggleParent(provider) { const resolve = resolveArticleBySku(provider); @@ -96,6 +97,7 @@ export default function toggleParent(provider) { await runner.query('DELETE FROM price WHERE article_id = @articleId', { articleId }); await runner.query('UPDATE article SET track_stock = 0 WHERE id = @articleId', { articleId }); + await removeArticleWarehouseSummary(runner, { articleId }); // If article is a child, verify attribute is not used by parent or any parent up the chain let currentParentId = currentArt?.parentarticle_id; diff --git a/src/store/articlewarehousesummary/syncSummary.js b/src/store/articlewarehousesummary/syncSummary.js new file mode 100644 index 0000000..a462350 --- /dev/null +++ b/src/store/articlewarehousesummary/syncSummary.js @@ -0,0 +1,266 @@ +/** + * Centralized sync and rebuild logic for the materialized `articlewarehousesummary` table. + * + * Keeps `articlewarehousesummary` in sync with ledger movements, track_stock status, + * and warehouses. + */ + +const SQL_FIND_ARTICLES_BY_MANIFESTARTICLE = ` + SELECT a.id AS articleId, a.company_id AS companyId, a.track_stock AS trackStock + FROM manifestarticle ma + INNER JOIN manifest m ON m.id = ma.manifest_id + INNER JOIN article a ON a.company_id = m.company_id + AND ( + a.sku = ma.sku + OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = a.id AND asup.sku = ma.sku) + ) + WHERE ma.id = @manifestarticleId AND m.company_id = @companyId; +`; + +const SQL_GET_STOCK_FROM_LEDGER = ` + SELECT COALESCE(SUM(l.amount), 0) AS stock + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + WHERE l.warehouse_id = @warehouseId + AND m.company_id = @companyId + AND ( + ma.sku = (SELECT sku FROM article WHERE id = @articleId) + OR ma.sku IN (SELECT asup.sku FROM articlesupplier asup WHERE asup.article_id = @articleId) + ); +`; + +const SQL_UPSERT_SUMMARY_SQLITE = ` + INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock) + VALUES (@articleId, @warehouseId, @stock) + ON CONFLICT (article_id, warehouse_id) DO UPDATE SET stock = excluded.stock; +`; + +const SQL_UPSERT_SUMMARY_MSSQL = ` + MERGE INTO articlewarehousesummary AS target + USING (SELECT @articleId AS article_id, @warehouseId AS warehouse_id) AS source + ON (target.article_id = source.article_id AND target.warehouse_id = source.warehouse_id) + WHEN MATCHED THEN + UPDATE SET stock = @stock + WHEN NOT MATCHED THEN + INSERT (article_id, warehouse_id, stock) + VALUES (@articleId, @warehouseId, @stock); +`; + +const SQL_INIT_FOR_ARTICLE_SQLITE = ` + INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock) + SELECT @articleId, w.id, 0 + FROM warehouse w + WHERE w.company_id = @companyId + ON CONFLICT (article_id, warehouse_id) DO UPDATE SET stock = 0; +`; + +const SQL_INIT_FOR_ARTICLE_MSSQL = ` + MERGE INTO articlewarehousesummary AS target + USING ( + SELECT @articleId AS article_id, w.id AS warehouse_id + FROM warehouse w + WHERE w.company_id = @companyId + ) AS source + ON (target.article_id = source.article_id AND target.warehouse_id = source.warehouse_id) + WHEN MATCHED THEN + UPDATE SET stock = 0 + WHEN NOT MATCHED THEN + INSERT (article_id, warehouse_id, stock) + VALUES (source.article_id, source.warehouse_id, 0); +`; + +const SQL_INIT_FOR_NEW_WAREHOUSE_SQLITE = ` + INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock) + SELECT a.id, @warehouseId, 0 + FROM article a + WHERE a.company_id = @companyId AND a.track_stock = 1 + ON CONFLICT (article_id, warehouse_id) DO UPDATE SET stock = 0; +`; + +const SQL_INIT_FOR_NEW_WAREHOUSE_MSSQL = ` + MERGE INTO articlewarehousesummary AS target + USING ( + SELECT a.id AS article_id, @warehouseId AS warehouse_id + FROM article a + WHERE a.company_id = @companyId AND a.track_stock = 1 + ) AS source + ON (target.article_id = source.article_id AND target.warehouse_id = source.warehouse_id) + WHEN MATCHED THEN + UPDATE SET stock = 0 + WHEN NOT MATCHED THEN + INSERT (article_id, warehouse_id, stock) + VALUES (source.article_id, source.warehouse_id, 0); +`; + +const SQL_DELETE_BY_ARTICLE = ` + DELETE FROM articlewarehousesummary WHERE article_id = @articleId; +`; + +/** + * Updates `articlewarehousesummary` after a ledger entry is appended with a warehouseId. + * + * @param {object} runner - Database runner. + * @param {object} opts + * @param {number} opts.companyId + * @param {number} opts.manifestarticleId + * @param {number|null} opts.warehouseId + */ +export async function syncArticleWarehouseSummaryForLedger(runner, { companyId, manifestarticleId, warehouseId }) { + if (warehouseId == null) { + return; + } + + const isSqlite = runner.dialect === 'sqlite3'; + const upsertSql = isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL; + + const res = await runner.query(SQL_FIND_ARTICLES_BY_MANIFESTARTICLE, { + manifestarticleId, + companyId, + }); + + const rows = res.rows ?? []; + for (const row of rows) { + const articleId = row.articleId; + const isTracked = Boolean(row.trackStock ?? 0); + + if (!isTracked) { + await runner.query(SQL_DELETE_BY_ARTICLE, { articleId }); + continue; + } + + const stockRes = await runner.query(SQL_GET_STOCK_FROM_LEDGER, { + articleId, + warehouseId, + companyId, + }); + const rawStock = stockRes.rows[0]?.stock ?? 0; + const stock = Math.round(Number(rawStock) * 1000) / 1000; + + await runner.query(upsertSql, { + articleId, + warehouseId, + stock, + }); + } +} + +/** + * Initializes `articlewarehousesummary` with stock = 0 for all warehouses of the company + * when track_stock is enabled for an article. + * + * @param {object} runner - Database runner. + * @param {object} opts + * @param {number} opts.companyId + * @param {number} opts.articleId + */ +export async function initArticleWarehouseSummary(runner, { companyId, articleId }) { + const isSqlite = runner.dialect === 'sqlite3'; + const sql = isSqlite ? SQL_INIT_FOR_ARTICLE_SQLITE : SQL_INIT_FOR_ARTICLE_MSSQL; + await runner.query(sql, { companyId, articleId }); +} + +/** + * Removes all rows for an article from `articlewarehousesummary` when track_stock is disabled. + * + * @param {object} runner - Database runner. + * @param {object} opts + * @param {number} opts.articleId + */ +export async function removeArticleWarehouseSummary(runner, { articleId }) { + await runner.query(SQL_DELETE_BY_ARTICLE, { articleId }); +} + +/** + * Initializes entries in `articlewarehousesummary` for a newly created warehouse + * for all articles with stock tracking enabled. + * + * @param {object} runner - Database runner. + * @param {object} opts + * @param {number} opts.companyId + * @param {number} opts.warehouseId + */ +export async function initWarehouseSummaryForNewWarehouse(runner, { companyId, warehouseId }) { + const isSqlite = runner.dialect === 'sqlite3'; + const sql = isSqlite ? SQL_INIT_FOR_NEW_WAREHOUSE_SQLITE : SQL_INIT_FOR_NEW_WAREHOUSE_MSSQL; + await runner.query(sql, { companyId, warehouseId }); +} + +/** + * Rebuilds the entire `articlewarehousesummary` for a given company. + * + * @param {object} runner - Database runner. + * @param {number} companyId - The company id. + */ +export async function rebuildArticleWarehouseSummaries(runner, companyId) { + const isSqlite = runner.dialect === 'sqlite3'; + + if (isSqlite) { + const sqlRebuildSqlite = ` + DELETE FROM articlewarehousesummary + WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId); + + INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock) + SELECT + a.id AS article_id, + w.id AS warehouse_id, + COALESCE(s.stock, 0) AS stock + FROM article a + CROSS JOIN warehouse w ON w.company_id = a.company_id + LEFT JOIN ( + SELECT + ca.id AS article_id, + l.warehouse_id, + SUM(l.amount) AS stock + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + INNER JOIN article ca ON ca.company_id = m.company_id + AND ( + ca.sku = ma.sku + OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = ca.id AND asup.sku = ma.sku) + ) + WHERE m.company_id = @companyId + AND l.warehouse_id IS NOT NULL + GROUP BY ca.id, l.warehouse_id + ) s ON s.article_id = a.id AND s.warehouse_id = w.id + WHERE a.company_id = @companyId + AND a.track_stock = 1; + `; + await runner.query(sqlRebuildSqlite, { companyId }); + } else { + const sqlRebuildMssql = ` + DELETE FROM articlewarehousesummary + WHERE article_id IN (SELECT id FROM article WHERE company_id = @companyId); + + INSERT INTO articlewarehousesummary (article_id, warehouse_id, stock) + SELECT + a.id AS article_id, + w.id AS warehouse_id, + COALESCE(s.stock, 0) AS stock + FROM article a + CROSS JOIN warehouse w + LEFT JOIN ( + SELECT + ca.id AS article_id, + l.warehouse_id, + SUM(l.amount) AS stock + FROM ledger l + INNER JOIN manifestarticle ma ON ma.id = l.manifestarticle_id + INNER JOIN manifest m ON m.id = ma.manifest_id + INNER JOIN article ca ON ca.company_id = m.company_id + AND ( + ca.sku = ma.sku + OR EXISTS (SELECT 1 FROM articlesupplier asup WHERE asup.article_id = ca.id AND asup.sku = ma.sku) + ) + WHERE m.company_id = @companyId + AND l.warehouse_id IS NOT NULL + GROUP BY ca.id, l.warehouse_id + ) s ON s.article_id = a.id AND s.warehouse_id = w.id + WHERE a.company_id = @companyId + AND w.company_id = @companyId + AND a.track_stock = 1; + `; + await runner.query(sqlRebuildMssql, { companyId }); + } +} diff --git a/src/store/entity/delete.js b/src/store/entity/delete.js index b82a577..d5aad20 100644 --- a/src/store/entity/delete.js +++ b/src/store/entity/delete.js @@ -1,11 +1,22 @@ -import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/entity.js'; -import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/entity.js'; +import { + DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE, + CHECK_SUPPLIER_IN_USE as MSSQL_CHECK_IN_USE, + GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_ID, +} from '../../providers/mssql/queries/entity.js'; +import { + DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE, + CHECK_SUPPLIER_IN_USE as SQLITE_CHECK_IN_USE, + GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_ID, +} from '../../providers/sqlite3/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; export default function del(provider) { const access = resolveSelectedCompanyAccess(provider); - const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE; + const isSqlite = provider?.dialect === 'sqlite3'; + const sql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + const checkInUseSql = isSqlite ? SQLITE_CHECK_IN_USE : MSSQL_CHECK_IN_USE; + const getByIdSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; /** * Deletes an entity by its id within the user's selected company. @@ -14,20 +25,38 @@ export default function del(provider) { * @param {number} id - The id of the entity to delete. * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} - * @throws {Error} If the entity is not found. + * @throws {Error} If the entity is not found or is in use. * * @example * await store.entity.delete(42, 5); */ return async (googleAuthId, id, tx) => { - if (!Number.isFinite(id)) { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { throw new Error('Entity not found'); } - const { companyId } = await access(googleAuthId, tx); + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); - const result = await makeRunner(provider, tx).query(sql, { - id, + const existing = await runner.query(getByIdSql, { + id: numericId, + companyId, + }); + if (!existing.rows || existing.rows.length === 0) { + throw new Error('Entity not found'); + } + + const usage = await runner.query(checkInUseSql, { + id: numericId, + companyId, + }); + if (usage.rows && usage.rows.length > 0) { + throw new Error('Entity in use'); + } + + const result = await runner.query(sql, { + id: numericId, companyId, }); @@ -36,3 +65,4 @@ export default function del(provider) { } }; } + diff --git a/src/store/ledger/add.js b/src/store/ledger/add.js index 750788b..b021471 100644 --- a/src/store/ledger/add.js +++ b/src/store/ledger/add.js @@ -1,5 +1,6 @@ import { syncDeliverySummary } from '../delivery/syncSummary.js'; import { syncShipmentSummary } from '../shipment/syncSummary.js'; +import { syncArticleWarehouseSummaryForLedger } from '../articlewarehousesummary/syncSummary.js'; import { createHash } from 'node:crypto'; import * as mssqlQueries from '../../providers/mssql/queries/ledger.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/ledger.js'; @@ -239,6 +240,12 @@ export default function add(provider) { memo: text, }); + await syncArticleWarehouseSummaryForLedger(runner, { + companyId, + manifestarticleId, + warehouseId: effectiveWhId, + }); + for (const match of articleMatches) { await recordWarehouseHistory(runner, { articleId: match.id, @@ -315,6 +322,12 @@ export default function add(provider) { memo: text, }); + await syncArticleWarehouseSummaryForLedger(runner, { + companyId, + manifestarticleId, + warehouseId: effectiveWhId, + }); + for (const match of articleMatches) { await recordWarehouseHistory(runner, { articleId: match.id, @@ -357,6 +370,12 @@ export default function add(provider) { memo: transactionText, }); + await syncArticleWarehouseSummaryForLedger(runner, { + companyId, + manifestarticleId, + warehouseId, + }); + return shape(row); }; diff --git a/src/store/stock/adjust.js b/src/store/stock/adjust.js index 1c8cfac..87f351d 100644 --- a/src/store/stock/adjust.js +++ b/src/store/stock/adjust.js @@ -10,6 +10,7 @@ import nextNumber from '../numbers/nextNumber.js'; import ledgerAdd from '../ledger/add.js'; import makeRunner from '../runner.js'; import { parseDate } from '../manifestLines.js'; +import { syncArticleWarehouseSummaryForLedger } from '../articlewarehousesummary/syncSummary.js'; const ADJUSTMENT_TYPE = 'adjustment'; @@ -140,6 +141,12 @@ export default function adjust(provider) { }); const ledgerRow = inserted.rows[0]; + await syncArticleWarehouseSummaryForLedger(runner, { + companyId, + manifestarticleId, + warehouseId, + }); + // Write warehouse history for tracked article if (manifestArticle.manifest_type === 'delivery') { await recordWarehouseHistory(runner, { diff --git a/src/store/stock/rebuildSummaries.js b/src/store/stock/rebuildSummaries.js new file mode 100644 index 0000000..10898c8 --- /dev/null +++ b/src/store/stock/rebuildSummaries.js @@ -0,0 +1,19 @@ +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; +import { rebuildArticleWarehouseSummaries } from '../articlewarehousesummary/syncSummary.js'; + +/** + * Rebuilds the `articlewarehousesummary` 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 rebuildArticleWarehouseSummaries(runner, companyId); + return { ok: true, companyId }; + }; +} diff --git a/src/store/supplier/delete.js b/src/store/supplier/delete.js index fcf8c6a..00f2aa7 100644 --- a/src/store/supplier/delete.js +++ b/src/store/supplier/delete.js @@ -1,14 +1,23 @@ import { DELETE_BY_ID as MSSQL_DELETE_BY_ID, + CHECK_SUPPLIER_IN_USE as MSSQL_CHECK_IN_USE, + GET_SUPPLIER_BY_ID as MSSQL_GET_SUPPLIER, SUPPLIER_GROUP_NAME, } from '../../providers/mssql/queries/entity.js'; -import { DELETE_BY_ID as SQLITE_DELETE_BY_ID } from '../../providers/sqlite3/queries/entity.js'; +import { + DELETE_BY_ID as SQLITE_DELETE_BY_ID, + CHECK_SUPPLIER_IN_USE as SQLITE_CHECK_IN_USE, + GET_SUPPLIER_BY_ID as SQLITE_GET_SUPPLIER, +} from '../../providers/sqlite3/queries/entity.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; export default function del(provider) { const access = resolveSelectedCompanyAccess(provider); - const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE_BY_ID : MSSQL_DELETE_BY_ID; + const isSqlite = provider?.dialect === 'sqlite3'; + const sql = isSqlite ? SQLITE_DELETE_BY_ID : MSSQL_DELETE_BY_ID; + const checkInUseSql = isSqlite ? SQLITE_CHECK_IN_USE : MSSQL_CHECK_IN_USE; + const getSupplierSql = isSqlite ? SQLITE_GET_SUPPLIER : MSSQL_GET_SUPPLIER; /** * Deletes a supplier by its id within the user's selected company. @@ -17,21 +26,39 @@ export default function del(provider) { * @param {number} id - The id of the supplier to delete. * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} - * @throws {Error} If the supplier is not found. + * @throws {Error} If the supplier is not found or is in use. * * @example * await store.supplier.delete(42, 5); */ return async (googleAuthId, id, tx) => { - if (!Number.isFinite(id)) { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { throw new Error('Supplier not found'); } const runner = makeRunner(provider, tx); - const { companyId } = await access(googleAuthId, tx); + const { companyId } = await access(googleAuthId, runner); + + const existing = await runner.query(getSupplierSql, { + id: numericId, + companyId, + groupName: SUPPLIER_GROUP_NAME, + }); + if (!existing.rows || existing.rows.length === 0) { + throw new Error('Supplier not found'); + } + + const usage = await runner.query(checkInUseSql, { + id: numericId, + companyId, + }); + if (usage.rows && usage.rows.length > 0) { + throw new Error('Supplier in use'); + } const result = await runner.query(sql, { - id, + id: numericId, companyId, groupName: SUPPLIER_GROUP_NAME, }); @@ -41,3 +68,4 @@ export default function del(provider) { } }; } + diff --git a/src/store/warehouse/create.js b/src/store/warehouse/create.js index 1f47607..b5a9b1b 100644 --- a/src/store/warehouse/create.js +++ b/src/store/warehouse/create.js @@ -9,6 +9,7 @@ import { import { isUniqueConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import makeRunner from '../runner.js'; +import { initWarehouseSummaryForNewWarehouse } from '../articlewarehousesummary/syncSummary.js'; function shape(row) { return { id: row.id, name: row.name, sort: row.sort }; @@ -65,6 +66,9 @@ export default function create(provider) { name: cleaned, sort, }; + + await initWarehouseSummaryForNewWarehouse(runner, { companyId, warehouseId: row.id }); + return shape(row); } catch (err) { if (