From e89bd149295451096ba7a8e27f0602af9754e6fa Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Sat, 17 Oct 2026 16:50:20 +0530 Subject: [PATCH 01/21] Add audited product availability and desktop duplicate review --- api/src/models/item.model.js | 5 + api/src/repositories/item.repository.js | 6 + api/src/routes/mobile-pos.routes.js | 2 + api/src/services/item-availability.js | 107 ++++++++++++++++++ api/src/services/mobile-inventory.js | 12 +- api/src/services/mobile-pos.js | 4 + api/src/services/pricing-authority.js | 6 + api/src/utils/item-channels.js | 4 +- .../fixtures/item-barcode-validation.cjs | 20 ++++ api/tests/mobile-inventory.integration.cjs | 49 ++++++++ docs/product-availability.md | 33 ++++++ frontend/modules/items.html | 1 + frontend/static/script/js/modules/js/items.js | 56 +++++++++ frontend/static/script/js/modules/js/sales.js | 24 ++++ tests/product-availability.test.js | 38 +++++++ 15 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 api/src/services/item-availability.js create mode 100644 docs/product-availability.md create mode 100644 tests/product-availability.test.js diff --git a/api/src/models/item.model.js b/api/src/models/item.model.js index eeb08a698..aa3d6f919 100644 --- a/api/src/models/item.model.js +++ b/api/src/models/item.model.js @@ -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 }, }, { @@ -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 }, diff --git a/api/src/repositories/item.repository.js b/api/src/repositories/item.repository.js index 6422fb277..dadb988e4 100644 --- a/api/src/repositories/item.repository.js +++ b/api/src/repositories/item.repository.js @@ -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 }] : []), @@ -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 }] : []), ], }; @@ -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 }], }, @@ -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, @@ -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 @@ -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), }; diff --git a/api/src/routes/mobile-pos.routes.js b/api/src/routes/mobile-pos.routes.js index a3a15ba92..f7b68e588 100644 --- a/api/src/routes/mobile-pos.routes.js +++ b/api/src/routes/mobile-pos.routes.js @@ -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)); diff --git a/api/src/services/item-availability.js b/api/src/services/item-availability.js new file mode 100644 index 000000000..3313a7734 --- /dev/null +++ b/api/src/services/item-availability.js @@ -0,0 +1,107 @@ +'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 }; diff --git a/api/src/services/mobile-inventory.js b/api/src/services/mobile-inventory.js index c2e2524d2..85f01b10a 100644 --- a/api/src/services/mobile-inventory.js +++ b/api/src/services/mobile-inventory.js @@ -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, }; @@ -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() @@ -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 @@ -525,6 +533,8 @@ async function policy(req) { }; } module.exports = { + access, + item, catalogue, history, prepare, diff --git a/api/src/services/mobile-pos.js b/api/src/services/mobile-pos.js index a8d2a9764..97e73b4e3 100644 --- a/api/src/services/mobile-pos.js +++ b/api/src/services/mobile-pos.js @@ -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 @@ -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' && diff --git a/api/src/services/pricing-authority.js b/api/src/services/pricing-authority.js index a1fc040e5..0e51ca410 100644 --- a/api/src/services/pricing-authority.js +++ b/api/src/services/pricing-authority.js @@ -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. diff --git a/api/src/utils/item-channels.js b/api/src/utils/item-channels.js index f4f98ae8f..12e660eae 100644 --- a/api/src/utils/item-channels.js +++ b/api/src/utils/item-channels.js @@ -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 }; @@ -157,7 +159,7 @@ function availableOn(item, channel, nowMins) { function channelFilter(channel) { const wanted = normalizeTarget(channel); if (!wanted) return {}; - return { channel_off: { $ne: wanted } }; + return { available_for_sale: { $ne: false }, channel_off: { $ne: wanted } }; } /** diff --git a/api/tests/fixtures/item-barcode-validation.cjs b/api/tests/fixtures/item-barcode-validation.cjs index 3ef2f5ee1..7400fb675 100644 --- a/api/tests/fixtures/item-barcode-validation.cjs +++ b/api/tests/fixtures/item-barcode-validation.cjs @@ -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); +}); diff --git a/api/tests/mobile-inventory.integration.cjs b/api/tests/mobile-inventory.integration.cjs index 32e91e351..9755bf420 100644 --- a/api/tests/mobile-inventory.integration.cjs +++ b/api/tests/mobile-inventory.integration.cjs @@ -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 }); +}); diff --git a/docs/product-availability.md b/docs/product-availability.md new file mode 100644 index 000000000..581d48184 --- /dev/null +++ b/docs/product-availability.md @@ -0,0 +1,33 @@ +# Product availability and duplicate-barcode review + +## Behaviour + +Inventory → Find stock → scan, or Review product availability. Exact scans include primary and alternate barcode matches, including hidden products. Compare product, image, SKU, barcode, tax-inclusive selling price, stock and supplier. Switch Available for sale on individual records, review the explicit changes, then confirm. There is no automatic winner, merge, deletion, stock adjustment or historical-sale rewrite. + +Desktop: Items → Product availability uses the same authenticated API and review. Existing item read/write permissions apply; mobile also honours the existing mobile inventory setting. No manager-approval workflow was added. + +`items.available_for_sale` is independent from deletion, item type, menu visibility, channel restrictions and legacy active flags. Missing means available; no backfill disables existing products. Other existing sales restrictions remain effective. Admin/inventory records remain available when hidden. + +## Delivery and conflicts + +`POST /mobile/v1/inventory/availability` accepts a stable request ID and explicit itemId, available, revision changes (maximum 100). Server scope is authenticated tenant+branch; mobile also binds shop/staff. Each item uses compare-and-set revision and an atomic audit entry containing request ID, actor, timestamp, previous and new values. Individual conflicts are returned; a lost response can retry the same ID without repeating a change. + +Selections/review are not applied. Sending is shown as awaiting confirmation. Transport errors say not confirmed, rather than claiming success. Availability changes require connection; they are not silently queued offline. Confirmed hides invalidate the local sales index immediately. Restores become sellable after a fresh signed catalogue sync. Inventory caches retain confirmed results. + +Desktop sales polls availability while its sales screen is visible (15 seconds and window focus), clears its item/shelf cache on revision change, and refreshes the shelf. Sales search/scan endpoints filter hidden records. A hidden product cannot be added through cached item details. Checkout validation rejects a hidden product in a new basket with an explanation; it does not remove or alter lines. Existing saved historical order/receipt pricing remains intact. Mobile checkout likewise preserves the basket and asks the user to remove or restore a newly hidden item. + +Offline tills cannot learn a hide until reconnecting. Already-paid offline mobile sales remain valid against their signed historical catalogue grant and synchronize normally; rejecting these would lose legitimate payment/history. This is not an instantaneous fleet-wide recall. + +## Sync design and rollout + +Items are already tenant/branch-scoped in all three manifests: POS api/src/sync/collections.json; Gateway apps/sync-gateway/src/collections.js; sync-agent sync-config.json. The existing batch lane carries the complete item (availability and audit included), so no collection/lane was added. Product IDs and image references stay unchanged; existing file sync is unchanged. + +Gateway and agent merge availability audit independently from whole-document last-write-wins stock/price updates. Event timestamps and IDs determine a consistent winner; audit events are retained, and an older incoming document with a previously unseen availability event advances the delivery checkpoint timestamp. Compare-and-set protects concurrent edits on one server. Independent offline servers reconcile deterministically after reconnect; their users cannot see remote edits before then. + +Roll out the matching Gateway merger and signed sync-agent component, POS API/desktop (bundling that agent), then Mobile App. Old whole-document sync agents can still overwrite availability, so do not advertise the feature as deployed before the coordinated rollout. The local prepare-sync-agent check passed with the modified source and all 27 promised collections. No installer, signed agent component or APK for this change has been published yet. + +## Verification + +Local automated checks: mobile TypeScript + 123 unit tests; 59 browser tests including duplicate review/hide/restore; POS mobile API/inventory suites (42 passed, 5 existing environment-dependent skips); 23 real-Mongo barcode tests including desktop scan/hide/restore; 9 desktop DOM/barcode chooser tests; gateway/agent real-Mongo reconnect test. Assertions cover different prices, alternate barcodes, unchanged stock, audit idempotency, stale revisions, item permissions, retained baskets and native SQLite hidden/restore lookup. + +No client production records changed and no client messages sent. Physical-device, packaged-installer and deployed multi-till acceptance are pending. New availability-screen copy currently uses English; existing translated app screens remain unchanged. diff --git a/frontend/modules/items.html b/frontend/modules/items.html index 92099786f..2a1baf223 100644 --- a/frontend/modules/items.html +++ b/frontend/modules/items.html @@ -19,6 +19,7 @@

It -->

diff --git a/frontend/static/script/js/core/shelf-labels.js b/frontend/static/script/js/core/shelf-labels.js index df97fe1c2..f7f56cf05 100644 --- a/frontend/static/script/js/core/shelf-labels.js +++ b/frontend/static/script/js/core/shelf-labels.js @@ -381,14 +381,14 @@ if (!desktop) return; var paper = $('#sl-paper').val(), saved = ''; try { saved = root.localStorage.getItem('posnic.shelf-label-printer.' + paper) || ''; } catch (_) {} - $('#sl-printer').empty().append($('