From 7b303f81ff0cc223726c554c0ba9153fe4d15de6 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Wed, 16 Sep 2026 00:43:21 +0530 Subject: [PATCH 1/7] A customer is told when the kitchen usually has it ready They place an order, get a token number, and then hear nothing. The list of this phone's orders said "With the kitchen" and that was the whole of it - which is the moment somebody walks up to the counter to ask, the one interruption an ordering channel exists to remove. Every food app they have used shows a time. Nothing here knows when food is actually finished: no cook marks a ticket done, so there is no ready signal and this does not invent one. It is an estimate from two things the shop really has told us - the slowest dish on the order, and the queue that was ahead of it - and the word "usually" does real work in the sentence. The slowest dish and not the sum of them. A kitchen does not cook the biryani, then the naan, then the dal; several hands work at once and the order leaves the pass when the slowest thing is done. Adding them up would quote an hour for a meal that takes twenty-five minutes, and an estimate always wrong in the same direction stops being read. Frozen at the moment it is made, and stored on the order. A figure recomputed on every refresh would creep as other orders arrive, and a promise that moves while somebody watches it is worse than one that is a little wrong. It stays silent wherever it would be guessing: a shop that has stated no prep times, an order still waiting for the shop to accept it (which says a length instead, because a clock time before anybody has started cooking is a fiction), a cancelled order, and a time that has already passed - an estimate counting backwards at somebody still waiting is worse than none. Found while wiring it: the estimate was computed after the document that uses it, which is a temporal dead zone and would have thrown on every single online order. --- api/src/repositories/sale.repository.js | 83 ++++++++++ api/src/utils/ready-by.js | 92 ++++++++++++ api/tests/unit/utils/ready-by.test.js | 115 ++++++++++++++ menu/i18n.js | 3 + order/assets/history/script.js | 13 ++ order/assets/i18n.js | 3 + order/assets/order.css | 14 ++ order/indexedDB.js | 36 +++++ tests/when-will-it-be-ready.test.js | 191 ++++++++++++++++++++++++ 9 files changed, 550 insertions(+) create mode 100644 api/src/utils/ready-by.js create mode 100644 api/tests/unit/utils/ready-by.test.js create mode 100644 tests/when-will-it-be-ready.test.js diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index ca633df9d..68145c118 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -41,6 +41,8 @@ async function withDayparts(shop) { const { notifyOrderAttention } = require('../helpers/order-attention'); const orderApproval = require('../utils/order-approval'); const spiceLevel = require('../utils/spice-level'); +const readyBy = require('../utils/ready-by'); +const { kitchenLoad, typicalRound } = require('../utils/kitchen-load'); const StockLogsRepository = require('./stock-log.repository'); const { PAYMENT_STATUS } = require('../constants'); const moment = require('moment-timezone'); @@ -8136,6 +8138,45 @@ class SalesRepository { const salesCollection = db.collection('sales'); const clientRecord = this._clientFacts(client); + /* + * The estimate, from what this shop has actually told us: the slowest + * dish on this order, plus what the queue was adding when it arrived. + * + * Non-fatal on purpose, like every other read in this method that is + * not the order itself. An order that saves without an estimate is an + * order; one that fails to save because a count timed out is a customer + * standing in a hotel room with no dinner. + */ + let readyMinutes = 0; + try { + let queueMinutes = 0; + if (branchDoc.table_options === true) { + const tableCount = await ( + await this.getCollection('tableorder') + ).countDocuments( + branchDoc.license + ? { branch_id: branchObjectId, license: branchDoc.license } + : { branch_id: branchObjectId } + ); + const openFilter = { + sale_process: { $regex: 'KOT', $options: 'i' }, + payment_status: 'Unpaid', + bill_printed_at: { $in: [null, undefined] }, + branch_id: branchObjectId, + }; + if (branchDoc.license) openFilter.license = branchDoc.license; + queueMinutes = kitchenLoad({ + tableService: true, + open: await salesCollection.countDocuments(openFilter), + capacity: tableCount, + round: typicalRound(saleItems.map((line) => line.prep_minutes)), + }).extra_minutes; + } + readyMinutes = readyBy.cookingMinutes({ lines: saleItems, queueMinutes }); + } catch (e) { + console.warn('[online order] could not estimate the wait:', e.message); + } + const saleDocument = { /* What makes a resend safe. Absent on orders taken before this shipped, which is why the lookup above is skipped without one. */ @@ -8266,6 +8307,22 @@ class SalesRepository { ...(clientRecord ? { client: clientRecord } : {}), // Initial change log entry for KOT printing changes: changesItems.length ? [{ timestamp: now, items: changesItems }] : [], + /* + * HOW LONG THE KITCHEN SHOULD TAKE, worked out once and kept. + * + * A customer places an order, gets a token and hears nothing; on every + * food app they have used, the next thing they see is a time. Nothing + * here knows when food is actually FINISHED - no cook marks a ticket + * done - so this is an estimate from the shop's own numbers and is + * offered as one: the slowest dish on the order, plus whatever the + * queue was when it arrived. + * + * Frozen at this moment rather than recomputed on every refresh: a + * promise that moves while somebody watches it is worse than one that + * is a little wrong. 0 means the shop has stated no prep times and the + * page says nothing at all. See utils/ready-by.js. + */ + ready_minutes: readyMinutes, }; /* A number taken a moment ago is taken again, not handed to the @@ -9365,6 +9422,9 @@ class SalesRepository { tax_fields: itemDoc.tax_fields || [], item_description: String(item.item_description || itemDoc.description || ''), spice_level: spiceLevel.levelOf(item.spice_level), + /* Same reason as the priced line: an added dish keeps the time + the kitchen said it took on the day it was added. */ + prep_minutes: Number(itemDoc.prep_minutes) || 0, track_inventory: itemDoc.track_inventory || false, negative_stock: itemDoc.negative_stock || false, }); @@ -9781,6 +9841,13 @@ class SalesRepository { * nonsense gets no promise made about somebody's food. */ spice_level: spiceLevel.levelOf(item.spice_level), + /* + * How long the kitchen says this dish takes, copied onto the line at + * the moment of ordering. On the LINE rather than looked up later + * because the shop may retime a section next week, and an order + * already placed should keep the estimate it was given. + */ + prep_minutes: Number(itemDoc.prep_minutes) || 0, // receipt-facing fields item_base_price: round(baseUnitPrice), item_quantity: qty, @@ -9895,6 +9962,22 @@ class SalesRepository { */ bill_no: String(order.sales_id || ''), placed_at: order.created_date || order.date || null, + /* + * When the kitchen usually has this ready, and how long that is. + * + * Counted from when the kitchen was TOLD, which on a shop that holds + * orders for approval is not when the order was placed - counting from + * the tap would have the food ready before anybody started it. + * + * Both sent: the instant for a clock time, the minutes for a page that + * would rather say "about 25 minutes". Empty and zero mean the shop has + * stated no prep times, and the page then says nothing. + */ + ready_minutes: Number(order.ready_minutes) || 0, + ready_by: readyBy.readyBy( + order.order_state_at || order.created_date || order.date, + order.ready_minutes + ), /* The three words a customer actually wants: is it off, is it paid, has the shop accepted it. */ state: cancelled ? 'cancelled' : String(order.order_state || 'accepted'), diff --git a/api/src/utils/ready-by.js b/api/src/utils/ready-by.js new file mode 100644 index 000000000..8b11e9491 --- /dev/null +++ b/api/src/utils/ready-by.js @@ -0,0 +1,92 @@ +'use strict'; +/* + * When the kitchen usually has an order ready. + * + * A customer places an order, gets a token number, and then hears nothing. On + * every food app they have ever used, the next thing they see is a time. Here + * they see "With the kitchen" and are left to guess, which is the point at + * which somebody walks up to the counter to ask - the one interruption an + * ordering channel exists to remove. + * + * WHAT WE CAN HONESTLY SAY, AND WHAT WE CANNOT. + * + * Nothing in this product knows when food is actually finished. No cook marks + * a ticket done, so there is no "ready" signal to report and this does not + * pretend there is one. What the shop HAS told us is how long each dish takes + * and how many tickets are ahead of this one, and those two together are an + * honest estimate - offered as one, in the shop's own numbers, and withheld + * entirely when the numbers are not there. + * + * THE LONGEST DISH, NOT THE SUM. + * + * A kitchen does not cook a biryani, then a naan, then a dal. Several hands + * work at once and the order leaves the pass when the SLOWEST thing on it is + * done. Adding the dishes up would quote an hour for a meal that takes + * twenty-five minutes, and an estimate that is always wrong in the same + * direction is worse than no estimate: people stop reading it. + * + * FROZEN AT THE MOMENT IT IS MADE. + * + * The number is worked out once and stored on the order. A figure recomputed + * on every refresh would creep as other orders arrive, and a promise that + * moves while somebody watches it is worse than one that is a little wrong - + * they can plan around wrong; they cannot plan around moving. + * + * NO DATABASE IMPORTS. + */ + +/** Minutes, to the nearest five: a wait is not a train timetable. */ +function toFive(minutes) { + return Math.round(minutes / 5) * 5; +} + +/** + * How long this order should take the kitchen, or 0 when nobody can say. + * + * @param {object} order + * lines the sale lines, each with the dish's stated prep_minutes + * queueMinutes what the queue adds, from kitchenLoad; 0 when not busy + * @returns {number} minutes, or 0 meaning "do not say anything" + */ +function cookingMinutes({ lines, queueMinutes } = {}) { + const stated = (Array.isArray(lines) ? lines : []) + .map((line) => Number(line && line.prep_minutes)) + .filter((n) => Number.isFinite(n) && n > 0); + + /* + * NOT ONE DISH OF THIS ORDER SAYS HOW LONG IT TAKES. + * + * Then there is no estimate. Falling back to the queue alone would quote a + * customer the time their food spends WAITING and none of the time it spends + * cooking, which is a smaller number than the truth and the worst kind of + * wrong to be. Same rule as the health badges: say nothing rather than + * something the numbers do not support. + */ + if (!stated.length) return 0; + + const slowest = Math.max(...stated); + const queue = Number(queueMinutes); + const waiting = Number.isFinite(queue) && queue > 0 ? queue : 0; + return toFive(slowest + waiting); +} + +/** + * The clock time to show, as an instant. + * + * Counted from when the KITCHEN was told, which is not always when the order + * was placed: a shop that holds orders for approval may accept one twenty + * minutes later, and counting from the tap would have the food ready before + * anybody started it. + * + * @param {Date|string} startedAt when the kitchen was told + * @param {number} minutes from cookingMinutes + * @returns {string} an ISO instant, or '' when there is nothing to say + */ +function readyBy(startedAt, minutes) { + const at = startedAt instanceof Date ? startedAt : new Date(startedAt || NaN); + const cooking = Number(minutes); + if (isNaN(at.getTime()) || !Number.isFinite(cooking) || cooking <= 0) return ''; + return new Date(at.getTime() + cooking * 60000).toISOString(); +} + +module.exports = { cookingMinutes, readyBy }; diff --git a/api/tests/unit/utils/ready-by.test.js b/api/tests/unit/utils/ready-by.test.js new file mode 100644 index 000000000..328c2ca0a --- /dev/null +++ b/api/tests/unit/utils/ready-by.test.js @@ -0,0 +1,115 @@ +'use strict'; + +/* + * When the kitchen usually has an order ready. + * + * A customer places an order, gets a token number, and then hears nothing. The + * order list says "With the kitchen" and that is the whole of it - which is the + * point at which somebody walks up to the counter to ask, the one interruption + * an ordering channel exists to remove. + * + * What this must never become is a promise nobody can keep. Nothing in this + * product knows when food is actually FINISHED - no cook marks a ticket done - + * so there is no "ready" signal to report and this does not invent one. It + * offers an estimate from two things the shop really has told us, and says + * nothing at all when it has not. + */ + +const { cookingMinutes, readyBy } = require('../../../src/utils/ready-by'); + +const lines = (...mins) => mins.map((prep_minutes) => ({ prep_minutes })); + +describe('how long the kitchen should take', () => { + test('the slowest dish, not the sum of them', () => { + /* + * THE DECISION THIS FILE TURNS ON. A kitchen does not cook the biryani, + * then the naan, then the dal - several hands work at once and the order + * leaves the pass when the slowest thing on it is done. Adding them up + * would quote an hour for a meal that takes twenty-five minutes, and an + * estimate always wrong in the same direction stops being read. + */ + expect(cookingMinutes({ lines: lines(30, 8, 15) })).toBe(30); + }); + + test('the queue is added on top, because it is time the food is not cooking', () => { + expect(cookingMinutes({ lines: lines(25), queueMinutes: 20 })).toBe(45); + }); + + test('it lands on a five', () => { + expect(cookingMinutes({ lines: lines(13), queueMinutes: 0 })).toBe(15); + }); + + test('a dish that states nothing is ignored, not counted as instant', () => { + /* Missing is not zero - the same rule the nutrition figures follow. */ + expect(cookingMinutes({ lines: lines(0, 0, 20) })).toBe(20); + }); +}); + +describe('when it says nothing at all', () => { + test('an order where no dish states a time', () => { + /* + * THE HONESTY RULE, and the case that shaped the whole feature. Falling + * back to the queue alone would quote the customer the time their food + * spends WAITING and none of the time it spends cooking - a smaller number + * than the truth, which is the worst direction to be wrong in. + */ + expect(cookingMinutes({ lines: lines(0, 0), queueMinutes: 20 })).toBe(0); + expect(cookingMinutes({ lines: [] })).toBe(0); + expect(cookingMinutes({})).toBe(0); + }); + + test('nonsense is not a prep time', () => { + expect(cookingMinutes({ lines: lines('soon', -5, NaN, null) })).toBe(0); + expect(cookingMinutes({ lines: lines('soon', 10) })).toBe(10); + }); + + test('a queue that is not a number does not become one', () => { + for (const bad of ['lots', null, undefined, NaN, -10]) { + expect(cookingMinutes({ lines: lines(20), queueMinutes: bad })).toBe(20); + } + }); +}); + +describe('the clock time', () => { + test('counted from when the kitchen was told', () => { + const told = new Date('2026-09-16T14:00:00.000Z'); + expect(readyBy(told, 25)).toBe('2026-09-16T14:25:00.000Z'); + }); + + test('an ISO string is as good as a date', () => { + expect(readyBy('2026-09-16T14:00:00.000Z', 30)).toBe('2026-09-16T14:30:00.000Z'); + }); + + test('no minutes, no time', () => { + /* The page must be able to ask without checking first, and get back + something it can safely draw nothing from. */ + expect(readyBy(new Date(), 0)).toBe(''); + expect(readyBy(new Date(), null)).toBe(''); + expect(readyBy(new Date(), 'soon')).toBe(''); + }); + + test('no start, no time', () => { + expect(readyBy(null, 25)).toBe(''); + expect(readyBy('not a date', 25)).toBe(''); + expect(readyBy(undefined, 25)).toBe(''); + }); +}); + +describe('the whole thing, as an order would use it', () => { + test('a busy kitchen and a slow dish', () => { + /* + * Chicken biryani at thirty minutes, on a kitchen two rounds behind. The + * customer is told an hour rather than half of one, which is the number + * they would otherwise have found out by waiting. + */ + const minutes = cookingMinutes({ lines: lines(30, 8), queueMinutes: 30 }); + expect(minutes).toBe(60); + expect(readyBy('2026-09-16T13:00:00.000Z', minutes)).toBe('2026-09-16T14:00:00.000Z'); + }); + + test('a shop that has entered nothing tells the customer nothing', () => { + const minutes = cookingMinutes({ lines: lines(0, 0), queueMinutes: 30 }); + expect(minutes).toBe(0); + expect(readyBy(new Date(), minutes)).toBe(''); + }); +}); diff --git a/menu/i18n.js b/menu/i18n.js index 218ed7ede..d1c4ec0da 100644 --- a/menu/i18n.js +++ b/menu/i18n.js @@ -172,6 +172,9 @@ "The kitchen is busy. Expect about {n} minutes longer than usual.": "சமையலறை பரபரப்பாக உள்ளது. வழக்கத்தை விட சுமார் {n} நிமிடங்கள் கூடுதலாக ஆகலாம்.", "The kitchen is very busy. Expect over an hour longer than usual.": "சமையலறை மிகவும் பரபரப்பாக உள்ளது. வழக்கத்தை விட ஒரு மணி நேரத்திற்கு மேல் ஆகலாம்.", "The kitchen is busy right now, so your order may take longer than usual.": "இப்போது சமையலறை பரபரப்பாக உள்ளதால், உங்கள் ஆர்டருக்கு வழக்கத்தை விட நேரம் ஆகலாம்.", + /* ---------------------------------------- when it will be ready */ + "Usually ready by about {when}": "வழக்கமாக {when} மணிக்கு தயாராகிவிடும்", + "About {n} minutes once the shop accepts it": "கடை ஏற்றதும் பிறகு சுமார் {n} நிமிடங்கள்", "Less spicy, no onion, extra gravy...": "காரம் குறைவாக, வெங்காயம் வேண்டாம், கூடுதல் குழம்பு...", "A note for the kitchen": "சமையலறைக்கு ஒரு குறிப்பு", "A note for the shop": "கடைக்கு ஒரு குறிப்பு", diff --git a/order/assets/history/script.js b/order/assets/history/script.js index 5e9dd174a..0e72fc952 100644 --- a/order/assets/history/script.js +++ b/order/assets/history/script.js @@ -368,6 +368,18 @@ head.appendChild(shop); head.appendChild(at); + /* + * When the kitchen usually has it ready. Drawn above the dishes + * because it is the one thing somebody opens this page to find out, + * and left out entirely when the shop has stated no prep times - see + * readyByWords in indexedDB.js. + */ + const ready = document.createElement("p"); + ready.className = "history-ready"; + const readyWords = typeof readyByWords === "function" ? readyByWords(said) : ""; + ready.textContent = readyWords; + ready.hidden = !readyWords; + const what = document.createElement("p"); what.className = "history-what"; what.textContent = lineWords((said && said.items && said.items.length ? said.items : kept.items) || []); @@ -404,6 +416,7 @@ open.setAttribute("aria-expanded", "false"); open.setAttribute("aria-controls", "details-" + kept.orderId); open.appendChild(head); + open.appendChild(ready); open.appendChild(what); open.appendChild(foot); diff --git a/order/assets/i18n.js b/order/assets/i18n.js index 218ed7ede..d1c4ec0da 100644 --- a/order/assets/i18n.js +++ b/order/assets/i18n.js @@ -172,6 +172,9 @@ "The kitchen is busy. Expect about {n} minutes longer than usual.": "சமையலறை பரபரப்பாக உள்ளது. வழக்கத்தை விட சுமார் {n} நிமிடங்கள் கூடுதலாக ஆகலாம்.", "The kitchen is very busy. Expect over an hour longer than usual.": "சமையலறை மிகவும் பரபரப்பாக உள்ளது. வழக்கத்தை விட ஒரு மணி நேரத்திற்கு மேல் ஆகலாம்.", "The kitchen is busy right now, so your order may take longer than usual.": "இப்போது சமையலறை பரபரப்பாக உள்ளதால், உங்கள் ஆர்டருக்கு வழக்கத்தை விட நேரம் ஆகலாம்.", + /* ---------------------------------------- when it will be ready */ + "Usually ready by about {when}": "வழக்கமாக {when} மணிக்கு தயாராகிவிடும்", + "About {n} minutes once the shop accepts it": "கடை ஏற்றதும் பிறகு சுமார் {n} நிமிடங்கள்", "Less spicy, no onion, extra gravy...": "காரம் குறைவாக, வெங்காயம் வேண்டாம், கூடுதல் குழம்பு...", "A note for the kitchen": "சமையலறைக்கு ஒரு குறிப்பு", "A note for the shop": "கடைக்கு ஒரு குறிப்பு", diff --git a/order/assets/order.css b/order/assets/order.css index d8eed0b1e..ff139bf7f 100644 --- a/order/assets/order.css +++ b/order/assets/order.css @@ -1981,6 +1981,20 @@ dialog.sheet:has(.sheet-gallery:not([hidden])) .sheet-handle { cursor: pointer; } +/* + * When the kitchen usually has it ready. + * + * Above the dishes and in ink rather than a colour: it is the answer somebody + * opened this page for, not a warning. Hidden entirely when the shop has + * stated no prep times, so the row simply looks the way it always has. + */ +.history-ready { + margin: 2px 0 6px; + font-size: 14px; + font-weight: 600; + color: var(--ink); +} + /* What was chosen, read back on the line in the basket. */ .item-spice { margin-top: 4px; diff --git a/order/indexedDB.js b/order/indexedDB.js index 57fe223d2..e3d224ffe 100644 --- a/order/indexedDB.js +++ b/order/indexedDB.js @@ -1898,6 +1898,42 @@ function catalogueItem(item, categoryName) { * prep times can support them, and this says the weaker true thing when they * cannot - the same rule the health badges follow. */ +/* + * "USUALLY READY BY ABOUT QUARTER PAST EIGHT." + * + * A customer places an order, gets a token number and then hears nothing. On + * every food app they have ever used the next thing they see is a time; here + * the list said "With the kitchen" and left them to guess, which is when + * somebody walks up to the counter to ask - the one interruption an ordering + * channel exists to remove. + * + * SAID AS AN ESTIMATE, BECAUSE THAT IS WHAT IT IS. Nothing in this product + * knows when food is actually finished - no cook marks a ticket done - so the + * server works it out from the slowest dish on the order and the queue that + * was ahead of it, and says nothing at all when the shop has stated no prep + * times. "Usually" is doing real work in that sentence and is not padding. + * + * The clock is the CUSTOMER'S, from an instant the server sent: a guest + * ordering from a hotel in another timezone reads their own watch, not the + * shop's. + */ +function readyByWords(order) { + if (!order || order.cancelled === true) return ""; + var at = order.ready_by ? new Date(order.ready_by) : null; + if (!at || isNaN(at.getTime())) return ""; + /* Past already, and still nothing served: a time that has been and gone + is worse than no time, so it stops being shown rather than counting + backwards at somebody waiting. */ + if (at.getTime() < Date.now()) return ""; + var clock = at.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + /* A shop that has not accepted the order yet has not started cooking, so + the clock would be a fiction. Say the length instead. */ + if (order.state === "pending") { + return t("About {n} minutes once the shop accepts it", { n: Number(order.ready_minutes) || 0 }); + } + return t("Usually ready by about {when}", { when: clock }); +} + function kitchenNoticeHtml(kitchen) { if (!kitchen || kitchen.busy !== true) return ""; var minutes = Number(kitchen.extra_minutes) || 0; diff --git a/tests/when-will-it-be-ready.test.js b/tests/when-will-it-be-ready.test.js new file mode 100644 index 000000000..9a905eaa3 --- /dev/null +++ b/tests/when-will-it-be-ready.test.js @@ -0,0 +1,191 @@ +'use strict'; + +/* + * Telling a customer when their food will be ready. + * + * They place an order, get a token number, and then hear nothing. The list of + * this phone's orders said "With the kitchen" and that was the whole of it - + * which is the moment somebody walks up to the counter to ask, the single + * interruption an ordering channel exists to remove. Every food app they have + * ever used shows a time. + * + * WHAT IS NOT BEING BUILT HERE. Nothing in this product knows when food is + * actually finished: no cook marks a ticket done, so there is no "ready" + * signal and this does not invent one. The server estimates from the slowest + * dish on the order plus the queue that was ahead of it, both of which the + * shop really told us, and the page says nothing whenever it would be + * guessing. "Usually" is doing real work in that sentence. + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const ROOT = path.join(__dirname, '..'); +const DB = fs.readFileSync(path.join(ROOT, 'order', 'indexedDB.js'), 'utf8'); + +/** A named function, lifted out of the bundle by brace matching. */ +function lift(name) { + const at = DB.indexOf('function ' + name + '('); + assert.ok(at !== -1, name + ' is not in order/indexedDB.js'); + let depth = 0; + let i = DB.indexOf('{', at); + for (; i < DB.length; i += 1) { + if (DB[i] === '{') depth += 1; + else if (DB[i] === '}') { + depth -= 1; + if (depth === 0) break; + } + } + return DB.slice(at, i + 1); +} + +/** The real sentence-writer, with the customer's own clock. */ +function words(order, now) { + const dom = new JSDOM('', { runScripts: 'outside-only' }); + const { window } = dom; + window.eval( + 'function t(text, vars) {' + + ' return String(text).replace(/\\{(\\w+)\\}/g, function (_, k) {' + + ' return vars && vars[k] != null ? vars[k] : "{" + k + "}"; }); }' + ); + if (now) { + window.eval('Date.now = function () { return ' + new Date(now).getTime() + '; };'); + } + window.eval(lift('readyByWords')); + return window.readyByWords(order); +} + +const SOON = '2026-09-16T19:15:00.000Z'; +const NOW = '2026-09-16T18:45:00.000Z'; + +/* --------------------------------------------------------- when it speaks */ + +test('an accepted order says the clock time', () => { + const said = words({ state: 'accepted', ready_by: SOON, ready_minutes: 30 }, NOW); + assert.match(said, /Usually ready by about/); + /* The time itself is the customer's own reading of the instant, so this + asserts that a time was rendered rather than which timezone ran the test. */ + assert.match(said, /\d/); +}); + +test('"usually" is in the sentence, because it is an estimate', () => { + /* + * Nothing here knows when food is finished. A sentence reading "Ready at + * 7:15" is a promise the product cannot keep, and the first time it is + * wrong the customer stops believing the next one. + */ + assert.match(words({ state: 'accepted', ready_by: SOON, ready_minutes: 30 }, NOW), /Usually/); +}); + +test('an order the shop has not accepted yet says a length, not a clock', () => { + /* + * A shop that holds orders for approval has not started cooking, so a clock + * time would be a fiction: the food is not twenty-five minutes away, it is + * twenty-five minutes away from whenever somebody presses accept. + */ + const said = words({ state: 'pending', ready_by: SOON, ready_minutes: 25 }, NOW); + assert.match(said, /25/); + assert.match(said, /once the shop accepts/i); +}); + +/* -------------------------------------------------------- when it is quiet */ + +test('a shop that has stated no prep times says nothing at all', () => { + /* + * THE HONESTY RULE. The server sends an empty ready_by when no dish on the + * order says how long it takes, and the row then looks exactly as it always + * has rather than carrying half a sentence. + */ + assert.strictEqual(words({ state: 'accepted', ready_by: '', ready_minutes: 0 }, NOW), ''); + assert.strictEqual(words({ state: 'accepted' }, NOW), ''); + assert.strictEqual(words(null, NOW), ''); +}); + +test('a time that has been and gone stops being shown', () => { + /* + * Worse than no time. An estimate counting backwards at somebody still + * waiting reads as the shop being late rather than as an estimate being + * approximate, and there is nothing they can do with it. + */ + const said = words({ state: 'accepted', ready_by: '2026-09-16T18:00:00.000Z', ready_minutes: 30 }, NOW); + assert.strictEqual(said, ''); +}); + +test('a cancelled order never says when it will be ready', () => { + assert.strictEqual( + words({ state: 'accepted', cancelled: true, ready_by: SOON, ready_minutes: 30 }, NOW), + '' + ); +}); + +test('an unreadable time is no time', () => { + for (const bad of ['soon', 'null', '2026-13-45', 0]) { + assert.strictEqual( + words({ state: 'accepted', ready_by: bad, ready_minutes: 30 }, NOW), + '', + JSON.stringify(bad) + ' was drawn as a time' + ); + } +}); + +/* ------------------------------------------------------------ the details */ + +test('the number goes through a placeholder, so another language can move it', () => { + /* + * The ordering pages translate by swapping whole sentences. One built by + * concatenation would leave the clock stranded in English word order. + */ + const source = lift('readyByWords'); + assert.match(source, /\{when\}/); + assert.match(source, /\{n\}/); + assert.doesNotMatch(source, /"\s*\+\s*clock\s*\+\s*"/); +}); + +test('both sentences are in the Tamil dictionary', () => { + /* A sentence the dictionary does not carry renders perfectly in English on + a Tamil page, which is how eleven words on the requests dock stayed + untranslated for the life of that panel. */ + const dict = fs.readFileSync(path.join(ROOT, 'order', 'assets', 'i18n.js'), 'utf8'); + const said = [...lift('readyByWords').matchAll(/t\(\s*"([^"]+)"/g)].map((m) => m[1]); + assert.strictEqual(said.length, 2, 'the sentences changed without this test knowing'); + for (const sentence of said) assert.ok(dict.includes(sentence), 'no Tamil for: ' + sentence); +}); + +test("the clock is the customer's own, not the shop's", () => { + /* + * A guest ordering from a hotel in another timezone reads their own watch. + * The server sends an instant; the page renders it locally. + */ + const source = lift('readyByWords'); + assert.match(source, /toLocaleTimeString/); + assert.match(source, /new Date\(order\.ready_by\)/); +}); + +test('the order list draws it, and hides the line when there is nothing to say', () => { + const history = fs.readFileSync( + path.join(ROOT, 'order', 'assets', 'history', 'script.js'), + 'utf8' + ); + assert.match(history, /readyByWords\(said\)/, 'the list never asks for the estimate'); + assert.match(history, /ready\.hidden = !readyWords/, 'an empty estimate still takes up the row'); + assert.match(history, /open\.appendChild\(ready\)/, 'the estimate is built and never added'); +}); + +test('the server sends both the instant and the minutes', () => { + /* + * The instant for a clock time, the minutes for the pending case where a + * clock would be a fiction. A page that had only one of them would have to + * do arithmetic the server has already done. + */ + const repo = fs.readFileSync( + path.join(ROOT, 'api', 'src', 'repositories', 'sale.repository.js'), + 'utf8' + ); + assert.match(repo, /ready_minutes: Number\(order\.ready_minutes\) \|\| 0/); + assert.match(repo, /ready_by: readyBy\.readyBy\(/); + /* Counted from when the kitchen was told, not when the customer tapped. */ + assert.match(repo, /order\.order_state_at \|\| order\.created_date/); +}); From 76c4ae3710a14172c9d3ae04e11f78c91587e339 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Wed, 16 Sep 2026 01:03:51 +0530 Subject: [PATCH 2/7] Adding is never a request, and a request says what it would do Two things, both about the moment a customer changes their mind. MORE FOOD NEEDS NOBODY'S PERMISSION. An extra naan costs the kitchen a naan it is glad to sell: nothing is wasted, nothing already cooked is thrown away, and the only answer anybody was ever going to give is yes. Holding that in a queue until somebody notices is a customer waiting on a decision that was not one. Taking something away is the opposite - the biryani may be in the pan, and whether it can be called back is a judgement only somebody in the kitchen can make. So the window now gates only the taking away. Inside it nothing has started and the order is still the customer's, exactly as before. Outside it, a request carrying both has its halves answered separately: the naan is already being made by the time the shop reads the question about the biryani. A delivery or a hotel room is still refused outright in both directions, because that is somebody else's money in the total rather than a question about the kitchen. AND THE CARD SAYS WHAT IT WOULD DO. It listed only the lines that moved, so "Chicken Biryani: 2 to 1" told a person nothing about whether that was most of the order or a detail of it - they had to open the sales screen to find out, by which time they were no longer deciding in a hurry. Every line is drawn now, the untouched ones stepped back, with what it was and what it would become side by side. And every request said "Asked to change", whether a customer had dropped one naan or emptied the order. Now that adding never becomes a request, every one of them is something being taken away and the card can say which: cancel the whole order, remove everything on it, remove an item, remove some items, or simply asked for fewer. A line going to nothing says REMOVED rather than "1 to 0", which is a sentence nobody reads at a glance. A request made before this shipped can still be sitting in the queue carrying an addition, so the card still reads one. --- api/src/services/customer-order.service.js | 108 +++++++- .../adding-is-never-a-request.test.js | 240 ++++++++++++++++++ .../static/script/js/core/request-dock.js | 123 ++++++++- frontend/static/style/css/custom.css | 31 +++ languages/_english.json | 6 + languages/ar.json | 6 + languages/de.json | 6 + languages/es.json | 6 + languages/fr.json | 6 + languages/hi.json | 6 + languages/id.json | 6 + languages/it.json | 6 + languages/kn.json | 6 + languages/ml.json | 6 + languages/ne.json | 6 + languages/nl.json | 6 + languages/pt.json | 6 + languages/server/_english.json | 3 +- languages/si.json | 6 + languages/sw.json | 6 + languages/ta.json | 6 + languages/te.json | 6 + languages/th.json | 6 + tests/request-dock.test.js | 13 +- ...the-request-card-says-what-changed.test.js | 232 +++++++++++++++++ 25 files changed, 836 insertions(+), 22 deletions(-) create mode 100644 api/tests/unit/services/adding-is-never-a-request.test.js create mode 100644 tests/the-request-card-says-what-changed.test.js diff --git a/api/src/services/customer-order.service.js b/api/src/services/customer-order.service.js index 0da505727..9d2f5a0d6 100644 --- a/api/src/services/customer-order.service.js +++ b/api/src/services/customer-order.service.js @@ -286,6 +286,72 @@ async function readMany(body, context) { * it simply changes; outside it the kitchen may have started, so the wish is * recorded and a person answers it in the queue the shop already works. */ +/* + * MORE FOOD NEVER NEEDS PERMISSION. LESS FOOD DOES. + * + * Owner: "if customer add new order no approval required. we can just send. if + * any cancel only need approval after few seconds based on settings." + * + * He is right, and the asymmetry is real rather than a convenience. An extra + * naan costs the kitchen a naan it is glad to sell; nothing is wasted, nothing + * already cooked is thrown away, and the only thing a person could say is yes. + * Holding that in a queue until somebody notices is a customer waiting on a + * decision nobody was ever going to make differently. + * + * Taking something away is the opposite. The biryani may be in the pan. That + * is food already paid for in labour and ingredients, and whether it can be + * called back is a judgement only somebody standing in the kitchen can make. + * + * So the window - which used to gate BOTH - now gates only the taking away. + * Inside it nothing has started and the order is still the customer's, exactly + * as before. Outside it, the additions go straight to the pass and only the + * reductions become a request. + * + * THE TWO HALVES OF ONE REQUEST ARE ANSWERED SEPARATELY, and that is the + * point. "Two more naan and drop the biryani" used to wait as a single wish + * until somebody looked; now the naan is already being made by the time the + * shop reads the question about the biryani. + */ +function splitTheWish(wanted, lines) { + const onOrder = new Map( + (Array.isArray(lines) ? lines : []).map((line) => [ + String(line.item_id || ''), + Math.max( + 0, + Math.round(Number(line.item_quantity != null ? line.item_quantity : line.quantity) || 0) + ), + ]) + ); + + const more = []; + const less = []; + for (const one of Array.isArray(wanted) ? wanted : []) { + const id = String((one && one.item_id) || ''); + if (!id) continue; + const asked = Math.max(0, Math.round(Number(one.quantity) || 0)); + const had = onOrder.has(id) ? onOrder.get(id) : 0; + if (asked > had) more.push({ ...one, item_id: id, quantity: asked }); + else if (asked < had) less.push({ ...one, item_id: id, quantity: asked }); + /* Asked for exactly what is already there: not a wish at all. */ + } + + /* + * What the order becomes once the additions are applied and nothing is + * taken away: every line it already has, raised where the customer asked + * for more, plus any dish that was not on it before. + * + * The WHOLE basket, because changeCustomerOrderItems reads the list as the + * order the customer wants - a line left out of it is a line removed. A + * "just the additions" list would silently cancel everything else, which is + * the exact opposite of what was asked for. + */ + const raised = new Map(onOrder); + for (const one of more) raised.set(one.item_id, one.quantity); + const withMore = [...raised.entries()].map(([item_id, quantity]) => ({ item_id, quantity })); + + return { more, less, withMore }; +} + async function change(body, context) { const { order, reason, held } = await heldOrder(body, context); const wanted = Array.isArray(body && body.items) ? body.items.slice(0, 40) : []; @@ -304,16 +370,48 @@ async function change(body, context) { if (reason === 'already_billed' || reason === 'already_paid' || reason === 'refused_by_shop') { return { status: false, message: reason, data: null }; } - /* A hotel room or a delivery carries somebody else's money in the total, - so it is not the customer's alone to move even by asking. */ + /* + * A hotel room or a delivery carries somebody else's money in the total, so + * it is not the customer's alone to move even by asking - and that includes + * adding to it. The rule below is about the KITCHEN having started; this one + * is about a commission somebody else is owed, and the two are not the same + * argument. + */ if (reason === 'at_the_counter') return { status: false, message: reason, data: null }; - const asked = await salesRepository.requestCustomerChange(held, wanted, held.items); + /* + * Past the window: more food goes now, less food is asked about. + * See splitTheWish for why those two are not the same question. + */ + const { more, less, withMore } = splitTheWish(wanted, held.items); + + let applied = null; + if (more.length) { + applied = await salesRepository.changeCustomerOrderItems(held, withMore); + if (!applied.status) return applied; + } + + if (!less.length) { + /* Nothing was taken away, so there is nothing for anybody to decide. */ + return applied + ? withTheWholeOrder(applied, held, context) + : { status: false, message: 'nothing_asked', data: null }; + } + + /* + * Asked against the order AS IT NOW STANDS, not as it was when the request + * arrived. The additions are already on it, and a queue card that showed + * yesterday's quantities beside today's wish would have the shop reading a + * before that no longer exists. + */ + const now = + applied && applied.data && Array.isArray(applied.data.items) ? applied.data.items : held.items; + const asked = await salesRepository.requestCustomerChange(held, less, now); if (!asked.status) return asked; return { status: true, - message: 'Change requested', - data: { ...asked.data, requested: true, why_not: reason }, + message: more.length ? 'Added, and the rest requested' : 'Change requested', + data: { ...asked.data, requested: true, added: more.length > 0, why_not: reason }, }; } diff --git a/api/tests/unit/services/adding-is-never-a-request.test.js b/api/tests/unit/services/adding-is-never-a-request.test.js new file mode 100644 index 000000000..31704a4a5 --- /dev/null +++ b/api/tests/unit/services/adding-is-never-a-request.test.js @@ -0,0 +1,240 @@ +'use strict'; + +/* + * More food never needs permission. Less food does. + * + * Owner: "if customer add new order no approval required. we can just send. if + * any cancel only need approval after few seconds based on settings." + * + * The asymmetry is real rather than a convenience. An extra naan costs the + * kitchen a naan it is glad to sell: nothing is wasted, nothing already cooked + * is thrown away, and the only answer anybody was ever going to give is yes. + * Holding that in a queue until somebody notices is a customer waiting on a + * decision that was never a decision. + * + * Taking something away is the opposite. The biryani may be in the pan, and + * whether it can be called back is a judgement only somebody standing in the + * kitchen can make. + * + * So the window now gates only the taking away, and a request carrying both + * has its two halves answered separately: the naan is already being made by + * the time the shop reads the question about the biryani. + */ + +const customerOrder = require('../../../src/services/customer-order.service'); +const salesRepository = require('../../../src/repositories/sale.repository'); + +const BRANCH = '646576656c6f7073616e6462'; +const ORDER_ID = '6aa5509215e3686c543e5cc3'; +const context = { branchId: BRANCH, licenseId: 'lic' }; + +function shopAllows(seconds) { + return jest.spyOn(customerOrder._settings(), 'resolveGroup').mockResolvedValue({ + status: true, + data: { values: seconds === undefined ? {} : { online_order_change_seconds: seconds } }, + }); +} + +/** A KOT placed an hour ago, so the window has long since closed. */ +function oldOrder(extra = {}) { + return { + _id: ORDER_ID, + branch_id: BRANCH, + token_id: '219', + sale_process: 'KOT', + payment_status: 'Unpaid', + created_date: new Date(Date.now() - 60 * 60 * 1000), + delivery_fee: 0, + venue_commission: 0, + items: [ + { item_id: 'm1', item_name: 'Chicken Biryani', item_quantity: 2, quantity: 2 }, + { item_id: 'r1', item_name: 'Butter Naan', item_quantity: 1, quantity: 1 }, + ], + ...extra, + }; +} + +let applied; +let requested; + +beforeEach(() => { + shopAllows(60); + applied = []; + requested = []; + jest.spyOn(salesRepository, 'findCustomerOrder').mockResolvedValue(oldOrder()); + jest.spyOn(salesRepository, 'changeCustomerOrderItems').mockImplementation((doc, wanted) => { + applied.push(wanted); + return Promise.resolve({ + status: true, + message: 'Order updated', + data: { + order_id: ORDER_ID, + token_id: '219', + items: wanted.map((w) => ({ + item_id: w.item_id, + name: w.item_id === 'm1' ? 'Chicken Biryani' : 'Butter Naan', + quantity: w.quantity, + })), + total: 0, + }, + }); + }); + jest.spyOn(salesRepository, 'requestCustomerChange').mockImplementation((doc, wanted, lines) => { + requested.push({ wanted, lines }); + return Promise.resolve({ status: true, message: 'asked', data: { order_id: ORDER_ID } }); + }); +}); + +afterEach(() => jest.restoreAllMocks()); + +const ask = (items) => customerOrder.change({ orderId: ORDER_ID, token: '219', items }, context); + +/* ----------------------------------------------------------- adding */ + +test('one more naan, an hour later, goes straight to the kitchen', async () => { + /* + * The whole point. Nobody was ever going to refuse this, and holding it in + * a queue is a customer waiting on a decision that was not one. + */ + const out = await ask([ + { item_id: 'm1', quantity: 2 }, + { item_id: 'r1', quantity: 3 }, + ]); + + expect(out.status).toBe(true); + expect(requested).toHaveLength(0); + expect(applied).toHaveLength(1); + expect(out.data.requested).toBeUndefined(); +}); + +test('a dish that was not on the order at all is still just an addition', async () => { + await ask([ + { item_id: 'm1', quantity: 2 }, + { item_id: 'r1', quantity: 1 }, + { item_id: 'd1', quantity: 1 }, + ]); + expect(requested).toHaveLength(0); + expect(applied[0]).toEqual(expect.arrayContaining([{ item_id: 'd1', quantity: 1 }])); +}); + +test('the applied list is the WHOLE order, not only the additions', async () => { + /* + * THE BUG THIS WOULD HAVE BEEN. changeCustomerOrderItems reads its list as + * the order the customer wants, so a line left out of it is a line removed. + * Sending "just the naan" would have silently cancelled the biryani - the + * exact opposite of a request to add something. + */ + await ask([{ item_id: 'r1', quantity: 3 }]); + expect(applied[0]).toHaveLength(2); + expect(applied[0]).toEqual( + expect.arrayContaining([ + { item_id: 'm1', quantity: 2 }, + { item_id: 'r1', quantity: 3 }, + ]) + ); +}); + +/* --------------------------------------------------------- taking away */ + +test('dropping a dish an hour later still asks the shop', async () => { + const out = await ask([ + { item_id: 'm1', quantity: 0 }, + { item_id: 'r1', quantity: 1 }, + ]); + expect(applied).toHaveLength(0); + expect(requested).toHaveLength(1); + expect(requested[0].wanted).toEqual([{ item_id: 'm1', quantity: 0 }]); + expect(out.data.requested).toBe(true); +}); + +test('asking for fewer is asking, not doing', async () => { + await ask([ + { item_id: 'm1', quantity: 1 }, + { item_id: 'r1', quantity: 1 }, + ]); + expect(applied).toHaveLength(0); + expect(requested[0].wanted).toEqual([{ item_id: 'm1', quantity: 1 }]); +}); + +/* ------------------------------------------------------- both at once */ + +test('"two more naan and drop the biryani" does both halves at once', async () => { + /* + * The case that decided the shape. It used to wait as a single wish until + * somebody looked at the queue; now the naan is already being made by the + * time the shop reads the question about the biryani. + */ + const out = await ask([ + { item_id: 'm1', quantity: 0 }, + { item_id: 'r1', quantity: 3 }, + ]); + + expect(applied).toHaveLength(1); + expect(applied[0]).toEqual( + expect.arrayContaining([ + { item_id: 'm1', quantity: 2 }, + { item_id: 'r1', quantity: 3 }, + ]) + ); + expect(requested).toHaveLength(1); + expect(requested[0].wanted).toEqual([{ item_id: 'm1', quantity: 0 }]); + expect(out.data.added).toBe(true); +}); + +test('the shop is shown the order as it NOW stands, not as it was', async () => { + /* + * The additions are already on the order by the time the card is drawn. A + * queue showing the old quantities beside the new wish would have the shop + * reading a "before" that no longer exists anywhere. + */ + await ask([ + { item_id: 'm1', quantity: 0 }, + { item_id: 'r1', quantity: 3 }, + ]); + const naan = requested[0].lines.find((l) => l.item_id === 'r1'); + expect(naan.quantity).toBe(3); +}); + +/* ------------------------------------------------- what has not changed */ + +test('inside the window everything still simply happens', async () => { + salesRepository.findCustomerOrder.mockResolvedValue( + oldOrder({ created_date: new Date(Date.now() - 5 * 1000) }) + ); + const out = await ask([{ item_id: 'm1', quantity: 0 }]); + expect(applied).toHaveLength(1); + expect(requested).toHaveLength(0); + expect(out.status).toBe(true); +}); + +test('a delivery is refused outright, additions included', async () => { + /* + * Not the same argument. The window is about the kitchen having started; + * a delivery fee or a venue commission is somebody else's money in the + * total, and adding to it moves what they are owed. + */ + salesRepository.findCustomerOrder.mockResolvedValue(oldOrder({ delivery_fee: 40 })); + const out = await ask([{ item_id: 'r1', quantity: 3 }]); + expect(out.status).toBe(false); + expect(out.message).toBe('at_the_counter'); + expect(applied).toHaveLength(0); + expect(requested).toHaveLength(0); +}); + +test('a paid order is nobody-changes-it, in either direction', async () => { + salesRepository.findCustomerOrder.mockResolvedValue(oldOrder({ payment_status: 'Paid' })); + const out = await ask([{ item_id: 'r1', quantity: 3 }]); + expect(out.status).toBe(false); + expect(applied).toHaveLength(0); +}); + +test('asking for exactly what is already there is not a wish at all', async () => { + const out = await ask([ + { item_id: 'm1', quantity: 2 }, + { item_id: 'r1', quantity: 1 }, + ]); + expect(applied).toHaveLength(0); + expect(requested).toHaveLength(0); + expect(out.status).toBe(false); + expect(out.message).toBe('nothing_asked'); +}); diff --git a/frontend/static/script/js/core/request-dock.js b/frontend/static/script/js/core/request-dock.js index 142c1f9b5..12478b194 100644 --- a/frontend/static/script/js/core/request-dock.js +++ b/frontend/static/script/js/core/request-dock.js @@ -147,28 +147,125 @@ new: ['lang_new_online_order', 'New order'], }; - /* What a customer asked to have changed, written as dish names and - quantities - the same sentence the queue page uses, because whoever reads - it is standing at a till in a hurry. */ + /* + * THE WHOLE ORDER, BEFORE AND AFTER. + * + * Owner: "when customer aks for change. cancel then desktop or captain app + * clearly can see the changes. what was before and what change customer + * wahts? cancel item or cancel order." + * + * It listed only the lines that MOVED. "Chicken Biryani: 2 to 1" tells you + * nothing about whether that is most of the order or a detail of it, and a + * person deciding in a hurry has to open the sales screen to find out - by + * which time they are no longer deciding in a hurry. + * + * So every line is drawn, the untouched ones dimmed, with what it was and + * what it would become side by side. A line going to nothing says REMOVED + * in words rather than "1 to 0", because zero of something is a sentence + * nobody reads at a glance. + */ function whatChanged(order) { var wants = (order.change_requested && order.change_requested.items) || []; if (!wants.length) return ''; + + var asked = {}; + wants.forEach(function (one) { + asked[String(one.item_id || one.name || '')] = one; + }); + + /* + * The order as it stands, plus anything asked for that is not on it. + * `items` is what the shop currently has; a dish the customer wants that + * was never on the order has no line to sit on, so it gets one. + */ + var rows = (order.items || []).map(function (line) { + var key = String(line.item_id || line.name || ''); + var want = asked[key]; + return { + name: line.name || '', + was: Number(line.quantity || 0), + now: want ? Number(want.quantity || 0) : Number(line.quantity || 0), + moved: !!want, + }; + }); + var seen = {}; + rows.forEach(function (r) { seen[r.name] = true; }); + wants.forEach(function (one) { + var name = one.name || ''; + if (!name || seen[name]) return; + rows.push({ name: name, was: Number(one.was || 0), now: Number(one.quantity || 0), moved: true }); + }); + return ( '' ); } + /* + * WHICH QUESTION THIS IS, IN THE WORDS THE ANSWER IS ABOUT. + * + * "Asked to change" covered a customer dropping one naan and a customer + * emptying the order, which are not the same decision. Now that ADDING + * never becomes a request - more food needs nobody's permission, see + * splitTheWish in customer-order.service.js - every change request is + * something being taken away, and the card can say which. + */ + function askedFor(order) { + var wants = (order.change_requested && order.change_requested.items) || []; + var dropped = wants.filter(function (one) { return !Number(one.quantity || 0); }).length; + var onOrder = (order.items || []).length; + + /* + * SPELLED OUT, not looked up in a table. + * + * The coverage scanner collects keys by matching the two-argument form of + * i18n.t written out in full. A key reached through a + * variable is invisible to it: never gathered, never translated, and + * falling back to English in every language - which renders perfectly, + * which is why nothing ever fails. This file has already paid that once, + * when a local t() helper hid fourteen of its words. + */ + if (order.cancel_requested === true) { + return i18n.t('lang_cancel_whole_order', 'Cancel the whole order'); + } + /* Every line gone is a cancellation in all but name, and should read as + one: a shop that says yes to this has no order left. */ + if (dropped && dropped >= onOrder) { + return i18n.t('lang_remove_everything', 'Remove everything on the order'); + } + if (dropped === 1) return i18n.t('lang_remove_one_item', 'Remove an item'); + if (dropped > 1) return i18n.t('lang_remove_items', 'Remove some items'); + return i18n.t('lang_fewer_asked', 'Asked for fewer'); + } + function card(order) { var id = String(order.sale_id || order._id || ''); var kind = kindOf(order); @@ -183,7 +280,9 @@ return ( '
  • ' + '
    ' + - '' + i18n.t(words[0], words[1]) + '' + + '' + + safe(kind === 'change' || kind === 'cancel' ? askedFor(order) : i18n.t(words[0], words[1])) + + '' + '' + safe(howLongAgo(order.created_date)) + '' + '
    ' + '
    ' + diff --git a/frontend/static/style/css/custom.css b/frontend/static/style/css/custom.css index a6a0d973d..688440da8 100644 --- a/frontend/static/style/css/custom.css +++ b/frontend/static/style/css/custom.css @@ -7254,6 +7254,37 @@ tr.s-doc-purchase-row:hover td { background: rgba(9, 105, 218, 0.05); } .request-dock-diff li { margin-bottom: 2px; } +/* + * THE THREE STATES OF A LINE IN A CHANGE REQUEST. + * + * Every line of the order is drawn now, not only the ones that moved: what is + * NOT changing is half of what the decision is about, and a person reading + * "Chicken Biryani: 2 to 1" could not tell whether that was most of the order + * or a detail of it without opening the sales screen. + * + * So the untouched ones step back and the moved ones step forward. Colour + * carries meaning only - theme tokens, never a hardcoded hex, because + * custom.css loads before theme-variables.css and a literal here would be the + * one thing a shop's theme could not change. + */ +.request-dock-diff li.is-same { + color: var(--theme-text-secondary, #6b7280); +} + +.request-dock-diff li.is-moved b { + font-weight: 700; +} + +.request-dock-diff li.is-gone { + color: var(--theme-danger, #b91c1c); +} + +.request-dock-diff li.is-gone b { + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; +} + .request-dock-do { display: flex; gap: 8px; diff --git a/languages/_english.json b/languages/_english.json index 7ccfcc33b..9201f3dbb 100644 --- a/languages/_english.json +++ b/languages/_english.json @@ -400,6 +400,7 @@ "lang_cancel_remaining": "Cancel remaining", "lang_cancel_return": "Cancel Return", "lang_cancel_title": "Cancel", + "lang_cancel_whole_order": "Cancel the whole order", "lang_cancellation_report": "Cancellation Report", "lang_cancelled": "Cancelled", "lang_captain_elsewhere": "Set up elsewhere", @@ -1201,6 +1202,7 @@ "lang_feature_intro_tour": "Save & show me around", "lang_feature_switches_saved": "Feature switches saved", "lang_features_back": "Features", + "lang_fewer_asked": "Asked for fewer", "lang_fg_all": "All", "lang_fg_channels": "Where you sell", "lang_fg_channels_sub": "Every door besides the counter. Each one is its own switch.", @@ -2515,14 +2517,18 @@ "lang_remove": "Remove", "lang_remove_charge": "Remove charge", "lang_remove_coupon": "Remove coupon", + "lang_remove_everything": "Remove everything on the order", "lang_remove_from_channel": "Do not sell here", "lang_remove_item": "Remove Item", + "lang_remove_items": "Remove some items", "lang_remove_line": "Remove line", + "lang_remove_one_item": "Remove an item", "lang_remove_partner": "Remove partner", "lang_remove_period": "Remove period", "lang_remove_record": "Remove Record", "lang_remove_this_line": "Remove this line", "lang_remove_tier": "Remove tier", + "lang_removed": "removed", "lang_removes_stock": "Removes stock", "lang_renaming_keeps_a_register_s_history_regist": "Renaming keeps a register's history. Register names need at least 3 characters.", "lang_reorder_point": "Reorder point", diff --git a/languages/ar.json b/languages/ar.json index 15fefa658..b9e6a2f21 100644 --- a/languages/ar.json +++ b/languages/ar.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "إلغاء المتبقي", "lang_cancel_return": "إلغاء الإرجاع", "lang_cancel_title": "إلغاء", + "lang_cancel_whole_order": "إلغاء الطلب بالكامل", "lang_cancellation_report": "تقرير الإلغاءات", "lang_cancelled": "ملغى", "lang_card": "بطاقة", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "حفظ & جولة تعريفية", "lang_feature_switches_saved": "تم حفظ مفاتيح الميزات", "lang_features_back": "الميزات", + "lang_fewer_asked": "طلب كمية أقل", "lang_field": "الحقل", "lang_file_size_should_be_less_than_5mb": "يجب أن يكون حجم الملف أقل من 5MB!", "lang_filebrowse_title": "تصفح", @@ -2186,11 +2188,15 @@ "lang_remove": "إزالة", "lang_remove_charge": "إزالة الرسوم", "lang_remove_coupon": "إزالة الكوبون", + "lang_remove_everything": "إزالة كل ما في الطلب", "lang_remove_item": "إزالة الصنف", + "lang_remove_items": "إزالة بعض الأصناف", "lang_remove_line": "إزالة السطر", + "lang_remove_one_item": "إزالة صنف", "lang_remove_record": "إزالة السجل", "lang_remove_this_line": "إزالة هذا السطر", "lang_remove_tier": "إزالة المستوى", + "lang_removed": "أزيل", "lang_removes_stock": "يخصم من المخزون", "lang_renaming_keeps_a_register_s_history_regist": "تغيير الاسم يحافظ على سجل الصندوق. يجب ألا يقل اسم الصندوق عن 3 أحرف.", "lang_reorder_point": "حد إعادة الطلب", diff --git a/languages/de.json b/languages/de.json index f6695f0ba..654e8a9f5 100644 --- a/languages/de.json +++ b/languages/de.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "Rest stornieren", "lang_cancel_return": "Rückgabe abbrechen", "lang_cancel_title": "Abbrechen", + "lang_cancel_whole_order": "Ganze Bestellung stornieren", "lang_cancellation_report": "Stornobericht", "lang_cancelled": "Storniert", "lang_card": "Karte", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "Speichern & Rundgang zeigen", "lang_feature_switches_saved": "Funktionsschalter gespeichert", "lang_features_back": "Funktionen", + "lang_fewer_asked": "Weniger gewünscht", "lang_field": "Feld", "lang_file_size_should_be_less_than_5mb": "Die Datei sollte kleiner als 5MB sein!", "lang_filebrowse_title": "Durchsuchen", @@ -2186,11 +2188,15 @@ "lang_remove": "Entfernen", "lang_remove_charge": "Gebühr entfernen", "lang_remove_coupon": "Gutschein entfernen", + "lang_remove_everything": "Alles von der Bestellung nehmen", "lang_remove_item": "Artikel entfernen", + "lang_remove_items": "Einige Artikel entfernen", "lang_remove_line": "Zeile entfernen", + "lang_remove_one_item": "Einen Artikel entfernen", "lang_remove_record": "Datensatz entfernen", "lang_remove_this_line": "Diese Zeile entfernen", "lang_remove_tier": "Stufe entfernen", + "lang_removed": "entfernt", "lang_removes_stock": "Verringert den Bestand", "lang_renaming_keeps_a_register_s_history_regist": "Beim Umbenennen bleibt der Verlauf einer Kasse erhalten. Kassennamen brauchen mindestens 3 Zeichen.", "lang_reorder_point": "Meldebestand", diff --git a/languages/es.json b/languages/es.json index 1cd624131..0ada1ef41 100644 --- a/languages/es.json +++ b/languages/es.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "Cancelar lo pendiente", "lang_cancel_return": "Cancelar devolución", "lang_cancel_title": "Cancelar", + "lang_cancel_whole_order": "Cancelar todo el pedido", "lang_cancellation_report": "Informe de cancelaciones", "lang_cancelled": "Cancelada", "lang_card": "Tarjeta", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "Guardar & ver un recorrido", "lang_feature_switches_saved": "Ajustes de funciones guardados", "lang_features_back": "Funciones", + "lang_fewer_asked": "Pide menos", "lang_field": "Campo", "lang_file_size_should_be_less_than_5mb": "¡El archivo debe pesar menos de 5MB!", "lang_filebrowse_title": "Examinar", @@ -2186,11 +2188,15 @@ "lang_remove": "Quitar", "lang_remove_charge": "Quitar cargo", "lang_remove_coupon": "Quitar cupón", + "lang_remove_everything": "Quitar todo del pedido", "lang_remove_item": "Quitar artículo", + "lang_remove_items": "Quitar varios platos", "lang_remove_line": "Quitar línea", + "lang_remove_one_item": "Quitar un plato", "lang_remove_record": "Quitar registro", "lang_remove_this_line": "Quitar esta línea", "lang_remove_tier": "Quitar nivel", + "lang_removed": "quitado", "lang_removes_stock": "Resta stock", "lang_renaming_keeps_a_register_s_history_regist": "Al cambiar el nombre se conserva el historial de la caja. Los nombres de caja necesitan al menos 3 caracteres.", "lang_reorder_point": "Punto de reposición", diff --git a/languages/fr.json b/languages/fr.json index b82e4f869..09daca92b 100644 --- a/languages/fr.json +++ b/languages/fr.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "Annuler le restant", "lang_cancel_return": "Annuler le retour", "lang_cancel_title": "Annuler", + "lang_cancel_whole_order": "Annuler toute la commande", "lang_cancellation_report": "Rapport des annulations", "lang_cancelled": "Annulé", "lang_card": "Carte", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "Enregistrer & me faire visiter", "lang_feature_switches_saved": "Fonctionnalités enregistrées", "lang_features_back": "Fonctionnalités", + "lang_fewer_asked": "Demande moins", "lang_field": "Champ", "lang_file_size_should_be_less_than_5mb": "La taille du fichier doit être inférieure à 5MB !", "lang_filebrowse_title": "Parcourir", @@ -2186,11 +2188,15 @@ "lang_remove": "Retirer", "lang_remove_charge": "Retirer les frais", "lang_remove_coupon": "Retirer le coupon", + "lang_remove_everything": "Retirer tout de la commande", "lang_remove_item": "Retirer l'article", + "lang_remove_items": "Retirer plusieurs plats", "lang_remove_line": "Retirer la ligne", + "lang_remove_one_item": "Retirer un plat", "lang_remove_record": "Retirer l'enregistrement", "lang_remove_this_line": "Retirer cette ligne", "lang_remove_tier": "Retirer le palier", + "lang_removed": "retiré", "lang_removes_stock": "Retire du stock", "lang_renaming_keeps_a_register_s_history_regist": "Renommer conserve l'historique de la caisse. Le nom d'une caisse doit compter au moins 3 caractères.", "lang_reorder_point": "Seuil de réapprovisionnement", diff --git a/languages/hi.json b/languages/hi.json index b22be7303..368c2c5e4 100644 --- a/languages/hi.json +++ b/languages/hi.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "बाकी रद्द करें", "lang_cancel_return": "वापसी रद्द करें", "lang_cancel_title": "रद्द करें", + "lang_cancel_whole_order": "पूरा ऑर्डर रद्द करें", "lang_cancellation_report": "रद्दीकरण रिपोर्ट", "lang_cancelled": "रद्द", "lang_card": "कार्ड", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "सहेजें & मुझे टूर दें", "lang_feature_switches_saved": "सुविधा स्विच सहेजे गए", "lang_features_back": "सुविधाएं", + "lang_fewer_asked": "कम की माँग", "lang_field": "फ़ील्ड", "lang_file_size_should_be_less_than_5mb": "फ़ाइल का आकार 5MB से कम होना चाहिए!", "lang_filebrowse_title": "ब्राउज़ करें", @@ -2186,11 +2188,15 @@ "lang_remove": "हटाएं", "lang_remove_charge": "शुल्क हटाएं", "lang_remove_coupon": "कूपन हटाएं", + "lang_remove_everything": "ऑर्डर से सब कुछ हटाएँ", "lang_remove_item": "वस्तु हटाएं", + "lang_remove_items": "कुछ व्यंजन हटाएँ", "lang_remove_line": "लाइन हटाएं", + "lang_remove_one_item": "एक व्यंजन हटाएँ", "lang_remove_record": "रिकॉर्ड निकालें", "lang_remove_this_line": "यह लाइन हटाएं", "lang_remove_tier": "स्तर हटाएं", + "lang_removed": "हटाया", "lang_removes_stock": "स्टॉक घटाता है", "lang_renaming_keeps_a_register_s_history_regist": "नाम बदलने पर कैश रजिस्टर का इतिहास बना रहता है। कैश रजिस्टर के नाम में कम से कम 3 अक्षर होने चाहिए।", "lang_reorder_point": "रीऑर्डर स्तर", diff --git a/languages/id.json b/languages/id.json index bfce34a9d..f1118e5f3 100644 --- a/languages/id.json +++ b/languages/id.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "Batalkan sisanya", "lang_cancel_return": "Batalkan retur", "lang_cancel_title": "Batal", + "lang_cancel_whole_order": "Batalkan seluruh pesanan", "lang_cancellation_report": "Laporan pembatalan", "lang_cancelled": "Dibatalkan", "lang_card": "Kartu", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "Simpan & tunjukkan sekilas", "lang_feature_switches_saved": "Pengaturan fitur disimpan", "lang_features_back": "Fitur", + "lang_fewer_asked": "Minta lebih sedikit", "lang_field": "Kolom", "lang_file_size_should_be_less_than_5mb": "Ukuran file harus kurang dari 5MB!", "lang_filebrowse_title": "Telusuri", @@ -2186,11 +2188,15 @@ "lang_remove": "Hapus", "lang_remove_charge": "Hapus biaya", "lang_remove_coupon": "Hapus kupon", + "lang_remove_everything": "Hapus semua dari pesanan", "lang_remove_item": "Hapus barang", + "lang_remove_items": "Hapus beberapa item", "lang_remove_line": "Hapus baris", + "lang_remove_one_item": "Hapus satu item", "lang_remove_record": "Hapus catatan", "lang_remove_this_line": "Hapus baris ini", "lang_remove_tier": "Hapus tingkat", + "lang_removed": "dihapus", "lang_removes_stock": "Mengurangi stok", "lang_renaming_keeps_a_register_s_history_regist": "Mengganti nama tidak menghapus riwayat mesin kasir. Nama mesin kasir minimal 3 karakter.", "lang_reorder_point": "Titik pemesanan ulang", diff --git a/languages/it.json b/languages/it.json index 3697779c1..8a71080fe 100644 --- a/languages/it.json +++ b/languages/it.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "Annulla il resto", "lang_cancel_return": "Annulla reso", "lang_cancel_title": "Annulla", + "lang_cancel_whole_order": "Annullare tutto l'ordine", "lang_cancellation_report": "Report annullamenti", "lang_cancelled": "Annullato", "lang_card": "Carta", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "Salva & mostrami in giro", "lang_feature_switches_saved": "Impostazioni delle funzioni salvate", "lang_features_back": "Funzioni", + "lang_fewer_asked": "Chiede di meno", "lang_field": "Campo", "lang_file_size_should_be_less_than_5mb": "Il file deve essere più piccolo di 5MB!", "lang_filebrowse_title": "Sfoglia", @@ -2186,11 +2188,15 @@ "lang_remove": "Rimuovi", "lang_remove_charge": "Rimuovi la spesa", "lang_remove_coupon": "Rimuovi il buono sconto", + "lang_remove_everything": "Togliere tutto dall'ordine", "lang_remove_item": "Rimuovi articolo", + "lang_remove_items": "Togliere alcuni piatti", "lang_remove_line": "Rimuovi riga", + "lang_remove_one_item": "Togliere un piatto", "lang_remove_record": "Rimuovi record", "lang_remove_this_line": "Rimuovi questa riga", "lang_remove_tier": "Rimuovi livello", + "lang_removed": "tolto", "lang_removes_stock": "Toglie giacenza", "lang_renaming_keeps_a_register_s_history_regist": "Rinominare una cassa ne mantiene lo storico. I nomi delle casse devono avere almeno 3 caratteri.", "lang_reorder_point": "Punto di riordino", diff --git a/languages/kn.json b/languages/kn.json index 6bfca6958..1753d3114 100644 --- a/languages/kn.json +++ b/languages/kn.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "ಉಳಿದದ್ದನ್ನು ರದ್ದುಮಾಡಿ", "lang_cancel_return": "ಹಿಂತಿರುಗಿಸುವಿಕೆ ರದ್ದುಮಾಡಿ", "lang_cancel_title": "ರದ್ದುಮಾಡಿ", + "lang_cancel_whole_order": "ಇಡೀ ಆರ್ಡರ್ ರದ್ದುಮಾಡಿ", "lang_cancellation_report": "ರದ್ದತಿ ವರದಿ", "lang_cancelled": "ರದ್ದಾಗಿದೆ", "lang_card": "ಕಾರ್ಡ್", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "ಉಳಿಸಿ & ಪರಿಚಯ ತೋರಿಸಿ", "lang_feature_switches_saved": "ವೈಶಿಷ್ಟ್ಯ ಸ್ವಿಚ್‌ಗಳನ್ನು ಉಳಿಸಲಾಗಿದೆ", "lang_features_back": "ವೈಶಿಷ್ಟ್ಯಗಳು", + "lang_fewer_asked": "ಕಡಿಮೆ ಕೇಳಿದ್ದಾರೆ", "lang_field": "ಕ್ಷೇತ್ರ", "lang_file_size_should_be_less_than_5mb": "ಫೈಲ್ ಗಾತ್ರ 5MB ಗಿಂತ ಕಡಿಮೆ ಇರಬೇಕು!", "lang_filebrowse_title": "ಬ್ರೌಸ್ ಮಾಡಿ", @@ -2186,11 +2188,15 @@ "lang_remove": "ತೆಗೆದುಹಾಕಿ", "lang_remove_charge": "ಶುಲ್ಕ ತೆಗೆದುಹಾಕಿ", "lang_remove_coupon": "ಕೂಪನ್ ತೆಗೆದುಹಾಕಿ", + "lang_remove_everything": "ಆರ್ಡರ್‌ನಿಂದ ಎಲ್ಲವನ್ನೂ ತೆಗೆದುಹಾಕಿ", "lang_remove_item": "ವಸ್ತು ತೆಗೆದುಹಾಕಿ", + "lang_remove_items": "ಕೆಲವು ಖಾದ್ಯಗಳನ್ನು ತೆಗೆದುಹಾಕಿ", "lang_remove_line": "ಸಾಲು ತೆಗೆದುಹಾಕಿ", + "lang_remove_one_item": "ಒಂದು ಖಾದ್ಯ ತೆಗೆದುಹಾಕಿ", "lang_remove_record": "ದಾಖಲೆ ತೆಗೆದುಹಾಕಿ", "lang_remove_this_line": "ಈ ಸಾಲು ತೆಗೆದುಹಾಕಿ", "lang_remove_tier": "ಹಂತ ತೆಗೆದುಹಾಕಿ", + "lang_removed": "ತೆಗೆಯಲಾಗಿದೆ", "lang_removes_stock": "ದಾಸ್ತಾನು ಕಡಿಮೆ ಮಾಡುತ್ತದೆ", "lang_renaming_keeps_a_register_s_history_regist": "ಹೆಸರು ಬದಲಾಯಿಸಿದರೂ ರಿಜಿಸ್ಟರ್‌ನ ಇತಿಹಾಸ ಉಳಿಯುತ್ತದೆ. ರಿಜಿಸ್ಟರ್ ಹೆಸರಿಗೆ ಕನಿಷ್ಠ 3 ಅಕ್ಷರಗಳು ಬೇಕು.", "lang_reorder_point": "ಮರು-ಆರ್ಡರ್ ಮಟ್ಟ", diff --git a/languages/ml.json b/languages/ml.json index 607935227..dd41f0268 100644 --- a/languages/ml.json +++ b/languages/ml.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "ബാക്കിയുള്ളത് റദ്ദാക്കുക", "lang_cancel_return": "മടക്കം റദ്ദാക്കുക", "lang_cancel_title": "റദ്ദാക്കുക", + "lang_cancel_whole_order": "ഓർഡർ പൂർണമായി റദ്ദാക്കുക", "lang_cancellation_report": "റദ്ദാക്കൽ റിപ്പോർട്ട്", "lang_cancelled": "റദ്ദാക്കി", "lang_card": "കാർഡ്", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "സേവ് ചെയ്യുക & ഒന്ന് കാണിച്ചുതരൂ", "lang_feature_switches_saved": "ഫീച്ചർ സ്വിച്ചുകൾ സേവ് ചെയ്തു", "lang_features_back": "ഫീച്ചറുകൾ", + "lang_fewer_asked": "കുറവ് ചോദിക്കുന്നു", "lang_field": "ഫീൽഡ്", "lang_file_size_should_be_less_than_5mb": "ഫയൽ വലുപ്പം 5MB-യിൽ കുറവായിരിക്കണം!", "lang_filebrowse_title": "ബ്രൗസ് ചെയ്യുക", @@ -2186,11 +2188,15 @@ "lang_remove": "നീക്കം ചെയ്യുക", "lang_remove_charge": "ചാർജ് നീക്കം ചെയ്യുക", "lang_remove_coupon": "കൂപ്പൺ നീക്കം ചെയ്യുക", + "lang_remove_everything": "ഓർഡറിൽ നിന്ന് എല്ലാം നീക്കുക", "lang_remove_item": "ഇനം നീക്കം ചെയ്യുക", + "lang_remove_items": "ചില വിഭവങ്ങൾ നീക്കുക", "lang_remove_line": "ലൈൻ നീക്കം ചെയ്യുക", + "lang_remove_one_item": "ഒരു വിഭവം നീക്കുക", "lang_remove_record": "റെക്കോർഡ് നീക്കം ചെയ്യുക", "lang_remove_this_line": "ഈ ലൈൻ നീക്കം ചെയ്യുക", "lang_remove_tier": "ടയർ നീക്കം ചെയ്യുക", + "lang_removed": "നീക്കി", "lang_removes_stock": "സ്റ്റോക്ക് കുറയ്ക്കും", "lang_renaming_keeps_a_register_s_history_regist": "പേര് മാറ്റിയാലും രജിസ്റ്ററിന്റെ ചരിത്രം നിലനിൽക്കും. രജിസ്റ്ററിന്റെ പേരിന് കുറഞ്ഞത് 3 അക്ഷരങ്ങൾ വേണം.", "lang_reorder_point": "റീഓർഡർ പോയിന്റ്", diff --git a/languages/ne.json b/languages/ne.json index ceffd5a4c..a699a9859 100644 --- a/languages/ne.json +++ b/languages/ne.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "बाँकी रद्द गर्नुहोस्", "lang_cancel_return": "फिर्ता रद्द गर्नुहोस्", "lang_cancel_title": "रद्द गर्नुहोस्", + "lang_cancel_whole_order": "पूरै अर्डर रद्द गर्नुहोस्", "lang_cancellation_report": "रद्द प्रतिवेदन", "lang_cancelled": "रद्द गरिएको", "lang_card": "कार्ड", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "सुरक्षित गर्नुहोस् & परिचय देखाउनुहोस्", "lang_feature_switches_saved": "सुविधा स्विचहरू सुरक्षित गरियो", "lang_features_back": "सुविधाहरू", + "lang_fewer_asked": "कम मागेको", "lang_field": "फिल्ड", "lang_file_size_should_be_less_than_5mb": "फाइलको आकार 5MB भन्दा कम हुनुपर्छ!", "lang_filebrowse_title": "ब्राउज गर्नुहोस्", @@ -2186,11 +2188,15 @@ "lang_remove": "हटाउनुहोस्", "lang_remove_charge": "शुल्क हटाउनुहोस्", "lang_remove_coupon": "कुपन हटाउनुहोस्", + "lang_remove_everything": "अर्डरबाट सबै हटाउनुहोस्", "lang_remove_item": "वस्तु हटाउनुहोस्", + "lang_remove_items": "केही परिकार हटाउनुहोस्", "lang_remove_line": "लाइन हटाउनुहोस्", + "lang_remove_one_item": "एउटा परिकार हटाउनुहोस्", "lang_remove_record": "रेकर्ड हटाउनुहोस्", "lang_remove_this_line": "यो लाइन हटाउनुहोस्", "lang_remove_tier": "तह हटाउनुहोस्", + "lang_removed": "हटाइयो", "lang_removes_stock": "स्टक घटाउँछ", "lang_renaming_keeps_a_register_s_history_regist": "नाम फेर्दा रजिस्टरको इतिहास यथावत् रहन्छ। रजिस्टरको नाममा कम्तीमा 3 अक्षर चाहिन्छ।", "lang_reorder_point": "पुनः अर्डर बिन्दु", diff --git a/languages/nl.json b/languages/nl.json index 3bac2caba..9f8745ffe 100644 --- a/languages/nl.json +++ b/languages/nl.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "Rest annuleren", "lang_cancel_return": "Retour annuleren", "lang_cancel_title": "Annuleren", + "lang_cancel_whole_order": "Hele bestelling annuleren", "lang_cancellation_report": "Annuleringsrapport", "lang_cancelled": "Geannuleerd", "lang_card": "Kaart", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "Opslaan & rondleiding tonen", "lang_feature_switches_saved": "Functieschakelaars opgeslagen", "lang_features_back": "Functies", + "lang_fewer_asked": "Vraagt minder", "lang_field": "Veld", "lang_file_size_should_be_less_than_5mb": "De bestandsgrootte moet kleiner zijn dan 5 MB!", "lang_filebrowse_title": "Bladeren", @@ -2186,11 +2188,15 @@ "lang_remove": "Verwijderen", "lang_remove_charge": "Kosten verwijderen", "lang_remove_coupon": "Kortingsbon verwijderen", + "lang_remove_everything": "Alles van de bestelling halen", "lang_remove_item": "Artikel verwijderen", + "lang_remove_items": "Enkele gerechten verwijderen", "lang_remove_line": "Regel verwijderen", + "lang_remove_one_item": "Een gerecht verwijderen", "lang_remove_record": "Record verwijderen", "lang_remove_this_line": "Deze regel verwijderen", "lang_remove_tier": "Niveau verwijderen", + "lang_removed": "verwijderd", "lang_removes_stock": "Vermindert de voorraad", "lang_renaming_keeps_a_register_s_history_regist": "Hernoemen behoudt de historie van een kassa. Kassanamen hebben minstens 3 tekens nodig.", "lang_reorder_point": "Bestelpunt", diff --git a/languages/pt.json b/languages/pt.json index 6d64b4c05..8493e008f 100644 --- a/languages/pt.json +++ b/languages/pt.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "Cancelar o restante", "lang_cancel_return": "Cancelar devolução", "lang_cancel_title": "Cancelar", + "lang_cancel_whole_order": "Cancelar toda a encomenda", "lang_cancellation_report": "Relatório de cancelamentos", "lang_cancelled": "Cancelado", "lang_card": "Cartão", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "Salvar & fazer o tour", "lang_feature_switches_saved": "Funcionalidades salvas", "lang_features_back": "Funcionalidades", + "lang_fewer_asked": "Pede menos", "lang_field": "Campo", "lang_file_size_should_be_less_than_5mb": "O arquivo deve ter menos de 5MB!", "lang_filebrowse_title": "Procurar", @@ -2186,11 +2188,15 @@ "lang_remove": "Remover", "lang_remove_charge": "Remover taxa", "lang_remove_coupon": "Remover cupom", + "lang_remove_everything": "Retirar tudo da encomenda", "lang_remove_item": "Remover item", + "lang_remove_items": "Retirar alguns pratos", "lang_remove_line": "Remover linha", + "lang_remove_one_item": "Retirar um prato", "lang_remove_record": "Remover registro", "lang_remove_this_line": "Remover esta linha", "lang_remove_tier": "Remover nível", + "lang_removed": "retirado", "lang_removes_stock": "Remove estoque", "lang_renaming_keeps_a_register_s_history_regist": "Renomear preserva o histórico do caixa. Os nomes de caixa precisam ter pelo menos 3 caracteres.", "lang_reorder_point": "Ponto de reposição", diff --git a/languages/server/_english.json b/languages/server/_english.json index 5cc59a683..f1f524152 100644 --- a/languages/server/_english.json +++ b/languages/server/_english.json @@ -371,8 +371,7 @@ "src/services/category.service.js" ], "Change requested": [ - "src/repositories/sale.repository.js", - "src/services/customer-order.service.js" + "src/repositories/sale.repository.js" ], "Changes Retrieved": [ "src/controllers/categories.controller.js", diff --git a/languages/si.json b/languages/si.json index 6c1ae6088..b0d2590b9 100644 --- a/languages/si.json +++ b/languages/si.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "ඉතිරිය අවලංගු කරන්න", "lang_cancel_return": "ආපසු ලබාදීම අවලංගු කරන්න", "lang_cancel_title": "අවලංගු කරන්න", + "lang_cancel_whole_order": "සම්පූර්ණ ඇණවුම අවලංගු කරන්න", "lang_cancellation_report": "අවලංගු කිරීම් වාර්තාව", "lang_cancelled": "අවලංගු කළා", "lang_card": "කාඩ්පත", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "සුරකා & මට පෙන්වන්න", "lang_feature_switches_saved": "විශේෂාංග ස්විච සුරකින ලදී", "lang_features_back": "විශේෂාංග", + "lang_fewer_asked": "අඩුවෙන් ඉල්ලයි", "lang_field": "ක්ෂේත්‍රය", "lang_file_size_should_be_less_than_5mb": "ගොනුවේ ප්‍රමාණය 5MB ට වඩා අඩු විය යුතුය!", "lang_filebrowse_title": "පිරික්සන්න", @@ -2186,11 +2188,15 @@ "lang_remove": "ඉවත් කරන්න", "lang_remove_charge": "ගාස්තුව ඉවත් කරන්න", "lang_remove_coupon": "කූපනය ඉවත් කරන්න", + "lang_remove_everything": "ඇණවුමෙන් සියල්ල ඉවත් කරන්න", "lang_remove_item": "අයිතමය ඉවත් කරන්න", + "lang_remove_items": "කෑම කිහිපයක් ඉවත් කරන්න", "lang_remove_line": "පේළිය ඉවත් කරන්න", + "lang_remove_one_item": "එක් කෑමක් ඉවත් කරන්න", "lang_remove_record": "වාර්තාව ඉවත් කරන්න", "lang_remove_this_line": "මෙම පේළිය ඉවත් කරන්න", "lang_remove_tier": "තලය ඉවත් කරන්න", + "lang_removed": "ඉවත් කළා", "lang_removes_stock": "තොගය අඩු කරයි", "lang_renaming_keeps_a_register_s_history_regist": "නම වෙනස් කළත් මුදල් ලාච්චුවේ ඉතිහාසය රැඳේ. මුදල් ලාච්චු නමකට අවම වශයෙන් අකුරු 3ක් අවශ්‍යයි.", "lang_reorder_point": "නැවත ඇණවුම් මට්ටම", diff --git a/languages/sw.json b/languages/sw.json index 4a2e133e0..382854c76 100644 --- a/languages/sw.json +++ b/languages/sw.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "Ghairi zilizobaki", "lang_cancel_return": "Ghairi rejesho", "lang_cancel_title": "Ghairi", + "lang_cancel_whole_order": "Ghairi agizo lote", "lang_cancellation_report": "Ripoti ya kughairi", "lang_cancelled": "Imeghairiwa", "lang_card": "Kadi", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "Hifadhi & nionyeshe", "lang_feature_switches_saved": "Swichi za vipengele zimehifadhiwa", "lang_features_back": "Vipengele", + "lang_fewer_asked": "Anaomba kidogo", "lang_field": "Sehemu", "lang_file_size_should_be_less_than_5mb": "Ukubwa wa faili unapaswa kuwa chini ya 5MB!", "lang_filebrowse_title": "Vinjari", @@ -2186,11 +2188,15 @@ "lang_remove": "Ondoa", "lang_remove_charge": "Ondoa gharama", "lang_remove_coupon": "Ondoa kuponi", + "lang_remove_everything": "Ondoa kila kitu kwenye agizo", "lang_remove_item": "Ondoa bidhaa", + "lang_remove_items": "Ondoa baadhi ya vyakula", "lang_remove_line": "Ondoa mstari", + "lang_remove_one_item": "Ondoa chakula kimoja", "lang_remove_record": "Ondoa rekodi", "lang_remove_this_line": "Ondoa mstari huu", "lang_remove_tier": "Ondoa daraja", + "lang_removed": "kimeondolewa", "lang_removes_stock": "Hupunguza akiba", "lang_renaming_keeps_a_register_s_history_regist": "Kubadilisha jina huhifadhi historia ya sanduku la fedha. Majina ya masanduku yanahitaji angalau herufi 3.", "lang_reorder_point": "Kiwango cha kuagiza tena", diff --git a/languages/ta.json b/languages/ta.json index ff98651ed..2719fba36 100644 --- a/languages/ta.json +++ b/languages/ta.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "மீதியை ரத்துசெய்", "lang_cancel_return": "திரும்பலை ரத்துசெய்", "lang_cancel_title": "ரத்துசெய்", + "lang_cancel_whole_order": "முழு ஆர்டரையும் ரத்து செய்யவும்", "lang_cancellation_report": "ரத்து அறிக்கை", "lang_cancelled": "ரத்துசெய்யப்பட்டது", "lang_card": "அட்டை", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "சேமித்து சுற்றிக் காட்டு", "lang_feature_switches_saved": "அம்ச சுவிட்சுகள் சேமிக்கப்பட்டன", "lang_features_back": "அம்சங்கள்", + "lang_fewer_asked": "குறைவாகக் கேட்கிறார்", "lang_field": "புலம்", "lang_file_size_should_be_less_than_5mb": "கோப்பின் அளவு 5MB-க்கும் குறைவாக இருக்க வேண்டும்!", "lang_filebrowse_title": "ப்ரவுஸ்", @@ -2186,11 +2188,15 @@ "lang_remove": "நீக்கு", "lang_remove_charge": "கட்டணத்தை நீக்கு", "lang_remove_coupon": "கூப்பனை நீக்கு", + "lang_remove_everything": "ஆர்டரிலிருந்து அனைத்தையும் நீக்கவும்", "lang_remove_item": "பொருளை நீக்கு", + "lang_remove_items": "சில உணவுகளை நீக்கவும்", "lang_remove_line": "வரியை நீக்கு", + "lang_remove_one_item": "ஒரு உணவை நீக்கவும்", "lang_remove_record": "பதிவை அகற்று", "lang_remove_this_line": "இந்த வரியை நீக்கு", "lang_remove_tier": "நிலையை நீக்கு", + "lang_removed": "நீக்கப்பட்டது", "lang_removes_stock": "இருப்பைக் குறைக்கும்", "lang_renaming_keeps_a_register_s_history_regist": "பெயரை மாற்றினாலும் ரிஜிஸ்டரின் வரலாறு அப்படியே இருக்கும். ரிஜிஸ்டர் பெயருக்கு குறைந்தது 3 எழுத்துகள் தேவை.", "lang_reorder_point": "மறு-ஆர்டர் நிலை", diff --git a/languages/te.json b/languages/te.json index d5de3aa75..9baa9cd8f 100644 --- a/languages/te.json +++ b/languages/te.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "మిగిలినవి రద్దు చేయి", "lang_cancel_return": "రిటర్న్ రద్దు చేయి", "lang_cancel_title": "రద్దు చేయి", + "lang_cancel_whole_order": "మొత్తం ఆర్డర్ రద్దు చేయండి", "lang_cancellation_report": "రద్దు నివేదిక", "lang_cancelled": "రద్దు చేయబడింది", "lang_card": "కార్డ్", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "సేవ్ చేయి & టూర్ చూపించు", "lang_feature_switches_saved": "ఫీచర్ స్విచ్‌లు సేవ్ అయ్యాయి", "lang_features_back": "ఫీచర్లు", + "lang_fewer_asked": "తక్కువ అడుగుతున్నారు", "lang_field": "ఫీల్డ్", "lang_file_size_should_be_less_than_5mb": "ఫైల్ పరిమాణం 5MB కంటే తక్కువగా ఉండాలి!", "lang_filebrowse_title": "బ్రౌజ్ చేయి", @@ -2186,11 +2188,15 @@ "lang_remove": "తీసివేయి", "lang_remove_charge": "ఛార్జీని తీసివేయి", "lang_remove_coupon": "కూపన్‌ను తీసివేయి", + "lang_remove_everything": "ఆర్డర్ నుండి అన్నీ తీసివేయండి", "lang_remove_item": "వస్తువును తీసివేయి", + "lang_remove_items": "కొన్ని వంటకాలు తీసివేయండి", "lang_remove_line": "లైన్‌ను తీసివేయి", + "lang_remove_one_item": "ఒక వంటకం తీసివేయండి", "lang_remove_record": "రికార్డును తీసివేయి", "lang_remove_this_line": "ఈ లైన్‌ను తీసివేయి", "lang_remove_tier": "శ్రేణిని తీసివేయి", + "lang_removed": "తీసివేయబడింది", "lang_removes_stock": "స్టాక్‌ను తగ్గిస్తుంది", "lang_renaming_keeps_a_register_s_history_regist": "పేరు మార్చినా రిజిస్టర్ చరిత్ర అలాగే ఉంటుంది. రిజిస్టర్ పేరుకు కనీసం 3 అక్షరాలు ఉండాలి.", "lang_reorder_point": "రీఆర్డర్ స్థాయి", diff --git a/languages/th.json b/languages/th.json index a7ccab365..1cf9cfae3 100644 --- a/languages/th.json +++ b/languages/th.json @@ -323,6 +323,7 @@ "lang_cancel_remaining": "ยกเลิกส่วนที่เหลือ", "lang_cancel_return": "ยกเลิกการคืนสินค้า", "lang_cancel_title": "ยกเลิก", + "lang_cancel_whole_order": "ยกเลิกทั้งออเดอร์", "lang_cancellation_report": "รายงานการยกเลิก", "lang_cancelled": "ยกเลิกแล้ว", "lang_card": "บัตร", @@ -1029,6 +1030,7 @@ "lang_feature_intro_tour": "บันทึก & พาชมระบบ", "lang_feature_switches_saved": "บันทึกการเปิด/ปิดฟีเจอร์แล้ว", "lang_features_back": "ฟีเจอร์", + "lang_fewer_asked": "ขอน้อยลง", "lang_field": "ช่อง", "lang_file_size_should_be_less_than_5mb": "ขนาดไฟล์ต้องน้อยกว่า 5MB!", "lang_filebrowse_title": "เรียกดู", @@ -2186,11 +2188,15 @@ "lang_remove": "นำออก", "lang_remove_charge": "นำค่าธรรมเนียมออก", "lang_remove_coupon": "นำคูปองออก", + "lang_remove_everything": "เอาทุกอย่างออกจากออเดอร์", "lang_remove_item": "นำสินค้าออก", + "lang_remove_items": "เอาบางเมนูออก", "lang_remove_line": "นำรายการออก", + "lang_remove_one_item": "เอาเมนูหนึ่งออก", "lang_remove_record": "นำรายการบันทึกออก", "lang_remove_this_line": "นำรายการนี้ออก", "lang_remove_tier": "นำระดับออก", + "lang_removed": "เอาออกแล้ว", "lang_removes_stock": "ลดสต็อก", "lang_renaming_keeps_a_register_s_history_regist": "การเปลี่ยนชื่อจะไม่ลบประวัติของเครื่องบันทึกเงินสด ชื่อเครื่องบันทึกเงินสดต้องมีอย่างน้อย 3 ตัวอักษร", "lang_reorder_point": "จุดสั่งซื้อซ้ำ", diff --git a/tests/request-dock.test.js b/tests/request-dock.test.js index 8ff0b957a..5939afd3f 100644 --- a/tests/request-dock.test.js +++ b/tests/request-dock.test.js @@ -141,8 +141,15 @@ test('each card says how long ago the order was placed', () => { }); test('a change request is written out as dishes, not as a diff nobody can read', () => { - /* Whoever reads this is standing at a till in a hurry: "2 to 3 Chicken - Biryani" is a decision at a glance; a JSON patch is not. */ + /* + * Whoever reads this is standing at a till in a hurry: "2 to 3 Chicken + * Biryani" is a decision at a glance; a JSON patch is not. + * + * This order carries no `items`, which is a request made before the queue + * started sending the whole order alongside the wish - and one carrying an + * ADDITION, which a customer cannot ask for any more. Both still sit in the + * database, so the card has to read them, which is what this pins. + */ const { document, window } = dockPage([ { sale_id: 'a1', @@ -160,7 +167,7 @@ test('a change request is written out as dishes, not as a diff nobody can read', ]); document.getElementById('request-dock-tab').click(); const lines = [...document.querySelectorAll('.request-dock-diff li')].map((li) => li.textContent); - assert.deepStrictEqual(lines, ['Chicken Biryani: 2 → 3', '+ 1 × Lime Soda', 'Remove Gulab Jamun']); + assert.deepStrictEqual(lines, ['Chicken Biryani 2 → 3', '+ 1 × Lime Soda', 'Gulab Jamun removed']); assert.strictEqual(document.querySelector('.request-dock-card').getAttribute('data-kind'), 'change'); window.close(); }); diff --git a/tests/the-request-card-says-what-changed.test.js b/tests/the-request-card-says-what-changed.test.js new file mode 100644 index 000000000..ff3561756 --- /dev/null +++ b/tests/the-request-card-says-what-changed.test.js @@ -0,0 +1,232 @@ +'use strict'; + +/* + * What the shop sees when a customer asks for a change. + * + * Owner: "when customer aks for change. cancel then desktop or captain app + * clearly can see the changes. what was before and what change customer + * wahts? cancel item or cancel order." + * + * TWO THINGS WERE WRONG AND THEY COMPOUND. + * + * The card listed only the lines that MOVED. "Chicken Biryani: 2 to 1" tells + * you nothing about whether that is most of the order or a detail of it, so + * the person deciding in a hurry has to open the sales screen to find out - by + * which time they are not deciding in a hurry. + * + * And every one of them said "Asked to change", whether the customer had + * dropped one naan or emptied the order. Those are not the same decision and + * should not read as the same question. + * + * Driven in a DOM against the real module, because a card is markup and the + * whole complaint is about what it looks like. + */ + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const { JSDOM } = require('jsdom'); + +const ROOT = path.join(__dirname, '..'); +const DOCK = fs.readFileSync( + path.join(ROOT, 'frontend', 'static', 'script', 'js', 'core', 'request-dock.js'), + 'utf8' +); + +/** + * The real dock, with a shop that answers however the test says. + * + * The module is an IIFE that wires itself to the page; it is evaluated whole + * so the card is built by the shipping code rather than by a copy of it. + */ +function dockPage(orders) { + const dom = new JSDOM('', { + url: 'https://shop.example/dashboard.html', + runScripts: 'outside-only', + }); + const { window } = dom; + window.PosnicPro = { + i18n: { t: (key, fallback) => fallback }, + local: { get: () => 'b1' }, + get: (opts, ok) => ok({ type: 'success', data: orders }), + post: (opts, ok) => ok({ type: 'success' }), + }; + window.setInterval = () => 0; + window.eval(DOCK); + window.document.dispatchEvent(new window.Event('DOMContentLoaded')); + return window; +} + +/** + * The card's markup for one order. + * + * Built through the module's own `card()` rather than by opening the dock and + * waiting for a poll: the same function the panel calls, with none of the + * timers, so a failure here is about the card and not about the plumbing. + */ +function card(order) { + const window = dockPage([order]); + const html = window.PosnicRequestDock.card(order); + assert.ok(html, 'the dock built no card at all'); + const host = window.document.createElement('ul'); + host.innerHTML = html; + const el = host.querySelector('.request-dock-card'); + assert.ok(el, 'the card carries no card element'); + return el; +} + +const BIRYANI = { item_id: 'm1', name: 'Chicken Biryani', quantity: 2 }; +const NAAN = { item_id: 'r1', name: 'Butter Naan', quantity: 3 }; +const DAL = { item_id: 'd1', name: 'Dal Tadka', quantity: 1 }; + +const asking = (wants, items) => ({ + sale_id: 's1', + sales_id: 'SID7', + token_id: '219', + created_date: new Date().toISOString(), + items: items || [BIRYANI, NAAN, DAL], + change_requested: { items: wants, at: new Date().toISOString() }, +}); + +/* ------------------------------------------------ the whole order, shown */ + +test('every line of the order is drawn, not only the ones that moved', () => { + /* + * What is NOT changing is half of what the decision is about. A shop asked + * to drop the biryani needs to see at a glance that there is still a naan + * and a dal on the ticket. + */ + const el = card(asking([{ item_id: 'm1', name: 'Chicken Biryani', was: 2, quantity: 1 }])); + const rows = [...el.querySelectorAll('.request-dock-diff li')].map((li) => li.textContent); + assert.strictEqual(rows.length, 3, 'the card shows only part of the order'); + assert.ok(rows.some((r) => r.includes('Chicken Biryani'))); + assert.ok(rows.some((r) => r.includes('Butter Naan'))); + assert.ok(rows.some((r) => r.includes('Dal Tadka'))); +}); + +test('a line that is not moving steps back', () => { + const el = card(asking([{ item_id: 'm1', name: 'Chicken Biryani', was: 2, quantity: 1 }])); + const same = [...el.querySelectorAll('.request-dock-diff li.is-same')].map((li) => li.textContent); + assert.strictEqual(same.length, 2); + assert.ok(same.some((r) => r.includes('Butter Naan'))); +}); + +test('a line that moves says what it was and what it would become', () => { + const el = card(asking([{ item_id: 'r1', name: 'Butter Naan', was: 3, quantity: 1 }])); + const moved = el.querySelector('.request-dock-diff li.is-moved'); + assert.ok(moved, 'nothing was marked as moving'); + assert.match(moved.textContent, /Butter Naan/); + assert.match(moved.textContent, /3/); + assert.match(moved.textContent, /1/); +}); + +test('a line going to nothing says so in words, not as a zero', () => { + /* + * "Butter Naan 3 to 0" is a sentence nobody reads at a glance, and zero is + * the single most skimmable-past number on a card full of numbers. + */ + const el = card(asking([{ item_id: 'r1', name: 'Butter Naan', was: 3, quantity: 0 }])); + const gone = el.querySelector('.request-dock-diff li.is-gone'); + assert.ok(gone, 'a removal was not marked as one'); + assert.match(gone.textContent, /Butter Naan/); + assert.match(gone.textContent, /removed/i); + assert.doesNotMatch(gone.textContent, /\b0\b/, 'it still reads as a zero'); +}); + +/* --------------------------------------------- which question this is */ + +test('cancelling the whole order says so', () => { + const el = card({ + sale_id: 's1', + sales_id: 'SID7', + token_id: '219', + created_date: new Date().toISOString(), + items: [BIRYANI, NAAN], + cancel_requested: true, + }); + assert.match(el.querySelector('.request-dock-kind').textContent, /cancel the whole order/i); +}); + +test('dropping one dish reads as removing an item', () => { + const el = card(asking([{ item_id: 'r1', name: 'Butter Naan', was: 3, quantity: 0 }])); + assert.match(el.querySelector('.request-dock-kind').textContent, /remove an item/i); +}); + +test('dropping several reads as removing some', () => { + const el = card( + asking([ + { item_id: 'r1', name: 'Butter Naan', was: 3, quantity: 0 }, + { item_id: 'd1', name: 'Dal Tadka', was: 1, quantity: 0 }, + ]) + ); + assert.match(el.querySelector('.request-dock-kind').textContent, /remove some items/i); +}); + +test('dropping every line reads as a cancellation, because that is what it is', () => { + /* + * A shop that says yes to this has no order left. Calling it "remove some + * items" would have somebody accept the end of an order thinking they were + * trimming it. + */ + const el = card( + asking([ + { item_id: 'm1', name: 'Chicken Biryani', was: 2, quantity: 0 }, + { item_id: 'r1', name: 'Butter Naan', was: 3, quantity: 0 }, + { item_id: 'd1', name: 'Dal Tadka', was: 1, quantity: 0 }, + ]) + ); + assert.match(el.querySelector('.request-dock-kind').textContent, /remove everything/i); +}); + +test('asking for fewer of something is not a removal', () => { + const el = card(asking([{ item_id: 'r1', name: 'Butter Naan', was: 3, quantity: 1 }])); + const kind = el.querySelector('.request-dock-kind').textContent; + assert.match(kind, /fewer/i); + assert.doesNotMatch(kind, /remove/i); +}); + +/* --------------------------------------------------------- the details */ + +test('a dish name with markup in it cannot reach the card as markup', () => { + const el = card( + asking([{ item_id: 'x', name: '', was: 1, quantity: 0 }], [ + { item_id: 'x', name: '', quantity: 1 }, + ]) + ); + assert.strictEqual(el.querySelectorAll('img').length, 0, 'a name was drawn as markup'); + assert.match(el.textContent, /onerror/); +}); + +test('every word on the card is a key the packs can answer', () => { + /* + * The coverage scanner reads the two-argument form of i18n.t out of the + * source, written out in full; a key reached through a variable is + * invisible to it - never + * gathered, never translated, falling back to English in every language, + * which renders perfectly and so never fails. This file has already paid + * that once, when a local t() helper hid fourteen of its words. + */ + const dir = path.join(ROOT, 'languages'); + const english = JSON.parse(fs.readFileSync(path.join(dir, '_english.json'), 'utf8')); + const keys = [ + 'lang_cancel_whole_order', + 'lang_remove_everything', + 'lang_remove_one_item', + 'lang_remove_items', + 'lang_fewer_asked', + 'lang_removed', + ]; + for (const key of keys) { + assert.ok(english[key], key + ' has no English, so no pack can be asked for it'); + assert.match( + DOCK, + new RegExp("i18n\\.t\\('" + key + "',"), + key + ' is not spelled out, so the scanner cannot see it' + ); + for (const file of fs.readdirSync(dir).filter((f) => /^[a-z]{2}\.json$/.test(f))) { + const pack = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')); + assert.ok(pack[key], file + ' has no ' + key); + } + } +}); From bcd64100d1b1f3268996ecadeafc3fad39ebc262 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Wed, 16 Sep 2026 01:31:03 +0530 Subject: [PATCH 3/7] A bill number a tax invoice may carry Owner: "we need year pattern required in the sales bill number example attached have 26 in the year. and then number increasing would be okay. you suggest per day increase or year wise reset better tell me international standards." And on the answer: "i accept recommandation and may configurable if people from EU and international. Blocker fix that. i dont know how its got SB1d14 and all." SB1D14-000051 is S, the branch's code, this till's gateway-assigned code, and a running number. The till code is what stops two tills in one shop minting the same number; it has been there since bill numbers went atomic. THE RULE. India, CGST Rule 46(b): a consecutive serial number, NOT EXCEEDING SIXTEEN CHARACTERS, of letters, digits, "-" and "/", UNIQUE FOR A FINANCIAL YEAR. The reference invoice he sent is exactly sixteen - VR26-27VIR006782 - because whoever built that hit the same wall. The EU VAT Directive asks only for a sequential number, with no length limit and no prescribed reset, so the year and the reset are settings and the charset is not. Not per day. A daily reset repeats numbers inside the year unless the date is in the number, and a date costs six to eight of the sixteen. It also destroys what an auditor actually uses: one consecutive run per year has visible gaps when something is cancelled, and three hundred and sixty-five short runs do not. utils/bill-number.js builds the number and shortens the RUNNING NUMBER when something has to give - never the year, which uniqueness depends on, and never the till code, without which two tills collide. With no year asked for it produces exactly what the template literal it replaced produced, pinned over 72 combinations. WHOSE MIDNIGHT. A bill rung up at 00:30 on 1 April in Chennai belongs to the new financial year. A cloud instance running in UTC would still call it March and number it into a year that closed half an hour ago. The period is worked out in the shop's own clock. THE ROLL-OVER IS ONE ATOMIC STEP. A read, a compare and a write would be three, and a year turns over at midnight in a restaurant that is still serving. One pipeline update on the counter row, so two tills billing in that second cannot both be given number one. Checked against a real mongod, not a mock of one. A counter written before any of this reads as "no period" rather than as a mismatch, or the first bill on every shop in the estate would restart at one. And it found a real one: the counter sale formatted its own number while the customer's ordering page called generateSalesIdForBranch, with both drawing on one counter. A shop turning the year on would have had it on its online orders and not on its counter bills, and the reset would have had the till reissuing numbers it had already given out. Both now come through one door. Off by default. Ninety shops are mid-year with a running series on their invoices; switching them on an upgrade would change the shape of every number overnight and restart the count in the middle of a year. A shop turns it on, best on the first day of its own year. --- api/src/controllers/settings.controller.js | 30 ++ api/src/models/branch.model.js | 29 ++ api/src/models/setting.model.js | 28 ++ api/src/repositories/sale.repository.js | 237 ++++++++++++-- api/src/services/sale.service.js | 14 +- api/src/services/settings-groups.js | 6 + api/src/utils/bill-number.js | 244 ++++++++++++++ .../unit/repositories/sale.repository.test.js | 10 +- .../the-year-on-a-bill-number.test.js | 304 +++++++++++++++++ api/tests/unit/services/sale.service.test.js | 13 + ...ill-number-a-tax-invoice-may-carry.test.js | 305 ++++++++++++++++++ frontend/modules/settings_write.html | 45 +++ .../static/script/js/modules/js/settings.js | 60 ++++ languages/_english.json | 7 + languages/ar.json | 9 +- languages/de.json | 9 +- languages/es.json | 9 +- languages/fr.json | 9 +- languages/hi.json | 9 +- languages/id.json | 9 +- languages/it.json | 9 +- languages/kn.json | 9 +- languages/ml.json | 9 +- languages/ne.json | 9 +- languages/nl.json | 9 +- languages/pt.json | 9 +- languages/server/_english.json | 6 + languages/si.json | 9 +- languages/sw.json | 9 +- languages/ta.json | 9 +- languages/te.json | 9 +- languages/th.json | 9 +- ...ll-number-setting-reaches-the-till.test.js | 243 ++++++++++++++ 33 files changed, 1691 insertions(+), 43 deletions(-) create mode 100644 api/src/utils/bill-number.js create mode 100644 api/tests/unit/repositories/the-year-on-a-bill-number.test.js create mode 100644 api/tests/unit/utils/a-bill-number-a-tax-invoice-may-carry.test.js create mode 100644 tests/a-bill-number-setting-reaches-the-till.test.js diff --git a/api/src/controllers/settings.controller.js b/api/src/controllers/settings.controller.js index f728cc607..6e6323e94 100644 --- a/api/src/controllers/settings.controller.js +++ b/api/src/controllers/settings.controller.js @@ -569,6 +569,36 @@ class SettingController extends BaseController { data: null, }); } + /* + * WHEN THE BILL NUMBER STARTS AGAIN, and it is refused rather than + * quietly corrected. + * + * The model coerces an unrecognised value to off, which is the right + * thing for a payload arriving from anywhere. Here, where a person is + * looking at a form, a typo that silently switched a shop's numbering + * off would be found by an accountant in April rather than by whoever + * pressed Save. See utils/bill-number.js. + */ + if ( + data.bill_number_reset !== undefined && + !['', 'off', 'financial', 'calendar'].includes(String(data.bill_number_reset).trim()) + ) { + return res.status(400).json({ + type: 'error', + message: 'Data Not Valid: bill_number_reset must be off, financial or calendar', + data: null, + }); + } + if (data.bill_number_fy_start_month !== undefined) { + const month = Number(data.bill_number_fy_start_month); + if (!Number.isInteger(month) || month < 1 || month > 12) { + return res.status(400).json({ + type: 'error', + message: 'Data Not Valid: bill_number_fy_start_month must be a month, 1 to 12', + data: null, + }); + } + } if ( data.receiving_prefix !== undefined && (String(data.receiving_prefix).length < 1 || String(data.receiving_prefix).length > 6) diff --git a/api/src/models/branch.model.js b/api/src/models/branch.model.js index 350101116..1dc49d036 100644 --- a/api/src/models/branch.model.js +++ b/api/src/models/branch.model.js @@ -177,6 +177,30 @@ const branchSchema = new Schema( discount_amount: { type: Number }, discount_percentage: { type: Number }, sales_prefix: { type: String, default: 'S' }, + /* + * WHEN THE BILL NUMBER STARTS AGAIN AT ONE, and whether the year is + * printed on it. + * + * Owner: "we need year pattern required in the sales bill number example + * attached have 26 in the year", and on the answer: "i accept + * recommandation and may configurable if people from EU and + * international." + * + * off what every shop does today: one run, for ever + * financial India, CGST Rule 46(b): unique for a FINANCIAL year + * calendar most of the EU, where the year is the calendar one + * + * EMPTY BY DEFAULT, which means off. Ninety shops are mid-year with a + * running series on their invoices; switching them all over on an upgrade + * would change the shape of every bill number overnight and restart the + * count in the middle of a year, which is the one thing an auditor reads + * a series for. A shop turns it on, ideally on the first of its own year. + * See utils/bill-number.js. + */ + bill_number_reset: { type: String, default: '' }, + /* Which month a financial year begins in, 1-12. India is April; a shop on + the calendar year sets `bill_number_reset` to `calendar` instead. */ + bill_number_fy_start_month: { type: Number, default: 4 }, receiving_prefix: { type: String, default: 'RID' }, smstype: { type: String }, @@ -312,6 +336,8 @@ class BranchModel { roundOff: { type: 'String', select: true }, sales_mail: { type: 'String', select: true }, sales_prefix: { type: 'String', select: true }, + bill_number_reset: { type: 'String', select: true }, + bill_number_fy_start_month: { type: 'Number', select: true }, sales_sms: { type: 'String', select: true }, auto_sms: { type: 'String', select: true }, server_dateformat: { type: 'String', select: true }, @@ -1070,6 +1096,9 @@ class BranchModel { discount_amount: 0, discount_percentage: 0.0, sales_prefix: 'S', + /* Off, which is what every existing shop does. See the schema. */ + bill_number_reset: '', + bill_number_fy_start_month: 4, receiving_prefix: 'RID', auto_sms: false, // Default to false or from session settings sales_sms: false, diff --git a/api/src/models/setting.model.js b/api/src/models/setting.model.js index cda3e4d51..1b0c7fad5 100644 --- a/api/src/models/setting.model.js +++ b/api/src/models/setting.model.js @@ -1297,6 +1297,32 @@ class SettingModel extends BaseModel { discount_percentage: parseFloat(data.discount_percentage), discount_amount: parseFloat(data.discount_amount), sales_prefix: data.sales_prefix, + /* + * WHEN THE BILL NUMBER STARTS AGAIN AT ONE. + * + * Only written when the form actually sent it, so a save from an + * older screen - or from any of the other forms that post into this + * same method - cannot silently switch a shop's numbering off. An + * unrecognised value is stored as empty, which is off: a typo must + * not restart a shop's invoice series. + */ + ...(data.bill_number_reset !== undefined + ? { + bill_number_reset: ['financial', 'calendar'].includes( + String(data.bill_number_reset || '').trim() + ) + ? String(data.bill_number_reset).trim() + : '', + } + : {}), + ...(data.bill_number_fy_start_month !== undefined + ? { + bill_number_fy_start_month: (() => { + const month = Number(data.bill_number_fy_start_month); + return Number.isInteger(month) && month >= 1 && month <= 12 ? month : 4; + })(), + } + : {}), // Shop's own outgoing mail (owner rule: theirs first, ours as the // cloud fallback). Password stored as given - it must be usable. ...(data.email_smtp_host !== undefined @@ -1490,6 +1516,8 @@ class SettingModel extends BaseModel { discount_percentage: 'discount_percentage', discount_amount: 'discount_amount', sales_prefix: 'sales_prefix', + bill_number_reset: 'bill_number_reset', + bill_number_fy_start_month: 'bill_number_fy_start_month', indian_gst: 'indian_gst', receiving_prefix: 'receiving_prefix', branch_gstin_number: 'branch_gstin_number', diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index 68145c118..0676834f4 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -40,6 +40,7 @@ async function withDayparts(shop) { const { notifyOrderAttention } = require('../helpers/order-attention'); const orderApproval = require('../utils/order-approval'); +const billNumber = require('../utils/bill-number'); const spiceLevel = require('../utils/spice-level'); const readyBy = require('../utils/ready-by'); const { kitchenLoad, typicalRound } = require('../utils/kitchen-load'); @@ -11856,7 +11857,20 @@ class SalesRepository { } } - async generateSalesIdForBranch(branchIdRaw, { reseed = false } = {}) { + /** + * The next bill number for a branch: the counter and the format together. + * + * THE ONLY DOOR. Both the till and the customer's own ordering page come + * through here, and they have to: they share one counter, so a year that + * resets on one path and not the other would have the till issuing numbers + * the year had already used. sale.service.js used to take the number and + * build it itself, which is how the two would have drifted. + * + * `fallbackPrefix` exists for those callers, which hold the shop's prefix + * already and would rather not have this read the branch to find the same + * answer twice. + */ + async generateSalesIdForBranch(branchIdRaw, { reseed = false, fallbackPrefix } = {}) { if (!branchIdRaw) { throw new Error('branchId is required to generate sales_id'); } @@ -11880,19 +11894,78 @@ class SalesRepository { // shop may want plain numbers - so only a branch that never set the field // falls back to the default 'S'. const prefixRaw = branchDoc?.sales_prefix ?? branchDoc?.salesPrefix; - const prefix = prefixRaw != null ? prefixRaw.toString().trim() : 'S'; + const prefix = + fallbackPrefix !== undefined + ? String(fallbackPrefix) + : prefixRaw != null + ? prefixRaw.toString().trim() + : 'S'; const prefixLength = prefix.length; void prefixLength; void salesCollection; + /* + * WHICH NUMBERING PERIOD THIS BILL BELONGS TO. + * + * Empty for every shop that has not asked for one, which is all of them + * until somebody turns it on - and an empty period produces exactly the + * number this method produced before it existed. See _billPeriod. + */ + const period = this._billPeriod(branchDoc); + /* The branch's own licence first: see nextSalesNumberForBranch. The ambient one is a fallback for a branch this process cannot read. */ const n = await this.nextSalesNumberForBranch( branchId, (branchDoc && branchDoc.license) || BaseModel.license, - { reseed } + { reseed, period } ); - return this.buildDocNumber('S', branchId, n, { fallbackPrefix: prefix }); + return this.buildDocNumber('S', branchId, n, { + fallbackPrefix: prefix, + period: period.label, + }); + } + + /** + * The numbering period a bill made RIGHT NOW falls in, for this branch. + * + * Owner: "we need year pattern required in the sales bill number example + * attached have 26 in the year... you suggest per day increase or year wise + * reset better tell me international standards", and on the answer: "i + * accept recommandation and may configurable if people from EU and + * international." + * + * IN THE SHOP'S OWN CLOCK, not the server's. A bill rung up at half past + * midnight on the first of April in Chennai belongs to the new financial + * year; a cloud instance running in UTC would still call it March and put + * it in the old one - which is a bill numbered into a year that has closed, + * and the kind of thing an auditor finds rather than a test. + * + * Midday local is used rather than the exact instant so that a daylight + * shift of an hour either way cannot move a bill across a year boundary. + * + * Off unless the shop asked, and off is the shape of every number issued in + * this product so far. See utils/bill-number.js for the rule this obeys. + */ + _billPeriod(branchDoc, when = new Date()) { + const reset = String((branchDoc && branchDoc.bill_number_reset) || '').trim(); + if (!billNumber.RESET_MODES.includes(reset) || reset === 'off') { + return { key: '', label: '', mode: 'off' }; + } + try { + const zone = onlineOrdering.normalizeTimeZone(branchDoc && branchDoc.time_zone); + const local = moment(when).tz(zone); + const atNoon = new Date(local.year(), local.month(), local.date(), 12, 0, 0); + return billNumber.periodFor(atNoon, { + reset, + financialYearStartMonth: Number(branchDoc && branchDoc.bill_number_fy_start_month) || 4, + }); + } catch (e) { + /* A time zone nobody can read must not stop a shop billing. Off is the + behaviour every shop had before this existed. */ + console.warn('[bill-number] could not read the shop clock:', e && e.message); + return { key: '', label: '', mode: 'off' }; + } } /* @@ -11911,7 +11984,7 @@ class SalesRepository { * collection that does not ride the sync wire, so each side numbers its own * writes and never inherits a counter that went backwards. */ - async nextSalesNumberForBranch(branchIdRaw, licenseRaw, { reseed = false } = {}) { + async nextSalesNumberForBranch(branchIdRaw, licenseRaw, { reseed = false, period = null } = {}) { const db = await BaseModel.getDb(); const counters = db.collection('counters'); @@ -11973,13 +12046,30 @@ class SalesRepository { // The bill-number uniqueness backstop, ensured alongside the counter. await this._ensureSalesIdIndex(db); + /* + * WHICH PERIOD THE COUNTER IS COUNTING, stored on the row rather than in + * the key. + * + * Keying on it would mean a second row per branch per year, and the + * unique index that guards this collection - one_counter_per_scope, on + * kind + branch + licence - would refuse it. Changing that index means a + * migration on ninety live shops in the path that numbers every bill, to + * store a number that fits perfectly well on the row already there. + * + * Empty when the shop has not asked for a reset, which is how every + * existing row already reads once $ifNull has done its work below. + */ + const wantedPeriod = String((period && period.key) || ''); + const existing = await counters.findOne(key); if (!existing) { - const seed = await this.maxIssuedSalesNumber(branchIdRaw, licenseRaw); + const seed = await this.maxIssuedSalesNumber(branchIdRaw, licenseRaw, { + periodLabel: (period && period.label) || '', + }); /* With the unique index, one of two concurrent seeders inserts and the other's upsert errors; both then increment the same row. */ await counters - .updateOne(key, { $setOnInsert: { seq: seed } }, { upsert: true }) + .updateOne(key, { $setOnInsert: { seq: seed, period_key: wantedPeriod } }, { upsert: true }) .catch(() => {}); } @@ -11993,15 +12083,58 @@ class SalesRepository { * ever raises, so a number allocated concurrently cannot be undone by it. */ if (reseed) { - const behind = await this.maxIssuedSalesNumber(branchIdRaw, license); + const behind = await this.maxIssuedSalesNumber(branchIdRaw, license, { + periodLabel: (period && period.label) || '', + }); if (behind > 0) { - await counters.updateOne(key, { $max: { seq: behind } }, { upsert: true }).catch(() => {}); + /* + * The period is stamped here too, so catching up cannot leave the row + * claiming a year it is no longer counting - which would make the + * very next bill roll over a second time and restart at one. + */ + await counters + .updateOne( + key, + { $max: { seq: behind }, $set: { period_key: wantedPeriod } }, + { upsert: true } + ) + .catch(() => {}); } } + /* + * THE ROLL-OVER, IN ONE ATOMIC STEP. + * + * A read, a compare and a write would be three, and a year turns over at + * midnight in a restaurant that is still serving: two tills billing in + * that second would both read the old year, both reset to zero and both + * issue number one. One pipeline update is a single atomic operation on + * the document, so the second caller sees whatever the first left. + * + * Same period, or no period at all: add one, exactly as this did before. + * A different period: start again at one and stamp the new period on the + * row. `$ifNull` is what makes every counter written before this change + * read as "no period" rather than as a mismatch - otherwise the first + * bill on every shop in the estate would restart at one. + * + * Needs MongoDB 4.2 for pipeline updates; the product ships 7.0. + */ const res = await counters.findOneAndUpdate( key, - { $inc: { seq: 1 } }, + [ + { + $set: { + seq: { + $cond: [ + { $eq: [{ $ifNull: ['$period_key', ''] }, wantedPeriod] }, + { $add: [{ $ifNull: ['$seq', 0] }, 1] }, + 1, + ], + }, + period_key: wantedPeriod, + }, + }, + ], { returnDocument: 'after' } ); const doc = res && typeof res.seq === 'number' ? res : res && res.value; @@ -12063,14 +12196,28 @@ class SalesRepository { * kiosk/QR path can never drift apart. Falls back to the old untagged form * only if a till code could not be read, which must never fail a sale. */ - async buildSalesId(prefix, n) { - const num = String(n).padStart(6, '0'); + async buildSalesId(prefix, n, { period = '' } = {}) { const tag = await this.deviceTag(); const p = (prefix || '').toString().trim(); + /* + * The one shape utils/bill-number.js cannot express: a prefix glued + * straight onto the number with no separator, which is what a till with + * no code has always produced. Kept byte for byte while no year is asked + * for, because changing the shape of every number on those shops is not + * something to do on the way past. + */ + if (!tag && !period) return `${p}${String(n).padStart(6, '0')}`; // An empty prefix yields just the tag+number (or a bare number) - no leading // dash - so a shop that clears its prefix still gets clean bill numbers. - if (tag) return p ? `${p}-${tag}-${num}` : `${tag}-${num}`; - return `${p}${num}`; + const head = tag ? (p ? `${p}-${tag}` : tag) : p; + const built = billNumber.compose({ + typeLetter: head, + period, + sequence: n, + sequenceWidth: 6, + }); + if (built.warning) console.warn('[bill-number]', built.warning); + return built.number; } /* @@ -12143,16 +12290,46 @@ class SalesRepository { * collision-free - so the visible format changes exactly once, cleanly, and * a sale never waits on the gateway. */ - async buildDocNumber(typeLetter, branchId, n, { isReturn = false, fallbackPrefix = 'S' } = {}) { - const num = String(n).padStart(6, '0'); + /** + * The bill number itself, inside the sixteen characters a tax invoice is + * allowed. + * + * CGST Rule 46(b): a consecutive serial number, not exceeding sixteen + * characters, of letters, digits, "-" and "/", unique for a financial year. + * The shop's own reference invoice - VR26-27VIR006782 - is exactly sixteen, + * because whoever built that ran into the same wall. + * + * The parts are assembled by utils/bill-number.js, which shortens the + * RUNNING NUMBER when something has to give and says so: dropping the year + * would break uniqueness for the year, and dropping the till code would let + * two tills mint the same number. With no year asked for it produces exactly + * what the expression here produced before, which is pinned by test. + */ + async buildDocNumber( + typeLetter, + branchId, + n, + { isReturn = false, fallbackPrefix = 'S', period = '' } = {} + ) { const [branchCode, deviceCode] = await Promise.all([ this.branchCode(branchId), this.deviceCode(), ]); if (branchCode && deviceCode) { - return `${isReturn ? 'R-' : ''}${typeLetter}${branchCode}${deviceCode}-${num}`; + const built = billNumber.compose({ + typeLetter: `${isReturn ? 'R-' : ''}${typeLetter}`, + branchCode, + deviceCode, + period, + sequence: n, + sequenceWidth: 6, + }); + /* A shop whose codes leave no room is told, once per bill, in words it + can act on. Never thrown: an ugly legal number beats no sale. */ + if (built.warning) console.warn('[bill-number]', built.warning); + return built.number; } - return this.buildSalesId(fallbackPrefix, n); + return this.buildSalesId(fallbackPrefix, n, { period }); } /* @@ -12310,7 +12487,7 @@ class SalesRepository { } } - async maxIssuedSalesNumber(branchIdRaw, licenseRaw) { + async maxIssuedSalesNumber(branchIdRaw, licenseRaw, { periodLabel = '' } = {}) { const db = await BaseModel.getDb(); const asObjectId = (v) => v instanceof mongoose.Types.ObjectId @@ -12324,9 +12501,29 @@ class SalesRepository { .collection('sales') .find(filter, { projection: { sales_id: 1 } }) .toArray(); + /* + * ONLY THIS YEAR'S NUMBERS, when the shop numbers by year. + * + * This is what catches a counter up after a number came back taken. On a + * shop that resets every year, the highest number it ever issued is in + * some earlier year - and $max-ing the new year's counter to it would + * jump the series from 4 to 901 on the first collision, which is a gap an + * auditor asks about. The label sits in its own dash-delimited segment + * immediately before the running number, so it can be matched exactly + * rather than searched for. + * + * No label means no filter, which is every shop that has not turned the + * year on and is exactly what this did before. + */ + const inPeriod = periodLabel + ? new RegExp(`(^|-)${String(periodLabel).replace(/[^A-Za-z0-9]/g, '')}-\\d+$`) + : null; + let max = 0; for (const r of rows) { - const m = /(\d+)\s*$/.exec(String((r && r.sales_id) || '')); + const salesId = String((r && r.sales_id) || ''); + if (inPeriod && !inPeriod.test(salesId)) continue; + const m = /(\d+)\s*$/.exec(salesId); if (m) max = Math.max(max, parseInt(m[1], 10)); } return max; diff --git a/api/src/services/sale.service.js b/api/src/services/sale.service.js index 2294256d0..47b17a8d8 100644 --- a/api/src/services/sale.service.js +++ b/api/src/services/sale.service.js @@ -688,11 +688,18 @@ const processSale = async (data, id = '', process = 'Add', context = {}) => { let prefixId = ''; if (id === '') { const prefixValue = context.salesPrefix != null ? context.salesPrefix : 'S'; - const n = await salesRepository.nextSalesNumberForBranch(branchId, licenseId); // Readable scheme (SB1D1-000045) once this till has its branch and its // gateway-assigned device code; until then a till-tagged number that is // already collision-free. Either way, two tills can never clash. - prefixId = await salesRepository.buildDocNumber('S', branchId, n, { + // + // THE COUNTER AND THE FORMAT TOGETHER, through the one method the + // customer's ordering page also uses. Taking the number here and + // building it separately is how the two came to disagree about the + // year: a shop that numbers by financial year would have had the year + // on its online orders and not on its counter bills, sharing one + // counter, and the till would have reissued numbers the new year had + // already given out. See repositories/sale.repository generateSalesIdForBranch. + prefixId = await salesRepository.generateSalesIdForBranch(branchId, { fallbackPrefix: prefixValue, }); } @@ -1196,8 +1203,7 @@ const processSale = async (data, id = '', process = 'Add', context = {}) => { // take the next number and retry rather than fail the sale. result = await salesRepository.createSaleUnique(finalSaleData, async () => { const pv = context.salesPrefix || 'INV'; - const nn = await salesRepository.nextSalesNumberForBranch(branchId, licenseId); - return salesRepository.buildDocNumber('S', branchId, nn, { fallbackPrefix: pv }); + return salesRepository.generateSalesIdForBranch(branchId, { fallbackPrefix: pv }); }); } catch (error) { for (const reservation of stockReservations.values()) { diff --git a/api/src/services/settings-groups.js b/api/src/services/settings-groups.js index 97b488cac..e069f2567 100644 --- a/api/src/services/settings-groups.js +++ b/api/src/services/settings-groups.js @@ -109,6 +109,12 @@ const PREFERENCES = [ 'default_supplier', 'default_tax', 'sales_prefix', + /* When the bill number starts again at one, and whether the year is printed + on it. CGST Rule 46(b) makes the financial year the unit of uniqueness in + India; the EU asks only for a sequential number, so it is a choice rather + than a constant. See utils/bill-number.js. */ + 'bill_number_reset', + 'bill_number_fy_start_month', 'receiving_prefix', /* Invoices (INVOICING_MODULE_DESIGN): the document number prefix and the credit days a new invoice's due date is counted from. invoice_terms, the diff --git a/api/src/utils/bill-number.js b/api/src/utils/bill-number.js new file mode 100644 index 000000000..056e1e396 --- /dev/null +++ b/api/src/utils/bill-number.js @@ -0,0 +1,244 @@ +'use strict'; + +/* + * WHAT A BILL NUMBER IS ALLOWED TO LOOK LIKE. + * + * Owner: "we need year pattern required in the sales bill number example + * attached have 26 in the year... you suggest per day increase or year wise + * reset better tell me international standards." And, on the answer: "i accept + * recommandation and may configurable if people from EU and international." + * + * THE RULE THAT DECIDES ALMOST EVERYTHING + * + * India, CGST Rules 2017, Rule 46(b). A tax invoice carries + * + * a consecutive serial number, NOT EXCEEDING SIXTEEN CHARACTERS, in one or + * multiple series, containing alphabets or numerals or special characters + * hyphen or dash and slash symbolised as "-" and "/" respectively, and any + * combination thereof, UNIQUE FOR A FINANCIAL YEAR. + * + * Three things follow, and they are not negotiable: + * + * 1. Sixteen characters is a hard ceiling, not a guideline. The shop's own + * reference invoice is exactly sixteen - VR26-27VIR006782 - because the + * people who built it ran into the same wall. + * 2. Only "-" and "/" may join the parts. No underscore, no space, no dot. + * 3. The financial year is the unit of uniqueness, which is why the series + * resets with it and not with the day. + * + * WHY NOT PER DAY, WHICH IS THE OTHER THING PEOPLE ASK FOR + * + * A daily reset repeats numbers inside the year unless the date is part of the + * number, and a date costs six to eight of the sixteen characters. It also + * destroys the one property an auditor actually uses: a single consecutive run + * per year has visible gaps when something is cancelled, and three hundred and + * sixty five short runs do not. + * + * ELSEWHERE + * + * The EU VAT Directive 2006/112/EC Article 226(2) asks only for "a sequential + * number, based on one or more series, which uniquely identifies the invoice" - + * no length limit and no prescribed reset. So the year segment and the reset + * are SETTINGS: a shop outside India can turn the year off, or reset on the + * calendar year, and nothing here objects. What is never configurable is the + * charset, because a character outside the allowed set is wrong everywhere. + */ + +/** Rule 46(b). The whole reason this module has to be careful. */ +const MAX_LENGTH = 16; + +/** Letters, digits, and the only two separators the rule permits. */ +const ALLOWED = /^[A-Za-z0-9/-]+$/; + +/** Never narrower than this: 999 bills is not a year. */ +const MIN_SEQUENCE_WIDTH = 4; + +/* + * When a financial year starts, by month, 1-12. + * + * India runs April to March, which is why the reference invoice says "26-27" + * rather than a single year: one financial year spans two calendar ones. A + * shop on the calendar year sets this to January and gets a single year back. + */ +const DEFAULT_FY_START_MONTH = 4; + +/** off | financial | calendar. What a shop can choose. */ +const RESET_MODES = Object.freeze(['off', 'financial', 'calendar']); + +function int(value, fallback) { + const n = Number(value); + return Number.isInteger(n) ? n : fallback; +} + +/** + * Which numbering period a date falls in. + * + * `key` is what the counter is keyed on, so a new period starts at 1. `label` + * is what goes in the number, and it is SHORT on purpose: two characters buys + * two more digits of sequence, and on a sixteen-character budget that is the + * difference between 9,999 bills a year and 999,999. + * + * @returns {{key: string, label: string, mode: string}} + */ +function periodFor(date = new Date(), options = {}) { + const mode = RESET_MODES.includes(options.reset) ? options.reset : 'off'; + const d = date instanceof Date ? date : new Date(date); + if (Number.isNaN(d.getTime())) return { key: '', label: '', mode: 'off' }; + + if (mode === 'calendar') { + const y = d.getFullYear(); + return { key: String(y), label: String(y % 100).padStart(2, '0'), mode }; + } + + if (mode === 'financial') { + const start = int(options.financialYearStartMonth, DEFAULT_FY_START_MONTH); + const month = d.getMonth() + 1; + /* Before April, a date belongs to the financial year that began LAST + April. Getting this backwards renumbers every bill in the first quarter, + which is the kind of mistake that is only noticed in an audit. */ + const startYear = month >= start ? d.getFullYear() : d.getFullYear() - 1; + const endYear = startYear + 1; + return { + key: `${startYear}-${endYear}`, + /* The year it ENDS in, which is what a reader recognises: a bill handed + over in February 2027 says 27. */ + label: String(endYear % 100).padStart(2, '0'), + mode, + }; + } + + return { key: '', label: '', mode: 'off' }; +} + +/** + * Build a number that fits, and say so when it cannot. + * + * The parts are fixed-width except the sequence, so the budget is worked out + * ONCE and the sequence takes what is left. Shedding digits from the sequence + * is the only safe way to lose characters: dropping the year would break + * uniqueness for the financial year, and dropping the till code would let two + * tills mint the same number. + * + * @returns {{number: string, ok: boolean, width: number, warning: string}} + */ +function compose(parts = {}) { + const typeLetter = String(parts.typeLetter || '').trim(); + const branchCode = String(parts.branchCode || '').trim(); + const deviceCode = String(parts.deviceCode || '').trim(); + const wantPeriod = String(parts.period || '').trim(); + const sequence = Math.max(0, int(parts.sequence, 0)); + const wanted = Math.max(MIN_SEQUENCE_WIDTH, int(parts.sequenceWidth, 6)); + + const head = `${typeLetter}${branchCode}${deviceCode}`; + + /* + * THE SEQUENCE SETS THE FLOOR, not the padding. + * + * padStart only ever pads: a counter at 12,345,678 stays eight digits in a + * four-wide field, and the number comes out seventeen characters. So the + * space the digits ACTUALLY need is worked out first, and everything else is + * fitted around it. That bug reached a test rather than a shop, which is the + * only reason this comment is short. + */ + const digitsNeeded = Math.max(String(sequence).length, MIN_SEQUENCE_WIDTH); + + const build = (h, period, width) => { + const bits = []; + if (h) bits.push(h); + if (period) bits.push(period); + bits.push(String(sequence).padStart(width, '0')); + return bits.join('-'); + }; + + /* 1. Everything, at the width the shop asked for. */ + const ideal = build(head, wantPeriod, Math.max(wanted, digitsNeeded)); + if (ideal.length <= MAX_LENGTH) { + return { number: ideal, ok: true, width: Math.max(wanted, digitsNeeded), warning: '' }; + } + + /* + * 2. Narrow the padding. The only safe thing to shed first: dropping the + * year would break uniqueness for the financial year, and dropping the till + * code would let two tills mint the same number. + */ + const narrowed = build(head, wantPeriod, digitsNeeded); + if (narrowed.length <= MAX_LENGTH) { + return { + number: narrowed, + ok: false, + width: digitsNeeded, + warning: + `The running number was shortened to ${digitsNeeded} digits to stay inside the ` + + `${MAX_LENGTH} characters a tax invoice is allowed.`, + }; + } + + /* 3. The year goes, rather than the legality. */ + if (wantPeriod) { + const bare = build(head, '', digitsNeeded); + if (bare.length <= MAX_LENGTH) { + return { + number: bare, + ok: false, + width: digitsNeeded, + warning: + `A bill number with the year does not fit in ${MAX_LENGTH} characters, so the ` + + `year was left out. Shorten the branch or till code, or switch the year off.`, + }; + } + } + + /* + * 4. The prefix itself is longer than a whole invoice number may be. Cut it, + * because handing over eighteen characters is handing over an illegal + * document. + */ + const keep = Math.max(0, MAX_LENGTH - digitsNeeded - 1); + const cut = head.slice(0, keep); + const number = build(cut, '', digitsNeeded).slice(0, MAX_LENGTH); + return { + number, + ok: false, + width: digitsNeeded, + warning: head + ? `The prefix "${head}" is too long for a ${MAX_LENGTH} character invoice number; ` + + `it was cut to "${cut}". Set a shorter prefix.` + : `The running number has outgrown ${MAX_LENGTH} characters. Start a new series.`, + }; +} + +/** + * Is this a number a tax invoice may carry? + * + * Checked separately from building one, because numbers also arrive from + * elsewhere - an imported sale, a shop's own prefix, a number typed by hand - + * and the rule applies to all of them equally. + */ +function validate(value) { + const s = String(value == null ? '' : value); + if (!s) return { ok: false, reason: 'A bill number cannot be empty.' }; + if (s.length > MAX_LENGTH) { + return { + ok: false, + reason: `${s.length} characters. A tax invoice number may not exceed ${MAX_LENGTH}.`, + }; + } + if (!ALLOWED.test(s)) { + return { + ok: false, + reason: 'Only letters, numbers, hyphen and slash are allowed in a bill number.', + }; + } + return { ok: true, reason: '' }; +} + +module.exports = { + MAX_LENGTH, + ALLOWED, + MIN_SEQUENCE_WIDTH, + DEFAULT_FY_START_MONTH, + RESET_MODES, + periodFor, + compose, + validate, +}; diff --git a/api/tests/unit/repositories/sale.repository.test.js b/api/tests/unit/repositories/sale.repository.test.js index 8fdf7b709..9fd0cf76d 100644 --- a/api/tests/unit/repositories/sale.repository.test.js +++ b/api/tests/unit/repositories/sale.repository.test.js @@ -443,7 +443,10 @@ describe('SalesRepository', () => { const r = await salesRepository.generateSalesIdForBranch(FAKE_BRANCH); expect(collections.counters.updateOne).toHaveBeenCalledWith( expect.objectContaining({ kind: 'sales_id' }), - { $setOnInsert: { seq: 42 } }, + /* Empty: this shop has not asked to number by year. The row carries + the period it is counting so a roll-over can be spotted without a + second counter, and empty is what every existing row means. */ + { $setOnInsert: { seq: 42, period_key: '' } }, { upsert: true } ); expect(r).toBe('S-DEV1-000043'); @@ -512,7 +515,10 @@ describe('SalesRepository', () => { expect(collections.counters.updateOne).toHaveBeenCalledWith( expect.objectContaining({ kind: 'sales_id' }), - { $max: { seq: 27 } }, + /* The period is stamped alongside, or catching up would leave the row + claiming a year it is no longer counting and the very next bill + would roll over a second time. */ + { $max: { seq: 27 }, $set: { period_key: '' } }, { upsert: true } ); expect(id).toBe('S-DEV1-000028'); diff --git a/api/tests/unit/repositories/the-year-on-a-bill-number.test.js b/api/tests/unit/repositories/the-year-on-a-bill-number.test.js new file mode 100644 index 000000000..af4e3ae03 --- /dev/null +++ b/api/tests/unit/repositories/the-year-on-a-bill-number.test.js @@ -0,0 +1,304 @@ +'use strict'; + +/* + * The year on a bill number, and the moment it turns over. + * + * Owner: "we need year pattern required in the sales bill number example + * attached have 26 in the year. and then number increasing would be okay. you + * suggest per day increase or year wise reset better tell me international + * standards." And on the answer: "i accept recommandation and may configurable + * if people from EU and international." + * + * The rule everything here obeys is CGST Rule 46(b): a consecutive serial + * number, NOT EXCEEDING SIXTEEN CHARACTERS, of letters, digits, "-" and "/", + * UNIQUE FOR A FINANCIAL YEAR. utils/bill-number.js holds the shape of a legal + * number and is tested on its own. This is about the two things only the + * repository can get wrong: + * + * 1. WHEN the counter starts again, which is one atomic step against a real + * database rather than a read, a compare and a write. A year turns over + * at midnight in a restaurant that is still serving, and two tills billing + * in that second must not both be given number one. + * + * 2. WHOSE MIDNIGHT it is. A bill rung up at half past midnight on the first + * of April in Chennai belongs to the new financial year; a cloud instance + * running in UTC would still call it March. + * + * Run against a real mongod, because the roll-over is an aggregation pipeline + * update and a mock of one would only prove that I can write down what I + * already believe. + */ + +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); + +const repo = require('../../../src/repositories/sale.repository'); +const BaseModel = require('../../../src/models/base.model'); + +let mem; +let db; + +const BRANCH = new mongoose.Types.ObjectId(); +const LICENSE = new mongoose.Types.ObjectId(); + +const shop = (over = {}) => ({ + _id: BRANCH, + license: LICENSE, + sales_prefix: 'S', + time_zone: 'Asia/Kolkata', + ...over, +}); + +beforeAll(async () => { + mem = await MongoMemoryServer.create(); + await mongoose.connect(mem.getUri('posnic')); + db = mongoose.connection.db; +}, 120000); + +afterAll(async () => { + await mongoose.disconnect(); + if (mem) await mem.stop(); +}); + +beforeEach(async () => { + jest.spyOn(BaseModel, 'getDb').mockResolvedValue(db); + jest.spyOn(console, 'warn').mockImplementation(() => {}); + await db.collection('counters').deleteMany({}); + await db.collection('sales').deleteMany({}); + await db.collection('branches').deleteMany({}); + await db.collection('device_meta').deleteMany({}); + await db.collection('branches').insertOne(shop()); + /* The gateway-assigned till code, which is what makes SB1D14. */ + await db.collection('device_meta').insertOne({ _id: 'device_code', code: 'D14' }); + repo.constructor._branchCodes = undefined; + repo.constructor._deviceCode = undefined; + repo.constructor._deviceTag = undefined; + repo.constructor._countersIndexEnsured = false; +}); + +afterEach(() => jest.restoreAllMocks()); + +/* ------------------------------------------------------ whose midnight */ + +describe('which year a bill belongs to', () => { + const on = (iso, over = {}) => repo._billPeriod(shop(over), new Date(iso)); + + test('A SHOP THAT HAS NOT ASKED HAS NO PERIOD AT ALL', () => { + /* Every shop in the estate, until somebody turns it on. An empty period + produces exactly the number this produced before any of it existed. */ + expect(on('2026-09-16T10:00:00Z')).toEqual({ key: '', label: '', mode: 'off' }); + expect(on('2026-09-16T10:00:00Z', { bill_number_reset: '' }).mode).toBe('off'); + expect(on('2026-09-16T10:00:00Z', { bill_number_reset: 'off' }).mode).toBe('off'); + }); + + test('a value nobody recognises is off, not a guess', () => { + /* A form posts strings. "Financial" with a capital, or a value from a + newer build, must never restart a shop's invoice series. */ + expect(on('2026-09-16T10:00:00Z', { bill_number_reset: 'yearly' }).mode).toBe('off'); + expect(on('2026-09-16T10:00:00Z', { bill_number_reset: 'Financial' }).mode).toBe('off'); + }); + + test("THE INDIAN FINANCIAL YEAR TURNS OVER IN THE SHOP'S OWN MIDNIGHT", () => { + /* + * 2026-03-31T18:31:00Z is 2026-04-01T00:01 in Chennai. On the server's + * clock that bill is still March and belongs to the year that closed an + * hour ago - a bill numbered into a financial year that has ended, which + * is the kind of thing an auditor finds rather than a test. + */ + const india = { bill_number_reset: 'financial' }; + expect(on('2026-03-31T18:29:00Z', india).key).toBe('2025-2026'); + expect(on('2026-03-31T18:31:00Z', india).key).toBe('2026-2027'); + }); + + test('and a shop in another zone turns over on its own clock', () => { + const london = { bill_number_reset: 'financial', time_zone: 'Europe/London' }; + /* 23:00 UTC on 31 March is still 31 March in London (BST, so midnight). */ + expect(on('2026-03-31T22:00:00Z', london).key).toBe('2025-2026'); + expect(on('2026-04-01T09:00:00Z', london).key).toBe('2026-2027'); + }); + + test('the label is the year the financial year ENDS in, which is how India says it', () => { + /* "FY27" in India means April 2026 to March 2027. Two characters, because + two characters buys four more digits of running number on a sixteen + character budget. */ + expect(on('2026-09-16T10:00:00Z', { bill_number_reset: 'financial' }).label).toBe('27'); + expect(on('2027-02-16T10:00:00Z', { bill_number_reset: 'financial' }).label).toBe('27'); + expect(on('2027-04-16T10:00:00Z', { bill_number_reset: 'financial' }).label).toBe('28'); + }); + + test('a shop on the calendar year gets the calendar year', () => { + const eu = { bill_number_reset: 'calendar', time_zone: 'Europe/Berlin' }; + expect(on('2026-09-16T10:00:00Z', eu)).toMatchObject({ key: '2026', label: '26' }); + expect(on('2026-12-31T23:30:00Z', eu).key).toBe('2027'); + }); + + test('a shop can start its financial year in any month', () => { + const uk = { bill_number_reset: 'financial', bill_number_fy_start_month: 1 }; + expect(on('2026-03-16T10:00:00Z', uk).key).toBe('2026-2027'); + }); + + test('A TIME ZONE NOBODY CAN READ DOES NOT STOP A SHOP BILLING', () => { + /* Off is what every shop had before this existed, and a shop that cannot + bill is a shop that cannot trade. */ + const broken = { bill_number_reset: 'financial', time_zone: 'Mars/Olympus_Mons' }; + expect(() => on('2026-09-16T10:00:00Z', broken)).not.toThrow(); + /* And whatever it decides, it is a period a legal number can be built + from: two digits or nothing, never an Invalid Date turned into text. */ + const said = on('2026-09-16T10:00:00Z', broken); + expect(said.label).toMatch(/^(\d\d)?$/); + expect(said.key === '' || /^\d{4}(-\d{4})?$/.test(said.key)).toBe(true); + }); +}); + +/* -------------------------------------------------- the counter itself */ + +describe('when the counter starts again', () => { + const next = (period) => repo.nextSalesNumberForBranch(BRANCH, LICENSE, { period }); + + test('A COUNTER WRITTEN BEFORE ANY OF THIS KEEPS COUNTING', () => { + /* + * The one that matters on merge day. Every counter in the estate was + * written without a period_key; if an absent field read as a mismatch, + * the first bill on every shop would restart at one and collide with a + * number the shop had already issued. + */ + return db + .collection('counters') + .insertOne({ + kind: 'sales_id', + branch_key: String(BRANCH), + license_key: String(LICENSE), + seq: 41, + }) + .then(async () => { + expect(await next({ key: '', label: '' })).toBe(42); + expect(await next({ key: '', label: '' })).toBe(43); + }); + }); + + test('turning the year on starts a new series at one', async () => { + await db.collection('counters').insertOne({ + kind: 'sales_id', + branch_key: String(BRANCH), + license_key: String(LICENSE), + seq: 900, + }); + expect(await next({ key: '2026-2027', label: '27' })).toBe(1); + expect(await next({ key: '2026-2027', label: '27' })).toBe(2); + }); + + test('AND THE YEAR TURNING OVER DOES THE SAME, without a second counter', async () => { + expect(await next({ key: '2026-2027', label: '27' })).toBe(1); + expect(await next({ key: '2026-2027', label: '27' })).toBe(2); + expect(await next({ key: '2027-2028', label: '28' })).toBe(1); + expect(await next({ key: '2027-2028', label: '28' })).toBe(2); + /* One row, because the unique index on this collection allows exactly + one per branch and licence. */ + const rows = await db.collection('counters').find({ kind: 'sales_id' }).toArray(); + expect(rows).toHaveLength(1); + expect(rows[0].period_key).toBe('2027-2028'); + }); + + test('TWO TILLS BILLING IN THE SAME SECOND GET DIFFERENT NUMBERS', async () => { + /* + * The reason the roll-over is one pipeline update rather than a read, a + * compare and a write. Midnight on the first of April is a restaurant + * still serving, and two numbers that are both 1 is two bills that are + * both SB1D14-28-000001. + */ + const got = await Promise.all( + Array.from({ length: 12 }, () => next({ key: '2027-2028', label: '28' })) + ); + expect(new Set(got).size).toBe(12); + expect(Math.min(...got)).toBe(1); + expect(Math.max(...got)).toBe(12); + }); + + test('a counter that has fallen behind catches up WITHIN its own year', async () => { + /* + * The recovery path, after a number came back taken. Last year's numbers + * are higher than this year's and are not this year's problem: catching + * up to them would jump the series from 4 to 901, which is a gap somebody + * asks about. + */ + await db.collection('sales').insertMany([ + { branch_id: BRANCH, license: LICENSE, sales_id: 'SB1D14-27-000900' }, + { branch_id: BRANCH, license: LICENSE, sales_id: 'SB1D14-28-000004' }, + ]); + await db.collection('counters').insertOne({ + kind: 'sales_id', + branch_key: String(BRANCH), + license_key: String(LICENSE), + seq: 2, + period_key: '2027-2028', + }); + + expect( + await repo.nextSalesNumberForBranch(BRANCH, LICENSE, { + reseed: true, + period: { key: '2027-2028', label: '28' }, + }) + ).toBe(5); + }); + + test('and with no year set it still catches up to everything', async () => { + await db.collection('sales').insertMany([ + { branch_id: BRANCH, license: LICENSE, sales_id: 'SB1D14-000027' }, + { branch_id: BRANCH, license: LICENSE, sales_id: 'SB1D14-000009' }, + ]); + await db.collection('counters').insertOne({ + kind: 'sales_id', + branch_key: String(BRANCH), + license_key: String(LICENSE), + seq: 5, + }); + expect( + await repo.nextSalesNumberForBranch(BRANCH, LICENSE, { + reseed: true, + period: { key: '', label: '' }, + }) + ).toBe(28); + }); +}); + +/* ------------------------------------------------- the number it makes */ + +describe('the number a shop hands over', () => { + test('A SHOP THAT HAS NOT ASKED GETS EXACTLY WHAT IT GOT BEFORE', async () => { + expect(await repo.generateSalesIdForBranch(BRANCH)).toBe('SB1D14-000001'); + expect(await repo.generateSalesIdForBranch(BRANCH)).toBe('SB1D14-000002'); + }); + + test('and a shop that asked for the year gets it, inside sixteen characters', async () => { + await db + .collection('branches') + .updateOne({ _id: BRANCH }, { $set: { bill_number_reset: 'financial' } }); + const number = await repo.generateSalesIdForBranch(BRANCH); + expect(number).toMatch(/^SB1D14-\d\d-000001$/); + expect(number.length).toBe(16); + /* Rule 46(b) again: letters, digits, hyphen and slash, and nothing else. */ + expect(number).toMatch(/^[A-Za-z0-9/-]+$/); + }); + + test('THE SIXTEEN CHARACTERS HOLD EVEN WHEN THE SHOP HAS LONG CODES', async () => { + /* + * A sixteen-character ceiling is not a guideline: a longer number is an + * invoice that does not comply. Something has to give, and it is the + * padding on the running number - never the year, which uniqueness + * depends on, and never the till code, without which two tills mint the + * same number. + */ + await db + .collection('device_meta') + .updateOne({ _id: 'device_code' }, { $set: { code: 'DEVICE99' } }); + repo.constructor._deviceCode = undefined; + await db + .collection('branches') + .updateOne({ _id: BRANCH }, { $set: { bill_number_reset: 'calendar' } }); + + const number = await repo.generateSalesIdForBranch(BRANCH); + expect(number.length).toBeLessThanOrEqual(16); + expect(number).toContain('DEVICE99'); + expect(number).toMatch(/\d\d-\d+$/); + }); +}); diff --git a/api/tests/unit/services/sale.service.test.js b/api/tests/unit/services/sale.service.test.js index bb43907b7..7927021b7 100644 --- a/api/tests/unit/services/sale.service.test.js +++ b/api/tests/unit/services/sale.service.test.js @@ -37,6 +37,7 @@ jest.mock('../../../src/repositories/sale.repository', () => ({ createSaleUnique: jest.fn(), buildSalesId: jest.fn(), buildDocNumber: jest.fn(), + generateSalesIdForBranch: jest.fn(), deviceTag: jest.fn(), getById: jest.fn(), save: jest.fn(), @@ -145,6 +146,18 @@ describe('SalesService', () => { async (type, branchId, n, opts) => `${(opts && opts.fallbackPrefix) || 'INV'}-TEST-${String(n).padStart(6, '0')}` ); + /* + * The service asks for the WHOLE number now, not for a count it then + * formats itself. The counter and the format have to move together: they + * are shared with the customer's own ordering page, and a shop numbering + * by financial year would otherwise have had the year on one and not the + * other while both drew on one counter. The number still comes from the + * counter mock, so the assertions below still say what they said. + */ + salesRepository.generateSalesIdForBranch.mockImplementation(async (branchId, opts) => { + const n = await salesRepository.nextSalesNumberForBranch(branchId); + return `${(opts && opts.fallbackPrefix) || 'INV'}-TEST-${String(n).padStart(6, '0')}`; + }); salesRepository.createSaleUnique.mockImplementation((data) => salesRepository.create(data)); salesRepository.save.mockResolvedValue({ _id: 'savedId' }); mockCustomerRepositoryInstance.findById.mockResolvedValue(null); diff --git a/api/tests/unit/utils/a-bill-number-a-tax-invoice-may-carry.test.js b/api/tests/unit/utils/a-bill-number-a-tax-invoice-may-carry.test.js new file mode 100644 index 000000000..04c342928 --- /dev/null +++ b/api/tests/unit/utils/a-bill-number-a-tax-invoice-may-carry.test.js @@ -0,0 +1,305 @@ +'use strict'; + +/* + * The year in the bill number, and the sixteen characters it has to live in. + * + * Owner, with a photograph of a hotel's tax invoice numbered + * VR26-27VIR006782: "we need year pattern required in the sales bill number... + * you suggest per day increase or year wise reset better tell me international + * standards." Then: "i accept recommandation and may configurable if people + * from EU and international." And: "Blocker fix that." + * + * THE BLOCKER + * + * India's CGST Rules 2017, Rule 46(b): a tax invoice number is a consecutive + * serial, NOT EXCEEDING SIXTEEN CHARACTERS, made only of letters, numerals and + * the two separators "-" and "/", UNIQUE FOR A FINANCIAL YEAR. + * + * Our number is already SB1D14-000051 - thirteen characters of type letter, + * branch, till and a six digit counter. Adding "26-27" the obvious way gives + * eighteen, which is not a legal invoice number. That is the blocker, and the + * reference invoice is exactly sixteen because whoever built it hit the same + * wall. + * + * WHY A YEAR AND NOT A DAY + * + * The financial year is the unit of uniqueness the rule names. A daily reset + * repeats numbers inside the year unless the date is in the number, and a date + * costs six to eight of the sixteen characters. It also destroys the property + * an auditor uses: one consecutive run per year shows a gap when a bill is + * cancelled; three hundred and sixty five short runs show nothing. + */ + +const assert = require('node:assert'); + +const bill = require('../../../src/utils/bill-number'); + +/* ------------------------------------------------------------ the year */ + +test('the Indian financial year runs April to March, not January to December', () => { + const fy = (y, m, d) => bill.periodFor(new Date(y, m - 1, d), { reset: 'financial' }); + + assert.strictEqual(fy(2026, 9, 14).key, '2026-2027', 'September 2026 is FY 2026-27'); + assert.strictEqual( + fy(2027, 3, 31).key, + '2026-2027', + 'the last day of March is still the old year' + ); + assert.strictEqual(fy(2027, 4, 1).key, '2027-2028', 'the first of April starts a new one'); +}); + +test('A DATE IN JANUARY BELONGS TO THE YEAR THAT BEGAN LAST APRIL', () => { + /* + * The one that would be wrong without thinking about it, and the damage is + * invisible until an audit: every bill from January to March gets the next + * year's number, so one financial year holds two series and neither is + * consecutive. + */ + for (const month of [1, 2, 3]) { + const p = bill.periodFor(new Date(2027, month - 1, 15), { reset: 'financial' }); + assert.strictEqual(p.key, '2026-2027', `month ${month} of 2027 is still FY 2026-27`); + assert.strictEqual(p.label, '27'); + } +}); + +test('the label is the year it ENDS in, which is what a customer reads', () => { + /* A bill handed over in February 2027 says 27, the same as one from + September 2026, because they are the same financial year. */ + assert.strictEqual(bill.periodFor(new Date(2026, 8, 14), { reset: 'financial' }).label, '27'); + assert.strictEqual(bill.periodFor(new Date(2027, 1, 15), { reset: 'financial' }).label, '27'); +}); + +test('a shop outside India can use the calendar year, or none at all', () => { + /* Owner: "may configurable if people from EU and international." The EU VAT + Directive asks only for a sequential number that uniquely identifies the + invoice - no length limit, no prescribed reset. */ + const cal = bill.periodFor(new Date(2027, 0, 1), { reset: 'calendar' }); + assert.strictEqual(cal.key, '2027'); + assert.strictEqual(cal.label, '27'); + + const off = bill.periodFor(new Date(2027, 0, 1), { reset: 'off' }); + assert.strictEqual(off.key, ''); + assert.strictEqual(off.label, '', 'a shop that wants no year got one anyway'); +}); + +test('and the financial year start month itself is a setting', () => { + /* The UK runs April too, Australia July, the US October for federal. */ + const july = bill.periodFor(new Date(2026, 7, 1), { + reset: 'financial', + financialYearStartMonth: 7, + }); + assert.strictEqual(july.key, '2026-2027'); + const june = bill.periodFor(new Date(2026, 5, 1), { + reset: 'financial', + financialYearStartMonth: 7, + }); + assert.strictEqual(june.key, '2025-2026', 'June is before a July year starts'); +}); + +test('rubbish in does not become a confident wrong year', () => { + assert.strictEqual(bill.periodFor(new Date('nonsense'), { reset: 'financial' }).key, ''); + assert.strictEqual(bill.periodFor(new Date(2026, 1, 1), { reset: 'nonsense' }).key, ''); +}); + +/* ------------------------------------------------- sixteen characters */ + +test('THE BLOCKER: the real number fits, with the year and six digits', () => { + /* SB1D14-000051 was thirteen. This is the whole point of the exercise. */ + const r = bill.compose({ + typeLetter: 'S', + branchCode: 'B1', + deviceCode: 'D14', + period: '27', + sequence: 51, + }); + assert.strictEqual(r.number, 'SB1D14-27-000051'); + assert.strictEqual(r.number.length, 16); + assert.ok(r.ok, r.warning); + assert.ok(bill.validate(r.number).ok); +}); + +test('NOTHING IT RETURNS IS EVER LONGER THAN SIXTEEN', () => { + /* + * The rule this module exists to keep. An over-length number is not a + * cosmetic problem: it is an invoice somebody else finds months later, on a + * document that cannot be reissued. + */ + const codes = ['', 'B1', 'B10', 'B100', 'BRANCH99']; + const devices = ['', 'D1', 'D14', 'D1000', 'DEVICE99']; + const periods = ['', '27', '2027']; + for (const branchCode of codes) { + for (const deviceCode of devices) { + for (const period of periods) { + for (const sequence of [0, 1, 999999, 12345678]) { + const r = bill.compose({ + typeLetter: 'S', + branchCode, + deviceCode, + period, + sequence, + }); + const v = bill.validate(r.number); + assert.ok( + v.ok, + `illegal number ${JSON.stringify(r.number)} (${r.number.length}) from ` + + `${branchCode}/${deviceCode}/${period}/${sequence}: ${v.reason}` + ); + } + } + } + } +}); + +test('when it does not fit, the SEQUENCE narrows before anything else', () => { + /* + * The only safe thing to shed. Dropping the year would break uniqueness for + * the financial year; dropping the till code would let two tills mint the + * same number. + */ + const r = bill.compose({ + typeLetter: 'S', + branchCode: 'B10', + deviceCode: 'D100', + period: '27', + sequence: 51, + }); + assert.strictEqual(r.number, 'SB10D100-27-0051'); + assert.strictEqual(r.number.length, 16); + assert.match(r.warning, /shortened to 4 digits/); + assert.ok(!r.ok, 'a shortened counter should still be reported'); +}); + +test('and if even that will not do, the YEAR goes - never the legality', () => { + const r = bill.compose({ + typeLetter: 'S', + branchCode: 'B100', + deviceCode: 'D1000', + period: '27', + sequence: 51, + }); + assert.ok(bill.validate(r.number).ok, 'returned something illegal'); + assert.ok(!r.number.includes('-27-'), 'kept the year and broke the length'); + assert.match(r.warning, /year was left out/); +}); + +test('a prefix longer than a whole invoice number is cut, not returned', () => { + /* + * This is where the first version recursed until the stack gave out: it + * handled "too long" by calling itself without the year, and without the + * year it was still too long. + */ + let r; + assert.doesNotThrow(() => { + r = bill.compose({ + typeLetter: 'S', + branchCode: 'BRANCH99', + deviceCode: 'DEVICE99', + period: '27', + sequence: 1, + }); + }, 'composing a long prefix threw'); + assert.ok(bill.validate(r.number).ok); + assert.match(r.warning, /Set a shorter prefix/); +}); + +/* --------------------------------------------------------- the charset */ + +test('only letters, numbers, hyphen and slash - the rule names those two', () => { + assert.ok(bill.validate('SB1D14-27-000051').ok); + assert.ok(bill.validate('VR26-27VIR006782').ok, "the owner's own reference invoice"); + assert.ok(bill.validate('INV/2027/00001').ok, 'slash is allowed too'); + + for (const bad of ['SB1_D14-000051', 'SB1 D14-000051', 'SB1.D14-000051', 'SB1#14']) { + assert.ok(!bill.validate(bad).ok, `${bad} should be refused`); + } +}); + +test('and the ceiling is checked on anything, not only what we build', () => { + /* Numbers arrive from imports and from a shop's own prefix too. */ + assert.ok(!bill.validate('SB1D14-2026-2027-000051').ok); + assert.match(bill.validate('SB1D14-2026-2027-000051').reason, /may not exceed 16/); + assert.ok(!bill.validate('').ok); +}); + +test('the reference invoice is exactly at the ceiling, which is the point', () => { + /* VR26-27VIR006782. Sixteen characters, not a coincidence - it is what a + shop ends up with when it designs against this rule. */ + assert.strictEqual('VR26-27VIR006782'.length, bill.MAX_LENGTH); +}); + +/* --------------------------------------- it renamed nothing on the way in */ + +test('WITH NO YEAR ASKED FOR IT PRODUCES EXACTLY THE OLD NUMBER, 72 times over', () => { + /* + * The one that matters on merge day. + * + * buildDocNumber used to be a single template literal. Every bill number on + * ninety shops has its shape, every sales report sorts on it, and the unique + * index that stops two tills issuing one number is built on it. Routing it + * through compose() is only safe if, for a shop that has asked for nothing, + * the answer is the same byte for byte - so it is checked against the + * expression it replaced rather than against what I meant to write. + * + * SALES ONLY. buildDocNumber takes an `isReturn` flag that puts "R-" on the + * front, and nothing in the product passes it. Writing this test found that + * the old expression produced R-SB12D999-000001 - SEVENTEEN characters, and + * not a number a tax invoice may carry. compose() shortens it to fifteen and + * says why, so that path is now legal where it was not; it is left out of + * this comparison deliberately rather than pinned to the old wrong answer. + */ + const theOldWay = (typeLetter, branchCode, deviceCode, n) => + `${typeLetter}${branchCode}${deviceCode}-${String(n).padStart(6, '0')}`; + + let checked = 0; + for (const branchCode of ['B1', 'B2', 'B12']) { + for (const deviceCode of ['D1', 'D14', 'D999']) { + for (const n of [1, 7, 42, 999, 1000, 51, 123456, 999999]) { + const built = bill.compose({ + typeLetter: 'S', + branchCode, + deviceCode, + period: '', + sequence: n, + sequenceWidth: 6, + }); + assert.strictEqual( + built.number, + theOldWay('S', branchCode, deviceCode, n), + `renamed a bill: ${branchCode} ${deviceCode} ${n}` + ); + assert.strictEqual(built.ok, true, 'the old shape no longer fits'); + checked += 1; + } + } + } + assert.ok(checked >= 72, `only ${checked} combinations covered`); +}); + +test('and with a year asked for it stays inside the sixteen', () => { + /* Same shapes, with two characters of year added. The running number is + what gives way, and only when it has to. */ + for (const branchCode of ['B1', 'B2', 'B12']) { + for (const deviceCode of ['D1', 'D14', 'D999']) { + for (const n of [1, 999999]) { + const built = bill.compose({ + typeLetter: 'S', + branchCode, + deviceCode, + period: '27', + sequence: n, + sequenceWidth: 6, + }); + assert.ok( + built.number.length <= bill.MAX_LENGTH, + `${built.number} is ${built.number.length} characters` + ); + assert.match(built.number, /^[A-Za-z0-9/-]+$/); + /* The year is never the thing dropped while anything else can give: + uniqueness for the financial year is what it is there for. */ + if (built.number.length === bill.MAX_LENGTH || built.ok) { + assert.match(built.number, /-27-/); + } + } + } + } +}); diff --git a/frontend/modules/settings_write.html b/frontend/modules/settings_write.html index c80a19c0c..650621c04 100644 --- a/frontend/modules/settings_write.html +++ b/frontend/modules/settings_write.html @@ -4451,6 +4451,51 @@
    placeholder="Ex :- S (or leave blank)" data-t-placeholder="lang_ex_s_or_leave_blank">
    + +
    +
    + + + Turning this on starts the numbering + again at one. Best done on the first day of a year. +
    +
    + + + + April in India, January in much of + the EU. +
    +

    Placed --

    + + + +
    +
    + + + + The alarm keeps asking either way, backing off as it goes. + +
    +
    + + + + Counted from when the order arrived. + +
    +