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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ before making a package-level licence statement.
[![Latest release](https://img.shields.io/github/v/release/Posnic/POS?include_prereleases&label=latest&color=blue)](https://github.com/Posnic/POS/releases/latest)
[![Tests](https://img.shields.io/badge/tests-9%2C000%2B%20passing-brightgreen)](docs/DEVELOPMENT.md#running-the-tests)
[![Coverage](https://img.shields.io/badge/coverage-69%25%20statements-yellow)](docs/DEVELOPMENT.md#running-the-tests)
[![API](https://img.shields.io/badge/REST%20API-895%20endpoints-blue)](docs/API.md)
[![API](https://img.shields.io/badge/REST%20API-897%20endpoints-blue)](docs/API.md)
[![Source licence](https://img.shields.io/badge/source%20licence-AGPL--3.0-blue)](LICENSE)
[![Package notices](https://img.shields.io/badge/package%20notices-component%20licences-informational)](THIRD-PARTY-NOTICES.md)
[![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](https://github.com/Posnic/POS/releases/latest)
Expand Down Expand Up @@ -235,7 +235,7 @@ the free edition worse. This is written down in [GOVERNANCE.md](docs/GOVERNANCE.
| [Contributor quickstart](docs/CONTRIBUTOR_QUICKSTART.md) | Local setup, test commands, issue map, PR flow and safety rules |
| [Developer guide](docs/DEVELOPMENT.md) | Setup, tests, conventions, good first issues |
| [Architecture](docs/ARCHITECTURE.md) | How it fits together, and the parts that bite |
| [REST API](docs/API.md) | 895 endpoints, generated from the routes |
| [REST API](docs/API.md) | 897 endpoints, generated from the routes |
| [Hardware](docs/HARDWARE_MATRIX.md) | Printers, scanners, drawers, scales — and how far each claim is checked |
| [India e-invoicing](docs/INDIA_EINVOICING_DESIGN.md) | Research, readiness inventory and design for GST e-invoicing as an optional feature; no live IRP submission is built |
| [India GST return gaps](docs/INDIA_GST_RETURNS_GAPS.md) | What the GST reports compute today, measured against GSTR-1, 3B and 9 |
Expand Down
5 changes: 5 additions & 0 deletions api/src/models/item.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ const itemSchema = new mongoose.Schema(
menu_marks: { type: [String], default: [] },

license: { type: mongoose.Schema.Types.ObjectId, ref: 'License' },
available_for_sale: { type: Boolean, default: true },
availability_revision: String,
availability_history: [mongoose.Schema.Types.Mixed],
is_active: { type: Boolean, default: true },
},
{
Expand Down Expand Up @@ -285,6 +288,8 @@ class ItemModel {
unit_id: { type: 'String', select: true },
track_inventory: { type: 'Boolean', select: true },
ecommerce: { type: 'Boolean', select: true },
available_for_sale: { type: 'Boolean', select: true },
availability_revision: { type: 'String', select: true },
show_on_menu: { type: 'Boolean', select: true },
channel_off: { type: 'Array', select: true },
channel_hours: { type: 'Object', select: true },
Expand Down
6 changes: 6 additions & 0 deletions api/src/repositories/item.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -2933,6 +2933,7 @@ class ItemRepository extends BaseModel {
{ del_status: { $nin: [1, '1', true] } },
{ '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 }] : []),
Expand Down Expand Up @@ -3126,6 +3127,7 @@ class ItemRepository extends BaseModel {
{ del_status: { $nin: [1, '1', true] } },
{ 'branch_access.branch_id': branchObjectId },
{ item_status: { $ne: 'instant' } },
{ available_for_sale: { $ne: false } },
...(licenseObjectId ? [{ license: licenseObjectId }] : []),
],
};
Expand Down Expand Up @@ -3852,6 +3854,7 @@ class ItemRepository extends BaseModel {
...(Object.keys(demoClause).length ? [demoClause] : []),
{ del_status: { $nin: [1, '1', true] } },
{ item_status: { $ne: ITEM_STATUS.INSTANT } },
{ available_for_sale: { $ne: false } },
{
$or: [{ available_quantity: { $gt: 0 } }, { negative_stock: true }],
},
Expand Down Expand Up @@ -3926,6 +3929,7 @@ class ItemRepository extends BaseModel {
$and: [
{ 'branch_access.branch_id': branchObjectId },
{ item_status: { $ne: 'instant' } },
{ available_for_sale: { $ne: false } },
{
selling_price: {
$gte: parseInt(startingPrice, 10) || 0,
Expand Down Expand Up @@ -5769,6 +5773,7 @@ class ItemRepository extends BaseModel {
const filter = {
'branch_access.branch_id': branchObjectId,
item_status: { $ne: ITEM_STATUS.INSTANT },
available_for_sale: { $ne: false },
};

const items = await collection
Expand Down Expand Up @@ -6398,6 +6403,7 @@ class ItemRepository extends BaseModel {
: {},
{ 'branch_access.branch_id': branchObjectId },
{ item_status: { $ne: ITEM_STATUS.INSTANT } },
{ available_for_sale: { $ne: false } },
...(licenseObjectId ? [{ license: licenseObjectId }] : []),
].filter((f) => Object.keys(f).length > 0),
};
Expand Down
2 changes: 2 additions & 0 deletions api/src/routes/mobile-pos.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,11 @@ router.post(
);
router.use(protect);
const inventory = require('../services/mobile-inventory');
router.get('/availability-status', wrap(require('../services/item-availability').status));
router.get('/inventory/policy', wrap(inventory.policy));
router.post('/inventory/policy', wrap(inventory.policy));
router.get('/inventory/catalogue', wrap(inventory.catalogue));
router.post('/inventory/availability', wrap(require('../services/item-availability').change));
router.get('/inventory/items/:id/history', wrap(inventory.history));
router.get('/inventory/requests/:id', wrap(inventory.requestStatus));
router.post('/inventory/requests/:id/cancel', wrap(inventory.abandonRequest));
Expand Down
110 changes: 110 additions & 0 deletions api/src/services/item-availability.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use strict';
const { ObjectId } = require('mongodb');
const inventory = require('./mobile-inventory');
const mobile = require('./mobile-pos');

// Audit and availability are one atomic item write, replicated with the item.
// No stock effect, deletion, approval workflow or offline command replay.
async function change(req) {
const c = await inventory.access(req, true);
const { id, changes } = req.body || {};
if (
!/^[a-zA-Z0-9_-]{12,80}$/.test(id || '') ||
!Array.isArray(changes) ||
!changes.length ||
changes.length > 100 ||
new Set(changes.map((x) => String(x?.itemId).toLowerCase())).size !== changes.length
)
mobile.fail('Review between 1 and 100 distinct products.', 400);
for (const x of changes) {
if (
!/^[a-f0-9]{24}$/i.test(x?.itemId || '') ||
typeof x.available !== 'boolean' ||
typeof x.revision !== 'string' ||
x.revision.length > 80
)
mobile.fail('Invalid availability change.', 400);
}
const items = req.db.collection('items');
const results = [];
for (const x of changes) {
const scope = {
_id: new ObjectId(x.itemId),
license: c.license,
branch_id: c.branchId,
is_deleted: { $ne: true },
del_status: { $nin: [1, '1', true] },
};
let row = await items.findOne(scope);
if (!row) {
results.push({ itemId: x.itemId, state: 'conflict' });
continue;
}
const replay = row.availability_history?.find((e) => e.id === id);
if (replay) {
if (replay.to !== x.available || replay.actor !== String(c.userId))
mobile.fail('Change identity already used.', 409);
if (row.availability_revision !== id) {
results.push({ itemId: x.itemId, state: 'conflict', item: inventory.item(row) });
continue;
}
} else {
if ((row.availability_revision || '') !== x.revision) {
results.push({ itemId: x.itemId, state: 'conflict', item: inventory.item(row) });
continue;
}
const lastAt = Math.max(
0,
...(row.availability_history || []).map((e) => +new Date(e.at) || 0)
);
const at = new Date(Math.max(Date.now(), lastAt + 1));
const result = await items.updateOne(
{
...scope,
...(x.revision
? { availability_revision: x.revision }
: {
$or: [{ availability_revision: { $exists: false } }, { availability_revision: '' }],
}),
},
{
$set: { available_for_sale: x.available, availability_revision: id, updated_date: at },
$push: {
availability_history: {
id,
actor: String(c.userId),
at,
from: row.available_for_sale !== false,
to: x.available,
},
},
}
);
if (!result.matchedCount) {
results.push({ itemId: x.itemId, state: 'conflict' });
continue;
}
row = await items.findOne(scope);
}
results.push({ itemId: x.itemId, state: 'confirmed', item: inventory.item(row) });
}
return { results, confirmedAt: new Date().toISOString() };
}
async function status(req) {
const c = await mobile.context(req, Boolean(req.handsetDevice));
if (!mobile.allowed(req.user, 'sales') && !mobile.allowed(req.user, 'item', 'read'))
mobile.fail('Catalogue access required.', 403);
const rows = await req.db
.collection('items')
.find(
{ license: c.license, branch_id: c.branchId, availability_revision: { $exists: true } },
{ projection: { _id: 1, available_for_sale: 1, availability_revision: 1 } }
)
.sort({ _id: 1 })
.toArray();
return {
revision: mobile.hash(mobile.canonical(rows)),
hidden: rows.filter((r) => r.available_for_sale === false).map((r) => String(r._id)),
};
}
module.exports = { change, status };
12 changes: 11 additions & 1 deletion api/src/services/mobile-inventory.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ function item(row) {
categoryId: String(row.category_id || ''),
supplierId: String(row.supplier_id || ''),
supplierName: String(row.supplier_name || ''),
sku: String(row.sku || row.item_code || ''),
sku: String(row.sku || row.item_code || row.itemid || ''),
availableForSale: row.available_for_sale !== false,
availabilityRevision: row.availability_revision || '',
minimum: Number(row.reorder_point || 0),
expires: row.items_expiry_date || null,
};
Expand All @@ -78,6 +80,7 @@ async function catalogue(req) {
mobile.fail('Supplier access is required.', 403);
const filter = kind === 'items' ? wall(c) : withBranchScope({ license: c.license }, c.branchId);
filter.is_deleted = { $ne: true };
filter.del_status = { $nin: [1, '1', true] };
if (req.query?.after) filter._id = { $gt: oid(req.query.after) };
const q = String(req.query?.q || '')
.trim()
Expand All @@ -87,10 +90,15 @@ async function catalogue(req) {
filter.$or = [
{ name: { $regex: escaped, $options: 'i' } },
{ barcode_id: q },
{ barcodes: q },
{ plu_code: q },
{ sku: q },
];
}
if (kind === 'items' && req.query?.barcode) {
const barcode = String(req.query.barcode).trim().slice(0, 512);
filter.$or = [{ barcode_id: barcode }, { barcodes: barcode }];
}
const projection =
kind === 'items' ? undefined : { name: 1, category_name: 1, supplier_name: 1, status: 1 };
const rows = await req.db
Expand Down Expand Up @@ -525,6 +533,8 @@ async function policy(req) {
};
}
module.exports = {
access,
item,
catalogue,
history,
prepare,
Expand Down
4 changes: 4 additions & 0 deletions api/src/services/mobile-pos.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ function mapItem(row, options = {}) {
price: Number.isSafeInteger(price) && price >= 0 && price <= 99999999999 ? price : 0,
code: /^\d{1,6}$/.test(code) ? code : '',
barcode: String(row.barcode_id || ''),
barcodes: require('../utils/item-barcodes').codes(row),
category: String(row.category_name || 'Items'),
visual: typeof row.icon === 'string' && row.icon ? row.icon : '■',
...(image
Expand All @@ -123,6 +124,9 @@ function mapItem(row, options = {}) {
taxBps: Number.isInteger(taxBps) ? Math.min(10000, Math.max(0, taxBps)) : 0,
taxInclusive: row.tax_type === 'inclusive',
active:
row.available_for_sale !== false &&
!row.is_deleted &&
![1, '1', true].includes(row.del_status) &&
row.activate !== false &&
row.active !== false &&
row.status !== 'inactive' &&
Expand Down
6 changes: 6 additions & 0 deletions api/src/services/pricing-authority.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ function resolve({
const monetary = Money.policy(branch);
const round = (value) => Money.fromMinor(Money.toMinor(value, monetary), monetary);
const name = product.name || product.item_name || 'Item';
if (product.available_for_sale === false && !previous)
fail(
'item_hidden',
`${name} is hidden from sales. Remove this basket line or ask an authorised user to restore the product.`,
{ itemId: String(product._id) }
);
const old = previous?.pricing;
// Only a stored snapshot may preserve a historical price. Clients cannot
// install one: adapters read previous from the persisted order.
Expand Down
6 changes: 4 additions & 2 deletions api/src/utils/item-channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ function normalizeItemChannels(item) {
* @returns {{available: boolean, reason: string, window: object|null}}
*/
function availableOn(item, channel, nowMins) {
if (item?.available_for_sale === false)
return { available: false, reason: 'hidden_from_sales', window: null };
const wanted = normalizeTarget(channel);
if (!wanted) return { available: true, reason: '', window: null };

Expand Down Expand Up @@ -156,8 +158,8 @@ function availableOn(item, channel, nowMins) {
*/
function channelFilter(channel) {
const wanted = normalizeTarget(channel);
if (!wanted) return {};
return { channel_off: { $ne: wanted } };
if (!wanted) return { available_for_sale: { $ne: false } };
return { available_for_sale: { $ne: false }, channel_off: { $ne: wanted } };
}

/**
Expand Down
20 changes: 20 additions & 0 deletions api/tests/fixtures/item-barcode-validation.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -386,3 +386,23 @@ test('Core Settings exact numeric toggle survives reload and controls real Sales
expect((await reload()).sales_exact_numeric_codes).toBe(false);
expect(await search()).toEqual(['11','110','111']);
});


test('availability hides primary and alternate duplicates from desktop sales but retains admin and stock', async () => {
const a = await seed({ selling_price: 10, available_quantity: 4, track_inventory: false });
const b = await seed({ name: 'Premium milk', barcode_id: 'other', barcodes: [code], selling_price: 20, available_quantity: 8, track_inventory: false });
const scan = async () => (await repo.getOnlineItemsAjaxList({ query: code, type: 'barcode' }, ctx)).data;
expect((await scan()).length).toBe(2);
const availability = require('../../src/services/item-availability');
const inventory = require('../../src/services/mobile-inventory');
const user = new ObjectId();
const req = { db, user: { _id: user, usertype: 'owner' }, tenantContext: { licenseId: String(ctx.licenseId), branchId: String(ctx.branchId) }, query: { barcode: code }, body: { id: 'desktop-hide-001', changes: [{ itemId: String(b._id), available: false, revision: '' }] } };
await availability.change(req);
expect((await scan()).map(i => i.item_id)).toEqual([String(a._id)]);
expect((await repo.getOnlineItemsAjaxList({ query: 'Premium' }, ctx)).data).toHaveLength(0);
expect((await inventory.catalogue(req)).rows).toHaveLength(2);
expect((await db.collection('items').findOne({ _id: b._id })).available_quantity).toBe(8);
req.body = { id: 'desktop-restore-001', changes: [{ itemId: String(b._id), available: true, revision: 'desktop-hide-001' }] };
await availability.change(req);
expect((await scan()).length).toBe(2);
});
49 changes: 49 additions & 0 deletions api/tests/mobile-inventory.integration.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,52 @@ test('mobile inventory defaults on, preserves explicit off and existing approval
assert.equal((await inventory.catalogue(req)).rows.length, 1);
assert.equal((await db.collection('items').findOne({ _id: row._id })).available_quantity, 10);
});

test('availability duplicate review, alternates, restore, audit, retry, conflict and stock preservation', async () => {
const availability = require('../src/services/item-availability');
const mapItem = require('../src/services/mobile-pos').mapItem;
await db
.collection('items')
.updateOne({ _id: row._id }, { $set: { barcode_id: '123', selling_price: 10, tax: 10 } });
const other = {
...row,
_id: new ObjectId(),
name: 'Other beans',
barcodes: ['123'],
selling_price: 20,
};
await db.collection('items').insertOne(other);
req.query.q = '123';
let page = await inventory.catalogue(req);
assert.equal(page.rows.length, 2);
assert.notEqual(page.rows[0].price, page.rows[1].price);
assert.ok(page.rows.every((i) => i.availableForSale));
req.body = {
id: 'availability-test-001',
changes: [{ itemId: String(row._id), available: false, revision: '' }],
};
assert.equal((await availability.change(req)).results[0].state, 'confirmed');
await availability.change(req);
let hidden = await db.collection('items').findOne({ _id: row._id });
assert.equal(mapItem(hidden).active, false);
assert.equal(hidden.available_quantity, 10);
assert.equal(hidden.availability_history.length, 1);
assert.equal(hidden.availability_history[0].actor, String(req.user._id));
assert.equal(await db.collection('stocklogs').countDocuments(), 0);
page = await inventory.catalogue(req);
assert.equal(page.rows.length, 2);
req.body = {
id: 'availability-test-002',
changes: [{ itemId: String(row._id), available: true, revision: '' }],
};
assert.equal((await availability.change(req)).results[0].state, 'conflict');
req.body.changes[0].revision = hidden.availability_revision;
await availability.change(req);
hidden = await db.collection('items').findOne({ _id: row._id });
assert.equal(mapItem(hidden).active, true);
assert.equal(hidden.availability_history.length, 2);
assert.equal(hidden.available_quantity, 10);
req.user.usertype = 'staff';
req.user.access = { item: { read: true, write: false } };
await assert.rejects(availability.change(req), { status: 403 });
});
4 changes: 3 additions & 1 deletion api/tests/unit/models/item.model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -791,12 +791,14 @@ describe('Item.LegacyItemModel › class identity', () => {
// deliberately NOT fields, so a dish can never store one).
// + nutrition_source (who said so: a person, or a machine that guessed;
// dish-facts publishes nothing derived from an estimate).
expect(Object.keys(LegacyItemModel.fields)).toHaveLength(80);
expect(Object.keys(LegacyItemModel.fields)).toHaveLength(82);
expect(LegacyItemModel.fields).toEqual(
expect.objectContaining({
/* Named as well as counted: a count alone passes if one field is
added while another is dropped, which is the change that silently
stops a read projecting a column. */
available_for_sale: expect.any(Object),
availability_revision: expect.any(Object),
default_language: expect.any(Object),
translations: expect.any(Object),
gtin: expect.any(Object),
Expand Down
Loading
Loading