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 = '