From b3329fc9f3d315f563db2fd1311cec3cfb9cd35c Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Mon, 28 Sep 2026 10:46:21 +0530 Subject: [PATCH 1/4] Add Captain service preparation, delivery status and staff controls Signed-off-by: Sridhar Bala --- api/src/controllers/items.controller.js | 2 + api/src/controllers/sales.controller.js | 61 +++++- api/src/helpers/bill-payload.js | 10 +- api/src/helpers/kitchen-rounds.js | 57 ++++-- api/src/helpers/kot-notify.js | 6 +- api/src/repositories/sale.repository.js | 193 ++++++++++++++---- api/src/routes/authorizations.routes.js | 7 +- api/src/routes/sales.routes.js | 31 +++ api/src/services/authorization.service.js | 2 + api/src/services/captain-access.js | 2 +- api/src/services/captain-edit-policy.js | 74 +++++++ api/src/services/captain-payments.js | 25 ++- api/src/services/captain-service.js | 159 +++++++++++++++ api/src/services/guest-bill.service.js | 32 ++- api/src/services/kitchen-delivery.js | 91 +++++++++ api/src/services/sale.service.js | 3 +- api/src/utils/currency.js | 113 ++++++++++ api/src/utils/order-line.js | 31 +++ api/src/utils/service-line.js | 45 ++++ .../an-order-changed-under-you.test.js | 65 ++++++ .../unit/services/captain-payments.test.js | 24 +++ .../unit/services/captain-service.test.js | 151 ++++++++++++++ docs/API.md | 12 +- docs/openapi.json | 142 +++++++++++++ frontend/pages_css_js_map.json | 1 + frontend/static/script/js/core/PosnicPro.js | 2 +- frontend/static/script/js/core/ajax.js | 27 +++ .../static/script/js/core/captain-money.js | 113 ++++++++++ .../static/script/js/core/captain-payments.js | 18 +- src/escpos-kot.js | 9 +- src/escpos-receipt.js | 3 +- src/hardware-ipc.js | 1 + src/kitchen-call.js | 12 +- src/kitchen-screen-feed.js | 33 ++- src/kitchen-screen.html | 4 + src/kitchen-screen.js | 31 ++- src/kot-manager.js | 44 ++++ src/preload.js | 2 +- tests/kitchen-service-rounds.test.js | 25 +++ tests/kot-multi-printer-recovery.test.js | 18 +- tests/the-kitchen-hears-a-ticket.test.js | 21 +- ...e-kitchen-screen-shows-the-kitchen.test.js | 17 ++ 42 files changed, 1584 insertions(+), 135 deletions(-) create mode 100644 api/src/services/captain-edit-policy.js create mode 100644 api/src/services/captain-service.js create mode 100644 api/src/services/kitchen-delivery.js create mode 100644 api/src/utils/currency.js create mode 100644 api/src/utils/order-line.js create mode 100644 api/src/utils/service-line.js create mode 100644 api/tests/unit/services/captain-service.test.js create mode 100644 frontend/static/script/js/core/captain-money.js diff --git a/api/src/controllers/items.controller.js b/api/src/controllers/items.controller.js index 4c17fb766..97cfd6228 100644 --- a/api/src/controllers/items.controller.js +++ b/api/src/controllers/items.controller.js @@ -908,6 +908,7 @@ class ItemsController extends BaseController { homebanner: data.store?.homebanner || '', advertisement: data.store?.advertisement || '', }, + money: require('../utils/currency').policy(data.store || {}), kiosk_payment: data.payment || {}, kiosk_print: data.print || {}, }, @@ -1001,6 +1002,7 @@ class ItemsController extends BaseController { homebanner: data.store?.homebanner || '', advertisement: data.store?.advertisement || '', }, + money: require('../utils/currency').policy(data.store || {}), kiosk_payment: data.payment || {}, /* The floor plan. The captain app reads this to draw its tables, and is the only caller that ever did. */ diff --git a/api/src/controllers/sales.controller.js b/api/src/controllers/sales.controller.js index 3e5d2a6fa..13f9da40f 100644 --- a/api/src/controllers/sales.controller.js +++ b/api/src/controllers/sales.controller.js @@ -7053,6 +7053,62 @@ class SalesController extends BaseController { * wall showed an empty list for ever, while setup mode filled itself with * samples and looked perfect. This is what feeds it. */ + async kitchenDeliveryReport(req, res) { + try { + return this.success( + res, + await require('../services/kitchen-delivery').report(req), + 'Received' + ); + } catch (error) { + return this.error(res, error.message, error.status || 500); + } + } + async kitchenDisplayReport(req, res) { + try { return this.success(res, await require('../services/kitchen-delivery').displayReport(req), 'Received'); } + catch (error) { return this.error(res, error.message, error.status || 500); } + } + async kitchenDeliveryStatus(req, res) { + try { + return this.success( + res, + await require('../services/kitchen-delivery').status(req), + 'Delivery status' + ); + } catch (error) { + return this.error(res, error.message, error.status || 500); + } + } + async handoverStaff(req, res) { + try { + return this.success(res, await require('../services/captain-service').staff(req), 'Staff'); + } catch (error) { + return this.error(res, error.message, error.status || 500); + } + } + async handoverOrder(req, res) { + try { + return this.success( + res, + await require('../services/captain-service').handover(req), + 'Order handed over' + ); + } catch (error) { + return this.error(res, error.message, error.status || 500); + } + } + async fireKitchenItems(req, res) { + try { + return this.success( + res, + await require('../services/captain-service').fire(req), + 'Course sent to kitchen' + ); + } catch (error) { + return this.error(res, error.message, error.status || 500); + } + } + async serveKitchenItems(req, res) { try { const out = await require('../repositories/sale.repository').serveKitchenItems({ @@ -7516,6 +7572,7 @@ class SalesController extends BaseController { */ async updateOrder(req, res) { try { + const editPolicy = await require('../services/captain-edit-policy').authorize(req); const orderId = req.body.order_id; const items = req.body.items || []; const totalAmount = req.body.total_amount || 0; @@ -7551,7 +7608,7 @@ class SalesController extends BaseController { newTableNo, dineType, personCount, - { SaleModel, newTableId, seenAt } + { SaleModel, newTableId, seenAt, editPolicy } ); if (response.status === true) { @@ -7561,7 +7618,7 @@ class SalesController extends BaseController { } } catch (error) { console.error('Error in updateOrder:', error); - return this.error(res, error.message, 500); + return this.error(res, error.message, error.status || 500); } } diff --git a/api/src/helpers/bill-payload.js b/api/src/helpers/bill-payload.js index d5f8fc7e8..1e4b83b55 100644 --- a/api/src/helpers/bill-payload.js +++ b/api/src/helpers/bill-payload.js @@ -97,6 +97,7 @@ function qtyText(value) { * and its amount is already inside the total. */ function itemLines(sale, branch) { + const monetary = require('../utils/currency').policy(branch || {}); const rows = Array.isArray(sale && sale.items) ? sale.items : []; return rows .filter((it) => it && !it.return && String(it.name || it.item_name || '').trim()) @@ -142,9 +143,12 @@ function itemLines(sale, branch) { * blank stripe down a year of old bills. */ hsn: wants(branch, 'bill_print_hsn') ? String(it.hsncode || '').trim() : '', - rate: rate > 0 ? rate.toFixed(2) : '', + rate: rate > 0 ? rate.toFixed(monetary.currencyDigits) : '', qty: qtyText(qty), - amount: Math.round(rate * qty * 100) / 100, + amount: require('../utils/currency').fromMinor( + require('../utils/currency').toMinor(rate * qty, monetary), + monetary + ), }; }); } @@ -522,6 +526,8 @@ function buildBillPayload(sale = {}, branch = {}) { * Empty for a shop that has not set one, which prints bare numbers, as * it always has. */ + currencyCode: require('../utils/currency').policy(branch || {}).currencyCode, + currencyDigits: require('../utils/currency').policy(branch || {}).currencyDigits, currency: String((branch && branch.currency) || '') .trim() .slice(0, 4), diff --git a/api/src/helpers/kitchen-rounds.js b/api/src/helpers/kitchen-rounds.js index d27085691..7d41a8c7a 100644 --- a/api/src/helpers/kitchen-rounds.js +++ b/api/src/helpers/kitchen-rounds.js @@ -1,5 +1,7 @@ 'use strict'; const { createHash } = require('crypto'); +const orderLine = require('../utils/order-line'); +const serviceLine = require('../utils/service-line'); // Change positions are append-only ticket identities, independent of product IDs. function date(value) { @@ -20,7 +22,7 @@ function rounds(sale) { const result = []; const current = new Map(); for (const line of sale.items || []) { - const key = product(line); + const key = orderLine.key(line) || product(line); current.set(key, (current.get(key) || 0) + quantity(line)); } const changes = Array.isArray(sale.changes) ? sale.changes : []; @@ -28,12 +30,30 @@ function rounds(sale) { const change = changes[c]; for (let i = 0; i < (change.items || []).length; i++) { const line = change.items[i]; - const key = product(line); + const key = orderLine.key(line) || product(line); const qty = quantity(line); - if (String(line.process).toLowerCase() === 'cancel') { + if (String(line.process).toLowerCase() === 'amend') { + for (const original of result.filter((row) => row.line_key === key)) { + const held = original.held; + Object.assign(original, serviceLine.metadata(line), { + held, + note: String(line.item_description || ''), + }); + } + } else if (String(line.process).toLowerCase() === 'fire') { + const original = result.find( + (row) => row.id === line.source_round_line && row.line_key === key + ); + if (original && original.held) { + original.held = false; + original.fired_at = date(change.timestamp); + original.round = `c${c}`; + + } + } else if (String(line.process).toLowerCase() === 'cancel') { let remaining = qty; // Cancel the newest outstanding additions first, keeping earlier service history. - for (const previous of [...result].reverse().filter((row) => row.product === key)) { + for (const previous of [...result].reverse().filter((row) => row.line_key === key)) { const removed = Math.min(remaining, previous.quantity); previous.quantity -= removed; remaining -= removed; @@ -42,7 +62,9 @@ function rounds(sale) { result.push({ id: `c${c}i${i}`, round: `c${c}`, - product: key, + product: product(line), + line_key: key, + ...serviceLine.metadata(line), ordered_at: date(change.timestamp) || date(sale.created_date), quantity: qty, name: String(line.item_name || line.name || ''), @@ -55,9 +77,9 @@ function rounds(sale) { // Legacy tickets without complete change logs still appear and can be served. for (let i = 0; i < (sale.items || []).length; i++) { const line = sale.items[i], - key = product(line); + key = orderLine.key(line) || product(line); const logged = result - .filter((row) => row.product === key) + .filter((row) => row.line_key === key) .reduce((n, row) => n + row.quantity, 0); const missing = Math.min(quantity(line), Math.max(0, (current.get(key) || 0) - logged)); if (missing) @@ -76,7 +98,9 @@ function rounds(sale) { .digest('hex') .slice(0, 24), round: 'legacy', - product: key, + product: product(line), + line_key: key, + ...serviceLine.metadata(line), ordered_at: date(sale.created_date), quantity: missing, name: String(line.item_name || line.name || line.sale_inline_item_name || ''), @@ -86,15 +110,15 @@ function rounds(sale) { } const groups = new Map(); for (const row of result) { - row.quantity = Math.min(row.quantity, current.get(row.product) || 0); - current.set(row.product, Math.max(0, (current.get(row.product) || 0) - row.quantity)); + row.quantity = Math.min(row.quantity, current.get(row.line_key) || 0); + current.set(row.line_key, Math.max(0, (current.get(row.line_key) || 0) - row.quantity)); if (!row.quantity) continue; const service = (sale.kitchen_service || {})[row.id] || {}; row.served = Math.min(row.quantity, Math.max(0, Number(service.quantity) || 0)); row.served_at = date(service.at); row.remaining = row.quantity - row.served; if (!groups.has(row.round)) - groups.set(row.round, { id: row.round, ordered_at: row.ordered_at, items: [] }); + groups.set(row.round, { id: row.round, ordered_at: row.ordered_at, fired_at: row.fired_at || null, items: [] }); groups.get(row.round).items.push(row); } return [...groups.values()]; @@ -102,14 +126,19 @@ function rounds(sale) { function tickets(sale) { const closed = date(sale.bill_requested_at || sale.bill_printed_at); return rounds(sale).flatMap((round) => { - if (closed && (!round.ordered_at || round.ordered_at <= closed)) return []; + const kitchenTime = round.fired_at || round.ordered_at; + if (closed && (!kitchenTime || kitchenTime <= closed)) return []; const items = round.items - .filter((line) => line.remaining > 0) + .filter((line) => !line.held && line.remaining > 0) .map((line) => ({ id: line.id, qty: line.remaining, name: line.name, note: line.note, + seat: line.seat, + course: line.course, + allergies: line.allergies, + allergy_note: line.allergy_note, })); return items.length ? [ @@ -117,7 +146,7 @@ function tickets(sale) { id: `${sale._id}:${round.id}`, table: String(sale.table_number || ''), orderNumber: String(sale.sales_id || sale.token_id || ''), - placedAt: round.ordered_at, + placedAt: kitchenTime, items, }, ] diff --git a/api/src/helpers/kot-notify.js b/api/src/helpers/kot-notify.js index fc23ff011..fa1def410 100644 --- a/api/src/helpers/kot-notify.js +++ b/api/src/helpers/kot-notify.js @@ -46,7 +46,11 @@ function notifyKotReady(details = {}) { // Send only the committed delta, with a distinct identity per revision. if (Array.isArray(details.items)) { for (const kind of ['cancel', 'add']) { - const items = details.items.filter((item) => item.process === kind); + const items = details.items.filter( + (item) => + !item.held && + (item.process === kind || (kind === 'add' && ['fire', 'amend'].includes(item.process))) + ); if (!items.length) continue; const eventKey = require('crypto') .createHash('sha256') diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index 20eab8522..cfce8c5f3 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -1,3 +1,6 @@ +const Money = require('../utils/currency'); +const serviceLine = require('../utils/service-line'); +const orderLine = require('../utils/order-line'); const itemText = require('../utils/item-localization'); const mongoose = require('mongoose'); const { currentConnection } = require('../db/tenant-context'); @@ -7740,9 +7743,12 @@ class SalesRepository { hasNewChanges = true; const change = changes[i]; const items = Array.isArray(change.items) ? change.items : []; - const addItems = items.filter((it) => String(it.process || '').toLowerCase() === 'add'); + const addItems = items.filter( + (it) => + !it.held && ['add', 'fire', 'amend'].includes(String(it.process || '').toLowerCase()) + ); const cancelItems = items.filter( - (it) => String(it.process || '').toLowerCase() === 'cancel' + (it) => !it.held && String(it.process || '').toLowerCase() === 'cancel' ); if (addItems.length > 0) { @@ -7766,6 +7772,7 @@ class SalesRepository { } if (hasNewChanges && printJobs.length > 0) { + sale.money = Money.policy(branchData); sale.print_jobs = printJobs; sale.new_last_printed_change_index = highestPrintedIndex; processedSales.push(sale); @@ -7901,7 +7908,13 @@ class SalesRepository { for (const requested of items) { const line = lines.find((row) => row.id === requested.id); const quantity = Number(requested.quantity); - if (!line || !Number.isFinite(quantity) || quantity < 0 || quantity > line.quantity) { + if ( + !line || + line.held || + !Number.isFinite(quantity) || + quantity < 0 || + quantity > line.quantity + ) { return { status: false, message: 'Order changed. Refresh before marking items served.' }; } // Absolute totals make retrying the same tap safe on slow connections. @@ -8385,6 +8398,8 @@ class SalesRepository { } } + const monetary = Money.policy(branchDoc); + const round = (value) => Money.fromMinor(Money.toMinor(value, monetary), monetary); const orderLocal = moment().tz(onlineOrdering.normalizeTimeZone(branchDoc.time_zone)); const orderDay = orderLocal.day(); const orderMinutes = orderLocal.hours() * 60 + orderLocal.minutes(); @@ -8394,7 +8409,11 @@ class SalesRepository { // Map items - use raw shape (no Mongoose ObjectId for item ref to avoid validation errors) const itemCollection = db.collection('items'); + if (!staffOrder && items.some((item) => item.held === true)) { + return { status: false, message: 'Sign in to hold a course.', data: null }; + } const saleItems = []; + orderLine.validate(items); for (const item of items) { /* Priced and checked in one place, shared with a line added to an order that has already gone. A refusal is returned as it stands. */ @@ -8525,6 +8544,7 @@ class SalesRepository { const total = round(price * qty); return { item_id: String(si.item_id || ''), + ...serviceLine.metadata(si), item_name: String(si.item_name || ''), ...itemText.snapshot(si), item_quantity: qty, @@ -8894,7 +8914,10 @@ class SalesRepository { alert: arrival.alert, state: arrival.state, total: finalTotal, - ticket: { table: saleDocument.table_number || '', items: saleDocument.items || [] }, + ticket: { + table: saleDocument.table_number || '', + items: (saleDocument.items || []).filter((item) => !item.held), + }, }); return { @@ -9939,6 +9962,8 @@ class SalesRepository { const items = (Array.isArray(doc.items) ? doc.items : []).map((item) => ({ id: item.item_id || '', item_id: item.item_id || '', + ...serviceLine.metadata(item), + modifiers: item.modifiers || [], name: item.item_name || item.name || '', item_name: item.item_name || item.name || '', ...itemText.snapshot(item), @@ -9959,6 +9984,7 @@ class SalesRepository { dine_type: doc.dine_type || 'Dine-in', status: derivedStatus, created_at: doc.created_date || doc.date, + assigned_staff: doc.assigned_staff, kitchen_rounds: require('../helpers/kitchen-rounds').rounds(doc), total_amount: doc.sales_total || doc.total || 0, subtotal: doc.sales_sub_total || doc.subtotal || 0, @@ -9992,7 +10018,7 @@ class SalesRepository { newTableNo, dineType, personCount, - { SaleModel, newTableId, seenAt } = {} + { SaleModel, newTableId, seenAt, editPolicy } = {} ) { let finishCaptainEdit; try { @@ -10010,6 +10036,33 @@ class SalesRepository { return { status: false, message: 'Order not found', data: [] }; } + const editFilter = { + _id: orderObjectId, + captain_payment_plan: { $exists: false }, + items: editPolicy?.expectedItems || orderDoc.items, + changes: + (editPolicy ? editPolicy.expectedChanges : orderDoc.changes) === undefined + ? { $exists: false } + : editPolicy + ? editPolicy.expectedChanges + : orderDoc.changes, + ...(editPolicy ? { branch_id: editPolicy.branchId, license: editPolicy.license } : {}), + }; + const actor = editPolicy?.actor || { + id: String(BaseModel.loggedUser || ''), + name: BaseModel.loggedUserName || 'Staff', + }; + const audit = { + at: new Date(), + actor, + reason: editPolicy?.reason || '', + approved_by: editPolicy?.approvedBy || [], + action: status === 'cancelled' ? 'cancel' : 'modify', + }; + const shop = await db + .collection('branches') + .findOne({ _id: orderDoc.branch_id, license: orderDoc.license }); + const monetary = Money.policy(shop || {}); finishCaptainEdit = await require('../services/captain-payment-guard').beginEdit( db, orderDoc @@ -10060,8 +10113,8 @@ class SalesRepository { payment_status: 'Cancelled', payment_pending: 0.0, updated_date: mongoDate, - updated_by: 'System', - updated_by_id: null, + updated_by: actor.name, + updated_by_id: actor.id || null, }; const existingItems = Array.isArray(orderDoc.items) ? orderDoc.items : []; @@ -10091,6 +10144,7 @@ class SalesRepository { item_id: idStr, item_name: String(ex.item_name || ''), ...itemText.snapshot(ex), + ...serviceLine.metadata(ex), item_quantity: qty, /* The typed note if the line has one. A cancellation ticket is read by the same cook as the order, so it follows the same rule: @@ -10106,14 +10160,19 @@ class SalesRepository { } if (changesItems.length > 0) { - existingChanges.push({ timestamp: mongoDate, items: changesItems }); + existingChanges.push({ + timestamp: mongoDate, + items: changesItems, + actor, + reason: audit.reason, + }); updateFields.changes = existingChanges; } - const updateResult = await salesCollection.updateOne( - { _id: orderObjectId, captain_payment_plan: { $exists: false } }, - { $set: updateFields } - ); + const updateResult = await salesCollection.updateOne(editFilter, { + $set: updateFields, + $push: { captain_audit: audit }, + }); /* * AND THE KITCHEN IS TOLD AT ONCE, exactly as a new order tells it. @@ -10156,19 +10215,23 @@ class SalesRepository { } : { status: false, - message: 'No changes made to the order', + message: 'order_changed', data: [], }; } // ---------- EDIT FLOW ---------- const existingItems = Array.isArray(orderDoc.items) ? orderDoc.items : []; + orderLine.validate(existingItems); + orderLine.validate(items); const oldItemsData = {}; for (const ex of existingItems) { - const idStr = ex.item_id ? String(ex.item_id) : ''; + const idStr = orderLine.key(ex); if (!idStr) continue; oldItemsData[idStr] = { + item_id: orderLine.product(ex), + ...serviceLine.metadata(ex), quantity: parseFloat(ex.item_quantity || 0), name: String(ex.item_name || ''), /* Carried so a REMOVED line can still say which one it was. Two of @@ -10186,7 +10249,7 @@ class SalesRepository { const existingChanges = Array.isArray(orderDoc.changes) ? orderDoc.changes : []; const existingIndex = {}; existingItems.forEach((ex, idx) => { - const key = ex.item_id ? String(ex.item_id) : ''; + const key = orderLine.key(ex); if (key) existingIndex[key] = idx; }); @@ -10198,12 +10261,40 @@ class SalesRepository { const rawId = item.product_id || item.item_id || ''; if (!rawId) continue; const productId = String(rawId); + const lineKey = orderLine.key(item); + const previousLine = existingItems[existingIndex[lineKey]]; + if (previousLine && orderLine.product(previousLine) !== productId) { + throw new Error('invalid_order_line'); + } + // Existing held food is released only through the idempotent fire action. + if (previousLine) item.held = previousLine.held === true; + const preparation = serviceLine.metadata({ ...previousLine, ...item }); + const newNote = + item.item_description != null + ? String(item.item_description) + : String(previousLine?.item_description || ''); + if ( + previousLine && + (JSON.stringify(preparation) !== JSON.stringify(serviceLine.metadata(previousLine)) || + newNote !== String(previousLine.item_description || '')) + ) { + changesItems.push({ + ...preparation, + item_id: productId, + item_name: previousLine.item_name || item.name || '', + item_quantity: Number(previousLine.item_quantity || previousLine.quantity || 0), + item_description: newNote, + process: 'amend', + instruction_only: true, + spice_level: spiceLevel.levelOf(item.spice_level ?? previousLine.spice_level), + }); + } const qty = parseFloat(item.quantity || item.item_quantity || 0); const price = parseFloat(item.price || item.unit_price || item.item_base_price || 0); if (!productId || qty <= 0 || price < 0) continue; - const oldQty = oldItemsData[productId] ? parseFloat(oldItemsData[productId].quantity) : 0; - if (oldItemsData[productId]) delete oldItemsData[productId]; + const oldQty = oldItemsData[lineKey] ? parseFloat(oldItemsData[lineKey].quantity) : 0; + if (oldItemsData[lineKey]) delete oldItemsData[lineKey]; let itemDoc = null; if (mongoose.Types.ObjectId.isValid(productId)) { @@ -10213,11 +10304,13 @@ class SalesRepository { } if (!itemDoc) { // Item not in catalog (e.g. KOT order item) - update in-place using existing data - if (existingIndex[productId] !== undefined) { - const i = existingIndex[productId]; + if (existingIndex[lineKey] !== undefined) { + const i = existingIndex[lineKey]; updatedItems[i] = { - ...updatedItems[i], + ...this._scaleOrderLine(updatedItems[i], oldQty, qty), + ...serviceLine.metadata({ ...updatedItems[i], ...item }), item_quantity: qty, + quantity: qty, ...(item.item_description != null ? { item_description: String(item.item_description) } : {}), @@ -10227,7 +10320,8 @@ class SalesRepository { ? { spice_level: spiceLevel.levelOf(item.spice_level) } : {}), }; - incomingProductIds.push(productId); + if (qty !== oldQty) changesItems.push({ ...preparation, item_id: productId, item_name: previousLine.item_name || '', item_quantity: Math.abs(qty - oldQty), item_description: newNote, spice_level: updatedItems[i].spice_level, process: qty > oldQty ? 'add' : 'cancel' }); + incomingProductIds.push(lineKey); } continue; } @@ -10245,6 +10339,7 @@ class SalesRepository { if (changeQty > 0) { changesItems.push({ item_id: productId, + ...preparation, item_name: String(itemDoc.name || item.name || ''), ...itemText.snapshot(itemDoc), item_quantity: changeQty, @@ -10259,7 +10354,7 @@ class SalesRepository { spice_level: spiceLevel.levelOf( item.spice_level != null ? item.spice_level - : (updatedItems[existingIndex[productId]] || {}).spice_level + : (updatedItems[existingIndex[lineKey]] || {}).spice_level ), process: changeProcess, item_code: String(itemDoc.itemid || ''), @@ -10269,12 +10364,12 @@ class SalesRepository { }); } - incomingProductIds.push(productId); + incomingProductIds.push(lineKey); const itemTaxRate = parseFloat(itemDoc.tax || 0); const taxType = itemDoc.tax_type || 'exclusive'; - if (existingIndex[productId] !== undefined) { - const i = existingIndex[productId]; + if (existingIndex[lineKey] !== undefined) { + const i = existingIndex[lineKey]; const existing = updatedItems[i]; const itemAmount = qty * price; const itemDiscountPer = parseFloat(existing.item_discount_percentage || 0); @@ -10294,8 +10389,15 @@ class SalesRepository { updatedItems[i] = { ...existing, + ...serviceLine.metadata({ ...existing, ...item }), item_quantity: qty, + quantity: qty, item_price: price, + unit_price: price, + item_base_price: price, + item_total: lineTotal, + total: lineTotal, + item_tax: taxAmount, item_discount: lineDiscount, total_amount: lineTotal, tax: itemTaxRate, @@ -10304,7 +10406,7 @@ class SalesRepository { cgst_tax: taxAmount / 2, sgst_tax: taxAmount / 2, }; - if (item.item_description) + if (item.item_description != null) updatedItems[i].item_description = String(item.item_description); /* The ticket is printed from the change record above; THIS is what the customer sees back on their own order and what a shop counts @@ -10344,8 +10446,15 @@ class SalesRepository { item_sku: itemDoc.itemid || '', item_price: sellingPrice, item_quantity: itemQuantity, + quantity: itemQuantity, + unit_price: sellingPrice, + item_base_price: sellingPrice, + item_total: lineTotal, + total: lineTotal, + item_tax: taxAmount, item_available_quantity: parseFloat(itemDoc.available_quantity || 0), item_id: productId, + ...preparation, item_unit: itemDoc.unit || 'qty', total_amount: lineTotal, barcode_id: itemDoc.barcode_id || '', @@ -10389,12 +10498,13 @@ class SalesRepository { } } - for (const [remItemId, remItemData] of Object.entries(oldItemsData)) { + for (const remItemData of Object.values(oldItemsData)) { const remQty = parseFloat(remItemData.quantity || 0); if (remQty <= 0) continue; const remPrice = parseFloat(remItemData.price || 0); changesItems.push({ - item_id: String(remItemId), + item_id: remItemData.item_id, + ...serviceLine.metadata(remItemData), item_name: String(remItemData.name || ''), ...itemText.snapshot(remItemData), item_quantity: remQty, @@ -10410,7 +10520,7 @@ class SalesRepository { const finalItems = updatedItems.filter((ex) => { if (!ex.item_id) return false; - return incomingProductIds.includes(String(ex.item_id)); + return incomingProductIds.includes(orderLine.key(ex)); }); let itemsSub = 0; @@ -10439,7 +10549,12 @@ class SalesRepository { const salesTotal = itemsSub - extraDiscountAmount; const mongoDate = new Date(); if (changesItems.length > 0) { - existingChanges.push({ timestamp: mongoDate, items: changesItems }); + existingChanges.push({ + timestamp: mongoDate, + items: changesItems, + actor, + reason: audit.reason, + }); } const updateFields = { @@ -10447,7 +10562,7 @@ class SalesRepository { changes: existingChanges, sales_sub_total: baseSubtotal, items_subtotal: baseSubtotal, - sales_total: Math.round(salesTotal * 100) / 100, + sales_total: Money.fromMinor(Money.toMinor(salesTotal, monetary), monetary), items_total: salesTotal, tax: taxTotal, discount: itemDiscountTotal, @@ -10455,7 +10570,7 @@ class SalesRepository { return_discount: 0, number_of_items: finalItems.length, updated_date: mongoDate, - updated_by: 'System', + updated_by: actor.name, sale_process: 'KOT', }; @@ -10497,10 +10612,10 @@ class SalesRepository { if (personCount !== null && personCount !== '') updateFields.person_count = parseInt(personCount, 10); - const updateResult = await salesCollection.updateOne( - { _id: orderObjectId, captain_payment_plan: { $exists: false } }, - { $set: updateFields } - ); + const updateResult = await salesCollection.updateOne(editFilter, { + $set: updateFields, + $push: { captain_audit: audit }, + }); /* An amended table order needs a fresh ticket in the kitchen just as much as a new one does, and the same event carries it. */ @@ -10527,7 +10642,7 @@ class SalesRepository { } : { status: false, - message: 'No changes made to the order', + message: 'order_changed', data: [], }; } catch (error) { @@ -10699,6 +10814,8 @@ class SalesRepository { orderMinutes, servicePoint, } = where; + const monetary = Money.policy(branchDoc); + const round = (value) => Money.fromMinor(Money.toMinor(value, monetary), monetary); const qty = Number(item.item_quantity) || 1; const itemId = String(item.item_id || ''); if (!ObjectId.isValid(itemId)) { @@ -10887,6 +11004,7 @@ class SalesRepository { return { line: { item_id: itemId, + ...serviceLine.metadata(item), item_name: itemDoc.name || item.item_name || '', ...itemText.snapshot(itemDoc), name: itemDoc.name || item.item_name || '', @@ -13059,6 +13177,7 @@ class SalesRepository { data: { list: docs.map((sale) => ({ ...sale, + assigned_staff: sale.assigned_staff, kitchen_rounds: require('../helpers/kitchen-rounds').rounds(sale), })), total, diff --git a/api/src/routes/authorizations.routes.js b/api/src/routes/authorizations.routes.js index 33317abb8..4c4e78b26 100644 --- a/api/src/routes/authorizations.routes.js +++ b/api/src/routes/authorizations.routes.js @@ -1,4 +1,5 @@ const express = require('express'); +const rateLimit = require('express-rate-limit'); const router = express.Router(); const authorizationsController = require('../controllers/authorizations.controller'); @@ -10,7 +11,11 @@ const bind = (handler) => (req, res, next) => router.use(protect); router.post('/set-manager-pin', bind(authorizationsController.setManagerPin)); -router.post('/verify-pin', bind(authorizationsController.verifyPin)); +router.post( + '/verify-pin', + rateLimit({ windowMs: 60000, limit: 5, standardHeaders: true, legacyHeaders: false }), + bind(authorizationsController.verifyPin) +); router.post('/set-rfid', bind(authorizationsController.setRfid)); router.post('/verify-card', bind(authorizationsController.verifyCard)); diff --git a/api/src/routes/sales.routes.js b/api/src/routes/sales.routes.js index 4170a2a90..8d30a53bb 100644 --- a/api/src/routes/sales.routes.js +++ b/api/src/routes/sales.routes.js @@ -373,6 +373,37 @@ router.post( protectOrKioskKey, bindController(salesController.getFrequentItems) ); +router.post('/kitchenDisplayReport', ensurePrintDevice, bindController(salesController.kitchenDisplayReport)); +router.post( + '/kitchenDeliveryReport', + ensurePrintDevice, + bindController(salesController.kitchenDeliveryReport) +); +router.get( + '/kitchenDeliveryStatus', + optionalProtect, + protectOrKioskKey, + bindController(salesController.kitchenDeliveryStatus) +); +router.get( + '/handoverStaff', + optionalProtect, + protectOrKioskKey, + bindController(salesController.handoverStaff) +); +router.post( + '/handoverOrder', + optionalProtect, + protectOrKioskKey, + bindController(salesController.handoverOrder) +); +router.post( + '/fireKitchenItems', + rateLimit({ windowMs: 60000, limit: 120, standardHeaders: true, legacyHeaders: false }), + optionalProtect, + protectOrKioskKey, + bindController(salesController.fireKitchenItems) +); router.post( '/serveKitchenItems', rateLimit({ windowMs: 60000, limit: 180, standardHeaders: true, legacyHeaders: false }), diff --git a/api/src/services/authorization.service.js b/api/src/services/authorization.service.js index 96d2bfac6..afee59be4 100644 --- a/api/src/services/authorization.service.js +++ b/api/src/services/authorization.service.js @@ -102,6 +102,7 @@ class AuthorizationService { approved_by_name: approverName, action, approval_token: signApproval({ + entity_id: entityId ? String(entityId) : null, action, approved_by_user_id: String(m._id), cashier_user_id: cashierId ? String(cashierId) : null, @@ -200,6 +201,7 @@ class AuthorizationService { action, method: 'rfid', approval_token: signApproval({ + entity_id: entityId ? String(entityId) : null, action, approved_by_user_id: String(m._id), cashier_user_id: cashierId ? String(cashierId) : null, diff --git a/api/src/services/captain-access.js b/api/src/services/captain-access.js index 9f64678ab..213d2dbbc 100644 --- a/api/src/services/captain-access.js +++ b/api/src/services/captain-access.js @@ -252,7 +252,7 @@ async function verifySession(req, user) { .replace(/^\/api/, '') .replace(/\/$/, ''); if ( - !/^\/(captain\/v1\/(session|logout|payment-options|payments\/(table|record|release))|items\/(accessQr|aiAvailability|soldOut|instanceItemInsert)|sales\/(qrOrder|myDay|guestBills\/(table|latest|print)|requestBillPrint|getTablesWithActiveOrders|getOrderHistory|updateOrder|getFrequentItems|pendingOnlineOrders|getListKot|[a-fA-F0-9]{24}\/(approval|print)|waiterCalls\/[^/]+\/seen|transcribe|voiceIntent))$/.test( + !/^\/(authorizations\/verify-pin|captain\/v1\/(session|logout|payment-options|payments\/(table|record|release))|items\/(accessQr|aiAvailability|soldOut|instanceItemInsert)|sales\/(qrOrder|myDay|guestBills\/(table|latest|print)|requestBillPrint|serveKitchenItems|fireKitchenItems|kitchenDeliveryStatus|handoverStaff|handoverOrder|getTablesWithActiveOrders|getOrderHistory|updateOrder|getFrequentItems|pendingOnlineOrders|getListKot|[a-fA-F0-9]{24}\/(approval|print)|waiterCalls\/[^/]+\/seen|transcribe|voiceIntent))$/.test( path ) ) diff --git a/api/src/services/captain-edit-policy.js b/api/src/services/captain-edit-policy.js new file mode 100644 index 000000000..f2a606084 --- /dev/null +++ b/api/src/services/captain-edit-policy.js @@ -0,0 +1,74 @@ +'use strict'; +const { ObjectId } = require('mongodb'); +const { context, allowed, fail } = require('../utils/branch-access'); +const { canPos } = require('../utils/pos-permission.util'); +const { verifyApproval } = require('../utils/approval-token.util'); +const lineIdentity = require('../utils/order-line'); +const quantity = (line) => Number(line.quantity ?? line.item_quantity ?? 0); +async function authorize(req) { + if (!req.user || !allowed(req.user, 'sales')) fail('Sales permission is required.', 403); + const c = await context(req), + body = req.body; + if (!ObjectId.isValid(String(body.order_id))) fail('Choose an order.'); + const sale = await req.db + .collection('sales') + .findOne({ + _id: new ObjectId(String(body.order_id)), + license: c.license, + branch_id: c.branchId, + }); + if (!sale) fail('Order not found.', 404); + const incoming = new Map( + (body.items || []).map((line) => [lineIdentity.key(line), quantity(line)]) + ); + const reduced = + body.status === 'cancelled' || + (sale.items || []).some((line) => (incoming.get(lineIdentity.key(line)) || 0) < Number(line.item_quantity ?? line.quantity ?? 0)); + const discount = + body.extra_discount != null && + (Number(body.extra_discount) !== Number(sale.extra_discount || 0) || + (Number(body.extra_discount) !== 0 && body.extra_discount_type !== sale.extra_discount_type)); + const reason = String(body.change_reason || body.discount_description || '').trim(); + if ((reduced || discount) && (reason.length < 3 || reason.length > 200)) + fail('Enter a reason for this change.', 422); + const actions = [...(reduced ? ['void_sale'] : []), ...(discount ? ['discount_apply'] : [])]; + const approved = []; + for (const action of actions) { + const cap = Number(req.user.access?.pos?.discount_max_percent) || 0; + const percentage = + body.extra_discount_type === 'percent' + ? Number(body.extra_discount) + : (Number(body.extra_discount) * 100) / + Math.max(0.01, Number(sale.sales_sub_total || sale.sales_total)); + if (canPos(req.user, action) && !(action === 'discount_apply' && cap > 0 && percentage > cap)) + continue; + const proof = verifyApproval(body.approval_tokens?.[action] || body.approval_token); + if ( + !proof || + proof.action !== action || + String(proof.cashier_user_id) !== String(req.user._id || req.user.id) || + String(proof.entity_id) !== String(sale._id) + ) { + fail( + action === 'void_sale' + ? 'Manager approval required: cancellation' + : 'Manager approval required: discount', + 422 + ); + } + approved.push(String(proof.approved_by_user_id)); + } + return { + actor: { + id: String(req.user._id || req.user.id), + name: String(req.user.name || req.user.username || ''), + }, + reason, + approvedBy: approved, + expectedItems: sale.items, + expectedChanges: sale.changes, + branchId: c.branchId, + license: c.license, + }; +} +module.exports = { authorize }; diff --git a/api/src/services/captain-payments.js b/api/src/services/captain-payments.js index e6fafeec4..49f88e3b8 100644 --- a/api/src/services/captain-payments.js +++ b/api/src/services/captain-payments.js @@ -1,5 +1,6 @@ 'use strict'; const crypto = require('node:crypto'); +const Money = require('../utils/currency'); const { ObjectId } = require('mongodb'); const { context, allowed, fail } = require('../utils/branch-access'); const { snapshotFrom, billForGuest } = require('./guest-bill.service'); @@ -111,7 +112,7 @@ function view(plan, options) { return { id: plan._id, table: plan.table, - currency: plan.snapshot.currency, + ...Money.snapshot(plan.snapshot), totalMinor: plan.snapshot.totalMinor, paidMinor, dueMinor: plan.snapshot.totalMinor - paidMinor, @@ -137,6 +138,8 @@ function view(plan, options) { // Every effect is derived from the durable payment journal. Replaying repairs // an interrupted write without incrementing money, touching stock or reprinting. async function reconcile(db, c, plan) { + const monetary = Money.snapshot(plan.snapshot), + factor = monetary.factor; const payments = plan.payments || []; if (!payments.length) return; for (const sale of plan.sales) { @@ -146,7 +149,8 @@ async function reconcile(db, c, plan) { const amount = payment.allocations[String(sale._id)] || 0; if (!amount) continue; paid += amount; - multi[payment.method] = Math.round(((multi[payment.method] || 0) + amount / 100) * 100) / 100; + multi[payment.method] = + Math.round(((multi[payment.method] || 0) + amount / factor) * factor) / factor; } const total = plan.saleTotals[String(sale._id)]; const due = total - paid; @@ -165,10 +169,10 @@ async function reconcile(db, c, plan) { { $set: { captain_payment_version: plan.version, - paid_amount: paid / 100, - partial_balance: paid / 100, - payment_pending: due / 100, - balance: due / 100, + paid_amount: paid / factor, + partial_balance: paid / factor, + payment_pending: due / factor, + balance: due / factor, payment_status: due ? 'Unpaid' : 'Paid', partial_check: due > 0 && paid > 0, payment_mode: Object.keys(multi).join(','), @@ -182,7 +186,7 @@ async function reconcile(db, c, plan) { .map((p) => ({ id: p.id, method: p.method, - amount: p.allocations[String(sale._id)] / 100, + amount: p.allocations[String(sale._id)] / factor, reference: p.reference, staffId: p.staffId, staffName: p.staffName, @@ -255,8 +259,11 @@ async function reconcile(db, c, plan) { payload.footer = 'Payment received. Keep this receipt.'; payload.extras = [ { label: 'Payment', value: payment.method }, - { label: 'Received', value: (payment.receivedMinor / 100).toFixed(2) }, - { label: 'Change', value: (payment.changeMinor / 100).toFixed(2) }, + { + label: 'Received', + value: (payment.receivedMinor / factor).toFixed(monetary.currencyDigits), + }, + { label: 'Change', value: (payment.changeMinor / factor).toFixed(monetary.currencyDigits) }, { label: 'Staff', value: payment.staffName }, ]; const copies = Math.max(1, Math.min(3, Number(c.branch.bill_print_copies) || 1)); diff --git a/api/src/services/captain-service.js b/api/src/services/captain-service.js new file mode 100644 index 000000000..1ee824d2d --- /dev/null +++ b/api/src/services/captain-service.js @@ -0,0 +1,159 @@ +'use strict'; +const { ObjectId } = require('mongodb'); +const { context, allowed, fail } = require('../utils/branch-access'); +const { rounds } = require('../helpers/kitchen-rounds'); +const serviceLine = require('../utils/service-line'); +const orderLine = require('../utils/order-line'); +async function scope(req) { + if (!req.user || !allowed(req.user, 'sales')) fail('Sales permission is required.', 403); + const c = await context(req); + if (String(req.body.branchId) !== String(c.branchId)) fail('Choose the authorized branch.', 403); + if (!ObjectId.isValid(String(req.body.saleId))) fail('Choose an order.'); + return { + ...c, + filter: { + _id: new ObjectId(String(req.body.saleId)), + branch_id: c.branchId, + license: c.license, + sale_process: 'KOT', + payment_status: { $nin: ['Paid', 'Cancelled'] }, + order_state: { $nin: ['pending', 'rejected', 'cancelled'] }, + }, + }; +} +async function fire(req) { + const c = await scope(req), + body = req.body; + if ( + typeof body.requestId !== 'string' || + !/^[a-zA-Z0-9_-]{16,80}$/.test(body.requestId) || + !Array.isArray(body.items) || + !body.items.length || + body.items.length > 200 || + body.items.some((id) => typeof id !== 'string') + ) + fail('Choose the held items to send.'); + const collection = req.db.collection('sales'); + const sale = await collection.findOne(c.filter); + if (!sale) fail('Open order not found.', 404); + if ((sale.changes || []).some((change) => change.request_id === body.requestId)) + return rounds(sale); + const current = rounds(sale).flatMap((round) => round.items); + const selected = [...new Set(body.items)].map((id) => current.find((line) => line.id === id)); + if (selected.some((line) => !line)) + fail('Order changed. Refresh before sending this course.', 409); + const held = selected.filter((line) => line.held && line.remaining > 0); + if (!held.length) return rounds(sale); + const now = new Date(); + const change = { + timestamp: now, + request_id: body.requestId, + actor: String(req.user.id || req.user._id), + action: 'fire', + items: held.map((line) => ({ + ...serviceLine.metadata(line), + held: false, + item_id: line.product, + item_name: line.name, + item_quantity: line.remaining, + item_description: line.note, + spice_level: line.spice_level, + process: 'fire', + source_round_line: line.id, + })), + }; + const firedKeys = new Set(held.map((line) => line.line_key)); + const items = sale.items.map((line) => + firedKeys.has(orderLine.key(line)) ? { ...line, held: false } : line + ); + const changes = [...(sale.changes || []), change]; + const updated = await collection.updateOne( + { + ...c.filter, + items: sale.items, + changes: sale.changes === undefined ? { $exists: false } : sale.changes, + captain_payment_plan: { $exists: false }, + }, + { $set: { items, changes, updated_date: now } } + ); + if (!updated.matchedCount) fail('Order changed. Refresh before sending this course.', 409); + require('../helpers/kot-notify').notifyKotReady({ + branchId: String(c.branchId), + saleId: String(sale._id), + reason: 'updated', + table: sale.table_number, + items: change.items, + revision: changes.length, + }); + return rounds({ ...sale, items, changes }); +} +async function staff(req) { + if (!req.user || !allowed(req.user, 'sales')) fail('Sales permission is required.', 403); + const c = await context(req); + const users = await req.db + .collection('users') + .find( + { + license: c.license, + activate: true, + $or: [{ branch_id: c.branchId }, { 'branch_access.branch_id': c.branchId }], + }, + { projection: { _id: 1, name: 1, username: 1, usertype: 1, role: 1, access: 1 } } + ) + .limit(500) + .toArray(); + return users + .filter((user) => allowed(user, 'sales')) + .map((user) => ({ id: String(user._id), name: String(user.name || user.username || '') })); +} +async function handover(req) { + const c = await scope(req), + body = req.body; + if ( + !ObjectId.isValid(String(body.staffId)) || + !/^[a-zA-Z0-9_-]{16,80}$/.test(body.requestId || '') + ) + fail('Choose a staff member.'); + const selected = (await staff(req)).find((user) => user.id === String(body.staffId)); + if (!selected) fail('Choose an active staff member in this branch.', 403); + const collection = req.db.collection('sales'), + sale = await collection.findOne(c.filter); + if (!sale) fail('Open order not found.', 404); + if ((sale.captain_audit || []).some((entry) => entry.request_id === body.requestId)) + return { staff: sale.assigned_staff }; + const actor = String(req.user._id || req.user.id), + current = String(sale.assigned_staff?.id || sale.client?.staff_id || ''); + const manager = [ + 'owner', + 'admin', + 'super_admin', + 'superadmin', + 'manager', + 'store_manager', + ].includes(String(req.user.usertype || req.user.role).toLowerCase()); + if (!manager && current !== actor && !(current === '' && selected.id === actor)) + fail('Ask the assigned staff member or manager to hand over this order.', 403); + const assigned_staff = { ...selected, at: new Date(), by: actor }; + const result = await collection.updateOne( + { + ...c.filter, + assigned_staff: sale.assigned_staff === undefined ? { $exists: false } : sale.assigned_staff, + }, + { + $set: { assigned_staff }, + $push: { + captain_audit: { + action: 'handover', + request_id: body.requestId, + at: new Date(), + actor: { id: actor, name: String(req.user.name || req.user.username || '') }, + from: current, + to: selected, + }, + }, + } + ); + if (!result.matchedCount) fail('Order changed. Please refresh.', 409); + return { staff: assigned_staff }; +} +module.exports = { fire, scope, staff, handover }; diff --git a/api/src/services/guest-bill.service.js b/api/src/services/guest-bill.service.js index 2b798bbeb..5a3973b65 100644 --- a/api/src/services/guest-bill.service.js +++ b/api/src/services/guest-bill.service.js @@ -1,10 +1,11 @@ 'use strict'; const crypto = require('node:crypto'); +const Money = require('../utils/currency'); const { buildBillPayload } = require('../helpers/bill-payload'); const { allocate, split } = require('../utils/guest-bill-split'); const hash = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex'); -const minor = (value) => { - const n = Math.round(Number(value || 0) * 100); +const amountMinor = (value, policy) => { + const n = Money.toMinor(value || 0, policy); if (!Number.isSafeInteger(n) || Math.abs(n) > 1e12) throw problem('Invalid bill amount.', 422); return n; }; @@ -12,6 +13,8 @@ function problem(message, status = 400) { return Object.assign(new Error(message), { status }); } function snapshotFrom(sales, branch, table) { + const monetary = Money.policy(branch); + const minor = (value) => amountMinor(value, monetary); const lines = [], labels = { base: 'Subtotal', discount: 'Discount', adjustment: 'Adjustments' }; let totalMinor = 0; @@ -55,6 +58,7 @@ function snapshotFrom(sales, branch, table) { name: item.name, ...require('../utils/item-localization').snapshot(item), quantity: Number(item.qty), + seat: Number(live[i]?.seat) || 0, components: parts, amountMinor: parts.reduce((n, c) => n + c.minor, 0), }); @@ -76,12 +80,12 @@ function snapshotFrom(sales, branch, table) { round: s.round_off, payment: s.payment_status, })) - .concat([{ lines, totalMinor }]) + .concat([{ lines, totalMinor, ...monetary }]) ); return { table, revision, - currency: String(branch.currency || ''), + ...monetary, totalMinor, labels, lines, @@ -90,9 +94,14 @@ function snapshotFrom(sales, branch, table) { } function billForGuest(snapshot, guest, branch, sale, batchId) { const base = buildBillPayload(sale, branch); - const value = (key) => (guest.components[key] || 0) / 100; + const monetary = Money.snapshot(snapshot); + const factor = monetary.factor; + const value = (key) => (guest.components[key] || 0) / factor; return { ...base, + currency: monetary.currencySymbol, + currencyCode: monetary.currencyCode, + currencyDigits: monetary.currencyDigits, title: 'GUEST BILL', billNo: batchId.slice(-8) + '-' + (guest.index + 1), customer: '', @@ -108,18 +117,21 @@ function billForGuest(snapshot, guest, branch, sale, batchId) { ? String(line.quantity) : `${line.weight}/${line.weightTotal} x ${line.quantity}`, rate: '', - amount: (line.components.base || 0) / 100, + amount: (line.components.base || 0) / factor, })), subTotal: value('base'), discount: -value('discount'), roundOff: value('adjustment'), - total: guest.totalMinor / 100, + total: guest.totalMinor / factor, taxes: Object.entries(guest.components) .filter(([key]) => key.startsWith('tax:')) - .map(([key, n]) => ({ label: snapshot.labels[key], amount: n / 100 })), + .map(([key, n]) => ({ label: snapshot.labels[key], amount: n / factor })), extras: [ { label: 'Payment', value: 'Pay at counter' }, - { label: 'Table total', value: (snapshot.totalMinor / 100).toFixed(2) }, + { + label: 'Table total', + value: (snapshot.totalMinor / factor).toFixed(monetary.currencyDigits), + }, ], footer: 'Guest share of the table bill. Not a payment receipt.', footerImage: null, @@ -278,7 +290,7 @@ function createService(deps = {}) { return { guests: batch.payload.guests.map((g) => ({ name: g.name, totalMinor: g.totalMinor })), totalMinor: batch.payload.snapshot.totalMinor, - currency: batch.payload.snapshot.currency, + ...Money.snapshot(batch.payload.snapshot), stale: snapshot.revision !== batch.payload.revision, }; } diff --git a/api/src/services/kitchen-delivery.js b/api/src/services/kitchen-delivery.js new file mode 100644 index 000000000..a12d74865 --- /dev/null +++ b/api/src/services/kitchen-delivery.js @@ -0,0 +1,91 @@ +'use strict'; +const crypto = require('crypto'); +const { ObjectId } = require('mongodb'); +const BaseModel = require('../models/base.model'); +const { context, allowed, fail } = require('../utils/branch-access'); +const states = ['accepted', 'failed', 'pending', 'unknown']; +async function report(req) { + const body = req.body; + if ( + !ObjectId.isValid(String(body.saleId)) || + !ObjectId.isValid(String(body.branchId)) || + typeof body.key !== 'string' || + body.key.length > 240 || + !Array.isArray(body.printers) || + body.printers.length > 100 || + body.printers.some( + (printer) => + !states.includes(printer.state) || + typeof printer.name !== 'string' || + printer.name.length > 200 + ) + ) + fail('Invalid delivery report.'); + const key = crypto.createHash('sha256').update(body.key).digest('hex'); + const at = new Date(body.at); + if (!Number.isFinite(at.getTime())) fail('Invalid delivery report.'); + const value = { + key: body.key, + at, + received_at: new Date(), + till: String(body.till || '').slice(0, 100), + printers: body.printers.map((printer) => ({ + name: printer.name, + copy: Number(printer.copy) || 1, + state: printer.state, + reason: String(printer.reason || '').slice(0, 300), + })), + }; + const db = req.db || (await BaseModel.getDb()); + const field = 'kitchen_delivery.' + key; + const filter = { + _id: new ObjectId(String(body.saleId)), + branch_id: new ObjectId(String(body.branchId)), + ...(BaseModel.license ? { license: BaseModel.license } : {}), + $or: [{ [field]: { $exists: false } }, { [field + '.at']: { $lte: at } }], + }; + await db.collection('sales').updateOne(filter, { $set: { [field]: value } }); + return { received: true }; +} +async function displayReport(req) { + const body = req.body || {}; + if (!ObjectId.isValid(String(body.branchId)) || !Array.isArray(body.saleIds) || body.saleIds.length > 2000 || body.saleIds.some(id => !ObjectId.isValid(String(id))) || !Array.isArray(body.screens) || !body.screens.length || body.screens.length > 100 || !body.till || String(body.till).length > 100) fail('Invalid display report.'); + const db = req.db || await BaseModel.getDb(); + const key = crypto.createHash('sha256').update(String(body.till)).digest('hex'); + const value = { at: new Date(), till: String(body.till), screens: body.screens.map(id => String(id).slice(0, 100)), saleIds: [...new Set(body.saleIds.map(String))] }; + await db.collection('branches').updateOne({ _id: new ObjectId(String(body.branchId)), ...(BaseModel.license ? { license: BaseModel.license } : {}) }, { $set: { ['kitchen_display_status.' + key]: value } }); + return { received: true }; +} +async function status(req) { + if (!req.user || !allowed(req.user, 'sales')) fail('Sales permission is required.', 403); + const c = await context(req), + id = req.query.saleId; + if (!ObjectId.isValid(String(id))) fail('Choose an order.'); + const sale = await req.db + .collection('sales') + .findOne( + { _id: new ObjectId(String(id)), license: c.license, branch_id: c.branchId }, + { + projection: { + kitchen_delivery: 1, + created_date: 1, + kitchen_service: 1, + changes: 1, + items: 1, + }, + } + ); + if (!sale) fail('Order not found.', 404); + return { + serverAccepted: true, + displays: Object.values(c.branch.kitchen_display_status || {}).filter(display => display.saleIds?.includes(String(id))).map(display => ({ at: display.at, till: display.till, screens: display.screens, recent: Date.now() - new Date(display.at).getTime() < 30000 })), + reports: Object.values(sale.kitchen_delivery || {}) + .sort((a, b) => new Date(b.at) - new Date(a.at)) + .slice(0, 20), + remaining: require('../helpers/kitchen-rounds') + .rounds(sale) + .flatMap((round) => round.items) + .reduce((n, line) => n + line.remaining, 0), + }; +} +module.exports = { report, displayReport, status }; diff --git a/api/src/services/sale.service.js b/api/src/services/sale.service.js index 08b56c298..255cd88e6 100644 --- a/api/src/services/sale.service.js +++ b/api/src/services/sale.service.js @@ -3969,7 +3969,7 @@ module.exports = { newTableNo, dineType, personCount, - { SaleModel, newTableId, seenAt } = {} + { SaleModel, newTableId, seenAt, editPolicy } = {} ) => salesRepository.updateOrderModel( orderId, @@ -3986,6 +3986,7 @@ module.exports = { SaleModel: getModel(SaleModel), newTableId, seenAt, + editPolicy, } ), getFrequentItemsForBranch: async (branchId, limit, { SaleModel } = {}) => diff --git a/api/src/utils/currency.js b/api/src/utils/currency.js new file mode 100644 index 000000000..571413dd7 --- /dev/null +++ b/api/src/utils/currency.js @@ -0,0 +1,113 @@ +/* Currency metadata and decimal/minor-unit conversion shared by Captain and POS. */ +(function (host) { + 'use strict'; + function policy(value = {}) { + if (typeof value === 'string') value = { currency: value }; + const text = [value.currencyCode, value.currency_code, value.currency_text, value.currency_type, value.currency] + .filter(Boolean) + .join(' '); + const supported = new Set( + Intl.supportedValuesOf ? [...Intl.supportedValuesOf('currency'), 'CLF', 'UYW'] : [] + ); + const code = + (text.match(/\b[A-Z]{3}\b/g) || []).find((part) => !supported.size || supported.has(part)) || + ''; + const explicit = value.currencyDigits ?? value.currency_digits; + const digits = + Number.isInteger(explicit) && explicit >= 0 && explicit <= 4 + ? explicit + : code + ? new Intl.NumberFormat('en', { style: 'currency', currency: code }).resolvedOptions() + .maximumFractionDigits + : 2; + const symbol = String( + value.currencySymbol ?? value.currency_symbol ?? value.currency ?? value.currency_type ?? (code || '') + ) + .split('') + .filter((character) => character.charCodeAt(0) >= 32 && !'<>&"\''.includes(character)) + .join('') + .slice(0, 32); + return { + currencyCode: code, + currencyDigits: digits, + currencySymbol: symbol, + currency: symbol, + factor: 10 ** digits, + }; + } + // Existing persisted bills used hundredths. Never reinterpret old journals + // using today's branch settings or a newly inferred currency precision. + function snapshot(value = {}) { + return policy({ ...value, currencyDigits: value.currencyDigits ?? 2 }); + } + function toMinor(value, setting = {}) { + const digits = policy(setting).currencyDigits; + const text = String(value ?? 0).trim(); + const match = /^([+-]?)(\d+)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(text); + if (!match || text.length > 128) throw new Error('Invalid amount'); + const fraction = match[3] || '', + exponent = Number(match[4] || 0); + if (Math.abs(exponent) > 100) throw new Error('Invalid amount'); + let amount = BigInt(match[2] + fraction); + const scale = digits + exponent - fraction.length; + if (scale >= 0) amount *= 10n ** BigInt(scale); + else { + const divisor = 10n ** BigInt(-scale); + amount = (amount + divisor / 2n) / divisor; + } + const result = Number(amount) * (match[1] === '-' ? -1 : 1); + if (!Number.isSafeInteger(result)) throw new Error('Invalid amount'); + return result; + } + function fromMinor(value, setting = {}) { + if (!Number.isSafeInteger(value)) throw new Error('Invalid amount'); + return value / policy(setting).factor; + } + function format(value, setting = {}, locale) { + const p = policy(setting), + amount = Number(value) || 0; + const options = { + minimumFractionDigits: p.currencyDigits, + maximumFractionDigits: p.currencyDigits, + }; + if (p.currencyCode) + return new Intl.NumberFormat(locale, { + ...options, + style: 'currency', + currency: p.currencyCode, + }).format(amount); + return p.currencySymbol + new Intl.NumberFormat(locale, options).format(amount); + } + const api = { policy, snapshot, toMinor, fromMinor, format }; + if (typeof module === 'object' && module.exports) module.exports = api; + if (host && host.localStorage) { + api.current = () => { + try { + return policy( + JSON.parse( + host.localStorage.getItem( + 'posnic.money.' + (host.localStorage.getItem('branch_id') || '') + ) || '{"currency":"₹","currencyCode":"INR"}' + ) + ); + } catch { + return policy(); + } + }; + api.remember = (value, branchId) => + host.localStorage.setItem( + 'posnic.money.' + (branchId || host.localStorage.getItem('branch_id') || ''), + JSON.stringify(policy(value)) + ); + api.display = (value) => + format(value, api.current(), host.document?.documentElement.lang || undefined); + api.html = (value) => + api + .display(value) + .replace( + /[&<>"']/g, + (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch] + ); + host.CaptainMoney = api; + } +})(globalThis.window || globalThis); diff --git a/api/src/utils/order-line.js b/api/src/utils/order-line.js new file mode 100644 index 000000000..bee45900f --- /dev/null +++ b/api/src/utils/order-line.js @@ -0,0 +1,31 @@ +'use strict'; + +// Product identity answers what was ordered; line identity answers which guest's +// preparation was ordered. Never use an array position as a persisted identity. +function product(line) { + return String(line.item_id || line.product_id || line.item || line._id || ''); +} +function id(line) { + const value = line.line_id; + if (value == null || value === '') return ''; + if (typeof value !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(value)) { + throw new Error('invalid_order_line'); + } + return value; +} +function key(line) { + return id(line) || product(line); +} +function identity(line) { + const value = id(line); + return value ? { line_id: value } : {}; +} +function validate(lines) { + const seen = new Set(); + for (const line of lines) { + const value = key(line); + if (!value || seen.has(value)) throw new Error('ambiguous_order_lines'); + seen.add(value); + } +} +module.exports = { product, id, key, identity, validate }; diff --git a/api/src/utils/service-line.js b/api/src/utils/service-line.js new file mode 100644 index 000000000..1a7be95d4 --- /dev/null +++ b/api/src/utils/service-line.js @@ -0,0 +1,45 @@ +'use strict'; +const orderLine = require('./order-line'); +const ALLERGIES = [ + 'milk', + 'eggs', + 'fish', + 'shellfish', + 'peanuts', + 'tree-nuts', + 'wheat', + 'soy', + 'sesame', + 'celery', + 'mustard', + 'lupin', + 'sulphites', +]; +function metadata(line) { + const seat = Number(line.seat || 0); + const course = String(line.course || '').trim(); + const allergies = line.allergies || []; + const allergyNote = String(line.allergy_note || '').trim(); + if ( + !Number.isInteger(seat) || + seat < 0 || + seat > 99 || + course.length > 40 || + [...course, ...allergyNote].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) || + !Array.isArray(allergies) || + allergies.length > ALLERGIES.length || + allergies.some((value) => !ALLERGIES.includes(value)) || + allergyNote.length > 200 || + (line.held != null && typeof line.held !== 'boolean') + ) + throw new Error('invalid_service_details'); + return { + ...orderLine.identity(line), + seat, + course, + held: line.held === true, + allergies: [...new Set(allergies)], + allergy_note: allergyNote, + }; +} +module.exports = { metadata, ALLERGIES }; diff --git a/api/tests/unit/repositories/an-order-changed-under-you.test.js b/api/tests/unit/repositories/an-order-changed-under-you.test.js index 86313c9e5..1d7c21bbb 100644 --- a/api/tests/unit/repositories/an-order-changed-under-you.test.js +++ b/api/tests/unit/repositories/an-order-changed-under-you.test.js @@ -176,3 +176,68 @@ describe('a save that is not stale', () => { expect(fresh.status).not.toBe(false); }); }); + +test('same-product preparations survive edits and cancellation with their own notes', async () => { + const id = await anOrder(CHANGED_AT); + await db.collection('sales').updateOne( + { _id: new mongoose.Types.ObjectId(id) }, + { + $set: { + items: [ + { + item_id: NAAN, + line_id: 'dry', + item_name: 'Naan', + item_quantity: 1, + item_price: 40, + item_description: 'No butter', + }, + { + item_id: NAAN, + line_id: 'butter', + item_name: 'Naan', + item_quantity: 2, + item_price: 40, + item_description: 'Extra butter', + }, + ], + }, + } + ); + const out = await repo.updateOrderModel( + id, + [ + { product_id: String(NAAN), line_id: 'dry', quantity: 1, price: 40, item_description: '' }, + { + product_id: String(NAAN), + line_id: 'butter', + quantity: 1, + price: 40, + item_description: 'Extra butter', + }, + ], + 80, + null, + null, + null, + null, + '4', + 'Dine-in', + 2, + {} + ); + expect(out.status).not.toBe(false); + const after = await stored(id); + expect(after.items).toHaveLength(2); + expect(after.items.find((line) => line.line_id === 'dry').item_description).toBe(''); + expect(after.items.find((line) => line.line_id === 'butter').item_quantity).toBe(1); + const cancelled = after.changes + .flatMap((change) => change.items) + .filter((line) => line.process === 'cancel'); + expect(cancelled).toHaveLength(1); + expect(cancelled[0]).toMatchObject({ + line_id: 'butter', + item_quantity: 1, + item_description: 'Extra butter', + }); +}); diff --git a/api/tests/unit/services/captain-payments.test.js b/api/tests/unit/services/captain-payments.test.js index 4bcc0ca56..2cbf6f7e4 100644 --- a/api/tests/unit/services/captain-payments.test.js +++ b/api/tests/unit/services/captain-payments.test.js @@ -235,3 +235,27 @@ test('version and request ID operators cannot bypass duplicate-payment protectio (await db.collection('captain_payment_plans').findOne({ _id: plan.id })).payments ).toHaveLength(0); }); + +test.each([ + ['JPY', 0, 105], + ['KWD', 3, 105000], +])( + 'payment journal uses %s precision and survives changed shop settings', + async (currencyCode, currencyDigits, totalMinor) => { + await db + .collection('branches') + .updateOne( + { _id: branch }, + { $set: { currency_code: currencyCode, currency: currencyCode } } + ); + const plan = await service.prepare(req()); + expect(plan).toMatchObject({ currencyCode, currencyDigits, dueMinor: totalMinor }); + await db + .collection('branches') + .updateOne({ _id: branch }, { $set: { currency_code: 'USD', currency: 'USD' } }); + const paid = await service.record(pay(plan)); + expect(paid.dueMinor).toBe(0); + const saved = await db.collection('sales').findOne({ _id: sale._id }); + expect(saved.paid_amount).toBe(105); + } +); diff --git a/api/tests/unit/services/captain-service.test.js b/api/tests/unit/services/captain-service.test.js new file mode 100644 index 000000000..d571358e4 --- /dev/null +++ b/api/tests/unit/services/captain-service.test.js @@ -0,0 +1,151 @@ +'use strict'; +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); +const { ObjectId } = require('mongodb'); +const service = require('../../../src/services/captain-service'); +const policy = require('../../../src/services/captain-edit-policy'); +const { signApproval } = require('../../../src/utils/approval-token.util'); +let mem, db, branch, license, actor, sale, other; +beforeAll(async () => { + mem = await MongoMemoryServer.create(); + await mongoose.connect(mem.getUri('captain-service')); + db = mongoose.connection.db; +}, 60000); +afterAll(async () => { + await mongoose.disconnect(); + await mem?.stop(); +}); +beforeEach(async () => { + await db.dropDatabase(); + branch = new ObjectId(); + license = new ObjectId(); + actor = new ObjectId(); + other = new ObjectId(); + await db.collection('branches').insertOne({ _id: branch, license }); + await db + .collection('users') + .insertMany( + [actor, other].map((id, index) => ({ + _id: id, + license, + branch_id: branch, + activate: true, + name: 'Staff ' + index, + access: { sales: { write: true } }, + })) + ); + const line = { + item_id: new ObjectId().toString(), + line_id: 'dessert', + item_name: 'Pudding', + item_quantity: 2, + item_price: 50, + held: true, + seat: 2, + course: 'Dessert', + allergies: ['milk'], + }; + sale = { + _id: new ObjectId(), + license, + branch_id: branch, + sale_process: 'KOT', + payment_status: 'Unpaid', + client: { staff_id: String(actor) }, + items: [line], + changes: [{ timestamp: new Date(), items: [{ ...line, process: 'add' }] }], + }; + await db.collection('sales').insertOne(sale); +}); +const req = (body) => ({ + db, + tenantContext: { branchId: branch, licenseId: license }, + user: { + _id: actor, + name: 'Staff 0', + access: { sales: { write: true }, pos: { void_sale: false, discount_apply: false } }, + }, + body: { saleId: String(sale._id), branchId: String(branch), ...body }, +}); +test('fire is idempotent under retries and preserves bill quantity and service identity', async () => { + const input = req({ requestId: require('crypto').randomUUID(), items: ['c0i0'] }); + const first = await service.fire(input); + const second = await service.fire(input); + expect(first).toEqual(second); + expect(first[0].items[0]).toMatchObject({ + id: 'c0i0', + quantity: 2, + held: false, + allergies: ['milk'], + }); + const saved = await db.collection('sales').findOne({ _id: sale._id }); + expect(saved.items).toHaveLength(1); + expect(saved.items[0].item_quantity).toBe(2); + expect(saved.changes).toHaveLength(2); +}); +test('a request cannot fire another branch or an unknown line', async () => { + await expect( + service.fire( + req({ + branchId: String(new ObjectId()), + items: ['c0i0'], + requestId: require('crypto').randomUUID(), + }) + ) + ).rejects.toMatchObject({ status: 403 }); + await expect( + service.fire(req({ items: ['c99i0'], requestId: require('crypto').randomUUID() })) + ).rejects.toMatchObject({ status: 409 }); +}); +test('handover validates branch and permission and retains original author', async () => { + const input = req({ staffId: String(other), requestId: require('crypto').randomUUID() }); + await service.handover(input); + await service.handover(input); + const saved = await db.collection('sales').findOne({ _id: sale._id }); + expect(saved.assigned_staff.id).toBe(String(other)); + expect(saved.client.staff_id).toBe(String(actor)); + expect(saved.captain_audit).toHaveLength(1); + const unauthorized = req({ staffId: String(actor), requestId: require('crypto').randomUUID() }); + await expect(service.handover(unauthorized)).rejects.toMatchObject({ status: 403 }); +}); +test('cancellation requires a reason and a manager proof bound to this order and actor', async () => { + const input = req({ + order_id: String(sale._id), + items: [{ ...sale.items[0], item_quantity: 1 }], + }); + await expect(policy.authorize(input)).rejects.toThrow('Enter a reason'); + input.body.change_reason = 'Guest changed their mind'; + await expect(policy.authorize(input)).rejects.toThrow('Manager approval required: cancellation'); + input.body.approval_token = signApproval({ + action: 'void_sale', + cashier_user_id: String(actor), + entity_id: String(new ObjectId()), + approved_by_user_id: String(other), + }); + await expect(policy.authorize(input)).rejects.toThrow('Manager approval required'); + input.body.approval_token = signApproval({ + action: 'void_sale', + cashier_user_id: String(actor), + entity_id: String(sale._id), + approved_by_user_id: String(other), + }); + expect(await policy.authorize(input)).toMatchObject({ + actor: { id: String(actor) }, + reason: 'Guest changed their mind', + approvedBy: [String(other)], + }); +}); +const delivery = require('../../../src/services/kitchen-delivery'); +test('delivery status distinguishes printer acceptance from display rendering and isolates branches', async () => { + const input = req({ key:'ticket-1', printers:[{name:'Kitchen',copy:1,state:'accepted'}],at:new Date().toISOString(),till:'Till 1' }); + await delivery.report(input); + await delivery.displayReport(req({saleIds:[String(sale._id)],screens:['screen-1'],till:'Till 1'})); + const read=req({});read.query={saleId:String(sale._id)}; + const state=await delivery.status(read); + expect(state.reports[0].printers[0].state).toBe('accepted'); + expect(state.displays[0]).toMatchObject({recent:true,till:'Till 1'}); + await delivery.displayReport(req({saleIds:[],screens:['screen-1'],till:'Till 1'})); + expect((await delivery.status(read)).displays).toEqual([]); + read.query.saleId=String(new ObjectId()); + await expect(delivery.status(read)).rejects.toMatchObject({status:404}); +}); diff --git a/docs/API.md b/docs/API.md index 40408fefe..21d6e1bd9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,7 +3,7 @@ Generated from `api/src/routes/` by `npm run docs:api`. Do not edit this file by hand; edit the routes and regenerate. -**710 endpoints across 45 route groups.** +**716 endpoints across 45 route groups.** All paths below are relative to the API root. On a desktop install the API runs on a port derived per installation (see `local-ports.js`); in the cloud it sits @@ -15,8 +15,8 @@ behind the tenant hostname. Postman, Insomnia or Bruno, point Swagger UI or Redoc at it, or generate a client from it. It is OpenAPI 3.0.3. -**103 of 710 endpoints carry a request schema** (15%), recovered from -their `express-validator` middleware. The remaining 607 are listed with +**103 of 716 endpoints carry a request schema** (14%), recovered from +their `express-validator` middleware. The remaining 613 are listed with path, method and handler but no body, because inventing a schema is worse than admitting the gap. @@ -726,6 +726,12 @@ Mounted at `/sales`. Source: `api/src/routes/sales.routes.js`. | PUT | `/sales/updateOrder` | — | `salesController.updateOrder` | | POST | `/sales/searchProducts` | — | `salesController.searchProducts` | | POST | `/sales/getFrequentItems` | — | `salesController.getFrequentItems` | +| POST | `/sales/kitchenDisplayReport` | — | `salesController.kitchenDisplayReport` | +| POST | `/sales/kitchenDeliveryReport` | — | `salesController.kitchenDeliveryReport` | +| GET | `/sales/kitchenDeliveryStatus` | — | `salesController.kitchenDeliveryStatus` | +| GET | `/sales/handoverStaff` | — | `salesController.handoverStaff` | +| POST | `/sales/handoverOrder` | — | `salesController.handoverOrder` | +| POST | `/sales/fireKitchenItems` | — | `salesController.fireKitchenItems` | | POST | `/sales/serveKitchenItems` | — | `salesController.serveKitchenItems` | | GET | `/sales/getListKot` | — | `salesController.getListKot` | | GET | `/sales/getCustomerPrint` | — | `salesController.getCustomerPrint` | diff --git a/docs/openapi.json b/docs/openapi.json index b01a72c4f..5b7bee85d 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -12696,6 +12696,148 @@ "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." } }, + "/sales/kitchenDisplayReport": { + "post": { + "summary": "kitchenDisplayReport", + "operationId": "post_sales_kitchenDisplayReport", + "tags": [ + "Sales" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + }, + "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." + } + }, + "/sales/kitchenDeliveryReport": { + "post": { + "summary": "kitchenDeliveryReport", + "operationId": "post_sales_kitchenDeliveryReport", + "tags": [ + "Sales" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + }, + "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." + } + }, + "/sales/kitchenDeliveryStatus": { + "get": { + "summary": "kitchenDeliveryStatus", + "operationId": "get_sales_kitchenDeliveryStatus", + "tags": [ + "Sales" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + } + } + }, + "/sales/handoverStaff": { + "get": { + "summary": "handoverStaff", + "operationId": "get_sales_handoverStaff", + "tags": [ + "Sales" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + } + } + }, + "/sales/handoverOrder": { + "post": { + "summary": "handoverOrder", + "operationId": "post_sales_handoverOrder", + "tags": [ + "Sales" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + }, + "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." + } + }, + "/sales/fireKitchenItems": { + "post": { + "summary": "fireKitchenItems", + "operationId": "post_sales_fireKitchenItems", + "tags": [ + "Sales" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + }, + "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." + } + }, "/sales/serveKitchenItems": { "post": { "summary": "serveKitchenItems", diff --git a/frontend/pages_css_js_map.json b/frontend/pages_css_js_map.json index 416a8ac62..f4c1876ed 100644 --- a/frontend/pages_css_js_map.json +++ b/frontend/pages_css_js_map.json @@ -371,6 +371,7 @@ "static/script/js/modules/js/report_receivings.js", "static/script/js/modules/js/report_sales.js", "static/script/js/modules/js/report_pricesettings.js", + "static/script/js/core/captain-money.js", "static/script/js/core/captain-payments.js", "static/script/js/modules/js/report_kot.js", "static/script/js/modules/js/report_suppliers.js", diff --git a/frontend/static/script/js/core/PosnicPro.js b/frontend/static/script/js/core/PosnicPro.js index 3ef9b2b84..22e466f67 100644 --- a/frontend/static/script/js/core/PosnicPro.js +++ b/frontend/static/script/js/core/PosnicPro.js @@ -2023,7 +2023,7 @@ PosnicPro = { _approvalMode: 'pin', // 'pin' | 'card' requireManagerApproval: function (action, opts, onApproved, onDenied) { opts = opts || {}; - if (PosnicPro.posCan(action)) { + if (!opts.force && PosnicPro.posCan(action)) { if (typeof onApproved === 'function') onApproved(null); return; } diff --git a/frontend/static/script/js/core/ajax.js b/frontend/static/script/js/core/ajax.js index ab873393a..cfc92d697 100644 --- a/frontend/static/script/js/core/ajax.js +++ b/frontend/static/script/js/core/ajax.js @@ -140,6 +140,9 @@ PosnicPro.request = function (params, callback, failure = null) { } } + if (xhr.status === 422 && String(params.url).replace(/^\//, '') === 'sales/updateOrder' && + PosnicPro.retryOrderApproval && PosnicPro.retryOrderApproval(params, callback, failure, response)) return; + // login.html / forgotpassword.html have no session by definition. // Redirecting to login from here just reloads the page and wipes // the error message before it can be read. @@ -326,3 +329,27 @@ PosnicPro.patch = function (params, callback, failure) { parameters.method = 'PATCH'; PosnicPro.request(parameters, callback, failure); }; + +// Approval is requested only after the server has refused the unchanged order. +PosnicPro.retryOrderApproval = function (params, callback, failure, response) { + var message = response && response.message; + if (['Enter a reason for this change.', 'Manager approval required: cancellation', 'Manager approval required: discount'].indexOf(message) < 0 || (params.orderApprovalAttempts || 0) >= 4) return false; + var body; + try { body = typeof params.data === 'string' ? JSON.parse(params.data) : Object.assign({}, params.data); } catch (_) { return false; } + var retry = function () { PosnicPro.request(Object.assign({}, params, { data: JSON.stringify(body), orderApprovalAttempts: (params.orderApprovalAttempts || 0) + 1 }), callback, failure); }; + var cancel = function () { callback({type:'error',message:PosnicPro.i18n.t('lang_changes_not_saved', 'Changes were not saved.')}); }; + if (message !== 'Enter a reason for this change.') { + var action = message.endsWith('cancellation') ? 'void_sale' : 'discount_apply'; + PosnicPro.requireManagerApproval(action, {saleId:body.order_id, force:true}, function (approval) { + if (!approval || !approval.approval_token) return cancel(); + body.approval_tokens = Object.assign({}, body.approval_tokens); body.approval_tokens[action] = approval.approval_token; retry(); + }, cancel); + return true; + } + var dialog=document.createElement('dialog'), form=document.createElement('form'), label=document.createElement('label'), input=document.createElement('textarea'), footer=document.createElement('div'), save=document.createElement('button'), close=document.createElement('button'); + dialog.style.cssText='width: min(440px, 92vw);border:0;border-radius:12px;padding:24px;box-shadow:0 12px 60px #0005'; + label.textContent=PosnicPro.i18n.t('lang_reason', 'Reason'); input.required=true; input.minLength=3;input.maxLength=200;input.style.cssText='display:block;width:100%;min-height:90px;margin:12px 0'; label.append(input); + save.type='submit';save.className='btn btn-primary';save.textContent=PosnicPro.i18n.t('lang_continue','Continue');close.type='button';close.className='btn btn-light';close.textContent=PosnicPro.i18n.t('lang_cancel','Cancel');footer.append(close,save);form.append(label,footer);dialog.append(form);document.body.append(dialog); + var finish=function(){dialog.close();dialog.remove();};close.onclick=function(){finish();cancel();};dialog.oncancel=function(event){event.preventDefault();finish();cancel();}; + form.onsubmit=function(event){event.preventDefault();if(input.value.trim().length<3)return;body.change_reason=input.value.trim();finish();retry();};dialog.showModal();input.focus();return true; +}; diff --git a/frontend/static/script/js/core/captain-money.js b/frontend/static/script/js/core/captain-money.js new file mode 100644 index 000000000..571413dd7 --- /dev/null +++ b/frontend/static/script/js/core/captain-money.js @@ -0,0 +1,113 @@ +/* Currency metadata and decimal/minor-unit conversion shared by Captain and POS. */ +(function (host) { + 'use strict'; + function policy(value = {}) { + if (typeof value === 'string') value = { currency: value }; + const text = [value.currencyCode, value.currency_code, value.currency_text, value.currency_type, value.currency] + .filter(Boolean) + .join(' '); + const supported = new Set( + Intl.supportedValuesOf ? [...Intl.supportedValuesOf('currency'), 'CLF', 'UYW'] : [] + ); + const code = + (text.match(/\b[A-Z]{3}\b/g) || []).find((part) => !supported.size || supported.has(part)) || + ''; + const explicit = value.currencyDigits ?? value.currency_digits; + const digits = + Number.isInteger(explicit) && explicit >= 0 && explicit <= 4 + ? explicit + : code + ? new Intl.NumberFormat('en', { style: 'currency', currency: code }).resolvedOptions() + .maximumFractionDigits + : 2; + const symbol = String( + value.currencySymbol ?? value.currency_symbol ?? value.currency ?? value.currency_type ?? (code || '') + ) + .split('') + .filter((character) => character.charCodeAt(0) >= 32 && !'<>&"\''.includes(character)) + .join('') + .slice(0, 32); + return { + currencyCode: code, + currencyDigits: digits, + currencySymbol: symbol, + currency: symbol, + factor: 10 ** digits, + }; + } + // Existing persisted bills used hundredths. Never reinterpret old journals + // using today's branch settings or a newly inferred currency precision. + function snapshot(value = {}) { + return policy({ ...value, currencyDigits: value.currencyDigits ?? 2 }); + } + function toMinor(value, setting = {}) { + const digits = policy(setting).currencyDigits; + const text = String(value ?? 0).trim(); + const match = /^([+-]?)(\d+)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(text); + if (!match || text.length > 128) throw new Error('Invalid amount'); + const fraction = match[3] || '', + exponent = Number(match[4] || 0); + if (Math.abs(exponent) > 100) throw new Error('Invalid amount'); + let amount = BigInt(match[2] + fraction); + const scale = digits + exponent - fraction.length; + if (scale >= 0) amount *= 10n ** BigInt(scale); + else { + const divisor = 10n ** BigInt(-scale); + amount = (amount + divisor / 2n) / divisor; + } + const result = Number(amount) * (match[1] === '-' ? -1 : 1); + if (!Number.isSafeInteger(result)) throw new Error('Invalid amount'); + return result; + } + function fromMinor(value, setting = {}) { + if (!Number.isSafeInteger(value)) throw new Error('Invalid amount'); + return value / policy(setting).factor; + } + function format(value, setting = {}, locale) { + const p = policy(setting), + amount = Number(value) || 0; + const options = { + minimumFractionDigits: p.currencyDigits, + maximumFractionDigits: p.currencyDigits, + }; + if (p.currencyCode) + return new Intl.NumberFormat(locale, { + ...options, + style: 'currency', + currency: p.currencyCode, + }).format(amount); + return p.currencySymbol + new Intl.NumberFormat(locale, options).format(amount); + } + const api = { policy, snapshot, toMinor, fromMinor, format }; + if (typeof module === 'object' && module.exports) module.exports = api; + if (host && host.localStorage) { + api.current = () => { + try { + return policy( + JSON.parse( + host.localStorage.getItem( + 'posnic.money.' + (host.localStorage.getItem('branch_id') || '') + ) || '{"currency":"₹","currencyCode":"INR"}' + ) + ); + } catch { + return policy(); + } + }; + api.remember = (value, branchId) => + host.localStorage.setItem( + 'posnic.money.' + (branchId || host.localStorage.getItem('branch_id') || ''), + JSON.stringify(policy(value)) + ); + api.display = (value) => + format(value, api.current(), host.document?.documentElement.lang || undefined); + api.html = (value) => + api + .display(value) + .replace( + /[&<>"']/g, + (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch] + ); + host.CaptainMoney = api; + } +})(globalThis.window || globalThis); diff --git a/frontend/static/script/js/core/captain-payments.js b/frontend/static/script/js/core/captain-payments.js index 45b3491f1..ca8056b2e 100644 --- a/frontend/static/script/js/core/captain-payments.js +++ b/frontend/static/script/js/core/captain-payments.js @@ -116,8 +116,10 @@ received = "", reference = "", verified = false; - const money = (n) => - `${plan?.currency || ""}${(Number(n || 0) / 100).toFixed(2)}`; + const monetary = () => CaptainMoney.snapshot(plan || {}); + const money = n => CaptainMoney.format(CaptainMoney.fromMinor(Number(n || 0), monetary()), monetary()); + const cashMinor = () => { try { return CaptainMoney.toMinor(received || 0, monetary()); } catch { return NaN; } }; + const receivedDefault = () => CaptainMoney.fromMinor(amount(), monetary()).toFixed(monetary().currencyDigits); const amount = () => selected === "" ? plan?.dueMinor || 0 @@ -142,7 +144,7 @@ body += ` ${plan.guests.filter((g) => !g.paid).length > 1 ? `` : ""}
${plan.methods.map((m) => ``).join("")}
- ${method === "Cash" ? `
${esc(t("Change to return"))}${esc(money(Math.max(0, Math.round(Number(received) * 100) - amount())))}
` : ``} + ${method === "Cash" ? `
${esc(t("Change to return"))}${esc(money(Math.max(0, cashMinor() - amount())))}
` : ``}

${esc(t("Confirm only after receiving the money. This does not charge a card or bank account."))}

`; if (plan.dueMinor === 0) body += `

${esc(t("Payment recorded"))}

`; @@ -169,7 +171,7 @@ throw Object.assign(new Error("Disabled"), { status: 403 }); method = plan.methods.includes(method) ? method : plan.methods[0]; selected = ""; - received = (amount() / 100).toFixed(2); + received = receivedDefault(); verified = false; } catch (e) { plan = null; @@ -191,7 +193,7 @@ } if (!pending) { const paid = amount(), - cash = Math.round(Number(received) * 100); + cash = cashMinor(); if (method === "Cash" && (!Number.isFinite(cash) || cash < paid)) { error = "Enter an amount at least equal to the bill."; render(); @@ -238,7 +240,7 @@ pending = null; localStorage.removeItem(key); selected = ""; - received = (amount() / 100).toFixed(2); + received = receivedDefault(); verified = false; reference = ""; error = ""; @@ -302,7 +304,7 @@ dialog.addEventListener("change", (e) => { if (e.target.id === "cp-guest") { selected = e.target.value; - received = (amount() / 100).toFixed(2); + received = receivedDefault(); verified = false; render(); } @@ -313,7 +315,7 @@ if (e.target.id === "cp-received") { received = e.target.value; dialog.querySelector("#cp-change").textContent = money( - Math.max(0, Math.round(Number(received) * 100) - amount()), + Math.max(0, cashMinor() - amount()), ); } }); diff --git a/src/escpos-kot.js b/src/escpos-kot.js index 613a7230b..4f925f62b 100644 --- a/src/escpos-kot.js +++ b/src/escpos-kot.js @@ -194,15 +194,20 @@ function renderKitchenTicket(ticket = {}, options = {}) { const worth = item && item.priced_at_table; if (worth !== undefined && worth !== null && Number(worth) > 0) { const amount = Number(worth); - const shown = Number.isInteger(amount) ? String(amount) : amount.toFixed(2); + const digits = Number.isInteger(ticket.money?.currencyDigits) ? ticket.money.currencyDigits : 2; + const shown = Number.isInteger(amount) ? String(amount) : amount.toFixed(digits); /* Bold like the name, because on these lines the amount IS part of what to cook. r.line takes no options - bold is set around it, the way the name line above does it. */ r.bold(true); - r.line(' Rs ' + shown); + r.line(' ' + (ticket.money?.currencyCode || ticket.money?.currency || 'Rs') + ' ' + shown); r.bold(false); } + if (item.instruction_only) { r.bold(true); r.line('PREPARATION UPDATE'); r.line('Do not add another item'); r.bold(false); } + if (item.seat || item.course) r.line(' ' + [item.seat ? 'Seat ' + item.seat : '', item.course || ''].filter(Boolean).join(' / ')); + const allergies = [...(item.allergies || []), item.allergy_note || ''].filter(Boolean); + if (allergies.length) { r.bold(true); r.line(' ALLERGY: ' + allergies.join(', ')); r.bold(false); } const hot = spiceLine(item && (item.spice_level != null ? item.spice_level : item.spice)); if (hot) r.line(' ' + hot); const note = String((item && (item.description || item.item_description)) || '').trim(); diff --git a/src/escpos-receipt.js b/src/escpos-receipt.js index 7cca11973..1305317d3 100644 --- a/src/escpos-receipt.js +++ b/src/escpos-receipt.js @@ -796,7 +796,8 @@ function renderSale(sale, options = {}, renderer) { * narrows the item name column, which wraps; it cannot overflow the line. */ const symbol = sale.currency ? String(sale.currency) : ''; - const money = (n) => symbol + Number(n || 0).toFixed(2); + const digits = Number.isInteger(sale.currencyDigits) && sale.currencyDigits >= 0 && sale.currencyDigits <= 4 ? sale.currencyDigits : 2; + const money = (n) => symbol + Number(n || 0).toFixed(digits); /* * The logo goes above the name, where a letterhead goes. diff --git a/src/hardware-ipc.js b/src/hardware-ipc.js index e24f43377..3bd27598d 100644 --- a/src/hardware-ipc.js +++ b/src/hardware-ipc.js @@ -963,6 +963,7 @@ function setupHardwareIPC(hardwareManager, kotManager, billManager) { }); /* The page says it has loaded and asks for its content. */ + ipcMain.handle('kitchen-screen:rendered',(event,receipt)=>require('./kitchen-screen').acknowledgeRender(event.sender,receipt)); ipcMain.handle('kitchen-screen:ready', (event, displayId) => { const screens = require('./kitchen-screen'); const setup = !screens.configFor(displayId).enabled; diff --git a/src/kitchen-call.js b/src/kitchen-call.js index 7ddce53b8..e7c050035 100644 --- a/src/kitchen-call.js +++ b/src/kitchen-call.js @@ -68,7 +68,7 @@ const countWord = (n) => { * the next one either. Six lines, then how many are left, and the ticket * itself is still on the spike for the detail. */ -const READ_AT_MOST = 6; +const READ_AT_MOST = Infinity; /* * The name as the shop typed it, tidied of whitespace and nothing else. @@ -108,7 +108,8 @@ const quantityOf = (item) => */ function script({ table, items, changed, cancelled, whole } = {}) { const said = (Array.isArray(items) ? items : []) - .map((item) => ({ name: spokenName(item), count: quantityOf(item) })) + .filter(item => !item.held) + .map((item) => ({ name: spokenName(item), count: quantityOf(item), instruction:item.instruction_only, allergies:[...(item.allergies || []),item.allergy_note || ''].filter(Boolean), seat:item.seat })) .filter((line) => line.name && line.count > 0); if (!said.length) return { head: [], items: [] }; @@ -185,8 +186,11 @@ function script({ table, items, changed, cancelled, whole } = {}) { * nothing else. "Cancel two naan" cannot be misheard as an order for two * naan; "two naan" after an opening they missed can. */ - if (cancelled) return `Cancel ${count} ${line.name}`; - return `${count.charAt(0).toUpperCase()}${count.slice(1)} ${line.name}`; + const allergy=line.allergies.length ? '. Allergy: ' + line.allergies.join(', ') : ''; + const seat=line.seat ? ', seat ' + line.seat : ''; + if(line.instruction)return `Preparation update for ${count} ${line.name}${seat}. Do not add another item${allergy}`; + if (cancelled) return `Cancel ${count} ${line.name}${seat}${allergy}`; + return `${count.charAt(0).toUpperCase()}${count.slice(1)} ${line.name}${seat}${allergy}`; }); const rest = said.length - read.length; diff --git a/src/kitchen-screen-feed.js b/src/kitchen-screen-feed.js index edd9d1707..9b4a04c41 100644 --- a/src/kitchen-screen-feed.js +++ b/src/kitchen-screen-feed.js @@ -36,6 +36,7 @@ const KEEP_LAST_ON_FAILURE = true; let timer = null; let lastGood = null; +let generation = 0; function screens() { return require('./kitchen-screen'); @@ -58,7 +59,7 @@ function apiUrl() { * interesting parts - what happens when the shop cannot be reached - can be * checked at all. */ -async function tick({ branchId, fetchImpl } = {}) { +async function tick({ branchId, fetchImpl, expectedGeneration = generation } = {}) { const branch = String(branchId || '').trim(); if (!branch) return { ok: false, why: 'no branch' }; @@ -82,9 +83,18 @@ async function tick({ branchId, fetchImpl } = {}) { if (!answer || answer.type === 'error' || answer.status === false || !Array.isArray(answer.data)) { return { ok: false, why: 'invalid response' }; } + if (expectedGeneration !== generation) return {ok:false,why:'feed changed'}; const tickets = answer.data; lastGood = tickets; - screens().setTickets(tickets); + const rendered = await screens().setTickets(tickets); + if (Array.isArray(rendered) && rendered.length) { + try { + await doFetch(`${apiUrl()}/sales/kitchenDisplayReport`, {method:'POST',signal:AbortSignal.timeout(3000), + headers:{'Content-Type':'application/json',kioskkey:process.env.KIOSK_API_KEY || ''}, + body:JSON.stringify({branchId:branch,till:require('os').hostname(),screens:rendered, + saleIds:[...new Set(tickets.map(ticket=>String(ticket.id || '').split(':')[0]))],at:new Date().toISOString()})}); + } catch {} // A status report must not clear the rendered tickets. + } return { ok: true, count: tickets.length }; } catch (e) { /* @@ -93,9 +103,9 @@ async function tick({ branchId, fetchImpl } = {}) { * screen that empties itself every time the API hiccups is a screen * nobody trusts. */ - if (KEEP_LAST_ON_FAILURE && lastGood) { + if (KEEP_LAST_ON_FAILURE && lastGood && expectedGeneration === generation) { try { - screens().setTickets(lastGood); + await screens().setTickets(lastGood); } catch (err) { /* nothing to do */ } @@ -109,17 +119,24 @@ function start({ branchId, everyMs = EVERY_MS } = {}) { stop(); const branch = String(branchId || '').trim(); if (!branch) return null; - timer = setInterval(() => { - tick({ branchId: branch }).catch(() => {}); - }, everyMs); + let busy = false; + const expectedGeneration = generation; + const refresh = async () => { + if (busy || expectedGeneration !== generation) return; + busy = true; + try { await tick({branchId:branch, expectedGeneration}); } finally { busy = false; } + }; + timer = setInterval(() => { refresh().catch(() => {}); }, everyMs); if (typeof timer.unref === 'function') timer.unref(); /* Straight away as well: a shop that has just opened a screen should not watch an empty wall for five seconds wondering whether it works. */ - tick({ branchId: branch }).catch(() => {}); + refresh().catch(() => {}); return timer; } function stop() { + generation++; + lastGood = null; if (!timer) return; clearInterval(timer); timer = null; diff --git a/src/kitchen-screen.html b/src/kitchen-screen.html index 742d74d08..c8d9bc789 100644 --- a/src/kitchen-screen.html +++ b/src/kitchen-screen.html @@ -174,6 +174,7 @@ } #setup b { color: var(--ink); } #setup.bad { border-color: var(--late); } +.allergy{font-weight:800;color:#8d2800;background:#fff0d6;padding:6px;border:2px solid currentColor;border-radius:5px} @@ -285,6 +286,9 @@ list.appendChild(row); /* NEVER dropped to save space. "less spicy" is the line that costs a plate of food when it is missed. */ + if (item.seat || item.course) list.appendChild(el('div', 'note', [item.seat ? 'Seat ' + item.seat : '', item.course || ''].filter(Boolean).join(' · '))); + var allergies = (item.allergies || []).concat(item.allergy_note || '').filter(Boolean); + if (allergies.length) list.appendChild(el('div', 'allergy', 'ALLERGY: ' + allergies.join(', '))); if (item.note && cfg.showItemNotes !== false) { list.appendChild(el('div', 'note', item.note)); } diff --git a/src/kitchen-screen.js b/src/kitchen-screen.js index b343c1528..c9950cfe8 100644 --- a/src/kitchen-screen.js +++ b/src/kitchen-screen.js @@ -53,6 +53,7 @@ function electron() { /* One window per display id. */ const windows = new Map(); +const renderReceipts = new Map(); let _watching = false; /* ------------------------------------------------------------------ config */ @@ -464,17 +465,26 @@ function push(displayId, { setupMode = false } = {}) { * display so a pass screen and a hot-kitchen screen can later be given * different lists without changing anything here. */ -function setTickets(list, displayId = null) { +async function setTickets(list, displayId = null) { const value = Array.isArray(list) ? list : []; - if (displayId) { - feeds.set(String(displayId), value); - push(displayId); - return; - } - for (const id of windows.keys()) { - feeds.set(id, value); - push(id); - } + const targets = displayId ? [String(displayId)] : [...windows.keys()]; + return Promise.all(targets.map(id => { + feeds.set(id,value); + const win=windows.get(id); + if(!win || win.isDestroyed() || !configFor(id).enabled)return Promise.resolve(null); + return new Promise(resolve=>{ + const token=require('crypto').randomUUID(); + const timer=setTimeout(()=>{renderReceipts.delete(token);resolve(null);},1500); + renderReceipts.set(token,{sender:win.webContents,resolve:()=>{clearTimeout(timer);renderReceipts.delete(token);resolve(id);}}); + try { push(id); win.webContents.send('kitchen-screen:tickets',value,token); } + catch {clearTimeout(timer);renderReceipts.delete(token);resolve(null);} + }); + })).then(ids=>ids.filter(Boolean)); +} +function acknowledgeRender(sender,token) { + const receipt=renderReceipts.get(String(token)); + if(!receipt || receipt.sender!==sender)return false; + receipt.resolve();return true; } module.exports = { @@ -482,6 +492,7 @@ module.exports = { displays, push, setTickets, + acknowledgeRender, sampleTickets, configFor, configuredIds, diff --git a/src/kot-manager.js b/src/kot-manager.js index 8f9854620..f92bbdfb9 100644 --- a/src/kot-manager.js +++ b/src/kot-manager.js @@ -663,6 +663,7 @@ class KOTManager { // restart that lands on a different port keeps working. const { branchId, printerNames } = this.config; const apiUrl = kotApiUrl(); + void this._flushDeliveryReports(); try { console.log('[KOT] Polling server...'); @@ -874,6 +875,7 @@ class KOTManager { dineType: f.dineType, saleId: f.saleIdDisplay, deliverTo: f.deliverTo, + money: sale.money, note: f.orderNote, items: f.items.map((it) => ({ name: require('./item-localization').name(it, itemLanguage) || it.product_name || it.itemName || '', @@ -884,6 +886,9 @@ class KOTManager { /* Carried through so the thermal renderer can print it; see spiceLine in escpos-kot.js. */ spice_level: it.spice_level != null ? it.spice_level : it.spice, + priced_at_table: it.priced_at_table, + instruction_only: it.instruction_only === true, + seat: it.seat, course: it.course, allergies: it.allergies, allergy_note: it.allergy_note, })), /* The HTML ticket has struck out cancelled dishes for as long as it has existed; the bytes could not, until strikeLine. Same field @@ -975,6 +980,40 @@ class KOTManager { * @param {string} [timing.via] 'bytes' for ESC/POS, 'window' for the PDF * fallback, which is about ten times slower */ + _queueDeliveryReport(sale, results) { + if (!sale._deliveryKey || !sale._id || !this.config?.branchId) return; + try { + const file=path.join(this.logsDir,'delivery-pending.json'); + fs.mkdirSync(this.logsDir,{recursive:true}); + let pending={};try{pending=JSON.parse(fs.readFileSync(file,'utf8'));}catch{} + const key=crypto.createHash('sha256').update(sale._deliveryKey).digest('hex'); + pending[key]={key:sale._deliveryKey,saleId:String(sale._id),branchId:String(this.config.branchId),till:this.tillId, + at:new Date().toISOString(),printers:results.map(result=>({name:result.name,copy:result.copy || 1, + state:result.status==='success'?'accepted':result.status==='failed'?'failed':result.reason?.includes('unknown')?'unknown':'pending',reason:result.reason || ''}))}; + fs.writeFileSync(file+'.tmp',JSON.stringify(pending));fs.renameSync(file+'.tmp',file); + void this._flushDeliveryReports(); + } catch(error) {console.warn('[KOT] Delivery report retained in ticket log:',error.message);} + } + + async _flushDeliveryReports() { + if(this._reportingDelivery)return; + this._reportingDelivery=true; + const file=path.join(this.logsDir,'delivery-pending.json'); + try { + let pending={};try{pending=JSON.parse(fs.readFileSync(file,'utf8'));}catch{return;} + for(const [key,report] of Object.entries(pending).slice(0,20)) { + const result=await fetch(kotApiUrl()+'/sales/kitchenDeliveryReport',{method:'POST', + headers:{'Content-Type':'application/json',kioskkey:process.env.KIOSK_API_KEY || ''}, + body:JSON.stringify(report),signal:AbortSignal.timeout(5000)}); + if(!result.ok)break; + const current=JSON.parse(fs.readFileSync(file,'utf8')); + if(current[key]?.at===report.at)delete current[key]; + fs.writeFileSync(file+'.tmp',JSON.stringify(current));fs.renameSync(file+'.tmp',file); + } + } catch(error) {console.warn('[KOT] Delivery status will retry:',error.message);} + finally {this._reportingDelivery=false;} + } + _logTicket(sale, printKind, kotNumber, saleDispId, saleDbId, printerResults, timing = {}) { if (printerResults.length && printerResults.every(r => r.cached || r.deferred)) return; const uid = crypto.randomUUID ? crypto.randomUUID() @@ -1151,6 +1190,7 @@ class KOTManager { this._logTicket(sale, printKind, kotNumber, saleDispId, saleDbId, rawResults, { ms: Date.now() - ticketStartedAt, via: 'bytes' }); } + this._queueDeliveryReport(sale, rawResults); return rawResults; } } @@ -1214,6 +1254,7 @@ class KOTManager { { ms: Date.now() - ticketStartedAt, via: 'window' }); } + this._queueDeliveryReport(sale, printerResults); return printerResults; } @@ -1361,6 +1402,9 @@ class KOTManager {
${this._esc(String(name))}
x${qty}
+ ${it.instruction_only ? '
PREPARATION UPDATE — do not add another item
' : ''} + ${it.seat || it.course ? `
${this._esc([it.seat ? 'Seat ' + it.seat : '', it.course || ''].filter(Boolean).join(' · '))}
` : ''} + ${(it.allergies || []).length || it.allergy_note ? `
ALLERGY: ${this._esc([...(it.allergies || []), it.allergy_note || ''].filter(Boolean).join(', '))}
` : ''} ${hot ? `
${this._esc(hot)}
` : ''} ${desc ? `
** ${this._esc(String(desc))} **
` : ''} `; diff --git a/src/preload.js b/src/preload.js index c00348b8d..f58cdd97a 100644 --- a/src/preload.js +++ b/src/preload.js @@ -402,7 +402,7 @@ contextBridge.exposeInMainWorld('posnicKitchenScreen', { return () => ipcRenderer.removeListener('kitchen-screen:config', h); }, onTickets: (cb) => { - const h = (_e, d) => cb(d); + const h = (_e, d, receipt) => { cb(d); if(receipt) ipcRenderer.invoke('kitchen-screen:rendered',receipt).catch(()=>{}); }; ipcRenderer.on('kitchen-screen:tickets', h); return () => ipcRenderer.removeListener('kitchen-screen:tickets', h); } diff --git a/tests/kitchen-service-rounds.test.js b/tests/kitchen-service-rounds.test.js index f4213e269..a123e0415 100644 --- a/tests/kitchen-service-rounds.test.js +++ b/tests/kitchen-service-rounds.test.js @@ -39,3 +39,28 @@ test('every dish on long kitchen tickets rotates into view, including a busy kit for(let i=0;i<4;i++){collect(nodes.board);timers[1]();} assert.equal(seen.size,10);assert.equal(nodes.count.textContent,1); }); + + +test('cancelling one preparation never removes another guest preparation of the same product',()=>{ + const dry={...line(1),line_id:'dry',item_description:'No chilli'}; + const gravy={...line(2),line_id:'gravy',item_description:'Extra sauce'}; + const order=sale();order.items=[dry,{...gravy,item_quantity:1}]; + order.changes=[{timestamp:order.created_date,items:[dry,gravy]}, + {timestamp:'2026-09-27T08:30:00Z',items:[{...gravy,item_quantity:1,process:'cancel'}]}]; + order.kitchen_service={c0i0:{quantity:1}}; + const remaining=tickets(order).flatMap(ticket=>ticket.items); + assert.equal(remaining.length,1);assert.equal(remaining[0].note,'Extra sauce');assert.equal(remaining[0].qty,1); +}); + + +test('held courses do not cook until fired and firing never adds chargeable quantity',()=>{ + const held={...line(2),line_id:'dessert',held:true,seat:2,course:'Dessert',allergies:['milk']}; + const order=sale();order.items=[held];order.changes=[{timestamp:order.created_date,items:[held]}]; + assert.deepEqual(tickets(order),[]);assert.equal(rounds(order)[0].items[0].held,true); + order.items=[{...held,held:false}]; + order.changes.push({timestamp:'2026-09-27T09:00:00Z',items:[{...held,held:false,process:'fire',source_round_line:'c0i0'}]}); + const rows=rounds(order).flatMap(round=>round.items); + assert.equal(rows.length,1);assert.equal(rows[0].id,'c0i0');assert.equal(rows[0].quantity,2); + assert.equal(tickets(order)[0].placedAt,'2026-09-27T09:00:00.000Z'); + assert.deepEqual(tickets(order)[0].items[0].allergies,['milk']); +}); diff --git a/tests/kot-multi-printer-recovery.test.js b/tests/kot-multi-printer-recovery.test.js index e821ee5a2..8b5240b72 100644 --- a/tests/kot-multi-printer-recovery.test.js +++ b/tests/kot-multi-printer-recovery.test.js @@ -9,24 +9,25 @@ Module._load=function(name,...args){if(name==='electron')return {app:{getPath:() const KOT=require('../src/kot-manager'),ledger=require('../src/print-ledger'),{kotJobKey}=require('../src/kot-job-key');Module._load=load; function rig(t,{windowPath=false,legacy=false}={}){ const dir=fs.mkdtempSync(path.join(root,'case-'));let now=Date.now();t.mock.method(Date,'now',()=>now); - const calls=[],marks=[];let behavior=()=>({success:true}),ack=true; + const calls=[],marks=[],reports=[];let behavior=()=>({success:true}),ack=true; const sale={_id:'sale1',sales_id:'S1',sale_process:'KOT',table_number:'23',items:[{item_name:'Rice',item_quantity:2}]}; if(!legacy)sale.print_jobs=[{type:'new',timestamp:'2026-09-26T17:00:00Z',items:sale.items}]; const config={branchId:'b1',printerNames:['Kitchen','Pass'],printers:[{name:'Kitchen',copies:2,pageSize:'80mm'},{name:'Pass',copies:1,pageSize:'58mm'}]}; let announced=0; function make(){ const manager=new KOT({hardware:windowPath?null:{sendRawToPrinter:async(name,bytes,label)=>{calls.push({name,label});return behavior(name,calls.length);}}}); - ledger.setDir(dir);manager.config=structuredClone(config);manager._announceToKitchen=()=>announced++; + manager.logsDir=path.join(dir,'logs');ledger.setDir(dir);manager.config=structuredClone(config);manager._announceToKitchen=()=>announced++; manager._waitForPrintPage=async()=>{}; manager._printToDeviceWithFallback=async(_w,name,size,strict)=>{calls.push({name,size,strict});return behavior(name,calls.length);}; return manager; } const manager=make(); t.mock.method(global,'fetch',async(url,init)=>{ + if(url.includes('kitchenDeliveryReport')){reports.push(JSON.parse(init.body));return {ok:true};} if(url.includes('multiKitchenPrint'))return {ok:true,json:async()=>({data:[sale]})}; marks.push(JSON.parse(init.body));return {ok:ack,status:ack?200:500}; }); - return {manager,calls,marks,sale,dir,make,set(fn){behavior=fn;},tick(ms=300001){now+=ms;},get announced(){return announced;},ack(value){ack=value;}}; + return {manager,calls,marks,reports,sale,dir,make,set(fn){behavior=fn;},tick(ms=300001){now+=ms;},get announced(){return announced;},ack(value){ack=value;}}; } test('partial failure retries only missing copies after restart and retains original targets',async t=>{ const r=rig(t);r.set((_name,n)=>({success:n!==2,error:'offline'})); @@ -82,3 +83,14 @@ test('a layout fallback is decided before sending any raw copy',async t=>{ assert.ok(r.calls.every(c=>c.strict===true),'some copies were sent raw before the window fallback'); assert.equal(r.marks.length,1); }); + + +test('delivery reports distinguish each printer copy and never label acceptance as paper printed',async t=>{ + const r=rig(t);r.set(name=>({success:name==='Pass',error:'offline'})); + await r.manager._pollOnce(); + await r.manager._flushDeliveryReports(); + assert.ok(r.reports.length>0); + const report=r.reports.at(-1);assert.equal(report.printers.length,3); + assert.deepEqual(report.printers.map(printer=>printer.state),['failed','failed','accepted']); + assert.equal(r.marks.length,0); +}); diff --git a/tests/the-kitchen-hears-a-ticket.test.js b/tests/the-kitchen-hears-a-ticket.test.js index a0233787a..fc8cadada 100644 --- a/tests/the-kitchen-hears-a-ticket.test.js +++ b/tests/the-kitchen-hears-a-ticket.test.js @@ -165,22 +165,11 @@ test('an amendment says so, because a cook must not start it twice', () => { assert.match(said, /order changed/); }); -test('a long ticket is cut short, and says how much it cut', () => { - /* - * Six courses from a table of six arrive within seconds. Read in full that - * is a minute of talking, and a kitchen that stopped listening does not hear - * the next one either. The paper ticket still has the detail. - */ - const items = Array.from({ length: 9 }, (_, i) => ({ - item_name: `Dish ${i + 1}`, - item_quantity: 1, - })); - - const said = kitchenCall.say({ table: '9', items }); - - assert.match(said, /One Dish 6\./); - assert.ok(!said.includes('Dish 7'), 'stops after six'); - assert.match(said, /And three more\./); +test('a queued long ticket reads every item, including later cancellations and allergies', () => { + const items = Array.from({ length: 10 }, (_, i) => ({item_name: `Dish ${i + 1}`,item_quantity: 1,allergies:i===9?['milk']:[]})); + const said=kitchenCall.say({table:'9',items}); + assert.match(said,/One Dish 10/);assert.match(said,/Allergy: milk/); + assert.ok(!said.includes('more.')); }); test('the name is the shop own, brackets and all', () => { diff --git a/tests/the-kitchen-screen-shows-the-kitchen.test.js b/tests/the-kitchen-screen-shows-the-kitchen.test.js index 03938d33e..118748830 100644 --- a/tests/the-kitchen-screen-shows-the-kitchen.test.js +++ b/tests/the-kitchen-screen-shows-the-kitchen.test.js @@ -194,3 +194,20 @@ test('starting twice leaves one timer, and stopping releases it', () => { /* And a shop with no branch never starts one. */ assert.strictEqual(feed.start({ branchId: '' }), null); }); + +test('only a renderer acknowledgment produces a kitchen display report', async () => { + const screens = require(path.join(ROOT, 'src', 'kitchen-screen.js')); + const real = screens.setTickets; + const calls=[]; + const fetchImpl=async (url,opts)=>{calls.push({url,body:JSON.parse(opts.body)});return {ok:true,json:async()=>({data:[{id:'507f1f77bcf86cd799439011:c0',items:[]}]})};}; + try { + screens.setTickets=async()=>[]; + await feed.tick({branchId:'branch',fetchImpl}); + assert.equal(calls.length,1); + screens.setTickets=async()=>['display-1']; + await feed.tick({branchId:'branch',fetchImpl}); + assert.equal(calls.length,3); + assert.ok(calls[2].url.endsWith('/sales/kitchenDisplayReport')); + assert.deepEqual(calls[2].body.saleIds,['507f1f77bcf86cd799439011']); + } finally { screens.setTickets=real; } +}); From 5fa1ed04eee460dde5eba8a6427492427dfd678c Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Mon, 28 Sep 2026 11:08:49 +0530 Subject: [PATCH 2/4] Finalize service compatibility, kitchen integration and release metadata Signed-off-by: Sridhar Bala --- CITATION.cff | 4 +- api/app.js | 3 +- api/package-lock.json | 4 +- api/package.json | 2 +- api/src/controllers/items.controller.js | 2 + api/src/controllers/sales.controller.js | 11 ++- api/src/helpers/kitchen-rounds.js | 22 ++++-- api/src/kitchen-board/board.css | 2 + api/src/kitchen-board/board.js | 13 ++++ api/src/repositories/sale.repository.js | 35 +++++++-- api/src/routes/sales.routes.js | 6 +- api/src/services/captain-edit-policy.js | 18 +++-- api/src/services/captain-service.js | 2 +- api/src/services/kitchen-board.js | 13 +++- api/src/services/kitchen-delivery.js | 70 +++++++++++++----- api/src/utils/currency.js | 14 +++- api/src/utils/service-line.js | 4 +- api/tests/api/route-coverage.test.js | 7 +- .../an-order-changed-under-you.test.js | 73 +++++++++++++++++++ .../unit/services/captain-service.test.js | 60 ++++++++++----- .../unit/the-kitchen-reads-how-hot.test.js | 2 +- builds/linux/com.posnic.app.metainfo.xml | 1 + codemeta.json | 8 +- docs/openapi.json | 2 +- frontend/login.html | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/static/script/js/core/PosnicPro.js | 10 ++- .../static/script/js/core/captain-money.js | 14 +++- languages/ar.json | 5 +- languages/bn.json | 5 +- languages/de.json | 5 +- languages/es.json | 5 +- languages/fr.json | 5 +- languages/gu.json | 5 +- languages/hi.json | 5 +- languages/id.json | 5 +- languages/it.json | 5 +- languages/ja.json | 5 +- languages/kn.json | 5 +- languages/ko.json | 5 +- languages/ml.json | 5 +- languages/mr.json | 5 +- languages/ms.json | 5 +- languages/ne.json | 5 +- languages/nl.json | 5 +- languages/pt.json | 5 +- languages/ru.json | 5 +- languages/server/ar.json | 1 - languages/server/de.json | 1 - languages/server/es.json | 1 - languages/server/fr.json | 1 - languages/server/hi.json | 1 - languages/server/ta.json | 1 - languages/si.json | 5 +- languages/sw.json | 5 +- languages/ta.json | 5 +- languages/te.json | 5 +- languages/th.json | 5 +- languages/tr.json | 5 +- languages/ur.json | 5 +- languages/vi.json | 5 +- languages/zh-CN.json | 5 +- languages/zh-TW.json | 5 +- package-lock.json | 4 +- package.json | 2 +- tests/captain-manager-approval.test.js | 28 +++++++ 67 files changed, 466 insertions(+), 129 deletions(-) create mode 100644 tests/captain-manager-approval.test.js diff --git a/CITATION.cff b/CITATION.cff index 3a2205478..e7da69376 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -16,8 +16,8 @@ authors: repository-code: https://github.com/Posnic/POS url: https://www.posnic.com/ license: AGPL-3.0-only -version: 1.8.3 -date-released: 2026-09-27 +version: 1.8.4 +date-released: 2026-09-28 keywords: - point of sale - POS software diff --git a/api/app.js b/api/app.js index 987c0ae26..d750920b9 100644 --- a/api/app.js +++ b/api/app.js @@ -1126,7 +1126,8 @@ for (const file of ['index.html', 'board.js', 'board.css', 'device.js', 'manifes file === 'index.html' ? ['/kitchen/', '/kitchen/index.html'] : '/kitchen/' + file, (_req, res) => { res.set('Cache-Control', 'no-store'); - res.sendFile(path.join(__dirname, 'src', 'kitchen-board', file)); + // Fixed server-owned filenames also work from hidden managed checkouts. + res.sendFile(path.join(__dirname, 'src', 'kitchen-board', file), { dotfiles: 'allow' }); } ); } diff --git a/api/package-lock.json b/api/package-lock.json index 7856de0bb..624a75254 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1,12 +1,12 @@ { "name": "Api_v2_express", - "version": "1.8.3", + "version": "1.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "Api_v2_express", - "version": "1.8.3", + "version": "1.8.4", "license": "AGPL-3.0-only", "dependencies": { "@aws-sdk/client-s3": "^3.1136.0", diff --git a/api/package.json b/api/package.json index de9ef5771..1588c7d83 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "Api_v2_express", - "version": "1.8.3", + "version": "1.8.4", "description": "Posnic API v2 - Express.js Backend (1:1 PHP Replica)", "main": "server.js", "scripts": { diff --git a/api/src/controllers/items.controller.js b/api/src/controllers/items.controller.js index 97cfd6228..0e6aae084 100644 --- a/api/src/controllers/items.controller.js +++ b/api/src/controllers/items.controller.js @@ -909,6 +909,7 @@ class ItemsController extends BaseController { advertisement: data.store?.advertisement || '', }, money: require('../utils/currency').policy(data.store || {}), + restaurant_service_v1: true, kiosk_payment: data.payment || {}, kiosk_print: data.print || {}, }, @@ -1003,6 +1004,7 @@ class ItemsController extends BaseController { advertisement: data.store?.advertisement || '', }, money: require('../utils/currency').policy(data.store || {}), + restaurant_service_v1: true, kiosk_payment: data.payment || {}, /* The floor plan. The captain app reads this to draw its tables, and is the only caller that ever did. */ diff --git a/api/src/controllers/sales.controller.js b/api/src/controllers/sales.controller.js index 13f9da40f..ee8bc910d 100644 --- a/api/src/controllers/sales.controller.js +++ b/api/src/controllers/sales.controller.js @@ -7065,8 +7065,15 @@ class SalesController extends BaseController { } } async kitchenDisplayReport(req, res) { - try { return this.success(res, await require('../services/kitchen-delivery').displayReport(req), 'Received'); } - catch (error) { return this.error(res, error.message, error.status || 500); } + try { + return this.success( + res, + await require('../services/kitchen-delivery').displayReport(req), + 'Received' + ); + } catch (error) { + return this.error(res, error.message, error.status || 500); + } } async kitchenDeliveryStatus(req, res) { try { diff --git a/api/src/helpers/kitchen-rounds.js b/api/src/helpers/kitchen-rounds.js index 7abc8df73..f340cfaef 100644 --- a/api/src/helpers/kitchen-rounds.js +++ b/api/src/helpers/kitchen-rounds.js @@ -48,7 +48,6 @@ function rounds(sale, { descriptions = true } = {}) { original.held = false; original.fired_at = date(change.timestamp); original.round = `c${c}`; - } } else if (String(line.process).toLowerCase() === 'cancel') { let remaining = qty; @@ -68,7 +67,9 @@ function rounds(sale, { descriptions = true } = {}) { ordered_at: date(change.timestamp) || date(sale.created_date), quantity: qty, name: String(line.item_name || line.name || ''), - note: String(line.item_note || ((descriptions || line.line_id) && line.item_description) || ''), + note: String( + line.item_note || ((descriptions || line.line_id) && line.item_description) || '' + ), spice_level: line.spice_level, }); } @@ -104,7 +105,9 @@ function rounds(sale, { descriptions = true } = {}) { ordered_at: date(sale.created_date), quantity: missing, name: String(line.item_name || line.name || line.sale_inline_item_name || ''), - note: String(line.item_note || ((descriptions || line.line_id) && line.item_description) || ''), + note: String( + line.item_note || ((descriptions || line.line_id) && line.item_description) || '' + ), spice_level: line.spice_level, }); } @@ -118,14 +121,21 @@ function rounds(sale, { descriptions = true } = {}) { row.served_at = date(service.at); row.remaining = row.quantity - row.served; if (!groups.has(row.round)) - groups.set(row.round, { id: row.round, ordered_at: row.ordered_at, fired_at: row.fired_at || null, items: [] }); + groups.set(row.round, { + id: row.round, + ordered_at: row.ordered_at, + fired_at: row.fired_at || null, + items: [], + }); groups.get(row.round).items.push(row); } return [...groups.values()]; } function tickets(sale) { - const closed = sale.kitchen_required ? null : date(sale.bill_requested_at || sale.bill_printed_at); - return rounds(sale, {descriptions:false}).flatMap((round) => { + const closed = sale.kitchen_required + ? null + : date(sale.bill_requested_at || sale.bill_printed_at); + return rounds(sale, { descriptions: false }).flatMap((round) => { const kitchenTime = round.fired_at || round.ordered_at; if (closed && (!kitchenTime || kitchenTime <= closed)) return []; const items = round.items diff --git a/api/src/kitchen-board/board.css b/api/src/kitchen-board/board.css index ee604ee93..34008acb4 100644 --- a/api/src/kitchen-board/board.css +++ b/api/src/kitchen-board/board.css @@ -349,3 +349,5 @@ footer { } } } + +.allergy {display:block;grid-column:1/-1;color:#fff;background:#8b1717;padding:6px 10px;border-radius:6px;font-size:1rem;} diff --git a/api/src/kitchen-board/board.js b/api/src/kitchen-board/board.js index 45fd32dd3..35c21c45e 100644 --- a/api/src/kitchen-board/board.js +++ b/api/src/kitchen-board/board.js @@ -155,6 +155,19 @@ const li = node('li'); li.append(node('span', 'quantity', item.qty + '×'), node('span', 'name', item.name)); if (item.note) li.append(node('span', 'note', item.note)); + if (item.seat || item.course) + li.append( + node( + 'span', + 'note', + [item.seat ? 'Seat ' + item.seat : '', item.course || ''] + .filter(Boolean) + .join(' · ') + ) + ); + const allergies = [...(item.allergies || []), item.allergy_note || ''].filter(Boolean); + if (allergies.length) + li.append(node('strong', 'allergy', 'ALLERGY: ' + allergies.join(', '))); const total = item.total ?? item.qty, ready = item.ready ?? (stage === 'ready' ? total : 0); const collected = item.collected || 0, diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index c520d509a..1384b8b23 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -10313,7 +10313,15 @@ class SalesRepository { }); } const qty = parseFloat(item.quantity || item.item_quantity || 0); - const price = parseFloat(item.price || item.unit_price || item.item_base_price || 0); + const price = Number( + item.price ?? + item.unit_price ?? + item.item_base_price ?? + item.item_price ?? + previousLine?.unit_price ?? + previousLine?.item_price ?? + 0 + ); if (!productId || qty <= 0 || price < 0) continue; const oldQty = oldItemsData[lineKey] ? parseFloat(oldItemsData[lineKey].quantity) : 0; @@ -10323,14 +10331,19 @@ class SalesRepository { if (mongoose.Types.ObjectId.isValid(productId)) { itemDoc = await itemCollection.findOne({ _id: new mongoose.Types.ObjectId(productId), + license: orderDoc.license, }); } if (!itemDoc) { + if (!previousLine) + throw new Error( + 'This product has already been removed, so you can not modify anything.' + ); // Item not in catalog (e.g. KOT order item) - update in-place using existing data if (existingIndex[lineKey] !== undefined) { const i = existingIndex[lineKey]; updatedItems[i] = { - ...this._scaleOrderLine(updatedItems[i], oldQty, qty), + ...this._scaleOrderLine(updatedItems[i], oldQty, qty, monetary), ...serviceLine.metadata({ ...updatedItems[i], ...item }), item_quantity: qty, quantity: qty, @@ -10343,7 +10356,16 @@ class SalesRepository { ? { spice_level: spiceLevel.levelOf(item.spice_level) } : {}), }; - if (qty !== oldQty) changesItems.push({ ...preparation, item_id: productId, item_name: previousLine.item_name || '', item_quantity: Math.abs(qty - oldQty), item_description: newNote, spice_level: updatedItems[i].spice_level, process: qty > oldQty ? 'add' : 'cancel' }); + if (qty !== oldQty) + changesItems.push({ + ...preparation, + item_id: productId, + item_name: previousLine.item_name || '', + item_quantity: Math.abs(qty - oldQty), + item_description: newNote, + spice_level: spiceLevel.levelOf(updatedItems[i].spice_level), + process: qty > oldQty ? 'add' : 'cancel', + }); incomingProductIds.push(lineKey); } continue; @@ -11382,10 +11404,13 @@ class SalesRepository { * the price they were quoted rather than to whatever the catalogue says by * the time they change their mind. */ - _scaleOrderLine(line, was, now) { + _scaleOrderLine(line, was, now, monetary) { if (!(was > 0) || now === was) return { ...line, item_quantity: now, quantity: now }; const factor = now / was; - const scale = (value) => round(Number(value || 0) * factor); + const scale = (value) => + monetary + ? Money.fromMinor(Money.toMinor(Number(value || 0) * factor, monetary), monetary) + : round(Number(value || 0) * factor); const scaled = { ...line, item_quantity: now, quantity: now }; for (const field of [ 'tax_amount', diff --git a/api/src/routes/sales.routes.js b/api/src/routes/sales.routes.js index 8d30a53bb..c9db99a08 100644 --- a/api/src/routes/sales.routes.js +++ b/api/src/routes/sales.routes.js @@ -373,7 +373,11 @@ router.post( protectOrKioskKey, bindController(salesController.getFrequentItems) ); -router.post('/kitchenDisplayReport', ensurePrintDevice, bindController(salesController.kitchenDisplayReport)); +router.post( + '/kitchenDisplayReport', + ensurePrintDevice, + bindController(salesController.kitchenDisplayReport) +); router.post( '/kitchenDeliveryReport', ensurePrintDevice, diff --git a/api/src/services/captain-edit-policy.js b/api/src/services/captain-edit-policy.js index f2a606084..4a857779f 100644 --- a/api/src/services/captain-edit-policy.js +++ b/api/src/services/captain-edit-policy.js @@ -10,20 +10,22 @@ async function authorize(req) { const c = await context(req), body = req.body; if (!ObjectId.isValid(String(body.order_id))) fail('Choose an order.'); - const sale = await req.db - .collection('sales') - .findOne({ - _id: new ObjectId(String(body.order_id)), - license: c.license, - branch_id: c.branchId, - }); + const sale = await req.db.collection('sales').findOne({ + _id: new ObjectId(String(body.order_id)), + license: c.license, + branch_id: c.branchId, + }); if (!sale) fail('Order not found.', 404); const incoming = new Map( (body.items || []).map((line) => [lineIdentity.key(line), quantity(line)]) ); const reduced = body.status === 'cancelled' || - (sale.items || []).some((line) => (incoming.get(lineIdentity.key(line)) || 0) < Number(line.item_quantity ?? line.quantity ?? 0)); + (sale.items || []).some( + (line) => + (incoming.get(lineIdentity.key(line)) || 0) < + Number(line.item_quantity ?? line.quantity ?? 0) + ); const discount = body.extra_discount != null && (Number(body.extra_discount) !== Number(sale.extra_discount || 0) || diff --git a/api/src/services/captain-service.js b/api/src/services/captain-service.js index 1ee824d2d..6ae3c236a 100644 --- a/api/src/services/captain-service.js +++ b/api/src/services/captain-service.js @@ -74,7 +74,7 @@ async function fire(req) { changes: sale.changes === undefined ? { $exists: false } : sale.changes, captain_payment_plan: { $exists: false }, }, - { $set: { items, changes, updated_date: now } } + { $set: { items, changes, updated_date: now, kitchen_required: true, kitchen_closed: false } } ); if (!updated.matchedCount) fail('Order changed. Refresh before sending this course.', 409); require('../helpers/kot-notify').notifyKotReady({ diff --git a/api/src/services/kitchen-board.js b/api/src/services/kitchen-board.js index 069cc5e1f..c37c6a398 100644 --- a/api/src/services/kitchen-board.js +++ b/api/src/services/kitchen-board.js @@ -19,7 +19,7 @@ function project(sale) { return rounds(sale, { descriptions: false }).flatMap((round) => { const work = sale.kitchen_work?.[round.id] || {}; const items = round.items - .filter((i) => i.remaining > 0) + .filter((i) => !i.held && i.remaining > 0) .map((i) => { const line = work.lines?.[i.id] || {}; const ready = Math.max( @@ -34,6 +34,10 @@ function project(sale) { total: i.quantity, served: i.served, note: i.note, + seat: i.seat, + course: i.course, + allergies: i.allergies, + allergy_note: i.allergy_note, ready, collected, collector: line.collector || '', @@ -60,7 +64,7 @@ function project(sale) { saleId: String(sale._id), roundId: round.id, table: String(sale.table_number || ''), - placedAt: round.ordered_at, + placedAt: round.fired_at || round.ordered_at, state, owner: String(owner.id || ''), ownerName: String(owner.name || ''), @@ -219,8 +223,9 @@ async function mutate(req, captain = false) { { $set: { kitchen_required: true, - kitchen_closed: - project({ ...sale, kitchen_work: work, kitchen_service: service }).length === 0, + kitchen_closed: !rounds({ ...sale, kitchen_work: work, kitchen_service: service }).some( + (round) => round.items.some((item) => item.remaining > 0) + ), kitchen_work: work, ...(b.operation === 'serve' ? { kitchen_service: service } : {}), }, diff --git a/api/src/services/kitchen-delivery.js b/api/src/services/kitchen-delivery.js index 11b9f5baf..68bfab1e2 100644 --- a/api/src/services/kitchen-delivery.js +++ b/api/src/services/kitchen-delivery.js @@ -49,11 +49,36 @@ async function report(req) { } async function displayReport(req) { const body = req.body || {}; - if (!ObjectId.isValid(String(body.branchId)) || !Array.isArray(body.saleIds) || body.saleIds.length > 2000 || body.saleIds.some(id => !ObjectId.isValid(String(id))) || !Array.isArray(body.screens) || !body.screens.length || body.screens.length > 100 || !body.till || String(body.till).length > 100) fail('Invalid display report.'); - const db = req.db || await BaseModel.getDb(); - const key = crypto.createHash('sha256').update(JSON.stringify([String(body.till), [...body.screens].map(String).sort()])).digest('hex'); - const value = { at: new Date(), till: String(body.till), screens: body.screens.map(id => String(id).slice(0, 100)), saleIds: [...new Set(body.saleIds.map(String))] }; - await db.collection('branches').updateOne({ _id: new ObjectId(String(body.branchId)), ...(BaseModel.license ? { license: BaseModel.license } : {}) }, { $set: { ['kitchen_display_status.' + key]: value } }); + if ( + !ObjectId.isValid(String(body.branchId)) || + !Array.isArray(body.saleIds) || + body.saleIds.length > 2000 || + body.saleIds.some((id) => !ObjectId.isValid(String(id))) || + !Array.isArray(body.screens) || + !body.screens.length || + body.screens.length > 100 || + !body.till || + String(body.till).length > 100 + ) + fail('Invalid display report.'); + const db = req.db || (await BaseModel.getDb()); + const key = crypto + .createHash('sha256') + .update(JSON.stringify([String(body.till), [...body.screens].map(String).sort()])) + .digest('hex'); + const value = { + at: new Date(), + till: String(body.till), + screens: body.screens.map((id) => String(id).slice(0, 100)), + saleIds: [...new Set(body.saleIds.map(String))], + }; + await db.collection('branches').updateOne( + { + _id: new ObjectId(String(body.branchId)), + ...(BaseModel.license ? { license: BaseModel.license } : {}), + }, + { $set: { ['kitchen_display_status.' + key]: value } } + ); return { received: true }; } async function status(req) { @@ -61,24 +86,29 @@ async function status(req) { const c = await context(req), id = req.query.saleId; if (!ObjectId.isValid(String(id))) fail('Choose an order.'); - const sale = await req.db - .collection('sales') - .findOne( - { _id: new ObjectId(String(id)), license: c.license, branch_id: c.branchId }, - { - projection: { - kitchen_delivery: 1, - created_date: 1, - kitchen_service: 1, - changes: 1, - items: 1, - }, - } - ); + const sale = await req.db.collection('sales').findOne( + { _id: new ObjectId(String(id)), license: c.license, branch_id: c.branchId }, + { + projection: { + kitchen_delivery: 1, + created_date: 1, + kitchen_service: 1, + changes: 1, + items: 1, + }, + } + ); if (!sale) fail('Order not found.', 404); return { serverAccepted: true, - displays: Object.values(c.branch.kitchen_display_status || {}).filter(display => display.saleIds?.includes(String(id))).map(display => ({ at: display.at, till: display.till, screens: display.screens, recent: Date.now() - new Date(display.at).getTime() < 30000 })), + displays: Object.values(c.branch.kitchen_display_status || {}) + .filter((display) => display.saleIds?.includes(String(id))) + .map((display) => ({ + at: display.at, + till: display.till, + screens: display.screens, + recent: Date.now() - new Date(display.at).getTime() < 30000, + })), reports: Object.values(sale.kitchen_delivery || {}) .sort((a, b) => new Date(b.at) - new Date(a.at)) .slice(0, 20), diff --git a/api/src/utils/currency.js b/api/src/utils/currency.js index 571413dd7..71cc3432d 100644 --- a/api/src/utils/currency.js +++ b/api/src/utils/currency.js @@ -3,7 +3,13 @@ 'use strict'; function policy(value = {}) { if (typeof value === 'string') value = { currency: value }; - const text = [value.currencyCode, value.currency_code, value.currency_text, value.currency_type, value.currency] + const text = [ + value.currencyCode, + value.currency_code, + value.currency_text, + value.currency_type, + value.currency, + ] .filter(Boolean) .join(' '); const supported = new Set( @@ -21,7 +27,11 @@ .maximumFractionDigits : 2; const symbol = String( - value.currencySymbol ?? value.currency_symbol ?? value.currency ?? value.currency_type ?? (code || '') + value.currencySymbol ?? + value.currency_symbol ?? + value.currency ?? + value.currency_type ?? + (code || '') ) .split('') .filter((character) => character.charCodeAt(0) >= 32 && !'<>&"\''.includes(character)) diff --git a/api/src/utils/service-line.js b/api/src/utils/service-line.js index 1a7be95d4..66eaae8fc 100644 --- a/api/src/utils/service-line.js +++ b/api/src/utils/service-line.js @@ -25,7 +25,9 @@ function metadata(line) { seat < 0 || seat > 99 || course.length > 40 || - [...course, ...allergyNote].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) || + [...course, ...allergyNote].some( + (char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 + ) || !Array.isArray(allergies) || allergies.length > ALLERGIES.length || allergies.some((value) => !ALLERGIES.includes(value)) || diff --git a/api/tests/api/route-coverage.test.js b/api/tests/api/route-coverage.test.js index 416ce77d1..7bbeeea20 100644 --- a/api/tests/api/route-coverage.test.js +++ b/api/tests/api/route-coverage.test.js @@ -135,7 +135,12 @@ describeIfDb('every route in the API', () => { process.env.ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || '0123456789abcdef0123456789abcdef'; process.env.ENCRYPTION_IV = process.env.ENCRYPTION_IV || '0123456789abcdef'; - client = new MongoClient(base, { serverSelectionTimeoutMS: 20000 }); + // MongoDB 7.6 loads its OS adapter through dynamic import. Jest's CJS VM + // does not enable that loader; use the real OS adapter explicitly here. + client = new MongoClient(base, { + serverSelectionTimeoutMS: 20000, + runtimeAdapters: { os: require('node:os') }, + }); await client.connect(); mongoose = require('mongoose'); diff --git a/api/tests/unit/repositories/an-order-changed-under-you.test.js b/api/tests/unit/repositories/an-order-changed-under-you.test.js index 1d7c21bbb..ca33545c8 100644 --- a/api/tests/unit/repositories/an-order-changed-under-you.test.js +++ b/api/tests/unit/repositories/an-order-changed-under-you.test.js @@ -241,3 +241,76 @@ test('same-product preparations survive edits and cancellation with their own no item_description: 'Extra butter', }); }); + +test('editing a removed catalogue dish keeps quantity and receipt fields consistent at shop precision', async () => { + const id = await anOrder(CHANGED_AT); + await db.collection('branches').updateOne({ _id: BRANCH }, { $set: { currency_code: 'KWD' } }); + await db + .collection('sales') + .updateOne( + { _id: new mongoose.Types.ObjectId(id) }, + { + $set: { + items: [ + { + item_id: NAAN, + item_name: 'Naan', + quantity: 2, + item_quantity: 2, + item_price: 40.005, + unit_price: 40.005, + total: 80.01, + item_total: 80.01, + total_amount: 80.01, + }, + ], + }, + } + ); + await db.collection('items').deleteOne({ _id: NAAN }); + const result = await repo.updateOrderModel( + id, + [{ item_id: String(NAAN), quantity: 1, item_price: 40.005 }], + 40.005, + null, + null, + null, + null, + '4', + 'Dine-in', + 2, + {} + ); + expect(result.status).not.toBe(false); + const saved = await stored(id); + expect(saved.items[0]).toMatchObject({ + quantity: 1, + item_quantity: 1, + total: 40.005, + item_total: 40.005, + total_amount: 40.005, + }); + expect(saved.changes.at(-1).items[0]).toMatchObject({ process: 'cancel', item_quantity: 1 }); +}); + +test('a newly added unknown dish refuses the whole edit instead of silently dropping it', async () => { + const id = await anOrder(CHANGED_AT); + const result = await repo.updateOrderModel( + id, + [ + { item_id: String(NAAN), quantity: 1, item_price: 40 }, + { item_id: String(new mongoose.Types.ObjectId()), quantity: 1, item_price: 20 }, + ], + 60, + null, + null, + null, + null, + '4', + 'Dine-in', + 2, + {} + ); + expect(result.status).toBe(false); + expect((await stored(id)).items).toHaveLength(2); +}); diff --git a/api/tests/unit/services/captain-service.test.js b/api/tests/unit/services/captain-service.test.js index d571358e4..a4f18a255 100644 --- a/api/tests/unit/services/captain-service.test.js +++ b/api/tests/unit/services/captain-service.test.js @@ -22,18 +22,16 @@ beforeEach(async () => { actor = new ObjectId(); other = new ObjectId(); await db.collection('branches').insertOne({ _id: branch, license }); - await db - .collection('users') - .insertMany( - [actor, other].map((id, index) => ({ - _id: id, - license, - branch_id: branch, - activate: true, - name: 'Staff ' + index, - access: { sales: { write: true } }, - })) - ); + await db.collection('users').insertMany( + [actor, other].map((id, index) => ({ + _id: id, + license, + branch_id: branch, + activate: true, + name: 'Staff ' + index, + access: { sales: { write: true } }, + })) + ); const line = { item_id: new ObjectId().toString(), line_id: 'dessert', @@ -137,15 +135,37 @@ test('cancellation requires a reason and a manager proof bound to this order and }); const delivery = require('../../../src/services/kitchen-delivery'); test('delivery status distinguishes printer acceptance from display rendering and isolates branches', async () => { - const input = req({ key:'ticket-1', printers:[{name:'Kitchen',copy:1,state:'accepted'}],at:new Date().toISOString(),till:'Till 1' }); + const input = req({ + key: 'ticket-1', + printers: [{ name: 'Kitchen', copy: 1, state: 'accepted' }], + at: new Date().toISOString(), + till: 'Till 1', + }); await delivery.report(input); - await delivery.displayReport(req({saleIds:[String(sale._id)],screens:['screen-1'],till:'Till 1'})); - const read=req({});read.query={saleId:String(sale._id)}; - const state=await delivery.status(read); + await delivery.displayReport( + req({ saleIds: [String(sale._id)], screens: ['screen-1'], till: 'Till 1' }) + ); + const read = req({}); + read.query = { saleId: String(sale._id) }; + const state = await delivery.status(read); expect(state.reports[0].printers[0].state).toBe('accepted'); - expect(state.displays[0]).toMatchObject({recent:true,till:'Till 1'}); - await delivery.displayReport(req({saleIds:[],screens:['screen-1'],till:'Till 1'})); + expect(state.displays[0]).toMatchObject({ recent: true, till: 'Till 1' }); + await delivery.displayReport(req({ saleIds: [], screens: ['screen-1'], till: 'Till 1' })); expect((await delivery.status(read)).displays).toEqual([]); - read.query.saleId=String(new ObjectId()); - await expect(delivery.status(read)).rejects.toMatchObject({status:404}); + read.query.saleId = String(new ObjectId()); + await expect(delivery.status(read)).rejects.toMatchObject({ status: 404 }); +}); + +test('kitchen board hides held food and carries seat and allergy details after firing', async () => { + const board = require('../../../src/services/kitchen-board'); + expect(board.project(sale)).toEqual([]); + await service.fire(req({ requestId: require('crypto').randomUUID(), items: ['c0i0'] })); + const after = await db.collection('sales').findOne({ _id: sale._id }); + expect(after.kitchen_closed).toBe(false); + expect(board.project(after)[0].items[0]).toMatchObject({ + seat: 2, + course: 'Dessert', + allergies: ['milk'], + }); + expect(board.project(after)[0].placedAt).toBe(after.changes[1].timestamp.toISOString()); }); diff --git a/api/tests/unit/the-kitchen-reads-how-hot.test.js b/api/tests/unit/the-kitchen-reads-how-hot.test.js index 8b9fbf1bc..1c5876077 100644 --- a/api/tests/unit/the-kitchen-reads-how-hot.test.js +++ b/api/tests/unit/the-kitchen-reads-how-hot.test.js @@ -94,7 +94,7 @@ test('an amendment prefers the level just chosen over the stored one', () => { * that only shows up when somebody actually changes an order. */ expect(REPO).toMatch(/item\.spice_level != null\s*\?\s*item\.spice_level/); - expect(REPO).toMatch(/updatedItems\[existingIndex\[productId\]\] \|\| \{\}\)\.spice_level/); + expect(REPO).toMatch(/updatedItems\[existingIndex\[lineKey\]\] \|\| \{\}\)\.spice_level/); }); test('every level is cleaned on the way in, never trusted', () => { diff --git a/builds/linux/com.posnic.app.metainfo.xml b/builds/linux/com.posnic.app.metainfo.xml index 410ae6e78..555f2eb60 100644 --- a/builds/linux/com.posnic.app.metainfo.xml +++ b/builds/linux/com.posnic.app.metainfo.xml @@ -55,6 +55,7 @@ + diff --git a/codemeta.json b/codemeta.json index d9fda80e8..88231dbb1 100644 --- a/codemeta.json +++ b/codemeta.json @@ -25,7 +25,7 @@ "codeRepository": "https://github.com/Posnic/POS", "issueTracker": "https://github.com/Posnic/POS/issues", "continuousIntegration": "https://github.com/Posnic/POS/actions", - "version": "1.8.3", + "version": "1.8.4", "developmentStatus": "active", "license": "https://spdx.org/licenses/AGPL-3.0-only.html", "isAccessibleForFree": true, @@ -67,11 +67,11 @@ "https://www.posnic.com/developers", "https://github.com/Posnic/POS/blob/main/docs/ADOPTION_EVIDENCE.md" ], - "softwareVersion": "1.8.3", - "dateModified": "2026-09-27", + "softwareVersion": "1.8.4", + "dateModified": "2026-09-28", "readme": "https://github.com/Posnic/POS/blob/main/README.md", "buildInstructions": "https://github.com/Posnic/POS/blob/main/docs/DEVELOPMENT.md", - "releaseNotes": "https://github.com/Posnic/POS/releases/tag/v1.8.3", + "releaseNotes": "https://github.com/Posnic/POS/releases/tag/v1.8.4", "isSourceCodeOf": { "@id": "https://www.posnic.com/#software", "@type": "SoftwareApplication", diff --git a/docs/openapi.json b/docs/openapi.json index 9cbdf46bf..baa326286 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "Posnic API", - "version": "1.8.3", + "version": "1.8.4", "description": "REST API for the Posnic point of sale.\n\nGenerated from the route table and express-validator chains by `npm run docs:api`. Do not edit by hand.\n\nRequest schemas are present for endpoints that carry validation middleware. The rest are listed with their path, method and handler but no body schema, because inventing one would be worse than the gap.", "license": { "name": "AGPL-3.0-only", diff --git a/frontend/login.html b/frontend/login.html index ddead62a7..47dd28f98 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -47,7 +47,7 @@ "codeRepository": "https://github.com/Posnic/POS", "applicationCategory": "BusinessApplication", "operatingSystem": "Windows, macOS, Linux, Web", - "softwareVersion": "1.8.3", + "softwareVersion": "1.8.4", "license": "https://github.com/Posnic/POS/blob/develop/LICENSE", "isAccessibleForFree": true, "keywords": [ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b60250fce..1cf1e157a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "posnic-frontend", - "version": "1.0.0", + "version": "1.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "posnic-frontend", - "version": "1.0.0", + "version": "1.8.4", "license": "AGPL-3.0-only", "devDependencies": { "archiver": "^8.0.0", diff --git a/frontend/package.json b/frontend/package.json index fddf6fcc0..74457bb6b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "posnic-frontend", - "version": "1.0.0", + "version": "1.8.4", "description": "Build pipeline for the Posnic web assets (gulp: sass, JS, HTML).", "main": "index.js", "scripts": { diff --git a/frontend/static/script/js/core/PosnicPro.js b/frontend/static/script/js/core/PosnicPro.js index 22e466f67..fd5edfe53 100644 --- a/frontend/static/script/js/core/PosnicPro.js +++ b/frontend/static/script/js/core/PosnicPro.js @@ -2035,7 +2035,12 @@ PosnicPro = { $('#manager_pin_error').addClass('d-none').text(''); $('#manager_pin_prompt').text(opts.prompt || "This action needs a manager's approval."); $('#manager_pin_submit').prop('disabled', false); - $('#manager_pin_modal').modal('show'); + $('#manager_pin_modal').off('hidden.bs.modal.orderApproval').on('hidden.bs.modal.orderApproval', function () { + var pending = PosnicPro._pendingApproval; + PosnicPro._pendingApproval = null; + $('#manager_pin_input, #manager_card_input').val(''); + if (pending && typeof pending.onDenied === 'function') pending.onDenied(); + }).modal('show'); setTimeout(function () { $('#manager_pin_input').trigger('focus'); }, 400); }, _setApprovalMode: function (mode) { @@ -2077,9 +2082,10 @@ PosnicPro = { url: byCard ? 'authorizations/verify-card' : 'authorizations/verify-pin', data: JSON.stringify(payload), }, function (response) { + if (PosnicPro._pendingApproval !== pending) return; if (response && response.type === 'success') { - $('#manager_pin_modal').modal('hide'); PosnicPro._pendingApproval = null; + $('#manager_pin_modal').modal('hide'); if (typeof pending.onApproved === 'function') pending.onApproved(response.data); } else { reEnable(); diff --git a/frontend/static/script/js/core/captain-money.js b/frontend/static/script/js/core/captain-money.js index 571413dd7..71cc3432d 100644 --- a/frontend/static/script/js/core/captain-money.js +++ b/frontend/static/script/js/core/captain-money.js @@ -3,7 +3,13 @@ 'use strict'; function policy(value = {}) { if (typeof value === 'string') value = { currency: value }; - const text = [value.currencyCode, value.currency_code, value.currency_text, value.currency_type, value.currency] + const text = [ + value.currencyCode, + value.currency_code, + value.currency_text, + value.currency_type, + value.currency, + ] .filter(Boolean) .join(' '); const supported = new Set( @@ -21,7 +27,11 @@ .maximumFractionDigits : 2; const symbol = String( - value.currencySymbol ?? value.currency_symbol ?? value.currency ?? value.currency_type ?? (code || '') + value.currencySymbol ?? + value.currency_symbol ?? + value.currency ?? + value.currency_type ?? + (code || '') ) .split('') .filter((character) => character.charCodeAt(0) >= 32 && !'<>&"\''.includes(character)) diff --git a/languages/ar.json b/languages/ar.json index 9b78fa257..91e622e08 100644 --- a/languages/ar.json +++ b/languages/ar.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "مقهى", "lang_pm_option_herbs": "حديقة أعشاب", "lang_pm_option_pizza": "مطعم بيتزا", - "lang_pm_option_grill": "شواية" + "lang_pm_option_grill": "شواية", + "lang_changes_not_saved": "لم تُحفظ التغييرات.", + "lang_continue": "متابعة", + "lang_reason": "السبب" } diff --git a/languages/bn.json b/languages/bn.json index 7cd9d1ae1..4a6f8c698 100644 --- a/languages/bn.json +++ b/languages/bn.json @@ -95,5 +95,8 @@ "lang_total_title": "মোট", "lang_yes": "হ্যাঁ", "lang_ai_describe_hint": "পণ্যের তথ্য থেকে বিবরণের খসড়া তৈরি করুন", - "lang_walk_in_customer_click_to_choose_or_add": "অনিবন্ধিত গ্রাহক। বেছে নিতে বা যোগ করতে ক্লিক করুন" + "lang_walk_in_customer_click_to_choose_or_add": "অনিবন্ধিত গ্রাহক। বেছে নিতে বা যোগ করতে ক্লিক করুন", + "lang_changes_not_saved": "পরিবর্তনগুলি সেভ হয়নি।", + "lang_continue": "এগিয়ে যান", + "lang_reason": "কারণ" } diff --git a/languages/de.json b/languages/de.json index 8e57454ff..35f792b33 100644 --- a/languages/de.json +++ b/languages/de.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "Café", "lang_pm_option_herbs": "Kräutergarten", "lang_pm_option_pizza": "Pizzeria", - "lang_pm_option_grill": "Grill" + "lang_pm_option_grill": "Grill", + "lang_changes_not_saved": "Änderungen wurden nicht gespeichert.", + "lang_continue": "Weiter", + "lang_reason": "Grund" } diff --git a/languages/es.json b/languages/es.json index e7f9cffc8..1ba34efb0 100644 --- a/languages/es.json +++ b/languages/es.json @@ -3665,5 +3665,8 @@ "lang_pm_option_coffee": "Cafetería", "lang_pm_option_herbs": "Jardín de hierbas", "lang_pm_option_pizza": "Pizzería", - "lang_pm_option_grill": "Parrilla" + "lang_pm_option_grill": "Parrilla", + "lang_changes_not_saved": "No se guardaron los cambios.", + "lang_continue": "Continuar", + "lang_reason": "Motivo" } diff --git a/languages/fr.json b/languages/fr.json index 7b14d9702..c2f3df37f 100644 --- a/languages/fr.json +++ b/languages/fr.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "Café", "lang_pm_option_herbs": "Jardin aromatique", "lang_pm_option_pizza": "Pizzeria", - "lang_pm_option_grill": "Grill barbecue" + "lang_pm_option_grill": "Grill barbecue", + "lang_changes_not_saved": "Les modifications n’ont pas été enregistrées.", + "lang_continue": "Continuer", + "lang_reason": "Motif" } diff --git a/languages/gu.json b/languages/gu.json index 59491871f..bbc387b45 100644 --- a/languages/gu.json +++ b/languages/gu.json @@ -95,5 +95,8 @@ "lang_total_title": "કુલ", "lang_yes": "હા", "lang_ai_describe_hint": "વસ્તુની વિગતો પરથી વર્ણનનો ડ્રાફ્ટ બનાવો", - "lang_walk_in_customer_click_to_choose_or_add": "નોંધણી વગરનો ગ્રાહક. પસંદ કરવા અથવા ઉમેરવા ક્લિક કરો" + "lang_walk_in_customer_click_to_choose_or_add": "નોંધણી વગરનો ગ્રાહક. પસંદ કરવા અથવા ઉમેરવા ક્લિક કરો", + "lang_changes_not_saved": "ફેરફારો સેવ થયા નથી.", + "lang_continue": "આગળ વધો", + "lang_reason": "કારણ" } diff --git a/languages/hi.json b/languages/hi.json index 697fa01d5..6601e8ff5 100644 --- a/languages/hi.json +++ b/languages/hi.json @@ -3669,5 +3669,8 @@ "lang_pm_option_coffee": "कॉफ़ी शॉप", "lang_pm_option_herbs": "जड़ी-बूटी उद्यान", "lang_pm_option_pizza": "पिज़्ज़ेरिया", - "lang_pm_option_grill": "बारबेक्यू ग्रिल" + "lang_pm_option_grill": "बारबेक्यू ग्रिल", + "lang_changes_not_saved": "बदलाव सेव नहीं हुए।", + "lang_continue": "आगे बढ़ें", + "lang_reason": "कारण" } diff --git a/languages/id.json b/languages/id.json index df887035e..43e23ba0a 100644 --- a/languages/id.json +++ b/languages/id.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "Kedai kopi", "lang_pm_option_herbs": "Kebun herba", "lang_pm_option_pizza": "Kedai pizza", - "lang_pm_option_grill": "Panggangan" + "lang_pm_option_grill": "Panggangan", + "lang_changes_not_saved": "Perubahan tidak disimpan.", + "lang_continue": "Lanjutkan", + "lang_reason": "Alasan" } diff --git a/languages/it.json b/languages/it.json index cca1b0c33..435192035 100644 --- a/languages/it.json +++ b/languages/it.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "Caffetteria", "lang_pm_option_herbs": "Giardino di erbe", "lang_pm_option_pizza": "Pizzeria", - "lang_pm_option_grill": "Barbecue" + "lang_pm_option_grill": "Barbecue", + "lang_changes_not_saved": "Le modifiche non sono state salvate.", + "lang_continue": "Continua", + "lang_reason": "Motivo" } diff --git a/languages/ja.json b/languages/ja.json index ecc665022..39172cb3a 100644 --- a/languages/ja.json +++ b/languages/ja.json @@ -95,5 +95,8 @@ "lang_total_title": "合計", "lang_yes": "はい", "lang_ai_describe_hint": "商品情報をもとに説明文の下書きを作成", - "lang_walk_in_customer_click_to_choose_or_add": "未登録のお客様。クリックして選択または追加" + "lang_walk_in_customer_click_to_choose_or_add": "未登録のお客様。クリックして選択または追加", + "lang_changes_not_saved": "変更は保存されませんでした。", + "lang_continue": "続ける", + "lang_reason": "理由" } diff --git a/languages/kn.json b/languages/kn.json index 9d5e0447f..15cfbd34b 100644 --- a/languages/kn.json +++ b/languages/kn.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "ಕಾಫಿ ಅಂಗಡಿ", "lang_pm_option_herbs": "ಗಿಡಮೂಲಿಕೆ ತೋಟ", "lang_pm_option_pizza": "ಪಿಜ್ಜಾ ಅಂಗಡಿ", - "lang_pm_option_grill": "ಬಾರ್ಬೆಕ್ಯೂ ಗ್ರಿಲ್" + "lang_pm_option_grill": "ಬಾರ್ಬೆಕ್ಯೂ ಗ್ರಿಲ್", + "lang_changes_not_saved": "ಬದಲಾವಣೆಗಳು ಉಳಿಸಲ್ಪಟ್ಟಿಲ್ಲ.", + "lang_continue": "ಮುಂದುವರಿಸಿ", + "lang_reason": "ಕಾರಣ" } diff --git a/languages/ko.json b/languages/ko.json index 9f24a98c8..ac272e8de 100644 --- a/languages/ko.json +++ b/languages/ko.json @@ -95,5 +95,8 @@ "lang_total_title": "합계", "lang_yes": "예", "lang_ai_describe_hint": "상품 정보를 바탕으로 설명 초안 작성", - "lang_walk_in_customer_click_to_choose_or_add": "미등록 고객. 클릭하여 선택하거나 추가" + "lang_walk_in_customer_click_to_choose_or_add": "미등록 고객. 클릭하여 선택하거나 추가", + "lang_changes_not_saved": "변경 사항이 저장되지 않았습니다.", + "lang_continue": "계속", + "lang_reason": "사유" } diff --git a/languages/ml.json b/languages/ml.json index 9b4007aab..434b4f603 100644 --- a/languages/ml.json +++ b/languages/ml.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "കോഫി ഷോപ്പ്", "lang_pm_option_herbs": "ഔഷധത്തോട്ടം", "lang_pm_option_pizza": "പിസ്സ കട", - "lang_pm_option_grill": "ബാർബിക്യൂ ഗ്രിൽ" + "lang_pm_option_grill": "ബാർബിക്യൂ ഗ്രിൽ", + "lang_changes_not_saved": "മാറ്റങ്ങൾ സേവ് ചെയ്തിട്ടില്ല.", + "lang_continue": "തുടരുക", + "lang_reason": "കാരണം" } diff --git a/languages/mr.json b/languages/mr.json index b7577e28f..ad36f5ca4 100644 --- a/languages/mr.json +++ b/languages/mr.json @@ -95,5 +95,8 @@ "lang_total_title": "एकूण", "lang_yes": "होय", "lang_ai_describe_hint": "वस्तूच्या तपशीलांवरून वर्णनाचा मसुदा तयार करा", - "lang_walk_in_customer_click_to_choose_or_add": "नोंदणी नसलेला ग्राहक. निवडण्यासाठी किंवा जोडण्यासाठी क्लिक करा" + "lang_walk_in_customer_click_to_choose_or_add": "नोंदणी नसलेला ग्राहक. निवडण्यासाठी किंवा जोडण्यासाठी क्लिक करा", + "lang_changes_not_saved": "बदल जतन झाले नाहीत.", + "lang_continue": "पुढे जा", + "lang_reason": "कारण" } diff --git a/languages/ms.json b/languages/ms.json index a5e98f67c..426ff0f12 100644 --- a/languages/ms.json +++ b/languages/ms.json @@ -95,5 +95,8 @@ "lang_total_title": "Jumlah", "lang_yes": "Ya", "lang_ai_describe_hint": "Sediakan draf penerangan daripada butiran item", - "lang_walk_in_customer_click_to_choose_or_add": "Pelanggan tanpa akaun. Klik untuk memilih atau menambah" + "lang_walk_in_customer_click_to_choose_or_add": "Pelanggan tanpa akaun. Klik untuk memilih atau menambah", + "lang_changes_not_saved": "Perubahan tidak disimpan.", + "lang_continue": "Teruskan", + "lang_reason": "Sebab" } diff --git a/languages/ne.json b/languages/ne.json index bb5ff4a44..23355cc4d 100644 --- a/languages/ne.json +++ b/languages/ne.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "कफी पसल", "lang_pm_option_herbs": "जडीबुटी बगैंचा", "lang_pm_option_pizza": "पिज्जा पसल", - "lang_pm_option_grill": "बार्बेक्यु ग्रिल" + "lang_pm_option_grill": "बार्बेक्यु ग्रिल", + "lang_changes_not_saved": "परिवर्तनहरू सेभ भएनन्।", + "lang_continue": "जारी राख्नुहोस्", + "lang_reason": "कारण" } diff --git a/languages/nl.json b/languages/nl.json index 8bee43124..d8d69a725 100644 --- a/languages/nl.json +++ b/languages/nl.json @@ -3928,5 +3928,8 @@ "lang_pm_image_help": "PNG, JPEG of WebP, maximaal 10 MB. Afbeeldingen worden aan de pagina aangepast en geoptimaliseerd voor afdrukken. Een geüploade afbeelding vervangt het patroon.", "lang_pm_intro": "Maak van het menu van deze vestiging menukaarten voor uw tafels. Pas het ontwerp aan en download vervolgens een PDF of druk het af.", "lang_print": "Afdrukken", - "lang_printable_menu": "Afdrukbare menukaart" + "lang_printable_menu": "Afdrukbare menukaart", + "lang_changes_not_saved": "Wijzigingen zijn niet opgeslagen.", + "lang_continue": "Doorgaan", + "lang_reason": "Reden" } diff --git a/languages/pt.json b/languages/pt.json index c2ad76c62..d430016f7 100644 --- a/languages/pt.json +++ b/languages/pt.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "Cafeteria", "lang_pm_option_herbs": "Jardim de ervas", "lang_pm_option_pizza": "Pizzaria", - "lang_pm_option_grill": "Churrasqueira" + "lang_pm_option_grill": "Churrasqueira", + "lang_changes_not_saved": "As alterações não foram guardadas.", + "lang_continue": "Continuar", + "lang_reason": "Motivo" } diff --git a/languages/ru.json b/languages/ru.json index c10c7421d..80e2fc5b3 100644 --- a/languages/ru.json +++ b/languages/ru.json @@ -95,5 +95,8 @@ "lang_total_title": "Итого", "lang_yes": "Да", "lang_ai_describe_hint": "Создать описание на основе данных товара", - "lang_walk_in_customer_click_to_choose_or_add": "Незарегистрированный клиент. Нажмите, чтобы выбрать или добавить" + "lang_walk_in_customer_click_to_choose_or_add": "Незарегистрированный клиент. Нажмите, чтобы выбрать или добавить", + "lang_changes_not_saved": "Изменения не сохранены.", + "lang_continue": "Продолжить", + "lang_reason": "Причина" } diff --git a/languages/server/ar.json b/languages/server/ar.json index bc91c420d..65c9f4689 100644 --- a/languages/server/ar.json +++ b/languages/server/ar.json @@ -330,7 +330,6 @@ "No category IDs provided": "لم يتم تقديم أي معرّفات فئات (ID)", "No category data provided": "لم يتم تقديم أي بيانات فئات", "No changes made or item not found": "لم يتم إجراء أي تغييرات أو لم يتم العثور على الصنف", - "No changes made to the order": "لم يتم إجراء أي تغييرات على الطلب", "No data to import": "لا توجد بيانات للاستيراد", "No database handle": "لا يوجد اتصال بقاعدة البيانات", "No documents found for restoration": "لم يتم العثور على مستندات للاستعادة", diff --git a/languages/server/de.json b/languages/server/de.json index 54e21d48e..9a1d79ded 100644 --- a/languages/server/de.json +++ b/languages/server/de.json @@ -330,7 +330,6 @@ "No category IDs provided": "Keine Kategorie-IDs angegeben", "No category data provided": "Keine Kategoriedaten angegeben", "No changes made or item not found": "Keine Änderungen vorgenommen oder Artikel nicht gefunden", - "No changes made to the order": "Keine Änderungen an der Bestellung vorgenommen", "No data to import": "Keine Daten zum Importieren", "No database handle": "Kein Datenbank-Handle", "No documents found for restoration": "Keine Dokumente zur Wiederherstellung gefunden", diff --git a/languages/server/es.json b/languages/server/es.json index e946ff8e3..465513c20 100644 --- a/languages/server/es.json +++ b/languages/server/es.json @@ -330,7 +330,6 @@ "No category IDs provided": "No se indicaron IDs de categoría", "No category data provided": "No se indicaron datos de categorías", "No changes made or item not found": "No se hicieron cambios o no se encontró el artículo", - "No changes made to the order": "No se hicieron cambios en el pedido", "No data to import": "No hay datos que importar", "No database handle": "No hay conexión con la base de datos", "No documents found for restoration": "No se encontraron documentos para restaurar", diff --git a/languages/server/fr.json b/languages/server/fr.json index eca061199..e786eff1b 100644 --- a/languages/server/fr.json +++ b/languages/server/fr.json @@ -330,7 +330,6 @@ "No category IDs provided": "Aucun ID de catégorie fourni", "No category data provided": "Aucune donnée de catégorie fournie", "No changes made or item not found": "Aucune modification effectuée ou article introuvable", - "No changes made to the order": "Aucune modification apportée à la commande", "No data to import": "Aucune donnée à importer", "No database handle": "Aucun accès à la base de données", "No documents found for restoration": "Aucun document trouvé à restaurer", diff --git a/languages/server/hi.json b/languages/server/hi.json index a981a1b8c..3a736161f 100644 --- a/languages/server/hi.json +++ b/languages/server/hi.json @@ -330,7 +330,6 @@ "No category IDs provided": "कोई श्रेणी ID नहीं दी गई", "No category data provided": "श्रेणी का कोई डेटा नहीं दिया गया", "No changes made or item not found": "कोई बदलाव नहीं हुआ या वस्तु नहीं मिली", - "No changes made to the order": "ऑर्डर में कोई बदलाव नहीं हुआ", "No data to import": "आयात करने के लिए कोई डेटा नहीं", "No database handle": "कोई डेटाबेस हैंडल नहीं", "No documents found for restoration": "पुनर्स्थापना के लिए कोई दस्तावेज़ नहीं मिला", diff --git a/languages/server/ta.json b/languages/server/ta.json index fb9cbd916..cefdc97a8 100644 --- a/languages/server/ta.json +++ b/languages/server/ta.json @@ -330,7 +330,6 @@ "No category IDs provided": "வகை ID-கள் எதுவும் தரப்படவில்லை", "No category data provided": "வகைத் தரவு எதுவும் தரப்படவில்லை", "No changes made or item not found": "மாற்றங்கள் எதுவும் செய்யப்படவில்லை அல்லது பொருள் கிடைக்கவில்லை", - "No changes made to the order": "ஆர்டரில் மாற்றங்கள் எதுவும் செய்யப்படவில்லை", "No data to import": "இறக்குமதி செய்ய தரவு எதுவும் இல்லை", "No database handle": "தரவுத்தள இணைப்பு இல்லை", "No documents found for restoration": "மீட்பதற்கு ஆவணங்கள் எதுவும் கிடைக்கவில்லை", diff --git a/languages/si.json b/languages/si.json index e68418def..e9851feb8 100644 --- a/languages/si.json +++ b/languages/si.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "කෝපි කඩය", "lang_pm_option_herbs": "ඔසු උයන", "lang_pm_option_pizza": "පීසා කඩය", - "lang_pm_option_grill": "බාබකියු ග්‍රිල්" + "lang_pm_option_grill": "බාබකියු ග්‍රිල්", + "lang_changes_not_saved": "වෙනස්කම් සුරැකුණේ නැත.", + "lang_continue": "ඉදිරියට", + "lang_reason": "හේතුව" } diff --git a/languages/sw.json b/languages/sw.json index 8a50d2eda..87dc43d5a 100644 --- a/languages/sw.json +++ b/languages/sw.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "Duka la kahawa", "lang_pm_option_herbs": "Bustani ya mimea", "lang_pm_option_pizza": "Duka la pizza", - "lang_pm_option_grill": "Jiko la kuchoma" + "lang_pm_option_grill": "Jiko la kuchoma", + "lang_changes_not_saved": "Mabadiliko hayakuhifadhiwa.", + "lang_continue": "Endelea", + "lang_reason": "Sababu" } diff --git a/languages/ta.json b/languages/ta.json index a746dbb75..772693c13 100644 --- a/languages/ta.json +++ b/languages/ta.json @@ -3691,5 +3691,8 @@ "lang_pm_option_coffee": "காபிக் கடை", "lang_pm_option_herbs": "மூலிகைத் தோட்டம்", "lang_pm_option_pizza": "பீட்சா கடை", - "lang_pm_option_grill": "பார்பிக்யூ கிரில்" + "lang_pm_option_grill": "பார்பிக்யூ கிரில்", + "lang_changes_not_saved": "மாற்றங்கள் சேமிக்கப்படவில்லை.", + "lang_continue": "தொடர்", + "lang_reason": "காரணம்" } diff --git a/languages/te.json b/languages/te.json index e1aa4c208..5a7d48ad9 100644 --- a/languages/te.json +++ b/languages/te.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "కాఫీ షాప్", "lang_pm_option_herbs": "మూలికల తోట", "lang_pm_option_pizza": "పిజ్జా దుకాణం", - "lang_pm_option_grill": "బార్బెక్యూ గ్రిల్" + "lang_pm_option_grill": "బార్బెక్యూ గ్రిల్", + "lang_changes_not_saved": "మార్పులు సేవ్ కాలేదు.", + "lang_continue": "కొనసాగించండి", + "lang_reason": "కారణం" } diff --git a/languages/th.json b/languages/th.json index 90c05da76..42efeaf17 100644 --- a/languages/th.json +++ b/languages/th.json @@ -3656,5 +3656,8 @@ "lang_pm_option_coffee": "ร้านกาแฟ", "lang_pm_option_herbs": "สวนสมุนไพร", "lang_pm_option_pizza": "ร้านพิซซ่า", - "lang_pm_option_grill": "เตาบาร์บีคิว" + "lang_pm_option_grill": "เตาบาร์บีคิว", + "lang_changes_not_saved": "ไม่ได้บันทึกการเปลี่ยนแปลง", + "lang_continue": "ดำเนินการต่อ", + "lang_reason": "เหตุผล" } diff --git a/languages/tr.json b/languages/tr.json index 3ec2cc7e9..469e4a1fc 100644 --- a/languages/tr.json +++ b/languages/tr.json @@ -95,5 +95,8 @@ "lang_total_title": "Toplam", "lang_yes": "Evet", "lang_ai_describe_hint": "Ürünün bilgilerinden bir açıklama taslağı oluştur", - "lang_walk_in_customer_click_to_choose_or_add": "Kayıtsız müşteri. Seçmek veya eklemek için tıklayın" + "lang_walk_in_customer_click_to_choose_or_add": "Kayıtsız müşteri. Seçmek veya eklemek için tıklayın", + "lang_changes_not_saved": "Değişiklikler kaydedilmedi.", + "lang_continue": "Devam et", + "lang_reason": "Neden" } diff --git a/languages/ur.json b/languages/ur.json index 8adb25124..2badfa931 100644 --- a/languages/ur.json +++ b/languages/ur.json @@ -95,5 +95,8 @@ "lang_total_title": "کل", "lang_yes": "ہاں", "lang_ai_describe_hint": "شے کی تفصیلات سے وضاحت کا مسودہ بنائیں", - "lang_walk_in_customer_click_to_choose_or_add": "غیر رجسٹرڈ گاہک۔ منتخب کرنے یا شامل کرنے کے لیے کلک کریں" + "lang_walk_in_customer_click_to_choose_or_add": "غیر رجسٹرڈ گاہک۔ منتخب کرنے یا شامل کرنے کے لیے کلک کریں", + "lang_changes_not_saved": "تبدیلیاں محفوظ نہیں ہوئیں۔", + "lang_continue": "جاری رکھیں", + "lang_reason": "وجہ" } diff --git a/languages/vi.json b/languages/vi.json index 1dad935af..32823ac1b 100644 --- a/languages/vi.json +++ b/languages/vi.json @@ -95,5 +95,8 @@ "lang_total_title": "Tổng cộng", "lang_yes": "Có", "lang_ai_describe_hint": "Soạn mô tả dựa trên thông tin của mặt hàng", - "lang_walk_in_customer_click_to_choose_or_add": "Khách lẻ. Nhấn để chọn hoặc thêm" + "lang_walk_in_customer_click_to_choose_or_add": "Khách lẻ. Nhấn để chọn hoặc thêm", + "lang_changes_not_saved": "Chưa lưu thay đổi.", + "lang_continue": "Tiếp tục", + "lang_reason": "Lý do" } diff --git a/languages/zh-CN.json b/languages/zh-CN.json index 68a17c2fd..a38502980 100644 --- a/languages/zh-CN.json +++ b/languages/zh-CN.json @@ -95,5 +95,8 @@ "lang_total_title": "合计", "lang_yes": "是", "lang_ai_describe_hint": "根据商品详情生成描述草稿", - "lang_walk_in_customer_click_to_choose_or_add": "散客。点击选择或添加" + "lang_walk_in_customer_click_to_choose_or_add": "散客。点击选择或添加", + "lang_changes_not_saved": "更改未保存。", + "lang_continue": "继续", + "lang_reason": "原因" } diff --git a/languages/zh-TW.json b/languages/zh-TW.json index ad2b46c4a..1e43a7826 100644 --- a/languages/zh-TW.json +++ b/languages/zh-TW.json @@ -95,5 +95,8 @@ "lang_total_title": "合計", "lang_yes": "是", "lang_ai_describe_hint": "根據商品資料產生描述草稿", - "lang_walk_in_customer_click_to_choose_or_add": "散客。點選以選擇或新增" + "lang_walk_in_customer_click_to_choose_or_add": "散客。點選以選擇或新增", + "lang_changes_not_saved": "變更未儲存。", + "lang_continue": "繼續", + "lang_reason": "原因" } diff --git a/package-lock.json b/package-lock.json index 0222836d3..89f789a1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "posnic", - "version": "1.8.3", + "version": "1.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "posnic", - "version": "1.8.3", + "version": "1.8.4", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 11ce0da9e..69e927712 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "posnic", - "version": "1.8.3", + "version": "1.8.4", "description": "Free, offline-first point-of-sale software for retail and restaurants, with open source POS code and billing workflows. Posnic source is AGPL-3.0-only; packages contain separately licensed components.", "main": "src/main.js", "desktopName": "com.posnic.app.desktop", diff --git a/tests/captain-manager-approval.test.js b/tests/captain-manager-approval.test.js new file mode 100644 index 000000000..f6a136ee1 --- /dev/null +++ b/tests/captain-manager-approval.test.js @@ -0,0 +1,28 @@ +const test=require('node:test'); +const assert=require('node:assert/strict'); +const vm=require('node:vm'); +const fs=require('node:fs'); +const source=fs.readFileSync('frontend/static/script/js/core/PosnicPro.js','utf8'); +function fixture(){ + const nodes=new Map();const $=selector=>{ + if(!nodes.has(selector)){const n={value:'',handlers:{},val(value){if(value===undefined)return this.value;this.value=value;return this;},off(){this.handlers={};return this;},on(name,fn){this.handlers[name]=fn;return this;},modal(action){if(action==='hide')this.handlers['hidden.bs.modal.orderApproval']?.();return this;},prop(){return this;},text(){return this;},addClass(){return this;},removeClass(){return this;},trigger(){return this;}};nodes.set(selector,n);}return nodes.get(selector); + }; + let reply;const pro={posCan:()=>true,i18n:{t:(_,fallback)=>fallback},post:(_body,callback)=>reply=callback}; + const methods=source.slice(source.indexOf(' requireManagerApproval: function'),source.indexOf(' /* ----- Shift')); + Object.assign(pro,vm.runInNewContext('({'+methods+'})',{PosnicPro:pro,$,setTimeout:()=>{}})); + return {pro,$,reply:value=>reply(value)}; +} +test('forced server approval is cancellable and a late PIN response cannot save the order',()=>{ + const f=fixture();let saved=0,cancelled=0; + f.pro.requireManagerApproval('void_sale',{force:true,saleId:'sale'},()=>saved++,()=>cancelled++); + f.$('#manager_pin_input').val('1234');f.pro.submitManagerApproval(); + f.$('#manager_pin_modal').modal('hide'); + f.reply({type:'success',data:{approval_token:'token'}}); + assert.equal(saved,0);assert.equal(cancelled,1);assert.equal(f.pro._pendingApproval,null); +}); +test('confirmed approval completes once without also cancelling',()=>{ + const f=fixture();let saved=0,cancelled=0; + f.pro.requireManagerApproval('void_sale',{force:true,saleId:'sale'},()=>saved++,()=>cancelled++); + f.$('#manager_pin_input').val('1234');f.pro.submitManagerApproval();f.reply({type:'success',data:{approval_token:'token'}}); + assert.equal(saved,1);assert.equal(cancelled,0); +}); From a2fa368c21c41d5cb839ba96652a53d1933946f6 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Mon, 28 Sep 2026 12:06:57 +0530 Subject: [PATCH 3/4] Format quantity regression coverage Signed-off-by: Sridhar Bala --- .../an-order-changed-under-you.test.js | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/api/tests/unit/repositories/an-order-changed-under-you.test.js b/api/tests/unit/repositories/an-order-changed-under-you.test.js index ca33545c8..5e2d59bc9 100644 --- a/api/tests/unit/repositories/an-order-changed-under-you.test.js +++ b/api/tests/unit/repositories/an-order-changed-under-you.test.js @@ -245,28 +245,26 @@ test('same-product preparations survive edits and cancellation with their own no test('editing a removed catalogue dish keeps quantity and receipt fields consistent at shop precision', async () => { const id = await anOrder(CHANGED_AT); await db.collection('branches').updateOne({ _id: BRANCH }, { $set: { currency_code: 'KWD' } }); - await db - .collection('sales') - .updateOne( - { _id: new mongoose.Types.ObjectId(id) }, - { - $set: { - items: [ - { - item_id: NAAN, - item_name: 'Naan', - quantity: 2, - item_quantity: 2, - item_price: 40.005, - unit_price: 40.005, - total: 80.01, - item_total: 80.01, - total_amount: 80.01, - }, - ], - }, - } - ); + await db.collection('sales').updateOne( + { _id: new mongoose.Types.ObjectId(id) }, + { + $set: { + items: [ + { + item_id: NAAN, + item_name: 'Naan', + quantity: 2, + item_quantity: 2, + item_price: 40.005, + unit_price: 40.005, + total: 80.01, + item_total: 80.01, + total_amount: 80.01, + }, + ], + }, + } + ); await db.collection('items').deleteOne({ _id: NAAN }); const result = await repo.updateOrderModel( id, From dd64b5f4fb90e5b71ac513db0984146643dbe26c Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Mon, 28 Sep 2026 12:16:03 +0530 Subject: [PATCH 4/4] Rate limit kitchen delivery and staff handover endpoints Signed-off-by: Sridhar Bala --- api/src/routes/sales.routes.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/src/routes/sales.routes.js b/api/src/routes/sales.routes.js index c9db99a08..1e26a884b 100644 --- a/api/src/routes/sales.routes.js +++ b/api/src/routes/sales.routes.js @@ -375,28 +375,33 @@ router.post( ); router.post( '/kitchenDisplayReport', + rateLimit({ windowMs: 60000, limit: 600, standardHeaders: true, legacyHeaders: false }), ensurePrintDevice, bindController(salesController.kitchenDisplayReport) ); router.post( '/kitchenDeliveryReport', + rateLimit({ windowMs: 60000, limit: 600, standardHeaders: true, legacyHeaders: false }), ensurePrintDevice, bindController(salesController.kitchenDeliveryReport) ); router.get( '/kitchenDeliveryStatus', + rateLimit({ windowMs: 60000, limit: 300, standardHeaders: true, legacyHeaders: false }), optionalProtect, protectOrKioskKey, bindController(salesController.kitchenDeliveryStatus) ); router.get( '/handoverStaff', + rateLimit({ windowMs: 60000, limit: 120, standardHeaders: true, legacyHeaders: false }), optionalProtect, protectOrKioskKey, bindController(salesController.handoverStaff) ); router.post( '/handoverOrder', + rateLimit({ windowMs: 60000, limit: 120, standardHeaders: true, legacyHeaders: false }), optionalProtect, protectOrKioskKey, bindController(salesController.handoverOrder)