removed files
This commit is contained in:
@@ -27,14 +27,23 @@ export function CataloguePanel() {
|
||||
|
||||
const categories = useTenantCategories(tenantid);
|
||||
|
||||
/**
|
||||
* No "Leave uncategorised" entry, and its absence is the fix.
|
||||
*
|
||||
* It sat first in this list, so it was what the picker showed until somebody
|
||||
* changed it — and it wrote `categoryid` 0, which the customer app cannot
|
||||
* return under any category it asks for. It was not an opt-out from filing a
|
||||
* product; it was an opt-out from selling it, offered as the default.
|
||||
*
|
||||
* A tenant with no categories now gets an explicit refusal from
|
||||
* `CatalogueBrowser` rather than a silent 0.
|
||||
*/
|
||||
const categoryOptions = useMemo(
|
||||
() => [
|
||||
{ value: '', label: 'Leave uncategorised' },
|
||||
...(categories.data ?? []).map((entry) => ({
|
||||
() =>
|
||||
(categories.data ?? []).map((entry) => ({
|
||||
value: String(entry.categoryid),
|
||||
label: entry.categoryname,
|
||||
})),
|
||||
],
|
||||
[categories.data],
|
||||
);
|
||||
|
||||
|
||||
117
src/features/store-admin/orderStatus.test.ts
Normal file
117
src/features/store-admin/orderStatus.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The counter-bill timestamp repair.
|
||||
*
|
||||
* Every fixture below is a real row from location 1185, copied from
|
||||
* `GET /live/api/v1/web/pos/sales`, because the bug is a property of what the
|
||||
* tills actually send rather than of anything this codebase does.
|
||||
*
|
||||
* These assert IST behaviour and set TZ to match. `billedAtMs` reads the
|
||||
* viewer's zone deliberately — the console and the shops it serves share one —
|
||||
* so a test run in UTC would exercise a correction of zero and prove nothing.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import { billedAtMs, syncLagMs } from './orderStatus';
|
||||
|
||||
/* Set after the imports on purpose, and it still takes: `billedAtMs` reads the
|
||||
zone when it is CALLED, not when this module loads, and assigning TZ calls
|
||||
tzset(). The import order that ESM enforces is therefore harmless here. */
|
||||
process.env['TZ'] = 'Asia/Kolkata';
|
||||
|
||||
test('the fixtures are being read in the zone they were written for', () => {
|
||||
// Guard, not ceremony. Every expectation below is IST-specific, and in UTC
|
||||
// the correction is a no-op — so without this the suite would go green while
|
||||
// testing nothing.
|
||||
assert.equal(
|
||||
new Date('2026-08-27T00:00:00Z').getTimezoneOffset(),
|
||||
-330,
|
||||
'TZ did not take effect; these assertions require Asia/Kolkata',
|
||||
);
|
||||
});
|
||||
|
||||
/** The common case: 19 of 20 bills. IST wall-clock wearing a `Z`. */
|
||||
const SKEWED = {
|
||||
billedat: '2026-08-27T17:17:39Z',
|
||||
receivedat: '2026-08-27T11:47:40.921521Z',
|
||||
};
|
||||
|
||||
/** `TB0B5` — the one terminal stamping real UTC. Must be left alone. */
|
||||
const CORRECT = {
|
||||
billedat: '2026-08-27T11:15:00Z',
|
||||
receivedat: '2026-08-27T11:21:11.179745Z',
|
||||
};
|
||||
|
||||
/** `T2188` — genuinely offline, caught up 87 minutes later. Skewed as well. */
|
||||
const LATE = {
|
||||
billedat: '2026-08-17T13:46:58Z',
|
||||
receivedat: '2026-08-17T09:44:06.189763Z',
|
||||
};
|
||||
|
||||
test('recovers the instant from a wall-clock stamp labelled Z', () => {
|
||||
// Rung at 17:17:39 IST, which is 11:47:39 UTC — a second before it landed.
|
||||
assert.equal(billedAtMs(SKEWED), Date.parse('2026-08-27T11:47:39Z'));
|
||||
});
|
||||
|
||||
test('leaves a correctly stamped bill untouched', () => {
|
||||
assert.equal(billedAtMs(CORRECT), Date.parse('2026-08-27T11:15:00Z'));
|
||||
});
|
||||
|
||||
test('renders the hour the sale was actually rung', () => {
|
||||
const shown = new Date(billedAtMs(SKEWED)!).toLocaleTimeString('en-IN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
// Not 22:47, which is what formatting the raw stamp produced.
|
||||
assert.equal(shown, '17:17');
|
||||
});
|
||||
|
||||
test('reports a real lag instead of null', () => {
|
||||
// Read raw this was −4.05 h, which fell under the threshold and returned
|
||||
// null, so the row claimed the bill synced on time.
|
||||
const lag = syncLagMs(LATE);
|
||||
assert.ok(lag !== null);
|
||||
assert.ok(lag > 80 * 60_000 && lag < 95 * 60_000, `lag was ${lag}`);
|
||||
});
|
||||
|
||||
test('a bill that arrived within the second reports no lag', () => {
|
||||
// Rung 17:17:39 IST, landed 17:17:40 IST. Under a minute, so nothing to say.
|
||||
assert.equal(syncLagMs(SKEWED), null);
|
||||
});
|
||||
|
||||
test('a correctly stamped bill is still measured, not waved through', () => {
|
||||
// `TB0B5` took 6 minutes. The timestamps are sound, the lag is real, and the
|
||||
// repair must not swallow it along with the skew.
|
||||
const lag = syncLagMs(CORRECT);
|
||||
assert.ok(lag !== null);
|
||||
assert.ok(lag > 6 * 60_000 && lag < 7 * 60_000, `lag was ${lag}`);
|
||||
});
|
||||
|
||||
test('tolerates a till clock a few minutes fast without correcting it', () => {
|
||||
const drifting = {
|
||||
billedat: '2026-08-27T11:20:00Z',
|
||||
receivedat: '2026-08-27T11:18:00Z',
|
||||
};
|
||||
// Two minutes of drift is drift, not a timezone — a 5.5 h shift here would
|
||||
// move the bill to the previous morning.
|
||||
assert.equal(billedAtMs(drifting), Date.parse('2026-08-27T11:20:00Z'));
|
||||
});
|
||||
|
||||
test('leaves a stamp the correction cannot rescue alone', () => {
|
||||
// Days in the future, not hours. Shifting by one zone does not resolve it,
|
||||
// so the raw value stands rather than being half-corrected into fiction.
|
||||
const wild = {
|
||||
billedat: '2026-09-30T10:00:00Z',
|
||||
receivedat: '2026-08-27T11:00:00Z',
|
||||
};
|
||||
assert.equal(billedAtMs(wild), Date.parse('2026-09-30T10:00:00Z'));
|
||||
});
|
||||
|
||||
test('survives the fields being absent or unparseable', () => {
|
||||
assert.equal(billedAtMs({}), null);
|
||||
assert.equal(billedAtMs({ billedat: 'not a date' }), null);
|
||||
// No `receivedat` means no way to test the stamp, so it is taken as sent.
|
||||
assert.equal(billedAtMs({ billedat: '2026-08-27T11:15:00Z' }), Date.parse('2026-08-27T11:15:00Z'));
|
||||
assert.equal(syncLagMs({ billedat: '2026-08-27T11:15:00Z' }), null);
|
||||
assert.equal(syncLagMs({ receivedat: '2026-08-27T11:15:00Z' }), null);
|
||||
});
|
||||
@@ -122,6 +122,52 @@ export function orderQuantity(row: { quantity?: number; itemcount?: number }): n
|
||||
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.
|
||||
*
|
||||
@@ -129,15 +175,21 @@ export function orderQuantity(row: { quantity?: number; itemcount?: number }): n
|
||||
* 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 = new Date(bill.billedat).getTime();
|
||||
const billed = billedAtMs(bill);
|
||||
const received = new Date(bill.receivedat).getTime();
|
||||
if (Number.isNaN(billed) || Number.isNaN(received)) return null;
|
||||
if (billed === null || Number.isNaN(received)) return null;
|
||||
const lag = received - billed;
|
||||
return lag >= 60_000 ? lag : null;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
orderQuantity,
|
||||
orderValue,
|
||||
statusColor,
|
||||
billedAtMs,
|
||||
syncLagMs,
|
||||
type StatusKey,
|
||||
} from '../orderStatus';
|
||||
@@ -114,7 +115,11 @@ export function SalesPage() {
|
||||
.toLowerCase()
|
||||
.includes(term),
|
||||
)
|
||||
.sort((a, b) => (b.bill.billedat ?? '').localeCompare(a.bill.billedat ?? ''));
|
||||
/* Sorted on the recovered instant, not the raw stamp. A string compare
|
||||
put the one terminal that sends correct UTC five and a half hours
|
||||
adrift of the rest, so newest-first was newest-first only within a
|
||||
terminal. */
|
||||
.sort((a, b) => (billedAtMs(b.bill) ?? 0) - (billedAtMs(a.bill) ?? 0));
|
||||
}, [billPages, branchIds, branches, keyword]);
|
||||
|
||||
/** Counts for the tab strip, from the unfiltered set. */
|
||||
@@ -753,6 +758,8 @@ function BillsTable({
|
||||
restarting its series would collide. */}
|
||||
{rows.map(({ bill, branch }) => {
|
||||
const lag = syncLagMs(bill);
|
||||
// The recovered instant, not the stamp — see `billedAtMs`.
|
||||
const billedAt = billedAtMs(bill);
|
||||
return (
|
||||
<tr key={bill.terminalorderid ?? `${branch}:${bill.posorderid}`}>
|
||||
<Td>
|
||||
@@ -773,7 +780,7 @@ function BillsTable({
|
||||
</Secondary>
|
||||
</Td>
|
||||
<Td isMuted>
|
||||
{clockOf(bill.billedat)}
|
||||
{clockOf(billedAt ?? bill.billedat)}
|
||||
</Td>
|
||||
{showBranch ? (
|
||||
<Td isMuted>
|
||||
@@ -824,10 +831,16 @@ function stamp(value: string | undefined): string {
|
||||
return `${parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' })}, ${clockOf(value)}`;
|
||||
}
|
||||
|
||||
function clockOf(value: string | undefined): string {
|
||||
if (!value) return '—';
|
||||
/**
|
||||
* Takes an epoch too, so a bill can pass the instant `billedAtMs` recovered
|
||||
* rather than the stamp it arrived with. Formatting the raw `billedat` shifted
|
||||
* an already-wrong time by another +05:30 and put a 17:17 sale on the row at
|
||||
* 22:47 — two bugs compounding into a plausible-looking number.
|
||||
*/
|
||||
function clockOf(value: string | number | undefined): string {
|
||||
if (value === undefined || value === '') return '—';
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return value;
|
||||
if (Number.isNaN(parsed.getTime())) return String(value);
|
||||
return parsed.toLocaleTimeString('en-IN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
|
||||
Reference in New Issue
Block a user