323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
import {
|
||
CLOCK_DRIFT_THRESHOLD_S,
|
||
clockTime,
|
||
spokenAge,
|
||
type TerminalStatus,
|
||
} from './posStatus';
|
||
|
||
/**
|
||
* What a counter has to say, in words a shopkeeper uses.
|
||
*
|
||
* This replaces a severity ladder — critical/warning/info over codes like
|
||
* `queue-stranded` and `clock-drift`. That vocabulary is borrowed from
|
||
* monitoring tools and means nothing to the person who actually opens this
|
||
* screen: a supervisor who knows the shop and nothing about networks. "Stale"
|
||
* is not a word about a cash register.
|
||
*
|
||
* So the model is now: a counter produces zero or more PROBLEMS, each of which
|
||
* is one plain sentence, one bucket, and one instruction. One problem, one
|
||
* card. A counter with two problems appears twice — which is the point, because
|
||
* a card carrying two instructions carries neither.
|
||
*/
|
||
export type Bucket = 'now' | 'look' | 'fine';
|
||
|
||
export type ProblemCode =
|
||
| 'money-stuck'
|
||
| 'gone-quiet'
|
||
| 'not-selling'
|
||
| 'printer-down'
|
||
| 'wrong-date'
|
||
| 'storage-low'
|
||
| 'battery-low'
|
||
| 'drawer-open'
|
||
| 'never-used'
|
||
| 'no-status';
|
||
|
||
export interface Problem {
|
||
code: ProblemCode;
|
||
bucket: Bucket;
|
||
/** The line in large type. Leads with what is at stake, not what is broken. */
|
||
headline: string;
|
||
/** One sentence of what is actually true. */
|
||
detail: string;
|
||
/** What a person should do. Rendered in bold; absent when there is nothing. */
|
||
action?: string;
|
||
/** A reassurance, when the honest answer is "this looks worse than it is". */
|
||
comfort?: string;
|
||
/** How long it has been true, in ms. null when we cannot know. */
|
||
forMs: number | null;
|
||
}
|
||
|
||
export const BUCKET_LABEL: Record<Bucket, string> = {
|
||
now: 'Needs someone now',
|
||
look: 'Worth a look',
|
||
fine: 'All fine',
|
||
};
|
||
|
||
export const BUCKET_RANK: Record<Bucket, number> = { fine: 0, look: 1, now: 2 };
|
||
|
||
/* `BUCKET_COLOR` was here — a red/amber/green ramp the board painted onto a
|
||
rail down each card and a dot beside each heading.
|
||
|
||
It is gone rather than moved. The console does not colour-code severity:
|
||
`KpiCard` maps every tone to `--color-brand`, deliberately, and a three-colour
|
||
ramp existed on this page and nowhere else in the product. Urgency is already
|
||
carried twice over — by the group's name, in plain words, and by the order the
|
||
groups appear in — so the colour was a third telling, in the loudest register
|
||
available, on the one page a supervisor opens when they are already worried. */
|
||
|
||
export interface ProblemContext {
|
||
/** Evaluation instant, so a whole board is judged against one clock. */
|
||
now: number;
|
||
/** The branch's trading hours, if known — `"09:00"` / `"21:30"`. */
|
||
opentime?: string;
|
||
closetime?: string;
|
||
/** Counters confirmed stale on the PREVIOUS read. See the `gone-quiet` note. */
|
||
wasStale?: ReadonlySet<string>;
|
||
}
|
||
|
||
/**
|
||
* Is the branch shut right now?
|
||
*
|
||
* Anything unparseable answers "open", on purpose. This flag only ever
|
||
* DOWNGRADES a problem, so failing to parse keeps the alert at full strength —
|
||
* the safe direction to be wrong in.
|
||
*/
|
||
export function isClosed(context: ProblemContext): boolean {
|
||
const open = parseClock(context.opentime);
|
||
const close = parseClock(context.closetime);
|
||
if (open === null || close === null) return false;
|
||
|
||
const at = new Date(context.now);
|
||
const mins = at.getHours() * 60 + at.getMinutes();
|
||
// A close time earlier than the open time means the shop trades past
|
||
// midnight, so the open window wraps rather than being empty.
|
||
return close < open ? mins >= close && mins < open : mins < open || mins >= close;
|
||
}
|
||
|
||
function parseClock(value: string | undefined): number | null {
|
||
if (!value) return null;
|
||
const match = /^(\d{1,2}):(\d{2})/.exec(value.trim());
|
||
if (!match) return null;
|
||
const hours = Number(match[1]);
|
||
const mins = Number(match[2]);
|
||
if (!Number.isFinite(hours) || !Number.isFinite(mins)) return null;
|
||
if (hours > 23 || mins > 59) return null;
|
||
return hours * 60 + mins;
|
||
}
|
||
|
||
/** ₹ with Indian grouping and no decimals. Whole rupees are what people say. */
|
||
export function rupees(amount: number): string {
|
||
return `₹${Math.round(amount).toLocaleString('en-IN')}`;
|
||
}
|
||
|
||
/**
|
||
* Roughly what the unsent bills are worth.
|
||
*
|
||
* The health payload carries a COUNT of pending bills and no value — Fiesta
|
||
* simply does not report one. But "41 sales haven't reached us" gives a
|
||
* supervisor no sense of whether that is four thousand rupees or four hundred
|
||
* thousand, and the difference decides whether they walk over now.
|
||
*
|
||
* So this estimates from the till's OWN average sale today, and every caller
|
||
* must render it with the word "roughly" attached. It returns null rather than
|
||
* guessing when the till has sold nothing today to average over — an estimate
|
||
* from no data is a fabrication, not an estimate.
|
||
*/
|
||
export function estimatePendingValue(terminal: TerminalStatus): number | null {
|
||
if (terminal.todayBills <= 0 || terminal.todayAmount <= 0) return null;
|
||
if (terminal.pendingBills <= 0) return null;
|
||
return (terminal.todayAmount / terminal.todayBills) * terminal.pendingBills;
|
||
}
|
||
|
||
const sales = (n: number) => `${n} sale${n === 1 ? '' : 's'}`;
|
||
|
||
/**
|
||
* Everything one counter has to say, worst first.
|
||
*/
|
||
export function problemsFor(
|
||
terminal: TerminalStatus,
|
||
context: ProblemContext,
|
||
): Problem[] {
|
||
const problems: Problem[] = [];
|
||
const closed = isClosed(context);
|
||
const absent = terminal.presence !== 'online';
|
||
const neverSold = terminal.todayBills === 0 && terminal.lastBillAt === null;
|
||
const pending = terminal.pendingBills;
|
||
|
||
/**
|
||
* Money on a counter nobody can see.
|
||
*
|
||
* The single worst thing this board reports, and the reason it exists. From
|
||
* `models/poshealth.go`: "A shop quietly accumulating unsynced takings looks
|
||
* completely normal from the shop floor, and this is the only thing that
|
||
* makes it visible before someone reconciles a till and finds a day missing."
|
||
*
|
||
* It absorbs the presence problem — a card that said both "money stuck" and
|
||
* "gone quiet" would be one card asking for the same walk to the same
|
||
* counter, twice.
|
||
*/
|
||
if (absent && pending > 0) {
|
||
const worth = estimatePendingValue(terminal);
|
||
problems.push({
|
||
code: 'money-stuck',
|
||
bucket: 'now',
|
||
headline: `${sales(pending)} haven’t reached us`,
|
||
detail: worth
|
||
? `Roughly ${rupees(worth)}, judging by this counter’s average sale today. The oldest has been waiting since ${clockTime(terminal.oldestPendingAt)}, and we can’t currently see this counter to watch them send.`
|
||
: `The oldest has been waiting since ${clockTime(terminal.oldestPendingAt)}, and we can’t currently see this counter to watch them send.`,
|
||
action: 'Check the counter is switched on and its internet is working.',
|
||
// The most important sentence on the page. It is the difference between
|
||
// a supervisor phoning head office and a supervisor checking a router.
|
||
comfort:
|
||
'The sales aren’t lost — the till keeps its own copy and sends them as soon as it reconnects.',
|
||
forMs: terminal.oldestPendingMs || terminal.silentForMs,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Dark, with nothing held.
|
||
*
|
||
* Urgent only when the shop is open and this counter has traded before.
|
||
* Outside trading hours it is a shop that shut; on a counter that has never
|
||
* sold it is almost certainly a spare, and `never-used` says so better.
|
||
*/
|
||
if (absent && pending === 0 && !neverSold) {
|
||
const urgent = !closed;
|
||
problems.push({
|
||
code: 'gone-quiet',
|
||
bucket: urgent ? 'now' : 'look',
|
||
headline: closed ? 'Switched off' : 'Gone quiet',
|
||
detail: closed
|
||
? `We haven’t heard from this counter since ${clockTime(terminal.receivedAt)}. The shop is outside its opening hours, so this is probably just closing up.`
|
||
: terminal.silentForMs === null
|
||
? 'We can’t see this counter at all, and the shop is open.'
|
||
: `We haven’t heard from it for ${spokenAge(terminal.silentForMs)}, and the shop is open.`,
|
||
...(urgent
|
||
? {
|
||
action:
|
||
'Check it’s switched on and connected. If it’s been turned off on purpose, you can ignore this.',
|
||
}
|
||
: {}),
|
||
forMs: terminal.silentForMs,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Reaching us, but nothing rung.
|
||
*
|
||
* `models/poshealth.go` again: "A till that is connected but has rung nothing
|
||
* in three hours usually means a jammed printer or an absent cashier, and
|
||
* neither shows up in a plain online/offline board."
|
||
*/
|
||
if (!absent && !closed && terminal.todayBills === 0 && terminal.lastBillAt !== null) {
|
||
problems.push({
|
||
code: 'not-selling',
|
||
bucket: 'look',
|
||
headline: 'Hasn’t sold anything today',
|
||
detail: `It’s switched on and reaching us, but nothing has been rung up. Its last sale was ${clockTime(terminal.lastBillAt)}.`,
|
||
action: 'Usually a jammed printer, or nobody on the counter.',
|
||
forMs: null,
|
||
});
|
||
}
|
||
|
||
if (terminal.printerReachable === false) {
|
||
problems.push({
|
||
code: 'printer-down',
|
||
bucket: 'look',
|
||
headline: 'Printer isn’t responding',
|
||
detail: 'Staff can still ring up sales on this counter, but it can’t print a receipt.',
|
||
action: 'Check the printer is switched on, connected and has paper.',
|
||
forMs: null,
|
||
});
|
||
}
|
||
|
||
if (
|
||
terminal.clockDriftSeconds !== null &&
|
||
Math.abs(terminal.clockDriftSeconds) > CLOCK_DRIFT_THRESHOLD_S
|
||
) {
|
||
const drift = Math.abs(terminal.clockDriftSeconds) * 1000;
|
||
problems.push({
|
||
code: 'wrong-date',
|
||
bucket: 'look',
|
||
headline: 'This counter’s clock is wrong',
|
||
detail: `It’s set ${spokenAge(drift)} ${terminal.clockDriftSeconds > 0 ? 'ahead of' : 'behind'} the real time. Sales rung here can be recorded against the wrong day, which shows up later as a day that doesn’t add up.`,
|
||
action: 'Set the device’s date and time to update automatically.',
|
||
forMs: null,
|
||
});
|
||
}
|
||
|
||
if (terminal.storageFreeMb !== null && terminal.storageFreeMb < 200) {
|
||
problems.push({
|
||
code: 'storage-low',
|
||
bucket: 'look',
|
||
headline: 'Running out of space',
|
||
detail: `Only ${terminal.storageFreeMb} MB left. If this counter goes offline it needs room to hold sales until it reconnects.`,
|
||
action: 'Ask whoever set the device up to clear some space on it.',
|
||
forMs: null,
|
||
});
|
||
}
|
||
|
||
if (terminal.batteryLevel !== null && terminal.batteryLevel < 20 && !terminal.batteryCharging) {
|
||
problems.push({
|
||
code: 'battery-low',
|
||
bucket: 'look',
|
||
headline: `Battery at ${terminal.batteryLevel}%`,
|
||
detail: 'It isn’t charging, so this counter will shut down on its own before long.',
|
||
action: 'Plug it in.',
|
||
forMs: null,
|
||
});
|
||
}
|
||
|
||
if (terminal.drawerStatus === 'open') {
|
||
problems.push({
|
||
code: 'drawer-open',
|
||
bucket: 'look',
|
||
headline: 'Cash drawer is open',
|
||
detail: 'This counter is reporting its cash drawer as open.',
|
||
forMs: null,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Never rung a sale.
|
||
*
|
||
* Said out loud rather than left silent. From the old console: "almost always
|
||
* a commissioning probe or a till set up and never put into service — worth
|
||
* saying, because 'offline' on such a terminal is not an incident." A board
|
||
* that shows spares as permanent emergencies is a board people stop reading.
|
||
*/
|
||
if (neverSold) {
|
||
problems.push({
|
||
code: 'never-used',
|
||
bucket: 'look',
|
||
headline: 'Never used',
|
||
detail:
|
||
'This counter has never rung a sale. It’s most likely a spare, or one that was set up and never put into service.',
|
||
action: 'If you don’t use it, hide it to keep this list tidy.',
|
||
forMs: null,
|
||
});
|
||
}
|
||
|
||
return problems.sort((a, b) => BUCKET_RANK[b.bucket] - BUCKET_RANK[a.bucket]);
|
||
}
|
||
|
||
/**
|
||
* The problem for a counter that sold but reports no status at all.
|
||
*
|
||
* These exist only in the sales figures — there is no presence record to build
|
||
* a card from, so without this they are invisible on the board while sitting
|
||
* plainly in the day's takings.
|
||
*/
|
||
export function noStatusProblem(bills: number, amount: number): Problem {
|
||
return {
|
||
code: 'no-status',
|
||
bucket: 'look',
|
||
headline: 'Not reporting its status',
|
||
detail: `This counter sold ${rupees(amount)} across ${sales(bills)}, but it doesn’t tell us whether it’s switched on or how it’s doing.`,
|
||
action: 'Usually an older version of the till app. Worth having it updated.',
|
||
comfort: 'Those sales are safely recorded — this is only about status.',
|
||
forMs: null,
|
||
};
|
||
}
|