Files
daily_console_web/src/features/store-admin/orderStatus.ts
2026-09-22 11:13:15 +05:30

363 lines
16 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.
/**
* 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;
}