diff --git a/api/src/repositories/category.repository.js b/api/src/repositories/category.repository.js index 27dd9a95a..863c02b91 100644 --- a/api/src/repositories/category.repository.js +++ b/api/src/repositories/category.repository.js @@ -312,8 +312,8 @@ class CategoryRepository extends BaseModel { * ItemModel::getCategoriesWithStockedItems and is aligned with * ItemRepository.getOnlineSalesItems: * - Same branch & license scoping - * - Only items that are sellable (respecting track_inventory, - * negative_stock, available_quantity, item_status) + * - Catalogue items remain visible when stock reaches zero. + * Sale quantity validation remains separate from browsing. * * As a result, Sales "Categories" tab will only show categories that * actually have valid items for the current branch/license. @@ -337,7 +337,7 @@ class CategoryRepository extends BaseModel { } // Aggregation pipeline to get only categories with at least one - // sellable item for the current branch/license + // catalogue item for the current branch/license return await this.model.aggregate([ { $match: matchStage }, { @@ -355,23 +355,7 @@ class CategoryRepository extends BaseModel { ...(licenseObjectId ? { license: licenseObjectId } : {}), item_status: { $ne: 'instant' }, is_deleted: { $ne: true }, - $or: [ - // Case 1: Inventory not tracked - always allowed - { track_inventory: false }, - - // Keep the category visible even after an allowed sale takes stock below zero. - { negative_stock: true }, - - // Case 3: Normal stock - quantity > 0 - { - $and: [ - { - $or: [{ negative_stock: false }, { negative_stock: { $exists: false } }], - }, - { available_quantity: { $gt: 0 } }, - ], - }, - ], + del_status: { $nin: [1, '1', true] }, }, }, { $limit: 1 }, diff --git a/api/src/repositories/item.repository.js b/api/src/repositories/item.repository.js index dadb988e4..b2f788d89 100644 --- a/api/src/repositories/item.repository.js +++ b/api/src/repositories/item.repository.js @@ -2908,19 +2908,8 @@ class ItemRepository extends BaseModel { } } - // Stock availability logic - // Items are available if: - // 1. track_inventory=false (always available), OR - // 2. available_quantity > 0, OR - // 3. negative_stock=true (can go below 0) - const stockCondition = { - $or: [ - { track_inventory: false }, - { item_kind: 'service' }, - { available_quantity: { $gt: 0 } }, - { negative_stock: true }, - ], - }; + // Catalogue visibility is separate from permission to sell. Keep sold-out + // products searchable; add-to-cart and sale validation enforce quantity. /* Same two clauses as the sale grid, same reason: search is a doorway to selling, and a hidden or deleted sample reachable by typing its @@ -2934,7 +2923,6 @@ class ItemRepository extends BaseModel { { 'branch_access.branch_id': branchObjectId }, { item_status: { $ne: 'instant' } }, { available_for_sale: { $ne: false } }, - type === 'barcode' ? null : stockCondition, ...(categoryId ? [{ category_id: new ObjectId(categoryId) }] : []), ...(licenseObjectId ? [{ license: licenseObjectId }] : []), ].filter(Boolean), @@ -3140,13 +3128,7 @@ class ItemRepository extends BaseModel { } match.$and.push({ category_id: new ObjectId(params.categoryId) }); } - match.$and.push({ - $or: [ - { track_inventory: { $ne: true } }, - { negative_stock: true }, - { available_quantity: { $gt: 0 } }, - ], - }); + // Zero stock must not make an imported catalogue look empty. // Expiry can be an epoch string or an ISO date. Missing/invalid dates // remain available, matching the billing search's expiry semantics. const expiry = { @@ -3206,57 +3188,44 @@ class ItemRepository extends BaseModel { nextOffset = items.length === limit ? offset + limit : null; } - const list = items - .filter((item) => { - const negativeStock = - typeof item.negative_stock === 'boolean' ? item.negative_stock : false; - - // PHP logic: - // if track_inventory === false OR negative_stock === true - // OR available_quantity > 0 then include in list - const trackInventory = item.track_inventory !== true ? false : true; - const availableQty = Number(item.available_quantity) || 0; - - return trackInventory === false || negativeStock === true || availableQty > 0; - }) - .map((item) => ({ - id: item._id?.toString?.() || '', - name: item.name || '', - item_name: item.name || '', - item_id: item._id?.toString?.() || '', - plu_code: item.plu_code || '', - barcode_id: item.barcode_id || '', - barcodes: item.barcodes || [], - short_code: item.short_code || '', - item_code: item.item_code || '', - track_inventory: item.track_inventory === true, - negative_stock: item.negative_stock === true, - item_kind: item.item_kind || 'product', - unit: item.unit || '', - ...itemText.snapshot(item), - selling_price: item.selling_price || 0, - itemid: item.itemid || '', - available_quantity: String(item.available_quantity || 0), - company_price: item.company_price || 0, - discount_amount: item.discount_amount || 0, - discount_percentage: item.discount_percentage || 0, - tax: item.tax || 0, - tax_type: item.tax_type || '', - category_id: item.category_id?.toString?.() || '', - category_name: item.category_name || '', - image: item.image || DEFAULTS.IMAGE, - supplier: item.supplier_name || '', - // Match PHP: expose items_expiry_date for frontend expiry checks - items_expiry_date: item.items_expiry_date != null ? String(item.items_expiry_date) : '', - // Variant family link (V1): lets the sale grid collapse a family - // into one tile with a picker. Empty strings for plain items. - variant_group_id: item.variant_group_id ? String(item.variant_group_id) : '', - variant_value: item.variant_value || '', - variant_parent_name: item.variant_parent_name || '', - // Tile colour (Loyverse study L2): the no-image tile's look. - tile_color: item.tile_color || '', - tile_shape: item.tile_shape || '', - })); + const list = items.map((item) => ({ + id: item._id?.toString?.() || '', + name: item.name || '', + item_name: item.name || '', + item_id: item._id?.toString?.() || '', + plu_code: item.plu_code || '', + barcode_id: item.barcode_id || '', + barcodes: item.barcodes || [], + short_code: item.short_code || '', + item_code: item.item_code || '', + track_inventory: item.track_inventory === true, + negative_stock: item.negative_stock === true, + item_kind: item.item_kind || 'product', + unit: item.unit || '', + ...itemText.snapshot(item), + selling_price: item.selling_price || 0, + itemid: item.itemid || '', + available_quantity: String(item.available_quantity || 0), + company_price: item.company_price || 0, + discount_amount: item.discount_amount || 0, + discount_percentage: item.discount_percentage || 0, + tax: item.tax || 0, + tax_type: item.tax_type || '', + category_id: item.category_id?.toString?.() || '', + category_name: item.category_name || '', + image: item.image || DEFAULTS.IMAGE, + supplier: item.supplier_name || '', + // Match PHP: expose items_expiry_date for frontend expiry checks + items_expiry_date: item.items_expiry_date != null ? String(item.items_expiry_date) : '', + // Variant family link (V1): lets the sale grid collapse a family + // into one tile with a picker. Empty strings for plain items. + variant_group_id: item.variant_group_id ? String(item.variant_group_id) : '', + variant_value: item.variant_value || '', + variant_parent_name: item.variant_parent_name || '', + // Tile colour (Loyverse study L2): the no-image tile's look. + tile_color: item.tile_color || '', + tile_shape: item.tile_shape || '', + })); return { status: true, diff --git a/docs/SOLD_OUT_CATALOGUE.md b/docs/SOLD_OUT_CATALOGUE.md new file mode 100644 index 000000000..618120b60 --- /dev/null +++ b/docs/SOLD_OUT_CATALOGUE.md @@ -0,0 +1,40 @@ +# Sold-out catalogue visibility + +An imported catalogue with tracked items and zero opening stock previously +appeared empty in New Sale. The tile API, name search and category lookup +filtered those records before rendering. Catalogue existence must remain +visible separately from permission to sell a quantity. + +The sale grid and name search now include zero/negative balances. Categories +remain visible when their last item sells out. Deleted records, tenant/branch +boundaries and tile expiry filtering remain enforced. Tiles show the stock +status, and selecting an unavailable tracked product displays an error. +Existing allowed-negative, untracked, service and return behavior is retained. +Server sale quantity validation remains authoritative. No stock is invented, +no tracking setting is changed and no migration balance is written. + +## Verification + +- `node --test tests/sold-out-catalogue.test.js tests/sales-tile-pages.test.js tests/shared-barcode-selection.test.js tests/item-zero-stock.test.js`: 24 tests passed. +- `tests/sales-tile-database.test.js`, against an isolated local MongoDB: four tests passed, including pagination, zero-stock search, category visibility and branch/license exclusion. +- Targeted API lint: no errors; existing repository warnings remain. +- Live cloud verification: the affected billing API changed from an empty result to 48 products and a next-page cursor. Browser verification confirms product cards appear. + +## Synchronization and rollout + +This is a read/display change. No persisted field, stock movement, timestamp, +checkpoint, delete operation or synchronization lane changes. Existing `items` +and `categories` lanes are branch-scoped in the POS, Gateway and sync-agent +manifests. There is no new action to replay after reconnecting. + +Cloud readers require the updated API and frontend. Desktop readers require +the same code in their bundled API/frontend; cloud deployment alone does not +update an installed desktop build. Live offline/reconnect and other-till +verification have not been performed. Existing quantity synchronization and +server sale validation are unchanged. + +The cloud hotfix backs up the three changed production files before applying +only the relevant patch. The affected account routes to a shared shard, so +the shard must reload to pick up API changes; its stopped legacy per-tenant +process is not the serving process. Do not change routing or create a second +active authority to apply this fix. diff --git a/frontend/static/script/js/modules/js/sales.js b/frontend/static/script/js/modules/js/sales.js index 01163d78b..ef255d2d7 100644 --- a/frontend/static/script/js/modules/js/sales.js +++ b/frontend/static/script/js/modules/js/sales.js @@ -7842,7 +7842,9 @@ PosnicPro.sales.itemsMenu = { if (_trackInv === true || _trackInv === 'true') { var _aq = getItemdata[i]['available_quantity']; _aq = (_aq === undefined || _aq === null || _aq === '') ? 0 : _aq; - _stockHtml = '
' + _aq + ' in stock
'; + _stockHtml = '
' + (Number(_aq) <= 0 + ? PosnicPro.i18n.t('lang_out_of_stock_2', 'Out of stock') + : Number(_aq) + ' in stock') + '
'; } /* Tile colour (Loyverse study L2): an item without a real image but with a chosen colour renders a coloured tile @@ -8462,6 +8464,14 @@ PosnicPro.sales.itemsMenu = { }, addToLineItemsList: function (item_id) { PosnicPro.sales.itemCache.get(item_id, function (item) { + // Browsing includes sold-out products; it must not authorize a sale. + var tracked = item.track_inventory === true || item.track_inventory === 'true'; + var negative = item.negative_stock === true || item.negative_stock === 'true'; + if (PosnicPro.sales.SaleAction !== 'return' && item.item_kind !== 'service' + && tracked && !negative && Number(item.available_quantity || 0) <= 0) { + PosnicPro.alert('error', PosnicPro.i18n.t('lang_some_items_are_out_of_stock', 'Some items are out of stock.')); + return; + } $("#save_submit").removeClass("disabled"); let hash = window.location.hash.slice(1); if (hash === '/sales/new') { diff --git a/tests/sales-tile-database.test.js b/tests/sales-tile-database.test.js index f3602efd4..4a0707780 100644 --- a/tests/sales-tile-database.test.js +++ b/tests/sales-tile-database.test.js @@ -5,6 +5,9 @@ const { withDatabase, ciDatabaseName } = require('../api/tests/api/ci-database') const demoData = require('../api/src/services/demo-data'); const originalDemoFilter = demoData.filter; const ItemRepository = require('../api/src/repositories/item.repository'); +const CategoryRepository = require('../api/src/repositories/category.repository'); +const SettingsRepository = require('../api/src/repositories/settings.repository'); +const originalResolveGroup = SettingsRepository.prototype.resolveGroup; const describeDb = process.env.CI_MONGODB_URI ? describe : describe.skip; describeDb('sales shelves page families in the database', () => { @@ -14,6 +17,7 @@ describeDb('sales shelves page families in the database', () => { beforeAll(async () => { client = new MongoClient(withDatabase(process.env.CI_MONGODB_URI, ciDatabaseName()), { serverSelectionTimeoutMS: 3000 }); await client.connect(); db = client.db(); demoData.filter = async () => ({}); + SettingsRepository.prototype.resolveGroup = async () => ({ status: true, data: { values: {} } }); repo = Object.create(ItemRepository.prototype); repo.collectionName = 'items'; repo.getCollection = async () => db.collection('items'); const row = (name, extra = {}) => ({ name, branch_access: [{ branch_id: branchId }], license: licenseId, category_id: categoryId, track_inventory: false, selling_price: 1, ...extra }); @@ -21,7 +25,8 @@ describeDb('sales shelves page families in the database', () => { ...Array.from({ length: 101 }, (_, i) => row('Item ' + String(i).padStart(3, '0'), { barcode_id: 'BC' + i })), row('Family A', { variant_group_id: 'family', variant_value: 'S' }), row('ZZ Family B', { variant_group_id: 'family', variant_value: 'L' }), - row('Excluded stock', { track_inventory: true, available_quantity: 0 }), + row('Zero stock', { track_inventory: true, available_quantity: 0, barcode_id: 'EMPTY' }), + row('Negative stock', { track_inventory: true, available_quantity: -2 }), row('Excluded ISO expiry', { items_expiry_date: '2000-01-01' }), row('Excluded epoch expiry', { items_expiry_date: '946684800000' }), row('Excluded deleted', { del_status: 1 }), @@ -29,7 +34,7 @@ describeDb('sales shelves page families in the database', () => { row('Excluded tenant', { license: new ObjectId() }), ]); }); - afterAll(async () => { demoData.filter = originalDemoFilter; if (db) await db.dropDatabase(); if (client) await client.close(); }); + afterAll(async () => { demoData.filter = originalDemoFilter; SettingsRepository.prototype.resolveGroup = originalResolveGroup; if (db) await db.dropDatabase(); if (client) await client.close(); }); test('48 tiles on the first page, complete family, every item reachable exactly once', async () => { let offset = 0, ids = []; const first = await repo.getOnlineSalesItems({ tilePage: true, limit: 48 }, context); @@ -43,7 +48,22 @@ describeDb('sales shelves page families in the database', () => { assert.equal(page.data.some(i => i.name.startsWith('Excluded')), false); ids.push(...page.data.map(i => i.id)); offset = page.next_offset; } while (offset !== null); - assert.equal(ids.length, 103); assert.equal(new Set(ids).size, 103); + assert.equal(ids.length, 105); assert.equal(new Set(ids).size, 105); + }); + test('a zero-stock imported catalogue stays visible without changing its stock policy', async () => { + const page = await repo.getOnlineSalesItems({ tilePage: true, limit: 48 }, context); + let rows = page.data, offset = page.next_offset; + while (offset !== null) { + const next = await repo.getOnlineSalesItems({ tilePage: true, limit: 48, offset }, context); + rows = rows.concat(next.data); offset = next.next_offset; + } + const zero = rows.find(i => i.name === 'Zero stock'); + assert.equal(zero.available_quantity, '0'); + assert.equal(zero.track_inventory, true); + assert.equal(zero.negative_stock, false); + const search = await repo.getOnlineItemsAjaxList({ query: 'Zero stock' }, context); + assert.equal(search.status, true); + assert.ok(search.data.some(i => i.name === 'Zero stock' || i.item_name === 'Zero stock')); }); test('category scope and full-catalogue barcode search do not depend on loaded pages', async () => { const empty = await repo.getOnlineSalesItems({ tilePage: true, categoryId: String(new ObjectId()) }, context); @@ -51,4 +71,19 @@ describeDb('sales shelves page families in the database', () => { const result = await repo.getOnlineItemsAjaxList({ query: 'BC100', type: 'barcode' }, context); assert.equal(result.status, true); assert.ok(result.data.some(i => i.name === 'Item 100' || i.item_name === 'Item 100')); }); + test('categories containing only sold-out products remain visible and deleted products do not count', async () => { + const otherBranch = new ObjectId(), stockedCategory = new ObjectId(), deletedCategory = new ObjectId(); + await db.collection('categories').insertMany([ + { _id: stockedCategory, name: 'Sold out', branch_id: otherBranch, license: licenseId }, + { _id: deletedCategory, name: 'Deleted only', branch_id: otherBranch, license: licenseId }, + ]); + await db.collection('items').insertMany([ + { category_id: stockedCategory, branch_access: [{ branch_id: otherBranch }], license: licenseId, track_inventory: true, available_quantity: 0 }, + { category_id: deletedCategory, branch_access: [{ branch_id: otherBranch }], license: licenseId, track_inventory: true, available_quantity: 0, del_status: 1 }, + ]); + const categories = Object.create(CategoryRepository.prototype); + categories.model = { aggregate: pipeline => db.collection('categories').aggregate(pipeline).toArray() }; + const result = await categories.findByBranchWithItems(otherBranch, { license: licenseId }); + assert.deepEqual(result.map(r => r.name), ['Sold out']); + }); }); diff --git a/tests/sold-out-catalogue.test.js b/tests/sold-out-catalogue.test.js new file mode 100644 index 000000000..3e736f2bb --- /dev/null +++ b/tests/sold-out-catalogue.test.js @@ -0,0 +1,34 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync('frontend/static/script/js/modules/js/sales.js', 'utf8'); +const start = source.indexOf(' addToLineItemsList: function (item_id)'); +const end = source.indexOf(' clickEffect:', start); +function choose(item, SaleAction) { + const added = [], errors = []; + const PosnicPro = { alert: (_, message) => errors.push(message), i18n: { t: (_, text) => text }, + sales: { SaleAction, itemCache: { get: (_, cb) => cb(item) }, + itemsMenu: { clickEffect() {} }, addSalesLineItems: item => added.push(item) } }; + const method = vm.runInNewContext('({' + source.slice(start, end) + '}).addToLineItemsList', + { PosnicPro, $: () => ({ removeClass() {} }), window: { location: { hash: '' } } }); + method('item'); return { added, errors }; +} +test('visible sold-out tiles cannot add tracked stock to a sale', () => { + for (const available_quantity of [0, -1, '0']) { + const result = choose({ track_inventory: true, available_quantity }); + assert.equal(result.added.length, 0); assert.equal(result.errors.length, 1); + } +}); +test('stock policy preserves in-stock, untracked, allowed-negative, service and return items', () => { + for (const [item, action] of [ + [{ track_inventory: true, available_quantity: 2 }], + [{ track_inventory: false, available_quantity: 0 }], + [{ track_inventory: true, negative_stock: true, available_quantity: 0 }], + [{ track_inventory: true, item_kind: 'service', available_quantity: 0 }], + [{ track_inventory: true, available_quantity: 0 }, 'return'], + ]) { + const result = choose(item, action); + assert.equal(result.added.length, 1); assert.equal(result.errors.length, 0); + } +});