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
2 changes: 1 addition & 1 deletion api/src/models/branch.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
9 changes: 5 additions & 4 deletions api/src/services/mobile-inventory.js
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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 (
Expand Down Expand Up @@ -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) ||
Expand All @@ -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,
};
}
Expand Down
5 changes: 3 additions & 2 deletions api/src/services/mobile-pos.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(',') + ']';
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions api/src/utils/mobile-inventory-policy.js
Original file line number Diff line number Diff line change
@@ -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;
45 changes: 33 additions & 12 deletions api/tests/mobile-inventory.integration.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
10 changes: 8 additions & 2 deletions docs/MOBILE_INVENTORY_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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).

Expand All @@ -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.
2 changes: 2 additions & 0 deletions frontend/modules/settings_write.html
Original file line number Diff line number Diff line change
Expand Up @@ -5149,6 +5149,7 @@ <h5 class="card-title mb-0">
<a class="nav-link active" data-toggle="pill" href="#inv-sub-general" role="tab"><i class="feather icon-sliders mr-2"></i><lang class="lang_common_settings">General</lang></a>

<a class="nav-link" data-toggle="pill" href="#inv-sub-stock" role="tab"><i class="feather icon-box mr-2"></i><lang class="lang_stock_preference">Stock preference</lang></a>
<a class="nav-link" data-toggle="pill" href="#inv-sub-mobile" role="tab"><i class="feather icon-smartphone mr-2"></i><lang class="lang_mobile_inventory">Mobile inventory</lang></a>
<a class="nav-link" data-toggle="pill" href="#inv-sub-expiry" role="tab"><i class="feather icon-bell mr-2"></i><lang class="lang_expiry_reminders">Expiry reminders</lang></a>
</div>
</div>
Expand Down Expand Up @@ -5210,6 +5211,7 @@ <h5 class="card-title mb-0">
<p><lang class="lang_applies_to_new_items_in_this_branch_existi">Applies to new items in this branch. Existing items keep their stock settings. You can still mark an item sold out manually.</lang></p>
<button class="btn btn-primary" type="button" id="item_settings_save_stock"><lang class="lang_save_title">Save</lang></button></div></div>
</div>
<div class="tab-pane fade" id="inv-sub-mobile" role="tabpanel"><div id="mobile_inventory_settings"></div></div>
<div class="tab-pane fade" id="inv-sub-expiry" role="tabpanel">
<div class="card"><div class="card-header"><h5><lang class="lang_expiry_reminders">Expiry reminders</lang></h5></div><div class="card-body">
<p><lang class="lang_expiry_reminders_help">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.</lang></p>
Expand Down
6 changes: 3 additions & 3 deletions frontend/static/script/js/modules/js/item-settings-report.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
},
load: function () {
if (!$('#inventory_approval_settings').length) {
var panel = $('<div id="inventory_approval_settings" class="card mt-3"><div class="card-body"><h5><lang class="lang_inventory_confirmation">Inventory confirmation</lang></h5><p>Staff with item write permission confirm directly by default. Require manager approval for selected mobile inventory actions:</p></div></div>');
$('<label class="d-block"><input type="checkbox" id="mobile_inventory_enabled"> Enable mobile inventory (pilot)</label>').appendTo(panel.find('.card-body'));
var panel = $('<div id="inventory_approval_settings" class="card mt-3"><div class="card-body"><h5><lang class="lang_mobile_inventory">Mobile inventory</lang></h5><p>Staff with item write permission confirm directly by default. Require manager approval for selected mobile inventory actions:</p></div></div>');
$('<label class="d-block"><input type="checkbox" id="mobile_inventory_enabled"> Enable mobile inventory</label>').appendTo(panel.find('.card-body'));
['count','receive','damage','loss'].forEach(function (mode) { $('<label class="mr-3">').append($('<input type="checkbox" class="inventory-approval-mode">').val(mode)).append(document.createTextNode(' ' + mode)).appendTo(panel.find('.card-body')); });
$('<button type="button" class="btn btn-primary d-block mt-3" id="inventory_approval_save"><lang class="lang_save_inventory_policy">Save inventory policy</lang></button>').appendTo(panel.find('.card-body'));
panel.insertAfter('#item_expiry_settings');
panel.appendTo('#mobile_inventory_settings');
}
PosnicPro.get('mobile/v1/inventory/policy', function (r) {
if (!Array.isArray(r.approvalActions)) return;
Expand Down
1 change: 1 addition & 0 deletions languages/_english.json
Original file line number Diff line number Diff line change
Expand Up @@ -1938,6 +1938,7 @@
"lang_inv": "INV-",
"lang_invalid_data": "Invalid data...!!!",
"lang_invenlog_title": "Inventory Logs",
"lang_mobile_inventory": "Mobile inventory",
"lang_inventory_confirmation": "Inventory confirmation",
"lang_inventory_count": "Inventory count",
"lang_inventory_log": "Inventory Log",
Expand Down
Loading