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
115 changes: 115 additions & 0 deletions api/src/repositories/sale.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ async function withDayparts(shop) {
const { notifyOrderAttention } = require('../helpers/order-attention');
const orderApproval = require('../utils/order-approval');
const billNumber = require('../utils/bill-number');
const orderProgress = require('../utils/order-progress');
const spiceLevel = require('../utils/spice-level');
const readyBy = require('../utils/ready-by');
const { kitchenLoad, typicalRound } = require('../utils/kitchen-load');
Expand All @@ -66,6 +67,21 @@ const partnerVenues = require('../utils/partner-venues');
but a refusal with an empty message would tell a customer nothing. */
const ONLINE_ORDERING_DISABLED = 'Online ordering is not enabled for this branch.';

/*
* The shop's own answering speed, remembered for a few minutes.
*
* Every phone watching an order asks for this on every poll, and the answer
* is a property of the SHOP that moves over days. Reading fifty sales per
* poll per phone to produce the same number would be the whole cost of this
* feature, for nothing.
*/
const ACCEPT_HISTORY_DAYS = 14;
const ACCEPT_HISTORY_ORDERS = 50;
const ACCEPT_ENOUGH_ORDERS = 5;
const ACCEPT_OUTLIER_MINUTES = 120;
const ACCEPT_CACHE_MS = 5 * 60 * 1000;
const ACCEPT_MINUTES_CACHE = new Map();

const activeTenantFilter = () => ({
...(BaseModel.license ? { license: BaseModel.license } : {}),
...(BaseModel.currentBranch ? { branch_id: BaseModel.currentBranch } : {}),
Expand Down Expand Up @@ -9989,6 +10005,16 @@ class SalesRepository {
bill_ready: paymentStatus === 'Paid' && !cancelled,
payment_status: paymentStatus,
payment_mode: String(order.payment_mode || ''),
/*
* WHERE IT HAS GOT TO, as a trail of what has actually happened.
*
* Stage 5's whole point, and the reason it lives here rather than in
* the service: every door a customer's phone can reach this order
* through - the read, the history page's bulk read, a change, a
* cancellation - is drawn from this one shape, so none of them can
* describe the same order differently. See utils/order-progress.
*/
progress: orderProgress.progressOf(order),
/* Asked for, and waiting on the shop. */
cancel_requested: order.cancel_requested === true,
fulfilment: String(order.fulfilment || ''),
Expand All @@ -10009,6 +10035,95 @@ class SalesRepository {
};
}

/**
* HOW LONG THIS SHOP USUALLY TAKES TO ANSWER, from its own history.
*
* Stage 5 asks for "an ETA computed from the shop's own history", and this
* is the only ETA the data can honestly produce. There is no cooking time
* in here: nothing marks an order ready, so any minutes-until-food figure
* would be invented, and an invented ETA is worse than none - it is the
* number a customer waits against and then complains about.
*
* What the data does hold is how long orders sit in the approval queue
* before somebody works it, and that is the minute a waiting customer is
* actually anxious about: has anyone seen this at all. So the answer is
* about acceptance, and the page words it as a description of the past
* rather than a promise about this order.
*
* Null rather than a guess whenever the history is too thin, too old or
* unreadable. A shop with four orders behind it gets no figure.
*/
async typicalAcceptMinutes(branchId) {
const key = String(branchId || '');
if (!key) return null;

const cached = ACCEPT_MINUTES_CACHE.get(key);
if (cached && cached.until > Date.now()) return cached.minutes;

let minutes = null;
try {
const db = await BaseModel.getDb();
const branchObjectId = mongoose.Types.ObjectId.isValid(key)
? new mongoose.Types.ObjectId(key)
: branchId;
/*
* BOUNDED BY _id, which every collection indexes.
*
* An ObjectId carries the second it was made, so `_id` above a
* fortnight ago is both a date range and an index walk - and the walk
* STOPS at the fortnight. Ranging on created_date instead would leave
* Mongo sorting a shop's entire sales collection in memory on a
* collection with no index for it, which on a busy shop is the kind of
* query that takes the rest of the process down with it.
*
* The minutes are still computed from created_date, which is the field
* that means what it says.
*/
const since = Date.now() - ACCEPT_HISTORY_DAYS * 86400000;
const rows = await db
.collection('sales')
.find(
{
branch_id: branchObjectId,
_id: { $gte: mongoose.Types.ObjectId.createFromTime(Math.floor(since / 1000)) },
order_state: 'accepted',
order_state_by: { $nin: [null, ''] },
order_state_at: { $ne: null },
...activeTenantFilter(),
},
{ projection: { created_date: 1, order_state_at: 1 } }
)
.sort({ _id: -1 })
.limit(ACCEPT_HISTORY_ORDERS)
.toArray();

const waits = rows
.map((row) => {
const from = new Date(row.created_date).getTime();
const to = new Date(row.order_state_at).getTime();
return Number.isFinite(from) && Number.isFinite(to) ? (to - from) / 60000 : NaN;
})
/* An order accepted an hour later is a shop that had gone home, not a
shop that is slow. Leaving those in drags the middle of a busy
evening out to a number no customer would recognise. */
.filter((wait) => Number.isFinite(wait) && wait >= 0 && wait <= ACCEPT_OUTLIER_MINUTES)
.sort((a, b) => a - b);

if (waits.length >= ACCEPT_ENOUGH_ORDERS) {
const middle = waits[Math.floor(waits.length / 2)];
minutes = Math.max(1, Math.ceil(middle));
}
} catch (error) {
/* A figure nobody can read is simply not shown. It is decoration on a
status page, and a status page must not fail over decoration. */
console.warn('[order-progress] could not read the accept history:', error && error.message);
minutes = null;
}

ACCEPT_MINUTES_CACHE.set(key, { minutes, until: Date.now() + ACCEPT_CACHE_MS });
return minutes;
}

/** One order of this branch's, by its id. Nothing wider: no list, no search. */
async findCustomerOrder({ branchId, orderId }) {
if (!mongoose.Types.ObjectId.isValid(String(orderId || ''))) return null;
Expand Down
51 changes: 49 additions & 2 deletions api/src/services/customer-order.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,35 @@ async function read(body, context) {
return { status: true, message: 'OK', data: await viewOf(order, context) };
}

/**
* How long this shop usually takes to answer, when that is what is being
* waited on.
*
* NOT A COOKING TIME, and there is deliberately no way to ask this for one.
* Nothing in the product marks an order ready, so minutes-until-food would be
* invented - and an invented ETA is worse than none, because it is the number
* the customer waits against and then comes to the counter about.
*
* What a held order's customer is actually anxious about is whether anybody
* has seen it, and the shop's own recent queue answers exactly that. Read
* only while the order is still waiting: a shop on automatic never holds one,
* and should never pay for the query.
*/
async function typicallyAcceptedIn(view, context) {
if (!view || !view.progress || view.progress.waiting_for !== 'acceptance') return {};
const minutes = await minutesOrNull(context);
return minutes ? { typically_accepted_in_minutes: minutes } : {};
}

async function minutesOrNull(context) {
try {
return await salesRepository.typicalAcceptMinutes(context && context.branchId);
} catch (e) {
/* Decoration on a status page. It is never worth failing the read. */
return null;
}
}

/*
* THE WHOLE ANSWER, SO THE PHONE NEED NOT ASK TWICE.
*
Expand All @@ -171,8 +200,12 @@ async function read(body, context) {
async function viewOf(order, context) {
const seconds = await changeSeconds(context);
const reason = whyNot(order, Date.now(), seconds);
const view = salesRepository.customerOrderView(order);
return {
...salesRepository.customerOrderView(order),
...view,
/* How long this shop usually takes to answer, and ONLY while this order
is waiting to be answered. See typicallyAcceptedIn. */
...(await typicallyAcceptedIn(view, context)),
/* Whether they may still move it, why not, and how long the shop
leaves it open - so one read answers every question the page has,
including what to count down. */
Expand Down Expand Up @@ -244,6 +277,9 @@ async function readMany(body, context) {
/* The window is the shop's, not the order's, so it is read once. */
const seconds = await changeSeconds(context);
const now = Date.now();
/* And so is the answering speed. One read for the whole page, rather than
one per row - the reason this endpoint exists at all. */
let acceptMinutes;
const found = [];
for (const one of asked) {
const orderId = String((one && one.orderId) || '').trim();
Expand All @@ -255,8 +291,19 @@ async function readMany(body, context) {
});
if (!order || String(order.token_id || '') !== token) continue;
const reason = whyNot(order, now, seconds);
const view = salesRepository.customerOrderView(order);
if (
view.progress &&
view.progress.waiting_for === 'acceptance' &&
acceptMinutes === undefined
) {
acceptMinutes = await minutesOrNull(context);
}
found.push({
...salesRepository.customerOrderView(order),
...view,
...(view.progress && view.progress.waiting_for === 'acceptance' && acceptMinutes
? { typically_accepted_in_minutes: acceptMinutes }
: {}),
can_change: reason === '',
why_not: reason || undefined,
change_seconds: seconds,
Expand Down
158 changes: 158 additions & 0 deletions api/src/utils/order-progress.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use strict';

/*
* WHERE AN ORDER HAS GOT TO, IN THE ONLY TERMS THAT ARE TRUE.
*
* Stage 5 of Intranet/docs/PRINT_APPROVAL_NOTIFICATION_ROADMAP.md - "the
* customer knows" - with the warning that stage carries printed on it:
*
* "Do not ship a stage nothing can move off. If `Ready` is unreachable
* because nobody presses anything, a frozen tracker is worse than none."
*
* So this is not a tracker. A tracker is a ladder with the future drawn on
* it, and the future is exactly the part we cannot promise: nothing in this
* product sets "ready", nobody presses "out for delivery", and a shop whose
* kitchen printer is off never reports a ticket at all. A ladder would draw
* three greyed rungs and stop on the first one, which tells a customer their
* order is stuck when it is being cooked.
*
* WHAT THIS IS INSTEAD: a TRAIL. Only what has actually happened, each entry
* with the moment it happened, newest last. A trail cannot freeze, because it
* never claims anything about what comes next. If the kitchen step never
* arrives the customer sees "Placed 7:42" and nothing missing - not a promise
* with a hole in it.
*
* The one thing it does name ahead of time is `waiting_for`, and only for the
* single case where the next move is guaranteed by the state machine rather
* than hoped for: an order held for approval MUST be accepted or refused by
* somebody, and order-approval.js is what makes that true. Nothing else is
* ever named before it happens.
*
* NO SENTENCES HERE, only keys and times. The ordering pages carry their own
* Tamil runtime with an English-keyed dictionary (assets/i18n.js); a sentence
* composed on the server arrives as English that no dictionary can reach. The
* page owns the words, this owns the facts, and neither can drift into the
* other's job.
*
* WHERE EACH FACT COMES FROM, and every one of them is written today by
* something other than this feature - which is the point. A progress view
* that needs new writes is a progress view that reports on itself:
*
* placed created_date, written when the order is inserted
* accepted order_state + order_state_at + order_state_by, written
* by the approval queue when a PERSON decides
* in the kitchen kitchen_printed_at, written when a till reports that a
* ticket actually came out of a printer
* refused order_state `rejected`
* cancelled sale_process `cancelled` / payment_status `Cancelled`
*/

/** The only steps that exist. There is deliberately no `ready`. */
const STEP = Object.freeze({
PLACED: 'placed',
ACCEPTED: 'accepted',
IN_THE_KITCHEN: 'in_the_kitchen',
REFUSED: 'refused',
CANCELLED: 'cancelled',
});

/** Nothing moves off these, so the page can stop asking. */
const SETTLED = Object.freeze([STEP.REFUSED, STEP.CANCELLED]);

/** A date, or null - never an Invalid Date, which renders as the word. */
function when(value) {
if (!value) return null;
const at = value instanceof Date ? value : new Date(value);
return Number.isFinite(at.getTime()) ? at : null;
}

const text = (value) => String(value == null ? '' : value).trim();

/**
* Was this order accepted by a PERSON, or did it simply never stop?
*
* A shop on automatic never held the order and never decided anything, so
* saying "Accepted 7:42" alongside "Placed 7:42" describes a decision nobody
* made - two lines, one second apart, one of them fiction. `order_state_by`
* carries the name of whoever worked the queue and is empty on arrival, which
* is exactly the difference.
*/
function aPersonDecided(order) {
return text(order.order_state_by) !== '';
}

/**
* The trail this order has left, and where that leaves it now.
*
* @param {object} order the sale document, as stored
* @returns {{step: string, trail: Array<{step: string, at: Date|null}>,
* waiting_for: string, settled: boolean}|null}
*/
function progressOf(order) {
if (!order) return null;

const process = text(order.sale_process).toLowerCase();
const paymentStatus = text(order.payment_status);
const state = text(order.order_state).toLowerCase();

const cancelled = process === 'cancelled' || paymentStatus === 'Cancelled';
const refused = state === 'rejected';

const trail = [{ step: STEP.PLACED, at: when(order.created_date) || when(order.date) }];

/*
* A DECISION, not a state. Only where somebody made one - and refusals are
* always somebody's, so they are always shown.
*/
if (state === 'accepted' && aPersonDecided(order)) {
trail.push({ step: STEP.ACCEPTED, at: when(order.order_state_at) });
}

/*
* Paper, in a kitchen. Not "the server sent it" and not "the queue holds
* it": a till reported that a printer produced it, which is the only form
* of this fact worth showing a customer.
*/
const kitchenAt = when(order.kitchen_printed_at);
if (kitchenAt || order.kitchen_printed === true) {
trail.push({ step: STEP.IN_THE_KITCHEN, at: kitchenAt });
}

/*
* An ending is the last line of the history, never an erasure of it.
*
* A customer who cancelled two minutes late wants to know the kitchen had
* already started - that is the difference between "fine" and "I should go
* and say something", and hiding the earlier trail hides it.
*/
if (refused) {
trail.push({ step: STEP.REFUSED, at: when(order.order_state_at) });
}
if (cancelled) {
trail.push({
step: STEP.CANCELLED,
at: when(order.customer_cancelled_at) || when(order.updated_date),
});
}

const step = trail[trail.length - 1].step;

/*
* THE ONE THING NAMED BEFORE IT HAPPENS.
*
* A pending order has to be answered: order-approval.js allows pending to
* move only to accepted or rejected, and waiting-order-policy.js keeps
* asking until somebody does. So this is a guarantee of the state machine,
* not an expectation of a printer. Every other "next" is left unsaid.
*/
const waitingFor = !cancelled && !refused && state === 'pending' ? 'acceptance' : '';

return {
step,
trail,
waiting_for: waitingFor,
settled: SETTLED.includes(step),
};
}

module.exports = { progressOf, STEP, SETTLED };
Loading
Loading