Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/src/models/item.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,17 @@ const itemSchema = new mongoose.Schema(
*/
nutrition_source: { type: String, trim: true, default: '' },

/*
* May a customer say how hot they want this one?
*
* Off unless the shop turns it on, dish by dish. Not because chillies look
* silly on a dessert - because a kitchen that batch-cooks its gravy CANNOT
* make one portion mild, and a customer who asked for mild and got hot is
* worse off than one who never asked. Only the kitchen knows which dishes
* it can really vary. See utils/spice-level.js.
*/
spice_choice: { type: Boolean, default: false },

/*
* What is and is not IN the dish: plant based, Jain, gluten free, nut
* free, organic, no added sugar. See FOOD_TAGS in utils/dish-facts.js.
Expand Down Expand Up @@ -277,6 +288,7 @@ class ItemModel {
prep_minutes: { type: 'Number', select: true },
nutrition: { type: 'Object', select: true },
nutrition_source: { type: 'String', select: true },
spice_choice: { type: 'Boolean', select: true },
food_tags: { type: 'Array', select: true },
menu_marks: { type: 'Array', select: true },
isAvailable: { type: 'Boolean', select: true },
Expand Down
4 changes: 4 additions & 0 deletions api/src/repositories/item.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,8 @@ class ItemRepository extends BaseModel {
* Sync replaces whole documents, so all three are written on every
* save or the next one deletes them.
*/
/* Whether a customer may say how hot they want it. */
spice_choice: Boolean(data.spice_choice),
nutrition: dishFacts.cleanNutrition(data.nutrition),
/* Only the one word means anything; everything else is a person. A
client that omits it is the item screen, where a person is looking
Expand Down Expand Up @@ -3971,6 +3973,7 @@ class ItemRepository extends BaseModel {
/* What is on the plate and what is in it. The health badges are
NOT read - they are derived from these below, so a dish can
never carry a claim its own nutrition contradicts. */
spice_choice: 1,
nutrition: 1,
nutrition_source: 1,
food_tags: 1,
Expand Down Expand Up @@ -4458,6 +4461,7 @@ class ItemRepository extends BaseModel {
prep_minutes: '$prep_minutes',
/* What is on the plate and what is in it. Folded into
facts and CLAIMS below and do not travel raw. */
spice_choice: '$spice_choice',
nutrition: '$nutrition',
nutrition_source: '$nutrition_source',
food_tags: '$food_tags',
Expand Down
53 changes: 52 additions & 1 deletion api/src/repositories/sale.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ async function withDayparts(shop) {

const { notifyOrderAttention } = require('../helpers/order-attention');
const orderApproval = require('../utils/order-approval');
const spiceLevel = require('../utils/spice-level');
const StockLogsRepository = require('./stock-log.repository');
const { PAYMENT_STATUS } = require('../constants');
const moment = require('moment-timezone');
Expand Down Expand Up @@ -8034,6 +8035,9 @@ class SalesRepository {
/* See the note at the cancel flow below: this list is what the
kitchen ticket is printed from. */
item_description: String(si.item_description || ''),
/* And for the same reason: a level stored on the sale and absent
from the change record never reaches the paper. */
spice_level: spiceLevel.levelOf(si.spice_level),
};
})
.filter((it) => it.item_id && it.item_quantity > 0);
Expand Down Expand Up @@ -8560,6 +8564,13 @@ class SalesRepository {
on the sale and printed on the ticket, and never shown to the
person deciding whether to accept the order. */
note: item.item_description || '',
/*
* And how hot they asked for it, for the same reason. This is the
* screen where an order is refused, and "we cannot make that one
* mild" is a reason to refuse it - which nobody can act on if the
* request is only visible on the paper in the kitchen.
*/
spice: spiceLevel.levelOf(item.spice_level),
})),
total: Number(row.total) || 0,
delivery_fee: Number(row.delivery_fee) || 0,
Expand Down Expand Up @@ -9078,6 +9089,7 @@ class SalesRepository {
item_name: String(ex.item_name || ''),
item_quantity: qty,
item_description: String(ex.item_description || ''),
spice_level: spiceLevel.levelOf(ex.spice_level),
process: 'cancel',
item_code: String(ex.item_sku || ''),
unit: String(ex.item_unit || 'qty'),
Expand Down Expand Up @@ -9120,8 +9132,10 @@ class SalesRepository {
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
the same dish on one table are told apart by the note. */
the same dish on one table are told apart by the note, and by how
hot each of them was to be. */
description: String(ex.item_description || ''),
spice_level: spiceLevel.levelOf(ex.spice_level),
item_code: String(ex.item_sku || ''),
price: parseFloat(ex.item_price || 0),
unit: String(ex.item_unit || 'qty'),
Expand Down Expand Up @@ -9167,6 +9181,11 @@ class SalesRepository {
...(item.item_description != null
? { item_description: String(item.item_description) }
: {}),
/* A KOT line the catalogue no longer holds still belongs to
somebody who may have changed their mind about the chillies. */
...(item.spice_level != null
? { spice_level: spiceLevel.levelOf(item.spice_level) }
: {}),
};
incomingProductIds.push(productId);
}
Expand All @@ -9191,6 +9210,16 @@ class SalesRepository {
/* From the request first: an amendment carries the note the person
just typed, and the stored copy is the one before it. */
item_description: String(item.item_note || item.item_description || ''),
/*
* Same order for the spice level, and the stored line is read
* through existingIndex rather than oldItemsData because that map
* has had this id deleted from it a few lines above.
*/
spice_level: spiceLevel.levelOf(
item.spice_level != null
? item.spice_level
: (updatedItems[existingIndex[productId]] || {}).spice_level
),
process: changeProcess,
item_code: String(itemDoc.itemid || ''),
unit: String(itemDoc.item_unit || itemDoc.unit || 'qty'),
Expand Down Expand Up @@ -9236,6 +9265,11 @@ class SalesRepository {
};
if (item.item_description)
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
later, so a change of mind has to land on both. */
if (item.spice_level != null)
updatedItems[i].spice_level = spiceLevel.levelOf(item.spice_level);
} else {
const itemQuantity = qty;
const sellingPrice = price;
Expand Down Expand Up @@ -9287,6 +9321,7 @@ class SalesRepository {
tax_amount: taxAmount,
tax_fields: itemDoc.tax_fields || [],
item_description: String(item.item_description || itemDoc.description || ''),
spice_level: spiceLevel.levelOf(item.spice_level),
track_inventory: itemDoc.track_inventory || false,
negative_stock: itemDoc.negative_stock || false,
});
Expand All @@ -9302,6 +9337,7 @@ class SalesRepository {
item_name: String(remItemData.name || ''),
item_quantity: remQty,
item_description: String(remItemData.description || ''),
spice_level: spiceLevel.levelOf(remItemData.spice_level),
process: 'cancel',
item_code: String(remItemData.item_code || ''),
unit: String(remItemData.unit || 'qty'),
Expand Down Expand Up @@ -9688,6 +9724,20 @@ class SalesRepository {
item_description: String(item.item_note || item.item_description || '')
.trim()
.slice(0, 200),
/*
* HOW HOT, AS A NUMBER AND NOT AS A SENTENCE.
*
* The obvious build appends "less spicy" to the note above, and it is
* wrong twice: a customer reading a Tamil menu writes Tamil, so the
* ticket carries prose the kitchen may misread, and prose cannot be
* counted afterwards. A level prints identically on every ticket
* whatever language the order was placed in, and a shop can learn that
* four orders in ten ask for mild.
*
* levelOf refuses anything that is not 1, 2 or 3, so a device sending
* nonsense gets no promise made about somebody's food.
*/
spice_level: spiceLevel.levelOf(item.spice_level),
// receipt-facing fields
item_base_price: round(baseUnitPrice),
item_quantity: qty,
Expand Down Expand Up @@ -9821,6 +9871,7 @@ class SalesRepository {
name: String(line.item_name || line.name || ''),
quantity: Number(line.item_quantity != null ? line.item_quantity : line.quantity || 0),
note: String(line.item_description || ''),
spice: spiceLevel.levelOf(line.spice_level),
total: Number(line.total != null ? line.total : line.item_total || 0),
})),
total: Number(order.total != null ? order.total : order.sales_total || 0),
Expand Down
104 changes: 104 additions & 0 deletions api/src/utils/spice-level.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use strict';
/*
* How hot, when the kitchen can decide.
*
* Owner: "when user order if food is speci food. we can have simple option
* like low, medium high with number chilly image like one, two, three chilli
* icons user can customize easy. we can add those into kitchen note."
*
* Spice is the thing an Indian restaurant is asked to change more often than
* anything else on its menu, and until now the only way to ask was typing it
* into the free-text note - in whatever words, in whatever language, for a
* kitchen that then has to read prose off a ticket at speed.
*
* THREE LEVELS, AND NOT CHOOSING IS ONE TOO.
*
* Mild, medium, spicy. "However the kitchen makes it" is simply not picking,
* which is why there is no fourth button for it: a default that has to be
* selected is a question, and this has to stay one tap. Somebody who wants no
* chilli at all still has the note, which has not gone anywhere.
*
* WHY IT IS A FIELD AND NOT A SENTENCE IN THE NOTE.
*
* The obvious build is to append "less spicy" to the kitchen note, and it is
* wrong twice over. A customer reading a Tamil menu writes Tamil, and the
* ticket then carries prose the kitchen may misread; a LEVEL prints the same
* on every ticket whatever language the order was placed in. And a number can
* be counted afterwards - a shop can learn that four orders in ten ask for
* mild, and cook accordingly - which no amount of free text will ever tell it.
*
* NOT EVERY DISH. See item.spice_choice: the shop says which dishes take one.
* That is not about chillies looking silly on a gulab jamun. A kitchen that
* batch-cooks its gravy CANNOT make one portion mild, and a customer who asked
* for mild and got hot is worse off than one who never asked - so the offer
* exists only where the kitchen can honour it, and only the kitchen knows
* where that is.
*
* THE TICKET SAYS IT IN ASCII. The chillies are drawn on the customer's screen
* and nowhere else: escpos-receipt.js puts every character through ascii() and
* latin1, so an emoji on a kitchen ticket prints as a question mark or as
* nothing. The paper gets the word and the count instead, and the count is
* there for a cook who does not read the word.
*
* NO DATABASE IMPORTS.
*/

/* Stored as the number of chillies, because that is what the customer taps
and what the ticket counts. 0 means nobody chose. */
const SPICE = Object.freeze({
NOT_SAID: 0,
MILD: 1,
MEDIUM: 2,
SPICY: 3,
});

const LEVELS = Object.freeze([SPICE.MILD, SPICE.MEDIUM, SPICE.SPICY]);

/* The words the paper prints. English, because the ticket is the kitchen's
and the kitchen is the shop's; the customer's own screen says it in the
customer's language, from the ordering dictionary. */
const WORDS = Object.freeze({
[SPICE.MILD]: 'MILD',
[SPICE.MEDIUM]: 'MEDIUM',
[SPICE.SPICY]: 'SPICY',
});

/**
* A level somebody actually chose, or nothing.
*
* Anything that is not one of the three reads as NOT SAID, deliberately: a
* value nobody recognises must never become a promise about somebody's food.
*/
function levelOf(value) {
/*
* A NUMBER OR THE TEXT OF ONE, AND NOTHING ELSE.
*
* Number(true) is 1, so a client sending spice_level: true would have had
* MILD printed on a real ticket for a request nobody made. Caught by the
* test rather than by anybody reading this, which is the point of the test.
* A string is still allowed: a handset that stringifies its form sends "2",
* and refusing that would silently drop the request.
*/
if (typeof value !== 'number' && typeof value !== 'string') return SPICE.NOT_SAID;
const n = Number(value);
return LEVELS.includes(n) ? n : SPICE.NOT_SAID;
}

/** Does this dish offer the choice at all? */
function offersChoice(item) {
return Boolean(item && item.spice_choice === true);
}

/**
* What the kitchen ticket says, or '' when nobody asked.
*
* The count is not decoration. A cook who does not read English still reads
* 2 of 3, and a ticket that printed only a word would be useless to them.
*/
function ticketLine(value) {
const level = levelOf(value);
if (!level) return '';
return 'SPICE: ' + WORDS[level] + ' (' + level + ' of ' + LEVELS.length + ')';
}

module.exports = { SPICE, LEVELS, WORDS, levelOf, offersChoice, ticketLine };
2 changes: 1 addition & 1 deletion api/tests/unit/models/item.model.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -791,7 +791,7 @@ describe('Item.LegacyItemModel › class identity', () => {
// deliberately NOT fields, so a dish can never store one).
// + nutrition_source (who said so: a person, or a machine that guessed;
// dish-facts publishes nothing derived from an estimate).
expect(Object.keys(LegacyItemModel.fields)).toHaveLength(77);
expect(Object.keys(LegacyItemModel.fields)).toHaveLength(78);
expect(LegacyItemModel.fields).toEqual(
expect.objectContaining({
/* Named as well as counted: a count alone passes if one field is
Expand Down
4 changes: 3 additions & 1 deletion api/tests/unit/services/customer-order.service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,10 @@ describe('the order, read back by the phone that placed it', () => {
can_change: false,
why_not: 'already_paid',
});
/* `spice` is 0 because nobody chose one: the level is only ever a number
the customer tapped, and a dish nobody asked about carries none. */
expect(out.data.items).toEqual([
{ item_id: 'm1', name: 'Chicken Biryani', quantity: 2, note: '', total: 660 },
{ item_id: 'm1', name: 'Chicken Biryani', quantity: 2, note: '', spice: 0, total: 660 },
]);
});

Expand Down
Loading
Loading