363 lines
16 KiB
TypeScript
363 lines
16 KiB
TypeScript
/**
|
||
* Order and delivery status, taken verbatim from the old console.
|
||
*
|
||
* Two ladders, not one, and that is the important part. An order walks
|
||
* created → pending → processing → confirmed/ready → delivered, while its
|
||
* delivery job walks pending → accepted → arrived → picked → active →
|
||
* delivered. Colouring a delivery with the order map paints "picked" and
|
||
* "arrived" grey, which is exactly the middle of the journey where an operator
|
||
* most needs to see progress.
|
||
*
|
||
* Values are the old console's `consoleUi.tsx` hex codes, unchanged, so a row
|
||
* that was amber there is amber here.
|
||
*/
|
||
|
||
/** The order lifecycle. */
|
||
export const ORDER_STATUS: Record<string, string> = {
|
||
created: '#0ea5e9',
|
||
pending: '#f59e0b',
|
||
processing: '#0ea5e9',
|
||
modified: '#06b6d4',
|
||
confirmed: '#10b981',
|
||
accepted: '#6366f1',
|
||
ready: '#6366f1',
|
||
delivered: '#10b981',
|
||
cancelled: '#ef4444',
|
||
};
|
||
|
||
/** The delivery lifecycle. Different words, different colours. */
|
||
export const DELIVERY_STATUS: Record<string, string> = {
|
||
pending: '#f59e0b',
|
||
accepted: '#6366f1',
|
||
arrived: '#06b6d4',
|
||
picked: '#8b5cf6',
|
||
active: '#14b8a6',
|
||
skipped: '#f97316',
|
||
/*
|
||
* Not from the old console — it never had one, so 30 live rows rendered in
|
||
* the neutral grey that `statusColor` falls back to. That was survivable
|
||
* while `rejected` only appeared on the deliveries table; it stopped being
|
||
* survivable when `orderProgress.ts` started surfacing it on ORDERS, where
|
||
* grey is the one colour that means "nothing to do here" about the one
|
||
* status that means "this order needs a rider now".
|
||
*
|
||
* Measured against every neighbour in this map before it was chosen: dE 39.5
|
||
* from `cancelled`, 72.6 from `skipped`, 73.5 from `picked` — all well clear
|
||
* of the 15 floor. The near misses were #e11d48 (13.9 from cancelled) and
|
||
* #be123c (20.4), either of which would read as a slightly-off red.
|
||
*/
|
||
rejected: '#db2777',
|
||
delivered: '#10b981',
|
||
cancelled: '#ef4444',
|
||
};
|
||
|
||
/**
|
||
* `waiting` is not in either map — it is the dispatch board's own word for work
|
||
* nobody has touched — but it is just as wrong as a name.
|
||
*/
|
||
const WAITING_WORD = 'waiting';
|
||
|
||
/**
|
||
* True when a value is a person's name and not a status wearing one.
|
||
*
|
||
* `deliveries.ridername` is not reliably a name. Every rider on tenant 916 has
|
||
* BOTH their name and a delivery status in that column, and for two of the five
|
||
* the status is the MORE common value (measured 2026-09-09):
|
||
*
|
||
* 883 { "Rajan": 21, "delivered": 42 }
|
||
* 897 { "Varun": 69, "delivered": 75 }
|
||
* 1111 { "Murali": 33, "delivered": 74, "cancelled": 1 }
|
||
* 1114 { "Tamilazhagan": 37, "delivered": 63 }
|
||
*
|
||
* So neither "first non-empty" nor "most common" finds the name on its own. The
|
||
* statuses have to be excluded first, and they are excluded by VOCABULARY — the
|
||
* two status maps above — rather than by a hand-written list, so a status added
|
||
* to the ladder is excluded here the same day.
|
||
*
|
||
* It lives beside the vocabularies rather than with either caller: the dispatch
|
||
* rail and the map both need it, and one importing it from the other would put
|
||
* a cycle between them.
|
||
*/
|
||
export function isRealName(value: string | undefined): boolean {
|
||
const name = value?.trim().toLowerCase();
|
||
if (!name) return false;
|
||
return !(name in DELIVERY_STATUS) && !(name in ORDER_STATUS) && name !== WAITING_WORD;
|
||
}
|
||
|
||
/** Unknown statuses fall to meta grey rather than to a colour that means something. */
|
||
export const statusColor = (map: Record<string, string>, status: string | undefined): string =>
|
||
map[(status ?? '').trim().toLowerCase()] ?? 'var(--color-ink-3)';
|
||
|
||
/**
|
||
* The status tabs — one set per kind of row, because they are different ladders.
|
||
*
|
||
* ── Why not one shared set ──────────────────────────────────────────────────
|
||
*
|
||
* An order and a delivery move through different lifecycles, and a single strip
|
||
* shown over both leaves half its tabs permanently at zero. Measured across
|
||
* every tenant on 2026-09-10:
|
||
*
|
||
* orders delivered 4240 · pending 876 · cancelled 827 · created 788
|
||
* deliveries delivered 1528 · pending 209 · cancelled 146 · picked 42 ·
|
||
* rejected 30 · active 22 · skipped 11 · accepted 6 · arrived 3
|
||
*
|
||
* So an order is never "arrived" or "picked", and a delivery is never
|
||
* "created". The previous six-tab set was shown over both and had the worse
|
||
* problem: "processing" swallowed accepted, picked, active and arrived into one
|
||
* bucket, so a dispatcher could not tell a rider who had reached the shop from
|
||
* one already riding, and "cancelled" quietly included skipped.
|
||
*
|
||
* ── Every status has exactly one tab ────────────────────────────────────────
|
||
*
|
||
* `rejected` is on the delivery strip even though it is easy to forget: 30 live
|
||
* rows carry it, and a status with no tab appears under "All" and under nothing
|
||
* else, so an operator working through the tabs never sees those rows at all.
|
||
* That is the failure this list is checked against.
|
||
*/
|
||
export const ORDER_STATUS_TABS = [
|
||
{ key: 'all', label: 'All' },
|
||
{ key: 'created', label: 'Created' },
|
||
{ key: 'pending', label: 'Pending' },
|
||
{ key: 'delivered', label: 'Delivered' },
|
||
{ key: 'cancelled', label: 'Cancelled' },
|
||
] as const;
|
||
|
||
export const DELIVERY_STATUS_TABS = [
|
||
{ key: 'all', label: 'All' },
|
||
{ key: 'pending', label: 'Pending' },
|
||
{ key: 'accepted', label: 'Accepted' },
|
||
{ key: 'arrived', label: 'Arrived' },
|
||
{ key: 'picked', label: 'Picked' },
|
||
{ key: 'active', label: 'Active' },
|
||
{ key: 'skipped', label: 'Skipped' },
|
||
{ key: 'rejected', label: 'Rejected' },
|
||
{ key: 'delivered', label: 'Delivered' },
|
||
{ key: 'cancelled', label: 'Cancelled' },
|
||
] as const;
|
||
|
||
export type StatusKey =
|
||
| (typeof ORDER_STATUS_TABS)[number]['key']
|
||
| (typeof DELIVERY_STATUS_TABS)[number]['key'];
|
||
|
||
/**
|
||
* Does a row belong under this tab?
|
||
*
|
||
* Substring matching, lowercased. Fiesta stores status as free text and the
|
||
* casing is inconsistent between writers, so an exact match is how a
|
||
* "Delivered" row silently stops counting the day someone writes "delivered".
|
||
*
|
||
* Each status now lands in exactly ONE tab. The order of these cases matters
|
||
* where words overlap: `undelivered` must not count as delivered, and
|
||
* `out for delivery` is a delivery in progress rather than a completed one.
|
||
*/
|
||
export function matchesStatus(key: StatusKey, status: string | undefined): boolean {
|
||
if (key === 'all') return true;
|
||
const s = (status ?? '').trim().toLowerCase();
|
||
if (!s) return false;
|
||
switch (key) {
|
||
case 'created':
|
||
return s.includes('created') || s.includes('new');
|
||
case 'pending':
|
||
return s.includes('pending');
|
||
case 'accepted':
|
||
// Not "accept" alone — that would also catch "unaccepted" if it appears.
|
||
return s.includes('accepted');
|
||
case 'arrived':
|
||
return s.includes('arrived');
|
||
case 'picked':
|
||
return s.includes('picked') || s.includes('pickup');
|
||
case 'active':
|
||
// Everything between collecting and dropping: the platform writes
|
||
// `active`, and `out for delivery` means the same thing.
|
||
return s.includes('active') || s.includes('out for') || s.includes('transit');
|
||
case 'skipped':
|
||
// Its own status and its own tab. The rider reached the address and
|
||
// nobody was in — which is not a cancellation, and used to be filed as
|
||
// one.
|
||
return s.includes('skip');
|
||
case 'rejected':
|
||
return s.includes('reject') || s.includes('declin');
|
||
case 'delivered':
|
||
return s.includes('deliver') && !s.includes('undeliver') && !s.includes('out for');
|
||
case 'cancelled':
|
||
return s.includes('cancel');
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* Finished, stopped, or still moving — asked once, in the vocabulary.
|
||
*
|
||
* Three screens needed this and three screens wrote their own version. The
|
||
* drawer's read it from a `SETTLED` array that listed two words; the dispatch
|
||
* board's was an inline `!== 'delivered' && !== 'cancelled' && !== 'skipped'`.
|
||
* They disagreed about `rejected`, which is how a job the rider had DECLINED
|
||
* came to be the one the board called "in progress".
|
||
*
|
||
* Routing through `matchesStatus` fixes a second fault the inline comparisons
|
||
* shared: they were exact matches on free text. Fiesta's casing is inconsistent
|
||
* between writers, so `Delivered` was silently outstanding forever.
|
||
*/
|
||
|
||
/** Nothing follows these. The job is over, however it ended. */
|
||
export function isSettled(status: string | undefined): boolean {
|
||
return matchesStatus('delivered', status) || matchesStatus('cancelled', status);
|
||
}
|
||
|
||
/**
|
||
* Stopped, but not finished — and the difference matters to the operator.
|
||
*
|
||
* `rejected` is the rider declining the job; `skipped` is a door nobody
|
||
* answered. Neither is going to progress on its own, so both need a person,
|
||
* but the person does something DIFFERENT about each — which is why this is
|
||
* its own state and not folded into settled.
|
||
*/
|
||
export function isStalled(status: string | undefined): boolean {
|
||
return matchesStatus('rejected', status) || matchesStatus('skipped', status);
|
||
}
|
||
|
||
/**
|
||
* Work actually under way: somebody is carrying it and it is still moving.
|
||
*
|
||
* The complement of the two above, deliberately — a status nobody has taught
|
||
* this module about counts as live, so a new word on the ladder shows up as
|
||
* outstanding work rather than vanishing from a board quietly.
|
||
*/
|
||
export function isLive(status: string | undefined): boolean {
|
||
return !isSettled(status) && !isStalled(status);
|
||
}
|
||
|
||
|
||
/**
|
||
* The money on an order row, in the order the old console reads it.
|
||
*
|
||
* `ordervalue` is the full figure (goods + tax + charges − promo), `orderamount`
|
||
* is the goods alone, and `deliveryamt` is what a delivery job carries. Which
|
||
* one is populated depends on which endpoint produced the row, so this walks
|
||
* them rather than picking one and showing blanks half the time.
|
||
*/
|
||
export function orderValue(row: {
|
||
ordervalue?: number;
|
||
orderamount?: number;
|
||
deliveryamt?: number;
|
||
}): number {
|
||
return row.ordervalue || row.orderamount || row.deliveryamt || 0;
|
||
}
|
||
|
||
/** Items on an order. `quantity` first, `itemcount` as the fallback. */
|
||
export function orderQuantity(row: { quantity?: number; itemcount?: number }): number {
|
||
return row.quantity || row.itemcount || 0;
|
||
}
|
||
|
||
/**
|
||
* A till clock running a few minutes off is ordinary and not worth correcting.
|
||
* Beyond this, a `billedat` in the future is a timezone fault, not drift.
|
||
*/
|
||
const CLOCK_TOLERANCE_MS = 5 * 60_000;
|
||
|
||
/**
|
||
* When the sale was actually rung, as a real instant.
|
||
*
|
||
* `billedat` cannot be trusted as sent. Most terminals stamp it with LOCAL
|
||
* wall-clock time and then label it `Z`, so a sale rung at 17:17 IST arrives as
|
||
* `2026-08-27T17:17:39Z` — five and a half hours in the future. Measured across
|
||
* location 1185: 19 of 20 bills had `billedat` ahead of `receivedat`, which is
|
||
* impossible, since a bill cannot be rung after the server received it. One
|
||
* terminal (`TB0B5`) sends correct UTC, so the fleet cannot be corrected
|
||
* wholesale either.
|
||
*
|
||
* `receivedat` is stamped by Fiesta and is therefore sound, and it gives a
|
||
* test that needs no knowledge of which terminal is which: if `billedat` is
|
||
* later than `receivedat` by more than clock drift, the digits are local time
|
||
* wearing a `Z`, and reading them back in the viewer's zone recovers the
|
||
* instant. The console and the shops it serves are in the same zone, so
|
||
* `getTimezoneOffset` is the offset that was dropped.
|
||
*
|
||
* The correction is applied only when it actually resolves the impossibility;
|
||
* a bill that is still in the future afterwards is something else, and guessing
|
||
* further would be inventing data. Everything else passes through untouched —
|
||
* which means this quietly stops correcting the day the POS team stamps UTC.
|
||
*/
|
||
export function billedAtMs(bill: { billedat?: string; receivedat?: string }): number | null {
|
||
if (!bill.billedat) return null;
|
||
const billed = new Date(bill.billedat).getTime();
|
||
if (Number.isNaN(billed)) return null;
|
||
|
||
if (!bill.receivedat) return billed;
|
||
const received = new Date(bill.receivedat).getTime();
|
||
if (Number.isNaN(received)) return billed;
|
||
|
||
if (billed <= received + CLOCK_TOLERANCE_MS) return billed;
|
||
|
||
// `getTimezoneOffset` is (UTC − local) in minutes: −330 for IST. Adding it
|
||
// turns wall-clock-read-as-UTC back into the instant it stood for.
|
||
const corrected = billed + new Date(billed).getTimezoneOffset() * 60_000;
|
||
return corrected <= received + CLOCK_TOLERANCE_MS ? corrected : billed;
|
||
}
|
||
|
||
/**
|
||
* How far behind a counter bill was when it reached us.
|
||
*
|
||
* `billedat` is when the sale was rung; `receivedat` is when it landed here. A
|
||
* gap means the till was offline and caught up later, and it is worth showing
|
||
* on the row: a day's takings that all arrived at 6pm did not happen at 6pm.
|
||
*
|
||
* Measured against `billedAtMs`, not the raw stamp. Read raw, every skewed bill
|
||
* produced a NEGATIVE lag — which fell under the threshold and returned null,
|
||
* so this reported "synced on time" for all twenty bills at location 1185 and
|
||
* would have gone on doing so through a real outage. Silent wrong is worse
|
||
* than blank.
|
||
*
|
||
* Returns null when the gap is under a minute (normal) or when either stamp is
|
||
* missing — a bill with no `receivedat` predates the field, and calling that
|
||
* "0 seconds late" would be a claim the data does not support.
|
||
*/
|
||
export function syncLagMs(bill: { billedat?: string; receivedat?: string }): number | null {
|
||
if (!bill.billedat || !bill.receivedat) return null;
|
||
const billed = billedAtMs(bill);
|
||
const received = new Date(bill.receivedat).getTime();
|
||
if (billed === null || Number.isNaN(received)) return null;
|
||
const lag = received - billed;
|
||
return lag >= 60_000 ? lag : null;
|
||
}
|
||
|
||
/**
|
||
* A lifecycle stamp that cannot have happened before the order did.
|
||
*
|
||
* The same class of fault as `billedAtMs`, in a second place. `getorders`
|
||
* returns `deliverydate` — which is `orders.deliverytime` under an alias — and
|
||
* on live tenant 1147 it is 5h29m48s behind `orderdate` on ALL FIFTY rows read:
|
||
*
|
||
* orderdate 2026-09-05T14:48:42+05:30
|
||
* deliverydate 2026-09-05T09:18:54+05:30
|
||
*
|
||
* A delivery cannot be stamped before the order it belongs to. The gap is one
|
||
* IST offset less a few seconds of processing, which is what a UTC instant
|
||
* looks like when it is written into a column whose other writer uses server
|
||
* local time and then serialised with a +05:30 tag. Fiesta's own default for
|
||
* `orderdate` is `time.Now()` in the server's zone, so `orderdate` is the sound
|
||
* reference and the one to measure against.
|
||
*
|
||
* Corrected only when the correction actually resolves the impossibility — a
|
||
* stamp still earlier than the order afterwards is something else, and guessing
|
||
* further would be inventing data. So this stops correcting by itself the day
|
||
* the caller sends a real instant.
|
||
*/
|
||
export function stampAfterMs(value: string | undefined, notBefore: string | undefined): number | null {
|
||
if (!value) return null;
|
||
const at = new Date(value).getTime();
|
||
if (Number.isNaN(at)) return null;
|
||
|
||
if (!notBefore) return at;
|
||
const floor = new Date(notBefore).getTime();
|
||
if (Number.isNaN(floor)) return at;
|
||
if (at >= floor - CLOCK_TOLERANCE_MS) return at;
|
||
|
||
// `getTimezoneOffset` is (UTC − local) in minutes: −330 for IST. SUBTRACTING
|
||
// it moves a UTC instant forward to the wall-clock reading it stood for,
|
||
// which is the direction this fault runs — the opposite of `billedAtMs`,
|
||
// where local digits were labelled `Z`.
|
||
const corrected = at - new Date(at).getTimezoneOffset() * 60_000;
|
||
return corrected >= floor - CLOCK_TOLERANCE_MS ? corrected : at;
|
||
}
|