removed files
This commit is contained in:
@@ -14,12 +14,11 @@
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
|
|
||||||
# Scratch. `_to_delete` is spent files awaiting removal and has no business in
|
# Transfer archives. The scratch files these rules were written for — the
|
||||||
# an image.
|
# `_to_delete` directory, `_inv.txt`, and two empty archives — are gone from the
|
||||||
_to_delete
|
# repository; the glob stays so the next one never reaches a build context.
|
||||||
*.zip
|
*.zip
|
||||||
*.tgz
|
*.tgz
|
||||||
_inv.txt
|
|
||||||
|
|
||||||
# Per-machine overrides. `.env` itself IS wanted — it carries VITE_API_BASE and
|
# Per-machine overrides. `.env` itself IS wanted — it carries VITE_API_BASE and
|
||||||
# the build needs it — but `.env.local` is a developer's private override and
|
# the build needs it — but `.env.local` is a developer's private override and
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -5,10 +5,8 @@ dist
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
# Spent files awaiting deletion. Was tracked, which put it in the repo and in
|
# Transfer archives. Nothing of the sort belongs in the repository — the two
|
||||||
# every Docker build context.
|
# that were here (`_sync.zip`, `_their-src.tgz`) had been committed empty and
|
||||||
_to_delete
|
# stayed for weeks.
|
||||||
|
|
||||||
# Transfer archives.
|
|
||||||
*.zip
|
*.zip
|
||||||
*.tgz
|
*.tgz
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const req = (label) => `label={<span>${label} <span style={{ color: 'var(--color-error)' }}>*</span></span>}`;
|
|
||||||
|
|
||||||
function processFile(file) {
|
|
||||||
let content = fs.readFileSync(file, 'utf8');
|
|
||||||
|
|
||||||
// Find all components with isRequired
|
|
||||||
// We need to match: label="Something" ... isRequired
|
|
||||||
|
|
||||||
// First, find all lines with label="..."
|
|
||||||
// If a block has isRequired, replace the label and remove isRequired.
|
|
||||||
|
|
||||||
const tags = ['TextInput', 'Selector', 'NumberInput', 'TimeInput'];
|
|
||||||
|
|
||||||
// A simple regex to find JSX tags and their props
|
|
||||||
const regex = /<(TextInput|Selector|NumberInput|TimeInput)([^>]+)isRequired([^>]*)>/g;
|
|
||||||
|
|
||||||
content = content.replace(regex, (match, tag, before, after) => {
|
|
||||||
// extract label
|
|
||||||
const labelMatch = before.match(/label="([^"]+)"/);
|
|
||||||
if (!labelMatch) {
|
|
||||||
// maybe label is after isRequired
|
|
||||||
const labelMatchAfter = after.match(/label="([^"]+)"/);
|
|
||||||
if (!labelMatchAfter) return match; // fallback
|
|
||||||
|
|
||||||
const newLabel = req(labelMatchAfter[1]);
|
|
||||||
return `<${tag}${before}${after.replace(labelMatchAfter[0], newLabel)}>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newLabel = req(labelMatch[1]);
|
|
||||||
return `<${tag}${before.replace(labelMatch[0], newLabel)}${after}>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(file, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
processFile('src/features/nearle-admin/pages/OnboardTenantPage.tsx');
|
|
||||||
processFile('src/features/nearle-admin/pages/OnboardBranchPage.tsx');
|
|
||||||
|
|
||||||
console.log("Done");
|
|
||||||
@@ -133,11 +133,17 @@ if (products.length === 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listed = new Set(listedRows.map((row) => row.productid));
|
const listed = new Set(listedRows.map((row) => row.productid));
|
||||||
|
/**
|
||||||
|
* `subcatid`/`subcatname`, not `subcategoryid`/`subcategoryname`.
|
||||||
|
*
|
||||||
|
* This read the long names — the ones `getproductsubcategories` does NOT send —
|
||||||
|
* so every entry was `undefined → undefined`, the map collapsed to a single
|
||||||
|
* junk key, and check C below could never match. The footer duly announced
|
||||||
|
* "(none returned)" for tenant 1147, whose category 2 has six subcategories.
|
||||||
|
* A diagnostic that is confidently wrong is worse than one that is missing.
|
||||||
|
*/
|
||||||
const realSubs = new Map(
|
const realSubs = new Map(
|
||||||
(Array.isArray(subcategories) ? subcategories : []).map((row) => [
|
(Array.isArray(subcategories) ? subcategories : []).map((row) => [row.subcatid, row.subcatname]),
|
||||||
row.subcategoryid,
|
|
||||||
row.subcategoryname,
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const inApp = new Set();
|
const inApp = new Set();
|
||||||
|
|||||||
@@ -224,10 +224,23 @@ export interface ProductCategory {
|
|||||||
categoryname: string;
|
categoryname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /web/products/getproductsubcategories`.
|
||||||
|
*
|
||||||
|
* The wire names are `subcatid` and `subcatname` — ABBREVIATED, unlike
|
||||||
|
* `ProductCategory` next door, which spells `categoryid`/`categoryname` in
|
||||||
|
* full. This interface claimed the long forms, so every field read off it was
|
||||||
|
* `undefined` and TypeScript had no way to know: the rows arrive typed by this
|
||||||
|
* declaration, not by what the server sent. `scripts/appgap.mjs` built its
|
||||||
|
* subcategory map from them and got a map of `undefined → undefined`, then
|
||||||
|
* reported "(none returned)" for a category that has six.
|
||||||
|
*/
|
||||||
export interface ProductSubCategory {
|
export interface ProductSubCategory {
|
||||||
subcategoryid: number;
|
subcatid: number;
|
||||||
subcategoryname: string;
|
subcatname: string;
|
||||||
categoryid?: number;
|
categoryid?: number;
|
||||||
|
image?: string;
|
||||||
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Body of POST /web/products/importcatalogueproduct — send an ARRAY of these. */
|
/** Body of POST /web/products/importcatalogueproduct — send an ARRAY of these. */
|
||||||
|
|||||||
@@ -100,7 +100,22 @@ export function CatalogueBrowser({
|
|||||||
const [brand, setBrand] = useState('');
|
const [brand, setBrand] = useState('');
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [debounced, setDebounced] = useState('');
|
const [debounced, setDebounced] = useState('');
|
||||||
const [importInto, setImportInto] = useState('');
|
/**
|
||||||
|
* `null` means "not chosen yet", which is NOT the same as "no category".
|
||||||
|
*
|
||||||
|
* This was `useState('')`, and an empty string went on to `Number('') || 0`
|
||||||
|
* and imported the product under `categoryid` 0. That reads like a harmless
|
||||||
|
* default and is not: the customer app queries products by category, and a
|
||||||
|
* product filed under 0 is returned for NO category value — verified against
|
||||||
|
* tenant 1147, where `getproductsbysubcategory` answered with the other five
|
||||||
|
* products at `categoryid=2` and with nothing at 0, 1 or 1001. Two real
|
||||||
|
* products sat invisible in the app while this console listed them happily,
|
||||||
|
* because `getlocationproducts` does not filter on category.
|
||||||
|
*
|
||||||
|
* Holding "unchosen" apart from "chosen nothing" lets the default below be
|
||||||
|
* the tenant's own category rather than the value that hides the product.
|
||||||
|
*/
|
||||||
|
const [importInto, setImportInto] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [justImported, setJustImported] = useState<Set<string>>(new Set());
|
const [justImported, setJustImported] = useState<Set<string>>(new Set());
|
||||||
const [open, setOpen] = useState<CatalogueProduct | null>(null);
|
const [open, setOpen] = useState<CatalogueProduct | null>(null);
|
||||||
@@ -188,8 +203,31 @@ export function CatalogueBrowser({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The category an import would actually be written under.
|
||||||
|
*
|
||||||
|
* Falls back to the tenant's first real category rather than to nothing.
|
||||||
|
* `categoryOptions` arrives asynchronously, so this is derived at render
|
||||||
|
* instead of pushed into state by an effect — state seeded from an empty
|
||||||
|
* list would keep the old default after the categories loaded, and the
|
||||||
|
* picker would then show one thing while the request sent another.
|
||||||
|
*/
|
||||||
|
const defaultCategory = categoryOptions.find((option) => option.value !== '')?.value ?? '';
|
||||||
|
const chosenCategory = importInto ?? defaultCategory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an import would produce a product the customer app cannot show.
|
||||||
|
*
|
||||||
|
* The tenant has no category to file into, so the write would go in under 0.
|
||||||
|
* Better to say so and refuse than to write a row that looks imported in this
|
||||||
|
* console and does not exist as far as a shopper is concerned.
|
||||||
|
*/
|
||||||
|
const hasNoCategory = !isReadOnly && !onImport && chosenCategory === '';
|
||||||
|
|
||||||
async function importDirect(product: CatalogueProduct) {
|
async function importDirect(product: CatalogueProduct) {
|
||||||
if (!tenantid || !locationid) return;
|
if (!tenantid || !locationid) return;
|
||||||
|
// Guarded rather than defaulted to 0 — see `hasNoCategory`.
|
||||||
|
if (chosenCategory === '') return;
|
||||||
const key = catalogueKey(product);
|
const key = catalogueKey(product);
|
||||||
setBusy(key);
|
setBusy(key);
|
||||||
try {
|
try {
|
||||||
@@ -198,11 +236,13 @@ export function CatalogueBrowser({
|
|||||||
locationid,
|
locationid,
|
||||||
brand: product.brand,
|
brand: product.brand,
|
||||||
catalogueid: product.id,
|
catalogueid: product.id,
|
||||||
// Uncategorised beats wrongly categorised — the backend leaves this
|
/* This used to read `Number(importInto) || 0`, defended as
|
||||||
// optional for exactly that reason. An unclassified product is visibly
|
"uncategorised beats wrongly categorised — an unclassified product is
|
||||||
// unfinished; a wrongly classified one looks done and is only found by
|
visibly unfinished". It is not visible anywhere: the console lists it
|
||||||
// somebody browsing the wrong aisle.
|
like any other product and only the customer app knows it is gone.
|
||||||
categoryid: Number(importInto) || 0,
|
A wrongly filed product is at least findable and fixable; one under
|
||||||
|
category 0 is returned by no query the app makes. */
|
||||||
|
categoryid: Number(chosenCategory),
|
||||||
subcategoryid: 0,
|
subcategoryid: 0,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
stocktype: 'in',
|
stocktype: 'in',
|
||||||
@@ -400,9 +440,9 @@ export function CatalogueBrowser({
|
|||||||
isBusy={busy === catalogueKey(open)}
|
isBusy={busy === catalogueKey(open)}
|
||||||
actionLabel={actionLabel}
|
actionLabel={actionLabel}
|
||||||
{...(showCategoryPicker
|
{...(showCategoryPicker
|
||||||
? { categoryOptions, categoryid: importInto, onCategoryChange: setImportInto }
|
? { categoryOptions, categoryid: chosenCategory, onCategoryChange: setImportInto }
|
||||||
: {})}
|
: {})}
|
||||||
{...(canImport && !isReadOnly
|
{...(canImport && !isReadOnly && !hasNoCategory
|
||||||
? {
|
? {
|
||||||
onImport: () => {
|
onImport: () => {
|
||||||
const product = open;
|
const product = open;
|
||||||
@@ -411,7 +451,16 @@ export function CatalogueBrowser({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {})}
|
: {})}
|
||||||
{...(blockedReason ? { blockedReason } : {})}
|
{...(blockedReason
|
||||||
|
? // The caller's reason wins: "no outlet selected" outranks "no
|
||||||
|
// category", because you cannot pick one without the other.
|
||||||
|
{ blockedReason }
|
||||||
|
: hasNoCategory
|
||||||
|
? {
|
||||||
|
blockedReason:
|
||||||
|
'This merchant has no product category yet, and a product imported without one is invisible in the customer app. Add a category first.',
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
onClose={() => setOpen(null)}
|
onClose={() => setOpen(null)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -27,14 +27,23 @@ export function CataloguePanel() {
|
|||||||
|
|
||||||
const categories = useTenantCategories(tenantid);
|
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(
|
const categoryOptions = useMemo(
|
||||||
() => [
|
() =>
|
||||||
{ value: '', label: 'Leave uncategorised' },
|
(categories.data ?? []).map((entry) => ({
|
||||||
...(categories.data ?? []).map((entry) => ({
|
|
||||||
value: String(entry.categoryid),
|
value: String(entry.categoryid),
|
||||||
label: entry.categoryname,
|
label: entry.categoryname,
|
||||||
})),
|
})),
|
||||||
],
|
|
||||||
[categories.data],
|
[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;
|
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.
|
* 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
|
* 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.
|
* 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
|
* 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
|
* missing — a bill with no `receivedat` predates the field, and calling that
|
||||||
* "0 seconds late" would be a claim the data does not support.
|
* "0 seconds late" would be a claim the data does not support.
|
||||||
*/
|
*/
|
||||||
export function syncLagMs(bill: { billedat?: string; receivedat?: string }): number | null {
|
export function syncLagMs(bill: { billedat?: string; receivedat?: string }): number | null {
|
||||||
if (!bill.billedat || !bill.receivedat) return 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();
|
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;
|
const lag = received - billed;
|
||||||
return lag >= 60_000 ? lag : null;
|
return lag >= 60_000 ? lag : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
orderQuantity,
|
orderQuantity,
|
||||||
orderValue,
|
orderValue,
|
||||||
statusColor,
|
statusColor,
|
||||||
|
billedAtMs,
|
||||||
syncLagMs,
|
syncLagMs,
|
||||||
type StatusKey,
|
type StatusKey,
|
||||||
} from '../orderStatus';
|
} from '../orderStatus';
|
||||||
@@ -114,7 +115,11 @@ export function SalesPage() {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(term),
|
.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]);
|
}, [billPages, branchIds, branches, keyword]);
|
||||||
|
|
||||||
/** Counts for the tab strip, from the unfiltered set. */
|
/** Counts for the tab strip, from the unfiltered set. */
|
||||||
@@ -753,6 +758,8 @@ function BillsTable({
|
|||||||
restarting its series would collide. */}
|
restarting its series would collide. */}
|
||||||
{rows.map(({ bill, branch }) => {
|
{rows.map(({ bill, branch }) => {
|
||||||
const lag = syncLagMs(bill);
|
const lag = syncLagMs(bill);
|
||||||
|
// The recovered instant, not the stamp — see `billedAtMs`.
|
||||||
|
const billedAt = billedAtMs(bill);
|
||||||
return (
|
return (
|
||||||
<tr key={bill.terminalorderid ?? `${branch}:${bill.posorderid}`}>
|
<tr key={bill.terminalorderid ?? `${branch}:${bill.posorderid}`}>
|
||||||
<Td>
|
<Td>
|
||||||
@@ -773,7 +780,7 @@ function BillsTable({
|
|||||||
</Secondary>
|
</Secondary>
|
||||||
</Td>
|
</Td>
|
||||||
<Td isMuted>
|
<Td isMuted>
|
||||||
{clockOf(bill.billedat)}
|
{clockOf(billedAt ?? bill.billedat)}
|
||||||
</Td>
|
</Td>
|
||||||
{showBranch ? (
|
{showBranch ? (
|
||||||
<Td isMuted>
|
<Td isMuted>
|
||||||
@@ -824,10 +831,16 @@ function stamp(value: string | undefined): string {
|
|||||||
return `${parsed.toLocaleDateString('en-IN', { day: '2-digit', month: 'short' })}, ${clockOf(value)}`;
|
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);
|
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', {
|
return parsed.toLocaleTimeString('en-IN', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
|||||||
Reference in New Issue
Block a user