From 34416c1d913761e740806cc1b8c969f8ef4ad110 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 10:23:39 +0530 Subject: [PATCH] Make mobile inventory discoverable and available by default --- api/src/models/branch.model.js | 2 +- api/src/services/mobile-inventory.js | 9 ++-- api/src/services/mobile-pos.js | 5 ++- api/src/utils/mobile-inventory-policy.js | 5 +++ api/tests/mobile-inventory.integration.cjs | 45 ++++++++++++++----- docs/MOBILE_INVENTORY_IMPLEMENTATION.md | 10 ++++- frontend/modules/settings_write.html | 2 + .../js/modules/js/item-settings-report.js | 6 +-- languages/_english.json | 1 + 9 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 api/src/utils/mobile-inventory-policy.js diff --git a/api/src/models/branch.model.js b/api/src/models/branch.model.js index 6854779b5..e783dfcf7 100644 --- a/api/src/models/branch.model.js +++ b/api/src/models/branch.model.js @@ -98,7 +98,7 @@ const branchSchema = new Schema( whatsapp_device_id: { type: String, default: '' }, item_stock_default: { type: String, enum: ['always_available', 'track_quantities'] }, inventory_policy: { - enabled: { type: Boolean, default: false }, + enabled: { type: Boolean, default: true }, approvalActions: { type: [String], enum: ['count', 'receive', 'damage', 'loss'], diff --git a/api/src/services/mobile-inventory.js b/api/src/services/mobile-inventory.js index f67fcc443..c2e2524d2 100644 --- a/api/src/services/mobile-inventory.js +++ b/api/src/services/mobile-inventory.js @@ -1,4 +1,5 @@ 'use strict'; +const inventoryEnabled = require('../utils/mobile-inventory-policy'); // Inventory uses the existing item balance and stocklogs. The effect marker is // written atomically with the balance, so a retry cannot apply a line twice. @@ -25,8 +26,8 @@ const revision = (row) => const wall = (c) => ({ license: c.license, branch_id: c.branchId }); async function access(req, write = false) { const c = await mobile.context(req, Boolean(req.handsetDevice)); - if (req.handsetDevice && c.branch.inventory_policy?.enabled !== true) - mobile.fail('Enable mobile inventory in Core Settings → Items/Inventory.', 403); + if (req.handsetDevice && !inventoryEnabled(c.branch)) + mobile.fail('Enable mobile inventory in Core Settings → Inventory → Mobile inventory.', 403); if (write && req.handsetDevice) { const scope = req.body?.scope; if ( @@ -497,7 +498,7 @@ async function policy(req) { if (req.method === 'POST') { if (!canManage) mobile.fail('An administrator must change inventory policy.', 403); const actions = req.body?.approvalActions; - const enabled = req.body?.enabled ?? c.branch.inventory_policy?.enabled === true; + const enabled = req.body?.enabled ?? inventoryEnabled(c.branch); if ( typeof enabled !== 'boolean' || !Array.isArray(actions) || @@ -519,7 +520,7 @@ async function policy(req) { } return { canManage, - enabled: c.branch.inventory_policy?.enabled === true, + enabled: inventoryEnabled(c.branch), approvalActions: permissions(req, c).approvalActions, }; } diff --git a/api/src/services/mobile-pos.js b/api/src/services/mobile-pos.js index d7afa3842..a8d2a9764 100644 --- a/api/src/services/mobile-pos.js +++ b/api/src/services/mobile-pos.js @@ -1,4 +1,5 @@ 'use strict'; +const inventoryEnabled = require('../utils/mobile-inventory-policy'); // Mobile writes have a durable intent before any effects. Each effect has its // own stable identity, so a lost response or process restart can resume safely @@ -13,7 +14,7 @@ const { fail } = require('../utils/branch-access'); const { allowed } = require('../utils/mobile-pos-access'); const canConnect = (user, branch) => allowed(user, 'sales') || - (allowed(user, 'item', 'read') && (!branch || branch.inventory_policy?.enabled === true)); + (allowed(user, 'item', 'read') && (!branch || inventoryEnabled(branch))); const minor = (n) => Math.round(Number(n || 0) * 100); function canonical(v) { if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'; @@ -220,7 +221,7 @@ async function bootstrap(req) { upiAccounts: c.config.upiAccounts, defaultUpiAccountId: c.config.defaultUpiAccountId || undefined, capabilities: { - inventory: c.branch.inventory_policy?.enabled === true && allowed(req.user, 'item', 'read'), + inventory: inventoryEnabled(c.branch) && allowed(req.user, 'item', 'read'), saleSync: true, devicePairing: true, tillPrint: true, diff --git a/api/src/utils/mobile-inventory-policy.js b/api/src/utils/mobile-inventory-policy.js new file mode 100644 index 000000000..cef01b070 --- /dev/null +++ b/api/src/utils/mobile-inventory-policy.js @@ -0,0 +1,5 @@ +'use strict'; + +// Available by default; an explicit administrator opt-out survives upgrades. +module.exports = (branch) => + branch?.inventory_policy?.enabled == null || branch.inventory_policy.enabled === true; diff --git a/api/tests/mobile-inventory.integration.cjs b/api/tests/mobile-inventory.integration.cjs index 9263ac2e9..32e91e351 100644 --- a/api/tests/mobile-inventory.integration.cjs +++ b/api/tests/mobile-inventory.integration.cjs @@ -173,9 +173,9 @@ test('inventory-only staff can enroll without gaining selling permission', async .updateOne({ _id: row.branch_id }, { $set: { module_mobile_pos_enable: false } }); await assert.rejects(inventory.catalogue(req), { status: 403 }); }); -test('approval policy is administrator-only and persists explicit opt-in', async () => { +test('approval policy is administrator-only and persists selected approval actions', async () => { assert.deepEqual((await inventory.policy(req)).approvalActions, []); - assert.equal((await inventory.policy(req)).enabled, false); + assert.equal((await inventory.policy(req)).enabled, true); req.method = 'POST'; req.body = { enabled: true, approvalActions: ['damage'] }; assert.deepEqual((await inventory.policy(req)).approvalActions, ['damage']); @@ -193,16 +193,14 @@ test('stock history reads the shared desktop audit and remains branch scoped', a assert.equal(history.rows[0].opening, '10'); assert.equal(history.rows[0].closing, '15'); assert.equal(history.rows[0].reason, 'Mobile inventory receive'); - await db - .collection('stocklogs') - .insertOne({ - _id: new ObjectId(), - view_item_id: row._id, - license: row.license, - branch_id: new ObjectId(), - created_date: new Date(), - process: 'Other branch', - }); + await db.collection('stocklogs').insertOne({ + _id: new ObjectId(), + view_item_id: row._id, + license: row.license, + branch_id: new ObjectId(), + created_date: new Date(), + process: 'Other branch', + }); assert.equal((await inventory.history(req)).rows.length, 1); }); test('catalogue is fully paged, including categories and suppliers from the server', async () => { @@ -267,3 +265,26 @@ test('null lines and case-variant duplicate ObjectIds are rejected before writin ]; assert.throws(() => inventory.validate(body), { status: 400 }); }); + +test('mobile inventory defaults on, preserves explicit off and existing approval choices', async () => { + req.method = 'GET'; + assert.equal((await inventory.policy(req)).enabled, true); + req.handsetDevice = 'test-phone'; + assert.equal((await inventory.catalogue(req)).rows.length, 1); + await db.collection('branches').updateOne( + { _id: row.branch_id }, + { + $set: { inventory_policy: { enabled: false, approvalActions: ['count'] } }, + } + ); + assert.equal((await inventory.policy(req)).enabled, false); + assert.deepEqual((await inventory.policy(req)).approvalActions, ['count']); + await assert.rejects(inventory.catalogue(req), { status: 403 }); + req.method = 'POST'; + req.body = { approvalActions: ['count'] }; + assert.equal((await inventory.policy(req)).enabled, false); + req.body.enabled = true; + assert.equal((await inventory.policy(req)).enabled, true); + assert.equal((await inventory.catalogue(req)).rows.length, 1); + assert.equal((await db.collection('items').findOne({ _id: row._id })).available_quantity, 10); +}); diff --git a/docs/MOBILE_INVENTORY_IMPLEMENTATION.md b/docs/MOBILE_INVENTORY_IMPLEMENTATION.md index ee4bd7e42..70b8e8faf 100644 --- a/docs/MOBILE_INVENTORY_IMPLEMENTATION.md +++ b/docs/MOBILE_INVENTORY_IMPLEMENTATION.md @@ -2,7 +2,7 @@ The mobile client and POS server must both include this change. Beta 20 does not include inventory. This document records implemented scope; it does not mark the complete inventory roadmap finished. -Mobile inventory is off by default. An administrator enables **Enable mobile inventory (pilot)** under **Core Settings → Items/Inventory**, then refreshes the phone's connection/catalogue. Existing explicit approval choices are preserved. +Mobile inventory is enabled by default for new branches and branches without a saved choice. An explicit saved disable remains off. Administrators manage **Enable mobile inventory** and approval actions under **Core Settings → Inventory → Mobile inventory**, then refresh the phone's connection/catalogue. Existing approval choices and role permissions are preserved. ## Implemented @@ -12,7 +12,7 @@ Mobile inventory is off by default. An administrator enables **Enable mobile inv - Persistent partial-count, direct-receiving, damage and loss drafts. Counted zero differs from an item never added to a count. Quantities use thousandths, with whole-unit validation. - Server prepare/review/confirm/status/cancel endpoints. A durable request identity and atomic per-item effect prevent duplicate stock changes after a lost reply or restart. Conflicted operations report applied and unapplied lines separately. - Current server ACL and branch/tenant checks, including approval policy rechecking at confirmation. An unchanged count uses an atomic expected-quantity/date/revision check; a changed item requires another observation. Pending sales on this phone block count confirmation. -- Direct confirmation by default. Administrators can require approval for selected actions under Core Settings → Items/Inventory. Owners/admins approve in Inventory → Stock Counts or in the phone's operation history. +- Direct confirmation by default. Administrators can require approval for selected actions under Core Settings → Inventory → Mobile inventory. Owners/admins approve in Inventory → Stock Counts or in the phone's operation history. - Stock changes update the existing item balance and stocklogs; the normal POS sync transports these records. Operation journals remain on the issuing server and are never replayed against a different server. - Mobile labels and messages in all 18 app languages (beta translations; native-speaker review remains necessary). @@ -33,3 +33,9 @@ Local tests use a disposable MongoDB database, never production shop stock: - Mobile: `npm run check`, web export and Playwright tests including receiving/review/confirm and saved zero counts across restart. Device acceptance still needs: offline count/restart, reconnect, interrupted confirmation, actual cashier/admin roles, branch isolation, fractional quantities, camera permission recovery and the installed desktop's sync behavior. + +## Settings rollout and synchronization + +The policy remains `branches.inventory_policy`, scoped to the selected licensed branch. Saving it updates `updated_date`; POS and Gateway classify branches as global tenant data, and the sync agent carries branches on its existing batch lane. No new collection or transport is introduced. Saved true/false and approval choices travel with the branch; unset is interpreted consistently by bootstrap, inventory access, policy reads and the branch model after this update. Operation journals remain local to their issuing server. + +Deploy the POS API and frontend together, then update connected tills to the same implementation. Refresh the mobile snapshot after changing policy. The menu is a server/web change and does not require a new APK. Mixed-version servers may interpret an unset policy differently until upgraded; saving an explicit choice avoids that ambiguity. Live cloud-to-till and other-till delivery is not verified by the local API tests. diff --git a/frontend/modules/settings_write.html b/frontend/modules/settings_write.html index 28aae6323..ab3bfd8bd 100644 --- a/frontend/modules/settings_write.html +++ b/frontend/modules/settings_write.html @@ -5149,6 +5149,7 @@
General Stock preference +Mobile inventory Expiry reminders @@ -5210,6 +5211,7 @@

Applies to new items in this branch. Existing items keep their stock settings. You can still mark an item sold out manually.

+
Expiry reminders

Reminders appear in the POS bell for products with stock and a saved expiry date. Dates use the shop timezone. No email or WhatsApp messages are sent.

diff --git a/frontend/static/script/js/modules/js/item-settings-report.js b/frontend/static/script/js/modules/js/item-settings-report.js index 6344c1083..a2d58636a 100644 --- a/frontend/static/script/js/modules/js/item-settings-report.js +++ b/frontend/static/script/js/modules/js/item-settings-report.js @@ -15,11 +15,11 @@ }, load: function () { if (!$('#inventory_approval_settings').length) { - var panel = $('
Inventory confirmation

Staff with item write permission confirm directly by default. Require manager approval for selected mobile inventory actions:

'); - $('').appendTo(panel.find('.card-body')); + var panel = $('
Mobile inventory

Staff with item write permission confirm directly by default. Require manager approval for selected mobile inventory actions:

'); + $('').appendTo(panel.find('.card-body')); ['count','receive','damage','loss'].forEach(function (mode) { $('