diff --git a/README.md b/README.md index 2cd6773f6..7a8136797 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 | 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..e9dfbbe22 --- /dev/null +++ b/api/src/services/item-availability.js @@ -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 }; 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..4abb02619 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 }; @@ -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 } }; } /** 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/api/tests/unit/models/item.model.test.js b/api/tests/unit/models/item.model.test.js index ba3ff68a4..e64ecf36c 100644 --- a/api/tests/unit/models/item.model.test.js +++ b/api/tests/unit/models/item.model.test.js @@ -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), diff --git a/api/tests/unit/utils/item-channels.test.js b/api/tests/unit/utils/item-channels.test.js index 7a16612f5..732b988c2 100644 --- a/api/tests/unit/utils/item-channels.test.js +++ b/api/tests/unit/utils/item-channels.test.js @@ -40,11 +40,21 @@ describe('an item is sold everywhere unless it says otherwise', () => { /* $ne rather than an $in on a list of channels: absent has to mean included, or a shop with two hundred items sells nothing until it has edited all of them. */ - expect(channelFilter('online')).toEqual({ channel_off: { $ne: 'online' } }); - expect(channelFilter('')).toEqual({}); + expect(channelFilter('online')).toEqual({ + available_for_sale: { $ne: false }, + channel_off: { $ne: 'online' }, + }); + expect(channelFilter('')).toEqual({ available_for_sale: { $ne: false } }); }); }); +test('global availability overrides every channel without changing channel configuration', () => { + for (const channel of ['', 'online', 'kiosk', 'pos']) { + expect(availableOn({ available_for_sale: false }, channel).available).toBe(false); + expect(availableOn({ available_for_sale: true }, channel).available).toBe(true); + } +}); + describe('per-channel hours', () => { const lunch = { channel_hours: { online: { from: '11:00', to: '15:00' } } }; diff --git a/docs/API.md b/docs/API.md index c026ff575..68ff58a71 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,7 +3,7 @@ Generated from `api/src/routes/` by `npm run docs:api`. Do not edit this file by hand; edit the routes and regenerate. -**895 endpoints across 53 route groups.** +**897 endpoints across 53 route groups.** All paths below are relative to the API root. On a desktop install the API runs on a port derived per installation (see `local-ports.js`); in the cloud it sits @@ -15,8 +15,8 @@ behind the tenant hostname. Postman, Insomnia or Bruno, point Swagger UI or Redoc at it, or generate a client from it. It is OpenAPI 3.0.3. -**104 of 895 endpoints carry a request schema** (12%), recovered from -their `express-validator` middleware. The remaining 791 are listed with +**104 of 897 endpoints carry a request schema** (12%), recovered from +their `express-validator` middleware. The remaining 793 are listed with path, method and handler but no body, because inventing a schema is worse than admitting the gap. @@ -733,9 +733,11 @@ Mounted at `/mobile/v1`. Source: `api/src/routes/mobile-pos.routes.js`. | GET | `/mobile/v1/discovery` | — | `Boolean` | | POST | `/mobile/v1/enrolment-proof` | — | `hex` | | POST | `/mobile/v1/pair` | — | `handsetLifetimeSeconds` | +| GET | `/mobile/v1/availability-status` | — | `availability` | | GET | `/mobile/v1/inventory/policy` | — | `inventory.policy` | | POST | `/mobile/v1/inventory/policy` | — | `inventory.policy` | | GET | `/mobile/v1/inventory/catalogue` | — | `inventory.catalogue` | +| POST | `/mobile/v1/inventory/availability` | — | `availability` | | GET | `/mobile/v1/inventory/items/:id/history` | — | `inventory.history` | | GET | `/mobile/v1/inventory/requests/:id` | — | `inventory.requestStatus` | | POST | `/mobile/v1/inventory/requests/:id/cancel` | — | `inventory.abandonRequest` | diff --git a/docs/openapi.json b/docs/openapi.json index 2d5099212..cb78b12ff 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -13024,6 +13024,29 @@ "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." } }, + "/mobile/v1/availability-status": { + "get": { + "summary": "availability", + "operationId": "get_mobile_v1_availability_status", + "tags": [ + "Mobile pos" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + } + } + }, "/mobile/v1/inventory/policy": { "get": { "summary": "policy", @@ -13092,6 +13115,30 @@ } } }, + "/mobile/v1/inventory/availability": { + "post": { + "summary": "availability", + "operationId": "post_mobile_v1_inventory_availability", + "tags": [ + "Mobile pos" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + }, + "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." + } + }, "/mobile/v1/inventory/items/{id}/history": { "get": { "summary": "history", diff --git a/docs/product-availability.md b/docs/product-availability.md new file mode 100644 index 000000000..182b379d2 --- /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. Gateway PR #45 is merged and deployed; signed sync-agent 1.7.4 is published on the beta channel. Mobile beta 26 and the coordinated POS test installer are being prepared. Public installers acquire the private agent after activation through the signed component feed. + +## 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. Mobile availability copy is included in all 18 supported languages. Desktop language packs include the new keys; automated translations remain subject to native-speaker review. diff --git a/frontend/modules/items.html b/frontend/modules/items.html index 92099786f..7360a140a 100644 --- a/frontend/modules/items.html +++ b/frontend/modules/items.html @@ -19,6 +19,7 @@

It -->