Files
daily_console_web/src/features/store-admin/terminalProblems.ts
2026-08-27 13:58:19 +05:30

323 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}