Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 4 additions & 20 deletions api/src/repositories/category.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 },
{
Expand All @@ -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 },
Expand Down
113 changes: 41 additions & 72 deletions api/src/repositories/item.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions docs/SOLD_OUT_CATALOGUE.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 11 additions & 1 deletion frontend/static/script/js/modules/js/sales.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<div class="text-center wsk-cp-stock">' + _aq + ' in stock</div>';
_stockHtml = '<div class="text-center wsk-cp-stock">' + (Number(_aq) <= 0
? PosnicPro.i18n.t('lang_out_of_stock_2', 'Out of stock')
: Number(_aq) + ' in stock') + '</div>';
}
/* Tile colour (Loyverse study L2): an item without a real
image but with a chosen colour renders a coloured tile
Expand Down Expand Up @@ -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') {
Expand Down
41 changes: 38 additions & 3 deletions tests/sales-tile-database.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -14,22 +17,24 @@ 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 });
await db.collection('items').insertMany([
...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 }),
row('Excluded branch', { branch_access: [{ branch_id: new ObjectId() }] }),
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);
Expand All @@ -43,12 +48,42 @@ 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);
assert.deepEqual(empty.data, []); assert.equal(empty.next_offset, null);
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']);
});
});
Loading
Loading