diff --git a/API.article.md b/API.article.md index 8fe79c3..96b8234 100644 --- a/API.article.md +++ b/API.article.md @@ -97,7 +97,7 @@ Creates a new article under a given parent category, allocating a SKU and a defa | `input.name` | `string` | The article name. | | `[input.desc]` | `string` | The article description text/HTML. | | `[input.seo]` | `string` | The article SEO text. | -| `[input.track_stock=true]` | `boolean|number` | Whether inventory stock is tracked. | +| `[input.track_stock=false]` | `boolean|number` | Whether inventory stock is tracked. | | `[tx]` | `object` | Optional transaction to run within. | **Returns** @@ -251,7 +251,7 @@ Imports article rows, creating articles by their SKU and returning per-row impor | `[input.rows[].net]` | `number|string` | The POS net price (accepts German decimals). | | `[input.rows[].tax]` | `number|string` | The tax rate in percent (e.g. `19`). | | `[input.rows[].eans]` | `Array|string` | The EANs to set. | -| `[input.rows[].track_stock=true]` | `boolean|number` | Whether inventory stock is tracked. | +| `[input.rows[].track_stock=false]` | `boolean|number` | Whether inventory stock is tracked. | | `[input.rows[].parentSku]` | `string` | The parent article SKU for a V/K compound link. | | `[input.rows[].deposit]` | `number|string` | The deposit (Pfand) value; currently ignored. | | `[tx]` | `object` | Optional transaction. | @@ -591,7 +591,7 @@ Sets the track_stock flag of an article by its SKU. **Throws** -`Error` — If trackStock is invalid or the article is not found. +`Error` — If trackStock is invalid, article not found, or no warehouse exists when enabling. **Example** diff --git a/API.warehouse.md b/API.warehouse.md index 1b1d9a9..5096aa0 100644 --- a/API.warehouse.md +++ b/API.warehouse.md @@ -37,7 +37,7 @@ const wh = await store.warehouse.create(googleAuthId, { name: 'Hauptlager' }); *Source: `src/store/warehouse/delete.js`* -Deletes a warehouse for the user's selected company, unless referenced in the ledger. +Deletes a warehouse for the user's selected company, unless referenced in the ledger or it is the last warehouse while articles have stock tracking enabled. **Parameters** @@ -53,7 +53,7 @@ Deletes a warehouse for the user's selected company, unless referenced in the le **Throws** -`Error` — If warehouse not found or is in use in the ledger. +`Error` — If warehouse not found, in use in the ledger, or is the last warehouse with track_stock articles. **Example** diff --git a/scripts/test-ledger.js b/scripts/test-ledger.js index f394832..983692d 100644 --- a/scripts/test-ledger.js +++ b/scripts/test-ledger.js @@ -47,6 +47,14 @@ async function run() { parentId: company.rootCategoryId, name: 'Ledger Linked Article', }); + assert.equal(article.track_stock, false, 'Newly created article must have track_stock disabled'); + + await assert.rejects( + async () => store.article.setTrackStockBySku(googleAuthId, article.sku, true), + /At least one warehouse is required to enable stock tracking/, + 'Should reject enabling stock tracking when no warehouse exists', + ); + await store.articlesupplier.create(googleAuthId, article.sku, { entityId: supplier.id, sku: 'SKU-ABC', @@ -278,6 +286,9 @@ async function run() { assert.equal(articleDetail.article.warehouses[1].id, wh1.id); assert.equal(articleDetail.article.warehouses[1].sort, 1); + // Enable stock tracking now that warehouses exist + await store.article.setTrackStockBySku(googleAuthId, article.sku, true); + // Add ledger row with warehouse const rowWithWh = await store.ledger.add(googleAuthId, { manifestarticle_id: manifestarticleId1, @@ -318,6 +329,38 @@ async function run() { const whListAfterDelete = await store.warehouse.list(googleAuthId); assert.equal(whListAfterDelete.length, 1); assert.equal(whListAfterDelete[0].id, wh1.id); + + // Test last warehouse guard with track_stock articles + const freshCompany = await store.company.create({ + name: 'Warehouse Test Co', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, freshCompany.id); + const testWh = await store.warehouse.create(googleAuthId, { name: 'Only Warehouse' }); + const testArt = await store.article.createUnderParent({ + googleAuthId, + parentId: freshCompany.rootCategoryId, + name: 'Tracked Article', + track_stock: true, + }); + assert.equal(testArt.track_stock, true); + await assert.rejects( + async () => store.warehouse.delete(googleAuthId, testWh.id), + /Cannot delete the last warehouse while articles have stock tracking enabled/, + 'Should reject deleting last warehouse when articles have track_stock enabled', + ); + // Disable track_stock on the article, then deleting the last warehouse must succeed + await store.article.setTrackStockBySku(googleAuthId, testArt.sku, false); + await store.warehouse.delete(googleAuthId, testWh.id); + const remainingWh = await store.warehouse.list(googleAuthId); + assert.equal(remainingWh.length, 0); + + // Switch back to original company + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + console.log('✓ Warehouse CRUD and ledger enforcement passed'); console.log('✓ All ledger tests passed successfully!'); diff --git a/scripts/test-shipment.js b/scripts/test-shipment.js index baad3a0..7b9c9bb 100644 --- a/scripts/test-shipment.js +++ b/scripts/test-shipment.js @@ -47,12 +47,14 @@ async function run() { parentId: company.rootCategoryId, name: 'Item A', sku: 'ITEM-A', + track_stock: true, }); const itemB = await store.article.createUnderParent({ googleAuthId, parentId: company.rootCategoryId, name: 'Item B', sku: 'ITEM-B', + track_stock: true, }); // Stock up Item A (20) and Item B (20) diff --git a/scripts/test-sqlite-storage.js b/scripts/test-sqlite-storage.js index 2907183..76a5ee7 100644 --- a/scripts/test-sqlite-storage.js +++ b/scripts/test-sqlite-storage.js @@ -157,14 +157,27 @@ async function runTests() { parentId: cat.id, name: 'Test Article', }); - assert.equal(article.track_stock, true, 'Default track_stock must be true'); + assert.equal(article.track_stock, false, 'Default track_stock must be false'); const fetchedArt = await store.article.getBySku(googleAuthId, article.sku); - assert.equal(fetchedArt.article.track_stock, true, 'getBySku must return track_stock: true'); + assert.equal(fetchedArt.article.track_stock, false, 'getBySku must return track_stock: false'); - await store.article.setTrackStockBySku(googleAuthId, article.sku, false); + // Enabling track_stock without a warehouse must fail + await assert.rejects( + async () => store.article.setTrackStockBySku(googleAuthId, article.sku, true), + /At least one warehouse is required to enable stock tracking/, + ); + + // Create warehouse to allow enabling track_stock + await store.warehouse.create(googleAuthId, { name: 'Storage WH' }); + + await store.article.setTrackStockBySku(googleAuthId, article.sku, true); const afterSet = await store.article.getBySku(googleAuthId, article.sku); - assert.equal(afterSet.article.track_stock, false, 'setTrackStockBySku false must update track_stock'); + assert.equal(afterSet.article.track_stock, true, 'setTrackStockBySku true must update track_stock'); + + await store.article.updateBySku(googleAuthId, article.sku, { track_stock: false }); + const afterDisable = await store.article.getBySku(googleAuthId, article.sku); + assert.equal(afterDisable.article.track_stock, false, 'updateBySku track_stock: false must update track_stock'); await store.article.updateBySku(googleAuthId, article.sku, { track_stock: true }); const afterUpdate = await store.article.getBySku(googleAuthId, article.sku); diff --git a/scripts/test-stueckliste-stock.js b/scripts/test-stueckliste-stock.js index 440fa30..c474ba9 100644 --- a/scripts/test-stueckliste-stock.js +++ b/scripts/test-stueckliste-stock.js @@ -40,11 +40,13 @@ async function run() { googleAuthId, parentId: company.rootCategoryId, name: 'Component Article 1', + track_stock: true, }); const comp2 = await store.article.createUnderParent({ googleAuthId, parentId: company.rootCategoryId, name: 'Component Article 2', + track_stock: true, }); // Create a bundle article diff --git a/src/providers/mssql/queries/article.js b/src/providers/mssql/queries/article.js index 8ef3d93..b78a4fd 100644 --- a/src/providers/mssql/queries/article.js +++ b/src/providers/mssql/queries/article.js @@ -652,3 +652,9 @@ SELECT ) AS articleJson FROM auth `; + +export const CHECK_HAS_WAREHOUSE = ` +SELECT TOP 1 id +FROM warehouse +WHERE company_id = @companyId; +`; diff --git a/src/providers/mssql/queries/warehouse.js b/src/providers/mssql/queries/warehouse.js index b9333f5..4d0414d 100644 --- a/src/providers/mssql/queries/warehouse.js +++ b/src/providers/mssql/queries/warehouse.js @@ -67,3 +67,16 @@ WHERE id = @id AND company_id = @companyId; export const CHECK_LEDGER_USAGE = ` SELECT TOP 1 id FROM ledger WHERE warehouse_id = @warehouseId; `; + +export const COUNT_BY_COMPANY = ` +SELECT COUNT(1) AS count +FROM warehouse +WHERE company_id = @companyId; +`; + +export const CHECK_TRACK_STOCK_ARTICLES = ` +SELECT TOP 1 id +FROM article +WHERE company_id = @companyId AND track_stock = 1; +`; + diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index e832275..7f2ebd6 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -322,7 +322,7 @@ BEGIN unit_id INT NOT NULL, unitamount FLOAT NOT NULL, weight FLOAT NOT NULL, - track_stock BIT NOT NULL CONSTRAINT DF_article_track_stock DEFAULT 1, + track_stock BIT NOT NULL CONSTRAINT DF_article_track_stock DEFAULT 0, brand_id INT NULL, articlelist_id INT NULL, optionslist_id INT NULL, @@ -358,7 +358,7 @@ BEGIN IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('article') AND name = 'track_stock') BEGIN - ALTER TABLE article ADD track_stock BIT NOT NULL CONSTRAINT DF_article_track_stock DEFAULT 1; + ALTER TABLE article ADD track_stock BIT NOT NULL CONSTRAINT DF_article_track_stock DEFAULT 0; END END `; diff --git a/src/providers/sqlite3/index.js b/src/providers/sqlite3/index.js index 7bd0e86..b10e70c 100644 --- a/src/providers/sqlite3/index.js +++ b/src/providers/sqlite3/index.js @@ -456,7 +456,7 @@ export class BetterSqlite3Provider extends BetterSqlite3Executor { const articleCols = this.db.pragma('table_info("article")').map((c) => c.name); if (articleCols.length > 0 && !articleCols.includes('track_stock')) { this.db.exec( - `ALTER TABLE article ADD COLUMN track_stock INTEGER NOT NULL DEFAULT 1`, + `ALTER TABLE article ADD COLUMN track_stock INTEGER NOT NULL DEFAULT 0`, ); } if (articleCols.length > 0 && !articleCols.includes('parentattributename_id')) { diff --git a/src/providers/sqlite3/queries/article.js b/src/providers/sqlite3/queries/article.js index dbf3668..90516bd 100644 --- a/src/providers/sqlite3/queries/article.js +++ b/src/providers/sqlite3/queries/article.js @@ -3,7 +3,7 @@ import { translateQueries } from './translate.js'; const queries = translateQueries(source, 'article'); export const { - ADD_CATEGORY, ADD_EAN, ARTICLE_LIST_COLUMNS, CREATE, CREATE_CHILD, + ADD_CATEGORY, ADD_EAN, ARTICLE_LIST_COLUMNS, CHECK_HAS_WAREHOUSE, CREATE, CREATE_CHILD, DELETE_CATEGORIES_BY_ARTICLE, DELETE_EANS_BY_ARTICLE, EAN_EXISTS, EAN_EXISTS_FOR_OTHER_ARTICLE, GET_BRAND_BY_COMPANY_AND_ID, GET_CATEGORY_ID_BY_SKU, GET_CATEGORY_TREE_FLAT, GET_ID_BY_SKU, diff --git a/src/providers/sqlite3/queries/warehouse.js b/src/providers/sqlite3/queries/warehouse.js index 55ccf86..29f1df6 100644 --- a/src/providers/sqlite3/queries/warehouse.js +++ b/src/providers/sqlite3/queries/warehouse.js @@ -67,3 +67,17 @@ WHERE id = @id AND company_id = @companyId; export const CHECK_LEDGER_USAGE = ` SELECT id FROM ledger WHERE warehouse_id = @warehouseId LIMIT 1; `; + +export const COUNT_BY_COMPANY = ` +SELECT COUNT(1) AS count +FROM warehouse +WHERE company_id = @companyId; +`; + +export const CHECK_TRACK_STOCK_ARTICLES = ` +SELECT id +FROM article +WHERE company_id = @companyId AND track_stock = 1 +LIMIT 1; +`; + diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index 5b5aa95..67b2c40 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -221,7 +221,7 @@ CREATE TABLE IF NOT EXISTS article ( unit_id INTEGER NOT NULL, unitamount REAL NOT NULL, weight REAL NOT NULL, - track_stock INTEGER NOT NULL DEFAULT 1, + track_stock INTEGER NOT NULL DEFAULT 0, brand_id INTEGER, articlelist_id INTEGER, optionslist_id INTEGER, diff --git a/src/store/article/addChild.js b/src/store/article/addChild.js index fc45197..1959f5f 100644 --- a/src/store/article/addChild.js +++ b/src/store/article/addChild.js @@ -177,7 +177,13 @@ export default function addChild(provider) { // Auto-create child article const rawTrack = options.track_stock ?? options.trackStock; - const finalTrackStock = rawTrack == null ? parent.track_stock : (rawTrack ? 1 : 0); + const finalTrackStock = rawTrack == null ? (parent.track_stock ? 1 : 0) : (rawTrack ? 1 : 0); + if (finalTrackStock === 1) { + const whRes = await runner.query(queries.CHECK_HAS_WAREHOUSE, { companyId }); + if (!whRes.rows || whRes.rows.length === 0) { + throw new Error('At least one warehouse is required to enable stock tracking'); + } + } const childRes = await runner.query(queries.CREATE_CHILD, { companyId, diff --git a/src/store/article/createUnderParent.js b/src/store/article/createUnderParent.js index d89537d..d879dda 100644 --- a/src/store/article/createUnderParent.js +++ b/src/store/article/createUnderParent.js @@ -1,5 +1,11 @@ -import { CREATE as MSSQL_CREATE_ARTICLE } from '../../providers/mssql/queries/article.js'; -import { CREATE as SQLITE_CREATE_ARTICLE } from '../../providers/sqlite3/queries/article.js'; +import { + CREATE as MSSQL_CREATE_ARTICLE, + CHECK_HAS_WAREHOUSE as MSSQL_CHECK_HAS_WAREHOUSE, +} from '../../providers/mssql/queries/article.js'; +import { + CREATE as SQLITE_CREATE_ARTICLE, + CHECK_HAS_WAREHOUSE as SQLITE_CHECK_HAS_WAREHOUSE, +} from '../../providers/sqlite3/queries/article.js'; import { ADD as MSSQL_ADD_CATEGORYARTICLE } from '../../providers/mssql/queries/categoryarticle.js'; import { ADD as SQLITE_ADD_CATEGORYARTICLE } from '../../providers/sqlite3/queries/categoryarticle.js'; import { CREATE as MSSQL_CREATE_ARTICLETEXT } from '../../providers/mssql/queries/articletext.js'; @@ -20,6 +26,7 @@ export default function createUnderParent(provider) { const isSqlite = provider?.dialect === 'sqlite3'; const createArticleSql = isSqlite ? SQLITE_CREATE_ARTICLE : MSSQL_CREATE_ARTICLE; + const checkHasWarehouseSql = isSqlite ? SQLITE_CHECK_HAS_WAREHOUSE : MSSQL_CHECK_HAS_WAREHOUSE; const addCategoryArticleSql = isSqlite ? SQLITE_ADD_CATEGORYARTICLE : MSSQL_ADD_CATEGORYARTICLE; const createArticleTextSql = isSqlite ? SQLITE_CREATE_ARTICLETEXT : MSSQL_CREATE_ARTICLETEXT; @@ -33,7 +40,7 @@ export default function createUnderParent(provider) { * @param {string} input.name - The article name. * @param {string} [input.desc] - The article description text/HTML. * @param {string} [input.seo] - The article SEO text. - * @param {boolean|number} [input.track_stock=true] - Whether inventory stock is tracked. + * @param {boolean|number} [input.track_stock=false] - Whether inventory stock is tracked. * @param {object} [tx] - Optional transaction to run within. * @returns {Promise<{id: number, parentId: number, name: string, sku: string, track_stock: boolean}>} The created article. * @@ -43,7 +50,7 @@ export default function createUnderParent(provider) { */ return async ({ googleAuthId, parentId, name, desc, description, seo, track_stock, trackStock, sku: inputSku }, tx) => { const rawTrack = track_stock ?? trackStock; - const finalTrackStock = rawTrack == null ? 1 : (rawTrack ? 1 : 0); + const finalTrackStock = rawTrack == null ? 0 : (rawTrack ? 1 : 0); const runCreate = async (runner) => { const { companyId, languageId, companyuserId } = await access( @@ -52,6 +59,13 @@ export default function createUnderParent(provider) { runner, ); + if (finalTrackStock === 1) { + const whRes = await runner.query(checkHasWarehouseSql, { companyId }); + if (!whRes.rows || whRes.rows.length === 0) { + throw new Error('At least one warehouse is required to enable stock tracking'); + } + } + const unitId = await ensureUnit(runner, languageId, companyId); const sku = (typeof inputSku === 'string' && inputSku.trim()) ? inputSku.trim() : await allocateSku(companyId, runner); const slug = await resolveSlug(runner, { companyId, languageId, name }); diff --git a/src/store/article/getBySku.js b/src/store/article/getBySku.js index 493f688..6f9dc35 100644 --- a/src/store/article/getBySku.js +++ b/src/store/article/getBySku.js @@ -55,7 +55,7 @@ export default function getBySku(provider) { seo: a.seo ?? null, unitamount: a.unitamount, weight: a.weight, - track_stock: Boolean(a.track_stock ?? 1), + track_stock: Boolean(a.track_stock ?? 0), unit: { si: a.unitSi, basesi: a.unitBasesi, diff --git a/src/store/article/importRows.js b/src/store/article/importRows.js index 99f0fa6..a65389a 100644 --- a/src/store/article/importRows.js +++ b/src/store/article/importRows.js @@ -107,7 +107,7 @@ export default function importRows(provider) { * @param {number|string} [input.rows[].net] - The POS net price (accepts German decimals). * @param {number|string} [input.rows[].tax] - The tax rate in percent (e.g. `19`). * @param {Array|string} [input.rows[].eans] - The EANs to set. - * @param {boolean|number} [input.rows[].track_stock=true] - Whether inventory stock is tracked. + * @param {boolean|number} [input.rows[].track_stock=false] - Whether inventory stock is tracked. * @param {string} [input.rows[].parentSku] - The parent article SKU for a V/K compound link. * @param {number|string} [input.rows[].deposit] - The deposit (Pfand) value; currently ignored. * @param {object} [tx] - Optional transaction. @@ -174,7 +174,13 @@ export default function importRows(provider) { const slug = await resolveSlug(runner, { companyId, languageId, name }); const rawTrack = row.track_stock ?? row.trackStock; - const trackStock = rawTrack == null ? 1 : (rawTrack ? 1 : 0); + const trackStock = rawTrack == null ? 0 : (rawTrack ? 1 : 0); + if (trackStock === 1) { + const whRes = await runner.query(articleQueries.CHECK_HAS_WAREHOUSE, { companyId }); + if (!whRes.rows || whRes.rows.length === 0) { + throw new Error('At least one warehouse is required to enable stock tracking'); + } + } const article = await runner.query(articleQueries.CREATE, { companyId, diff --git a/src/store/article/listExport.js b/src/store/article/listExport.js index dc5a448..c9a089c 100644 --- a/src/store/article/listExport.js +++ b/src/store/article/listExport.js @@ -94,7 +94,7 @@ export default function listExport(provider) { unitSi: row.unitSi, unitSymbol: row.unitSymbol, unitamount: row.unitamount, - track_stock: Boolean(row.track_stock ?? 1), + track_stock: Boolean(row.track_stock ?? 0), brandName: row.brandName, net: row.net, taxRate: row.taxRate, diff --git a/src/store/article/queryBySku.js b/src/store/article/queryBySku.js index 75a858a..192df13 100644 --- a/src/store/article/queryBySku.js +++ b/src/store/article/queryBySku.js @@ -10,7 +10,7 @@ function shapeArticleDetail(row, categoryIds, photos, eans, prices) { slug: row.slug, unitamount: row.unitamount, weight: row.weight, - track_stock: Boolean(row.track_stock ?? 1), + track_stock: Boolean(row.track_stock ?? 0), unit: { si: row.unitSi, basesi: row.unitBasesi, diff --git a/src/store/article/setTrackStockBySku.js b/src/store/article/setTrackStockBySku.js index 88fa6e5..09c70bb 100644 --- a/src/store/article/setTrackStockBySku.js +++ b/src/store/article/setTrackStockBySku.js @@ -1,5 +1,11 @@ -import { UPDATE_TRACK_STOCK as MSSQL_UPDATE_TRACK_STOCK } from '../../providers/mssql/queries/article.js'; -import { UPDATE_TRACK_STOCK as SQLITE_UPDATE_TRACK_STOCK } from '../../providers/sqlite3/queries/article.js'; +import { + UPDATE_TRACK_STOCK as MSSQL_UPDATE_TRACK_STOCK, + CHECK_HAS_WAREHOUSE as MSSQL_CHECK_HAS_WAREHOUSE, +} from '../../providers/mssql/queries/article.js'; +import { + UPDATE_TRACK_STOCK as SQLITE_UPDATE_TRACK_STOCK, + CHECK_HAS_WAREHOUSE as SQLITE_CHECK_HAS_WAREHOUSE, +} from '../../providers/sqlite3/queries/article.js'; import queryBySku from './queryBySku.js'; import resolveArticleBySku from './resolveArticleBySku.js'; import { recordTrackStockChangeHistory } from '../articlehistory/record.js'; @@ -8,10 +14,9 @@ import makeRunner from '../runner.js'; export default function setTrackStockBySku(provider) { const resolve = resolveArticleBySku(provider); const load = queryBySku(provider); - const sql = - provider?.dialect === 'sqlite3' - ? SQLITE_UPDATE_TRACK_STOCK - : MSSQL_UPDATE_TRACK_STOCK; + const isSqlite = provider?.dialect === 'sqlite3'; + const sql = isSqlite ? SQLITE_UPDATE_TRACK_STOCK : MSSQL_UPDATE_TRACK_STOCK; + const checkHasWarehouseSql = isSqlite ? SQLITE_CHECK_HAS_WAREHOUSE : MSSQL_CHECK_HAS_WAREHOUSE; /** * Sets the track_stock flag of an article by its SKU. @@ -21,7 +26,7 @@ export default function setTrackStockBySku(provider) { * @param {boolean|number} trackStock - Whether stock tracking is enabled. * @param {object} [tx] - Optional transaction. * @returns {Promise} The updated article. - * @throws {Error} If trackStock is invalid or the article is not found. + * @throws {Error} If trackStock is invalid, article not found, or no warehouse exists when enabling. * * @example * const article = await store.article.setTrackStockBySku(42, 'SKU-1', true); @@ -33,15 +38,22 @@ export default function setTrackStockBySku(provider) { } const runner = makeRunner(provider, tx); - const { articleId, languageId, companyuserId } = await resolve(googleAuthId, sku, runner); + const { articleId, languageId, companyId, companyuserId } = await resolve(googleAuthId, sku, runner); + + const newTrack = Boolean(trackStock); + if (newTrack) { + const whRes = await runner.query(checkHasWarehouseSql, { companyId }); + if (!whRes.rows || whRes.rows.length === 0) { + throw new Error('At least one warehouse is required to enable stock tracking'); + } + } // Read old track_stock for history const oldRes = await runner.query( 'SELECT track_stock FROM article WHERE id = @articleId', { articleId }, ); - const oldTrack = Boolean(oldRes.rows[0]?.track_stock ?? 1); - const newTrack = Boolean(trackStock); + const oldTrack = Boolean(oldRes.rows[0]?.track_stock ?? 0); const result = await runner.query(sql, { articleId, diff --git a/src/store/article/updateBySku.js b/src/store/article/updateBySku.js index 6319e2b..75662b7 100644 --- a/src/store/article/updateBySku.js +++ b/src/store/article/updateBySku.js @@ -98,6 +98,13 @@ export default function updateBySku(provider) { throw new Error('Article not found'); } + if (hasTrackStock && Boolean(rawTrackStock)) { + const whRes = await runner.query(articleQueries.CHECK_HAS_WAREHOUSE, { companyId }); + if (!whRes.rows || whRes.rows.length === 0) { + throw new Error('At least one warehouse is required to enable stock tracking'); + } + } + let unitId; let newUnitSymbol = current.unitSymbol ?? current.unitSi ?? ''; if (hasUnitSi) { @@ -139,7 +146,7 @@ export default function updateBySku(provider) { } } if (hasTrackStock) { - const oldTrack = Boolean(current.track_stock ?? 1); + const oldTrack = Boolean(current.track_stock ?? 0); const newTrack = Boolean(rawTrackStock); if (oldTrack !== newTrack) { historyLines.push( diff --git a/src/store/category/queryCategoryArticles.js b/src/store/category/queryCategoryArticles.js index dc0aff5..790d0d8 100644 --- a/src/store/category/queryCategoryArticles.js +++ b/src/store/category/queryCategoryArticles.js @@ -24,7 +24,7 @@ export default function queryCategoryArticles(provider) { } = row; return { ...rest, - track_stock: Boolean(rest.track_stock ?? 1), + track_stock: Boolean(rest.track_stock ?? 0), // MSSQL returns BIT as boolean, SQLite as 0/1 integer. Normalize so both // dialects expose isParent as a real boolean, matching article/getBySku. isParent: Boolean(rest.isParent), diff --git a/src/store/warehouse/delete.js b/src/store/warehouse/delete.js index e67a87f..f872cca 100644 --- a/src/store/warehouse/delete.js +++ b/src/store/warehouse/delete.js @@ -1,10 +1,14 @@ import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE, CHECK_LEDGER_USAGE as MSSQL_CHECK_USAGE, + COUNT_BY_COMPANY as MSSQL_COUNT, + CHECK_TRACK_STOCK_ARTICLES as MSSQL_CHECK_TRACK_STOCK, } from '../../providers/mssql/queries/warehouse.js'; import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE, CHECK_LEDGER_USAGE as SQLITE_CHECK_USAGE, + COUNT_BY_COMPANY as SQLITE_COUNT, + CHECK_TRACK_STOCK_ARTICLES as SQLITE_CHECK_TRACK_STOCK, } from '../../providers/sqlite3/queries/warehouse.js'; import { isForeignKeyConstraintError } from '../../providers/provider.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; @@ -15,15 +19,18 @@ export default function del(provider) { const isSqlite = provider?.dialect === 'sqlite3'; const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; const checkUsageSql = isSqlite ? SQLITE_CHECK_USAGE : MSSQL_CHECK_USAGE; + const countSql = isSqlite ? SQLITE_COUNT : MSSQL_COUNT; + const checkTrackStockSql = isSqlite ? SQLITE_CHECK_TRACK_STOCK : MSSQL_CHECK_TRACK_STOCK; /** - * Deletes a warehouse for the user's selected company, unless referenced in the ledger. + * Deletes a warehouse for the user's selected company, unless referenced in the ledger + * or it is the last warehouse while articles have stock tracking enabled. * * @param {number} googleAuthId - The id of the Google auth user. * @param {number} id - The warehouse ID to delete. * @param {object} [tx] - Optional transaction to run within. * @returns {Promise} - * @throws {Error} If warehouse not found or is in use in the ledger. + * @throws {Error} If warehouse not found, in use in the ledger, or is the last warehouse with track_stock articles. * * @example * await store.warehouse.delete(googleAuthId, 1); @@ -43,6 +50,16 @@ export default function del(provider) { throw new Error('Warehouse in use in ledger'); } + // Cannot delete the last warehouse if there are articles with track_stock enabled + const countRes = await runner.query(countSql, { companyId }); + const count = Number(countRes.rows[0]?.count ?? 0); + if (count <= 1) { + const trackRes = await runner.query(checkTrackStockSql, { companyId }); + if (trackRes.rows && trackRes.rows.length > 0) { + throw new Error('Cannot delete the last warehouse while articles have stock tracking enabled'); + } + } + try { const result = await runner.query(deleteSql, { id: numericId,