updates on the address while creasating the orders and security updates as well

This commit is contained in:
2026-09-11 11:39:34 +05:30
parent 8936669f94
commit c85f57324a
24 changed files with 4409 additions and 170 deletions

View File

@@ -0,0 +1,374 @@
/**
* Ops Agent drawer — the console surface for the analysis in opsAnalysis.js.
*
* Every number rendered here comes from a pure function in that module, and
* every finding carries the order IDs it was derived from. That is the contract
* the drawer keeps: nothing is shown that cannot be pointed back at rows.
*
* There is no model behind this yet. `buildAgentContext` produces the fact
* sheet an LLM would be handed, and "Copy agent context" puts it on the
* clipboard so the prompt side can be developed against real output before the
* endpoint exists.
*/
import React, { useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import {
Box,
Button,
Chip,
Collapse,
Divider,
Drawer,
IconButton,
Paper,
Stack,
Tooltip,
Typography
} from '@mui/material';
import {
MdClose,
MdContentCopy,
MdErrorOutline,
MdExpandLess,
MdExpandMore,
MdCheckCircleOutline,
MdWarningAmber
} from 'react-icons/md';
import { buildAgentContext, formatMinutes, scanDataQuality, summariseDay, zoneRollup } from './opsAnalysis';
// Shared with the rest of the operator pages — see reports/ordersDetails.js.
const BRAND = '#662582';
const DT = {
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
surfaceAlt: '#f8fafc'
};
const SEVERITY = {
blocking: { label: 'Blocking', color: '#b3261e', bg: '#fdecea', Icon: MdErrorOutline },
high: { label: 'High', color: '#9a5b00', bg: '#fdf1de', Icon: MdWarningAmber },
medium: { label: 'Medium', color: '#475569', bg: '#f1f5f9', Icon: MdWarningAmber },
low: { label: 'Low', color: '#475569', bg: '#f1f5f9', Icon: MdWarningAmber }
};
const mono = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace';
// ============================================================================
const Tile = ({ label, value, hint, tone }) => (
<Paper
elevation={0}
sx={{
flex: '1 1 128px',
minWidth: 128,
px: 1.5,
py: 1.125,
borderRadius: 2,
border: '1px solid',
borderColor: DT.borderSubtle,
bgcolor: '#fff'
}}
>
<Typography sx={{ fontSize: 10, letterSpacing: 0.6, textTransform: 'uppercase', color: DT.textMuted, fontWeight: 700 }}>
{label}
</Typography>
<Typography sx={{ fontSize: 24, fontWeight: 700, lineHeight: 1.2, color: tone || DT.textPrimary, fontVariantNumeric: 'tabular-nums' }}>
{value}
</Typography>
{hint && <Typography sx={{ fontSize: 11, color: DT.textSecondary }}>{hint}</Typography>}
</Paper>
);
Tile.propTypes = { label: PropTypes.string, value: PropTypes.node, hint: PropTypes.string, tone: PropTypes.string };
// ============================================================================
const Finding = ({ finding, expanded, onToggle, onHighlight }) => {
const meta = SEVERITY[finding.severity] || SEVERITY.medium;
const { Icon } = meta;
return (
<Paper
elevation={0}
sx={{
borderRadius: 2,
border: '1px solid',
borderColor: DT.borderSubtle,
borderLeft: `3px solid ${meta.color}`,
overflow: 'hidden',
bgcolor: '#fff'
}}
>
<Box
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}}
sx={{
px: 1.5,
py: 1.25,
cursor: 'pointer',
display: 'flex',
gap: 1,
alignItems: 'flex-start',
'&:hover': { bgcolor: DT.surfaceAlt },
'&:focus-visible': { outline: `2px solid ${BRAND}`, outlineOffset: -2 }
}}
>
<Icon size={17} color={meta.color} style={{ flex: 'none', marginTop: 2 }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" sx={{ mb: 0.25 }}>
<Chip
label={meta.label}
size="small"
sx={{ height: 18, fontSize: 10, fontWeight: 700, bgcolor: meta.bg, color: meta.color, '& .MuiChip-label': { px: 0.75 } }}
/>
{finding.orderIds.length > 0 && (
<Typography sx={{ fontSize: 11, color: DT.textMuted }}>{finding.orderIds.length} orders</Typography>
)}
</Stack>
<Typography sx={{ fontSize: 13.5, fontWeight: 600, color: DT.textPrimary, lineHeight: 1.4 }}>{finding.title}</Typography>
</Box>
{expanded ? <MdExpandLess size={18} color={DT.textMuted} /> : <MdExpandMore size={18} color={DT.textMuted} />}
</Box>
<Collapse in={expanded} unmountOnExit>
<Box sx={{ px: 1.5, pb: 1.5, pt: 0 }}>
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary, lineHeight: 1.6, mb: 1 }}>{finding.detail}</Typography>
{finding.evidence.length > 0 && (
<Box sx={{ p: 1, borderRadius: 1.5, bgcolor: DT.surfaceAlt, border: '1px solid', borderColor: DT.borderSubtle, mb: 1 }}>
{finding.evidence.map((line) => (
<Typography key={line} sx={{ fontFamily: mono, fontSize: 11, color: DT.textSecondary, whiteSpace: 'pre-wrap' }}>
{line}
</Typography>
))}
</Box>
)}
<Typography sx={{ fontSize: 12.5, color: DT.textPrimary, lineHeight: 1.6 }}>
<Box component="span" sx={{ fontSize: 10, fontWeight: 800, letterSpacing: 0.6, textTransform: 'uppercase', color: BRAND, mr: 0.75 }}>
Fix
</Box>
{finding.fix}
</Typography>
{finding.orderIds.length > 0 && onHighlight && (
<Button
size="small"
onClick={() => onHighlight(finding.orderIds)}
sx={{ mt: 1, textTransform: 'none', fontSize: 12, fontWeight: 700, color: BRAND, px: 0.5 }}
>
Show these {finding.orderIds.length} orders
</Button>
)}
</Box>
</Collapse>
</Paper>
);
};
Finding.propTypes = {
finding: PropTypes.object.isRequired,
expanded: PropTypes.bool,
onToggle: PropTypes.func.isRequired,
onHighlight: PropTypes.func
};
// ============================================================================
const OpsAgentDrawer = ({ open, onClose, rows, meta, onHighlight, totalAvailable }) => {
const [expandedId, setExpandedId] = useState(null);
const [copied, setCopied] = useState(false);
// Only recompute when the drawer is actually open — the scan walks every row
// several times and the reports page re-renders on every filter change.
const analysis = useMemo(() => {
if (!open) return null;
const source = Array.isArray(rows) ? rows : [];
return { summary: summariseDay(source), findings: scanDataQuality(source), zones: zoneRollup(source) };
}, [open, rows]);
const copyContext = async () => {
try {
await navigator.clipboard.writeText(JSON.stringify(buildAgentContext(rows || [], meta), null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
};
const summary = analysis && analysis.summary;
const blocking = analysis ? analysis.findings.filter((f) => f.severity === 'blocking').length : 0;
// The table pages in rows as the operator scrolls, so the drawer routinely
// sees fewer orders than the filter matches. Saying "20 orders" beside a page
// reading "62" would read as a bug in the analysis; say which it is.
const partial = Boolean(summary && totalAvailable && totalAvailable > summary.orderCount);
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
PaperProps={{ sx: { width: { xs: '100%', sm: 460 }, bgcolor: DT.surfaceAlt } }}
>
<Stack sx={{ height: '100%' }}>
{/* Header */}
<Stack
direction="row"
alignItems="center"
spacing={1}
sx={{ px: 2, py: 1.5, bgcolor: '#fff', borderBottom: '1px solid', borderColor: DT.borderSubtle }}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: 14.5, fontWeight: 800, color: DT.textPrimary, lineHeight: 1.3 }}>Ops Agent</Typography>
<Typography sx={{ fontSize: 11.5, color: DT.textSecondary }}>
{summary
? `${summary.orderCount}${partial ? ` of ${totalAvailable}` : ''} order${summary.orderCount === 1 ? '' : 's'} · ` +
`${summary.riders.length} rider${summary.riders.length === 1 ? '' : 's'}`
: '—'}
{meta && meta.range ? ` · ${meta.range}` : ''}
</Typography>
</Box>
<Tooltip title={copied ? 'Copied' : 'Copy the fact sheet an agent would be given'} arrow>
<IconButton size="small" onClick={copyContext} aria-label="Copy agent context">
{copied ? <MdCheckCircleOutline size={18} color="#2c6b45" /> : <MdContentCopy size={17} color={DT.textSecondary} />}
</IconButton>
</Tooltip>
<IconButton size="small" onClick={onClose} aria-label="Close">
<MdClose size={18} color={DT.textSecondary} />
</IconButton>
</Stack>
<Box sx={{ flex: 1, overflowY: 'auto', px: 2, py: 2 }}>
{!summary || summary.orderCount === 0 ? (
<Typography sx={{ fontSize: 13, color: DT.textSecondary }}>
No orders in the current filter. Widen the date range or clear the status filter, then reopen this panel.
</Typography>
) : (
<Stack spacing={2}>
{/* Verdict */}
<Paper
elevation={0}
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: '#fff',
border: '1px solid',
borderColor: DT.borderSubtle,
borderLeft: `3px solid ${blocking ? SEVERITY.blocking.color : '#2c6b45'}`
}}
>
<Typography sx={{ fontSize: 13.5, fontWeight: 700, color: DT.textPrimary, lineHeight: 1.45 }}>
{blocking
? `${summary.trustworthyDeliveryTimes} of ${summary.deliveryStampCount} delivery times can be trusted.`
: 'Timestamps on this range look sound.'}
</Typography>
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary, lineHeight: 1.55, mt: 0.5 }}>
{blocking
? 'Until the findings below are resolved, treat any lateness, ETA or productivity number from this range as unmeasured.'
: 'No blocking data-quality problems were detected in this range.'}
</Typography>
</Paper>
{/* Tiles */}
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Tile
label="Trusted times"
value={`${summary.trustworthyDeliveryTimes}/${summary.deliveryStampCount}`}
hint="delivery stamps"
tone={summary.trustworthyDeliveryTimes < summary.deliveryStampCount ? SEVERITY.blocking.color : '#2c6b45'}
/>
<Tile
label="Lifecycle capture"
value={`${Math.round(summary.lifecycleRatio * 100)}%`}
hint={`${summary.lifecycleCaptured} of ${summary.lifecycleExpected} stamps`}
tone={summary.lifecycleRatio < 0.9 ? SEVERITY.high.color : '#2c6b45'}
/>
<Tile
label="Slot → assign"
value={summary.medianAssignDelayMin === null ? '—' : `${summary.medianAssignDelayMin > 0 ? '+' : ''}${summary.medianAssignDelayMin}m`}
hint={summary.skew.suspected ? 'median, skew-corrected' : 'median'}
/>
<Tile label="Settled" value={`₹${summary.totalCharge}`} hint={`${summary.totalKm} km`} />
</Stack>
{/* Findings */}
<Box>
<Typography sx={{ fontSize: 10.5, fontWeight: 800, letterSpacing: 0.8, textTransform: 'uppercase', color: DT.textMuted, mb: 1 }}>
{analysis.findings.length ? `${analysis.findings.length} findings` : 'Findings'}
</Typography>
{analysis.findings.length === 0 ? (
<Typography sx={{ fontSize: 13, color: DT.textSecondary }}>Nothing flagged in this range.</Typography>
) : (
<Stack spacing={1}>
{analysis.findings.map((f) => (
<Finding
key={f.id}
finding={f}
expanded={expandedId === f.id}
onToggle={() => setExpandedId(expandedId === f.id ? null : f.id)}
onHighlight={onHighlight}
/>
))}
</Stack>
)}
</Box>
{/* Zones */}
{analysis.zones.length > 0 && (
<Box>
<Typography sx={{ fontSize: 10.5, fontWeight: 800, letterSpacing: 0.8, textTransform: 'uppercase', color: DT.textMuted, mb: 1 }}>
By zone
</Typography>
<Paper elevation={0} sx={{ borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, bgcolor: '#fff', overflow: 'hidden' }}>
{analysis.zones.map((z, i) => (
<Box key={z.zone}>
{i > 0 && <Divider />}
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 1.5, py: 1 }}>
<Typography sx={{ flex: 1, fontSize: 12.5, fontWeight: 600, color: DT.textPrimary }}>{z.zone}</Typography>
<Typography sx={{ fontFamily: mono, fontSize: 11.5, color: DT.textSecondary }}>
{z.orders} · {z.km} km · ₹{z.charge}
</Typography>
</Stack>
</Box>
))}
</Paper>
</Box>
)}
<Typography sx={{ fontSize: 11, color: DT.textMuted, lineHeight: 1.55 }}>
Computed in the browser from the {summary.orderCount} rows currently loaded
{partial ? ` of ${totalAvailable} matching this filter — scroll the table to load the rest` : ''}
{summary.skew.suspected ? `, slots corrected by ${formatMinutes(summary.skew.suggestedShiftMin)}` : ''}. No model was
called.
</Typography>
</Stack>
)}
</Box>
</Stack>
</Drawer>
);
};
OpsAgentDrawer.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
rows: PropTypes.array,
meta: PropTypes.object,
onHighlight: PropTypes.func,
totalAvailable: PropTypes.number
};
export default OpsAgentDrawer;

View File

@@ -0,0 +1,147 @@
/**
* Smoke test for the Ops Agent drawer.
*
* The rest of this folder tests pure logic, which is the repo's preference —
* but "does the component actually render without throwing" is exactly the gap
* a build cannot close, and this drawer is the only place the analysis is ever
* seen. It needs no react-query, router or notistack provider, so it can be
* mounted directly; there is no @testing-library in this project, so it mounts
* through react-dom/client and reads the DOM.
*
* MUI renders Drawer content into a portal on document.body, so assertions read
* body text rather than the container.
*/
import React from 'react';
import { createRoot } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import OpsAgentDrawer from './OpsAgentDrawer';
global.IS_REACT_ACT_ENVIRONMENT = true;
const order = (orderid, deliverydate, assigntime, deliverytime, extra = {}) => ({
orderid,
tenantname: 'Bawa medical Nagercoil',
rider: 'Sivakumar Subramani',
deliverydate: `2026-09-02T${deliverydate}:00+05:30`,
assigntime: `2026-09-02 ${assigntime}`,
starttime: '',
arrivaltime: '',
pickuptime: '',
deliverytime: `2026-09-02 ${deliverytime}`,
deliverysuburb: 'King Nagar',
...extra
});
// The real 2 Sep day, trimmed to what the drawer renders.
const DAY = [
order('908-20246318', '14:00', '19:20:42', '22:59:24', { kms: '3', deliverycharge: '60', deliverylocation: 'Parvathipuram' }),
order('908-20246317', '11:30', '19:20:42', '22:59:33', { kms: '1', deliverycharge: '60', deliverylocation: 'Puthukudierupu' }),
order('908-20246316', '09:30', '14:36:45', '22:59:42', { kms: '3', deliverycharge: '60', deliverylocation: 'CBH ROAD' }),
order('908-20246315', '07:30', '13:26:51', '22:59:51', { kms: '4', deliverycharge: '60', deliverylocation: 'NGL' }),
order('908-20246313', '07:30', '13:26:51', '22:59:59', { kms: '3', deliverycharge: '60', deliverylocation: 'Erulapappuram' }),
order('908-20246314', '07:30', '13:26:51', '23:00:07', { kms: '2', deliverycharge: '60', deliverylocation: 'Ramavarmapuram' }),
order('908-20246312', '07:30', '12:39:45', '23:00:18', {
kms: '1',
deliverycharge: '60',
deliverylocation: 'Simon Nagar',
deliverysuburb: 'Weavers Colony'
}),
order('908-20246308', '05:30', '11:43:03', '23:00:25', {
kms: '1',
deliverycharge: '60',
deliverylocation: 'ராமவர்மபுரம்',
deliverysuburb: 'Weavers Colony'
}),
order('908-20246309', '06:00', '11:43:03', '23:00:34', { kms: '6', deliverycharge: '84', deliverylocation: 'Erumbukadu (post' }),
order('908-20246310', '06:00', '11:43:03', '23:00:42', { kms: '4', deliverycharge: '60', deliverylocation: 'Kurusady' }),
order('908-20246311', '06:00', '11:43:03', '23:00:50', { kms: '5', deliverycharge: '72', deliverylocation: 'NGL' }),
order('908-20246307', '04:30', '10:04:53', '10:50:56', {
kms: '3',
deliverycharge: '60',
deliverylocation: 'Udhayamarthandam',
starttime: '2026-09-02 10:50:40',
arrivaltime: '2026-09-02 10:33:25',
pickuptime: '2026-09-02 10:33:38'
})
];
let container;
let root;
const mount = (props) => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root.render(<OpsAgentDrawer open onClose={() => {}} rows={DAY} {...props} />);
});
};
afterEach(() => {
if (root) act(() => root.unmount());
if (container) container.remove();
document.body.innerHTML = '';
root = null;
container = null;
});
describe('OpsAgentDrawer', () => {
it('renders the day without throwing and shows the verdict', () => {
mount();
const text = document.body.textContent;
expect(text).toContain('Ops Agent');
expect(text).toContain('12 orders');
expect(text).toContain('1 rider');
// 0 of 12 delivery stamps survive the bulk-close and event-order checks.
expect(text).toContain('0/12');
});
it('renders the headline tiles with the tested figures', () => {
mount();
const text = document.body.textContent;
expect(text).toContain('45%'); // lifecycle capture, 27 of 60
expect(text).toContain('27 of 60 stamps');
expect(text).toContain('+13m'); // median slot to assignment, skew-corrected
expect(text).toContain('₹756');
expect(text).toContain('36 km');
});
it('lists findings with the blocking ones present', () => {
mount();
const text = document.body.textContent;
expect(text).toContain('Blocking');
expect(text).toContain('Delivery timestamps were written in bulk');
expect(text).toContain('Promised slots read exactly one IST offset early');
});
it('rolls up both zones', () => {
mount();
const text = document.body.textContent;
expect(text).toContain('King Nagar');
expect(text).toContain('Weavers Colony');
});
it('expands a finding to reveal its evidence and fix', () => {
mount();
const heading = [...document.querySelectorAll('[role="button"]')].find((el) =>
el.textContent.includes('Delivery timestamps were written in bulk')
);
expect(heading).toBeTruthy();
act(() => {
heading.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(document.body.textContent).toContain('Stamp delivery server-side from the rider action');
});
it('shows an empty state rather than fabricating numbers when there are no rows', () => {
mount({ rows: [] });
expect(document.body.textContent).toContain('No orders in the current filter');
});
it('says plainly that no model was called', () => {
mount();
expect(document.body.textContent).toContain('No model was called');
});
});

View File

@@ -0,0 +1,255 @@
/**
* Weekly intake parser — phase 1.
*
* Reads a sheet against the INTAKE_COLUMNS contract and classifies every row as
* ok / needs-review / rejected, with the reason attached. It writes nothing and
* calls nothing: given rows, it returns a verdict, which is what makes it
* testable and what makes a dry run meaningful.
*
* Two decisions worth knowing:
*
* 1. **Unknown columns are reported, never ignored.** This codebase has already
* been bitten by silent field drift — the API sends `ridername` and
* `deliverycharges` while the CSV export writes `rider` and `deliverycharge`,
* and reading the wrong one fails as a blank or a zero rather than an error.
* A column we do not recognise is surfaced so someone decides about it.
*
* 2. **Rejected vs needs-review is about who can fix it.** Rejected means the
* row cannot become an order at all (no phone, no address, outside the
* service area). Needs-review means it could, but a person should look first.
* Nothing is silently dropped.
*/
import { INTAKE_COLUMNS, REQUIRED_KEYS, headerToKey, normaliseHeader } from './intakeTemplate';
import { hasNonLatinScript } from './opsAnalysis';
export const STATUS = { OK: 'ok', REVIEW: 'needs-review', REJECTED: 'rejected' };
const COLUMN_BY_KEY = INTAKE_COLUMNS.reduce((acc, c) => {
acc[c.key] = c;
return acc;
}, {});
const str = (v) => (v === null || v === undefined ? '' : String(v).trim());
/**
* Strip leading numbering operators paste in from numbered lists — "1. Dhivya",
* "02 Selvi". Same rule as cleanReceiverName in orders/multipleOrders.js.
*/
export const cleanName = (name) => str(name).replace(/^[\d.\s]+/, '').trim();
/**
* Normalise an Indian mobile number to 10 digits, or return null.
*
* Accepts +91/91 prefixes and a leading 0 because both turn up in operator
* spreadsheets. Rejects anything that is not a real mobile — a landline or a
* truncated number would resolve to the wrong customer or none at all, and
* phone is the key customer resolution matches on.
*/
export const normalisePhone = (value) => {
const digits = str(value).replace(/\D/g, '');
let local = digits;
if (local.length === 12 && local.startsWith('91')) local = local.slice(2);
else if (local.length === 11 && local.startsWith('0')) local = local.slice(1);
if (local.length !== 10) return null;
if (!/^[6-9]/.test(local)) return null;
return local;
};
const toNumber = (value, fallback) => {
const s = str(value);
if (s === '') return fallback;
const n = Number(s);
return Number.isFinite(n) ? n : null;
};
/** Great-circle distance in km. Inline so this module stays dependency-free. */
export const haversineKm = (a, b) => {
const R = 6371;
const rad = (d) => (d * Math.PI) / 180;
const dLat = rad(b.lat - a.lat);
const dLng = rad(b.lng - a.lng);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(s));
};
/** A row is blank when every required column is empty — a trailing sheet row. */
const isBlankRow = (mapped) => REQUIRED_KEYS.every((k) => str(mapped[k]) === '');
/**
* Natural key for within-file duplicate detection. Phone plus a loosened
* address: the same customer typed twice rarely matches character for
* character, but almost always matches on digits and squashed letters.
*/
export const rowFingerprint = (normalised) =>
`${normalised.receiverphone || ''}|${str(normalised.receiveraddress).toLowerCase().replace(/[^a-z0-9]/g, '')}`;
/**
* @param {object[]} sheetRows Rows as XLSX.utils.sheet_to_json / Papa.parse produce them.
* @param {object} options
* @param {{lat:number,lng:number}} [options.serviceCentre] Tenant hub, for the radius check.
* @param {number} [options.serviceRadiusKm=25]
* @param {Set<string>} [options.knownRunKeys] Fingerprints already committed in a previous run.
*/
export const parseIntakeFile = (sheetRows, options = {}) => {
const { serviceCentre = null, serviceRadiusKm = 25, knownRunKeys = null } = options;
const source = Array.isArray(sheetRows) ? sheetRows : [];
// With no rows there are no headers to inspect, so we cannot honestly say a
// column is missing — the file is simply empty, and that is the useful thing
// to report. Claiming four missing columns would send someone editing headers
// that may well be correct.
if (!source.length) {
return { missingColumns: [], unknownColumns: [], rows: [], summary: { total: 0, ok: 0, needsReview: 0, rejected: 0 } };
}
const headers = Object.keys(source[0]);
const headerKeys = {};
const unknownColumns = [];
headers.forEach((h) => {
const key = headerToKey(h);
if (key) headerKeys[h] = key;
else if (normaliseHeader(h) !== '') unknownColumns.push(h);
});
const presentKeys = new Set(Object.values(headerKeys));
const missingColumns = REQUIRED_KEYS.filter((k) => !presentKeys.has(k)).map((k) => COLUMN_BY_KEY[k].label);
const seen = new Map();
const rows = [];
source.forEach((raw, i) => {
const mapped = {};
Object.keys(raw).forEach((h) => {
const key = headerKeys[h];
if (key) mapped[key] = raw[h];
});
if (isBlankRow(mapped)) return;
const reasons = [];
const normalised = { notes: str(mapped.notes) };
normalised.receivername = cleanName(mapped.receivername);
if (!normalised.receivername) reasons.push({ level: 'reject', field: 'receivername', message: 'Receiver name is empty.' });
normalised.receiverphone = normalisePhone(mapped.receiverphone);
if (!normalised.receiverphone) {
reasons.push({
level: 'reject',
field: 'receiverphone',
message: str(mapped.receiverphone)
? `"${str(mapped.receiverphone)}" is not a 10-digit Indian mobile.`
: 'Receiver phone is empty.'
});
}
normalised.receiveraddress = str(mapped.receiveraddress);
if (!normalised.receiveraddress) reasons.push({ level: 'reject', field: 'receiveraddress', message: 'Receiver address is empty.' });
normalised.itemdescription = str(mapped.itemdescription);
if (!normalised.itemdescription) reasons.push({ level: 'reject', field: 'itemdescription', message: 'Item description is empty.' });
const qty = toNumber(mapped.quantity, 1);
if (qty === null || !Number.isInteger(qty) || qty < 1) {
reasons.push({ level: 'reject', field: 'quantity', message: `Quantity must be a whole number of 1 or more, got "${str(mapped.quantity)}".` });
normalised.quantity = null;
} else {
normalised.quantity = qty;
}
const cash = toNumber(mapped.collectcash, 0);
if (cash === null || cash < 0) {
reasons.push({ level: 'reject', field: 'collectcash', message: `Collect Cash must be zero or more, got "${str(mapped.collectcash)}".` });
normalised.collectcash = null;
} else {
normalised.collectcash = cash;
}
// Coordinates are optional, but a present-and-wrong pair is worse than none.
const lat = toNumber(mapped.receiverlat, null);
const lng = toNumber(mapped.receiverlong, null);
const hasCoords = lat !== null && lng !== null;
if ((lat === null) !== (lng === null)) {
reasons.push({ level: 'review', field: 'receiverlat', message: 'Only one of latitude/longitude is present; both are needed to route.' });
}
if (hasCoords && (Math.abs(lat) > 90 || Math.abs(lng) > 180)) {
reasons.push({ level: 'reject', field: 'receiverlat', message: `Coordinates ${lat}, ${lng} are not a valid point on earth.` });
} else if (hasCoords && serviceCentre) {
const km = haversineKm(serviceCentre, { lat, lng });
if (km > serviceRadiusKm) {
reasons.push({
level: 'reject',
field: 'receiverlat',
message: `Drop is ${km.toFixed(1)} km from the hub, beyond the ${serviceRadiusKm} km service radius.`
});
}
normalised.distanceKm = Number(km.toFixed(2));
}
normalised.receiverlat = hasCoords ? lat : null;
normalised.receiverlong = hasCoords ? lng : null;
if (!hasCoords) {
reasons.push({ level: 'review', field: 'receiverlat', message: 'No coordinates — the address will need geocoding before routing.' });
}
if (hasNonLatinScript(normalised.receiveraddress) || hasNonLatinScript(normalised.receivername)) {
reasons.push({
level: 'review',
field: 'receiveraddress',
message: 'Contains non-Latin script; the same place may already exist under a Latin spelling.'
});
}
// Duplicates: within this file, and against anything already committed.
const fingerprint = rowFingerprint(normalised);
const sheetRow = i + 2; // 1-based, plus the header row — what the operator sees in Excel
if (normalised.receiverphone) {
if (seen.has(fingerprint)) {
// Cite the spreadsheet row, not the array index. The operator is looking
// at Excel, and a message that disagrees with the Row column is worse
// than no message.
reasons.push({ level: 'review', field: 'receiverphone', message: `Same phone and address as row ${seen.get(fingerprint)}.` });
} else {
seen.set(fingerprint, sheetRow);
}
if (knownRunKeys && knownRunKeys.has(fingerprint)) {
reasons.push({ level: 'reject', field: 'receiverphone', message: 'Already created in an earlier run — this row would be a duplicate order.' });
}
}
const rejected = reasons.some((r) => r.level === 'reject');
rows.push({
index: i,
sheetRow,
raw,
normalised,
fingerprint,
reasons,
status: rejected ? STATUS.REJECTED : reasons.length ? STATUS.REVIEW : STATUS.OK
});
});
return {
missingColumns,
unknownColumns,
rows,
summary: {
total: rows.length,
ok: rows.filter((r) => r.status === STATUS.OK).length,
needsReview: rows.filter((r) => r.status === STATUS.REVIEW).length,
rejected: rows.filter((r) => r.status === STATUS.REJECTED).length
}
};
};
/** Only rows safe to carry into customer resolution. */
export const committableRows = (result) => (result ? result.rows.filter((r) => r.status !== STATUS.REJECTED) : []);
/** One-line summary for a snackbar or a log. */
export const describeResult = (result) => {
if (!result) return 'No file parsed.';
if (result.missingColumns.length) return `Missing required column(s): ${result.missingColumns.join(', ')}.`;
const { total, ok, needsReview, rejected } = result.summary;
if (!total) return 'The file has no data rows.';
return `${total} rows — ${ok} ready, ${needsReview} need review, ${rejected} rejected.`;
};

View File

@@ -0,0 +1,278 @@
/**
* Tests for the weekly intake parser — phase 1.
*
* Rows are built from real deliveries in the 2 Sep 2026 Bawa Medical export,
* re-expressed in the intake template's columns. Real values matter here: the
* awkward cases in this file (a trailing space in a name, a Tamil-script
* locality, an address that is mostly a plus-code) are the ones that actually
* occur in this tenant's data, not ones invented to be awkward.
*/
import {
parseIntakeFile,
normalisePhone,
cleanName,
haversineKm,
rowFingerprint,
committableRows,
describeResult,
STATUS
} from './intakeParser';
import { INTAKE_COLUMNS, REQUIRED_KEYS, headerToKey, templateRows } from './intakeTemplate';
// Bawa Medical, King Nagar — the pickup point on every order in the export.
const HUB = { lat: 8.1841265, lng: 77.4318002 };
const sheet = (over = {}) => ({
'Receiver Name*': 'Dhivya',
'Receiver Phone*': '7358856373',
'Receiver Address*': 'Gopala Krishna Complex, Joshua St, Puthukudierupu, Nagercoil 629001',
'Item Description*': 'Medicines',
Quantity: 1,
'Collect Cash': 0,
'Receiver Latitude': 8.1877496,
'Receiver Longitude': 77.4241515,
Notes: '',
...over
});
const parse = (rows, opts) => parseIntakeFile(rows, { serviceCentre: HUB, ...opts });
// ==========================================================================
// Template contract
// ==========================================================================
describe('intake template', () => {
it('requires exactly the four things a sheet can legitimately assert', () => {
expect(REQUIRED_KEYS).toEqual(['receivername', 'receiverphone', 'receiveraddress', 'itemdescription']);
});
it('does not ask the sheet for sender, date or charge', () => {
const keys = INTAKE_COLUMNS.map((c) => c.key);
expect(keys).not.toContain('sendername');
expect(keys).not.toContain('deliverydate');
expect(keys).not.toContain('deliverycharge');
});
it('emits a header row plus a filled example row', () => {
const [headers, example] = templateRows();
expect(headers).toHaveLength(INTAKE_COLUMNS.length);
expect(headers[0]).toBe('Receiver Name*');
expect(example[0]).toBe('Dhivya');
});
it('accepts its own labels, bare keys, and the report export names', () => {
expect(headerToKey('Receiver Name*')).toBe('receivername');
expect(headerToKey('receivername')).toBe('receivername');
expect(headerToKey(' RECEIVER NAME* ')).toBe('receivername');
expect(headerToKey('deliverycustomer')).toBe('receivername');
expect(headerToKey('deliverycontactno')).toBe('receiverphone');
expect(headerToKey('rider')).toBe('');
});
});
// ==========================================================================
// Field normalisation
// ==========================================================================
describe('normalisePhone', () => {
it('accepts the forms operators actually paste', () => {
expect(normalisePhone('7358856373')).toBe('7358856373');
expect(normalisePhone('+91 73588 56373')).toBe('7358856373');
expect(normalisePhone('91-7358856373')).toBe('7358856373');
expect(normalisePhone('07358856373')).toBe('7358856373');
expect(normalisePhone(7358856373)).toBe('7358856373');
});
it('rejects what would resolve to the wrong customer or none', () => {
expect(normalisePhone('735885637')).toBeNull(); // truncated
expect(normalisePhone('1234567890')).toBeNull(); // not a mobile prefix
expect(normalisePhone('')).toBeNull();
expect(normalisePhone(null)).toBeNull();
});
});
describe('cleanName', () => {
it('strips list numbering and trailing space', () => {
expect(cleanName('1. Dhivya')).toBe('Dhivya');
expect(cleanName('02 Selvi ')).toBe('Selvi');
// Real value from the export — trailing space included.
expect(cleanName('HAMEEZ RAMEEZ ')).toBe('HAMEEZ RAMEEZ');
});
});
describe('haversineKm', () => {
it('measures a known drop against the hub', () => {
// Bawa Medical → Puthukudierupu, recorded as 1 km in the export.
expect(haversineKm(HUB, { lat: 8.1877496, lng: 77.4241515 })).toBeCloseTo(0.9, 0);
});
});
// ==========================================================================
// Row classification
// ==========================================================================
describe('parseIntakeFile — happy path', () => {
it('accepts a complete row with no reasons attached', () => {
const r = parse([sheet()]);
expect(r.missingColumns).toEqual([]);
expect(r.unknownColumns).toEqual([]);
expect(r.summary).toEqual({ total: 1, ok: 1, needsReview: 0, rejected: 0 });
expect(r.rows[0].reasons).toEqual([]);
expect(r.rows[0].normalised).toMatchObject({
receivername: 'Dhivya',
receiverphone: '7358856373',
itemdescription: 'Medicines',
quantity: 1,
collectcash: 0
});
});
it('defaults quantity to 1 and cash to 0 when the columns are blank', () => {
const r = parse([sheet({ Quantity: '', 'Collect Cash': '' })]);
expect(r.rows[0].status).toBe(STATUS.OK);
expect(r.rows[0].normalised.quantity).toBe(1);
expect(r.rows[0].normalised.collectcash).toBe(0);
});
it('reports the Excel row number an operator would look for', () => {
const r = parse([sheet(), sheet({ 'Receiver Phone*': '9443094352' })]);
expect(r.rows.map((x) => x.sheetRow)).toEqual([2, 3]);
});
});
describe('parseIntakeFile — rejections', () => {
const rejectFor = (over) => {
const r = parse([sheet(over)]);
expect(r.rows[0].status).toBe(STATUS.REJECTED);
return r.rows[0].reasons.filter((x) => x.level === 'reject');
};
it('rejects each missing required field, naming it', () => {
expect(rejectFor({ 'Receiver Name*': '' })[0].field).toBe('receivername');
expect(rejectFor({ 'Receiver Address*': '' })[0].field).toBe('receiveraddress');
expect(rejectFor({ 'Item Description*': '' })[0].field).toBe('itemdescription');
});
it('rejects an unusable phone and quotes what was given', () => {
const [reason] = rejectFor({ 'Receiver Phone*': '12345' });
expect(reason.field).toBe('receiverphone');
expect(reason.message).toContain('12345');
});
it('rejects a drop outside the service radius', () => {
// Kanyakumari, ~20 km south — outside a 10 km radius.
const r = parse([sheet({ 'Receiver Latitude': 8.0883, 'Receiver Longitude': 77.5385 })], { serviceRadiusKm: 10 });
expect(r.rows[0].status).toBe(STATUS.REJECTED);
expect(r.rows[0].reasons.some((x) => x.message.includes('service radius'))).toBe(true);
});
it('rejects impossible coordinates', () => {
const reasons = rejectFor({ 'Receiver Latitude': 800, 'Receiver Longitude': 77.4 });
expect(reasons[0].message).toContain('not a valid point');
});
it('rejects a fractional or zero quantity', () => {
expect(rejectFor({ Quantity: 0 })[0].field).toBe('quantity');
expect(rejectFor({ Quantity: 1.5 })[0].field).toBe('quantity');
});
it('rejects negative cash', () => {
expect(rejectFor({ 'Collect Cash': -50 })[0].field).toBe('collectcash');
});
});
describe('parseIntakeFile — needs review', () => {
it('flags a row with no coordinates rather than dropping it', () => {
const r = parse([sheet({ 'Receiver Latitude': '', 'Receiver Longitude': '' })]);
expect(r.rows[0].status).toBe(STATUS.REVIEW);
expect(r.rows[0].reasons.some((x) => x.message.includes('geocoding'))).toBe(true);
});
it('flags a half-populated coordinate pair', () => {
const r = parse([sheet({ 'Receiver Longitude': '' })]);
expect(r.rows[0].reasons.some((x) => x.message.includes('Only one of'))).toBe(true);
});
it('flags non-Latin script, because the same place exists in Latin too', () => {
const r = parse([sheet({ 'Receiver Address*': 'ராமவர்மபுரம், Nagercoil' })]);
expect(r.rows[0].status).toBe(STATUS.REVIEW);
expect(r.rows[0].reasons.some((x) => x.message.includes('non-Latin'))).toBe(true);
});
it('flags a repeat of the same phone and address inside one file', () => {
const r = parse([sheet(), sheet()]);
expect(r.rows[0].status).toBe(STATUS.OK);
expect(r.rows[1].status).toBe(STATUS.REVIEW);
// Cites the spreadsheet row the operator sees, not the array index.
expect(r.rows[1].reasons.some((x) => x.message.includes('row 2'))).toBe(true);
expect(r.rows[1].reasons.some((x) => x.message.includes('row 1'))).toBe(false);
});
it('does not flag the same customer at a genuinely different address', () => {
const r = parse([sheet(), sheet({ 'Receiver Address*': 'Beach Rd, Erulapappuram, Nagercoil' })]);
expect(r.rows.every((x) => x.status === STATUS.OK)).toBe(true);
});
});
// ==========================================================================
// Idempotency and file-level checks
// ==========================================================================
describe('parseIntakeFile — replay protection', () => {
it('rejects a row already committed in an earlier run', () => {
const first = parse([sheet()]);
const known = new Set([first.rows[0].fingerprint]);
const second = parse([sheet()], { knownRunKeys: known });
expect(second.rows[0].status).toBe(STATUS.REJECTED);
expect(second.rows[0].reasons.some((x) => x.message.includes('earlier run'))).toBe(true);
});
it('builds a fingerprint that survives address punctuation and casing', () => {
const a = rowFingerprint({ receiverphone: '7358856373', receiveraddress: 'Joshua St, Nagercoil' });
const b = rowFingerprint({ receiverphone: '7358856373', receiveraddress: 'joshua st nagercoil' });
expect(a).toBe(b);
});
});
describe('parseIntakeFile — file-level problems', () => {
it('names every missing required column', () => {
const r = parse([{ 'Receiver Name*': 'Dhivya', Quantity: 1 }]);
expect(r.missingColumns).toEqual(['Receiver Phone*', 'Receiver Address*', 'Item Description*']);
});
it('surfaces unknown columns instead of silently dropping them', () => {
// The lesson from ridername/deliverycharges: a column we do not recognise
// must be reported, because reading the wrong field fails as a blank.
const r = parse([sheet({ 'Delivery Charge': 60, Rider: 'Sivakumar Subramani' })]);
expect(r.unknownColumns).toEqual(expect.arrayContaining(['Delivery Charge', 'Rider']));
});
it('ignores trailing blank rows', () => {
const r = parse([sheet(), { 'Receiver Name*': '', 'Receiver Phone*': '', 'Receiver Address*': '', 'Item Description*': '' }]);
expect(r.summary.total).toBe(1);
});
it('handles an empty sheet without throwing', () => {
const r = parse([]);
expect(r.summary.total).toBe(0);
expect(describeResult(r)).toBe('The file has no data rows.');
});
});
describe('committableRows and describeResult', () => {
it('carries forward everything except rejections', () => {
const r = parse([sheet(), sheet({ 'Receiver Phone*': 'bad' }), sheet({ 'Receiver Latitude': '', 'Receiver Longitude': '' })]);
const out = committableRows(r);
expect(out).toHaveLength(2);
expect(out.every((x) => x.status !== STATUS.REJECTED)).toBe(true);
});
it('leads with the blocking problem when a column is missing', () => {
expect(describeResult(parse([{ 'Receiver Name*': 'Dhivya' }]))).toContain('Missing required column');
});
it('otherwise counts the three outcomes', () => {
expect(describeResult(parse([sheet(), sheet({ 'Receiver Phone*': 'bad' })]))).toBe('2 rows — 1 ready, 0 need review, 1 rejected.');
});
});

View File

@@ -0,0 +1,129 @@
/**
* The weekly order intake contract.
*
* One column list, and everything downstream derives from it: the downloadable
* template, the parser's validation, and the error messages an operator reads.
* Changing the intake format means changing INTAKE_COLUMNS and nothing else.
*
* What is deliberately NOT here matters as much as what is:
*
* • no sender columns — the pickup location is chosen per batch, and carries
* applocationid / partnerid / locationid / moduleid,
* none of which a spreadsheet should be asserting
* • no delivery date — that comes from the pickup slot chosen for the run.
* `deliverydate` from the API is also timezone-suspect
* (see opsAnalysis.detectOffsetSkew), so inheriting a
* date from a sheet would bake that defect in
* • no charge — computed from tenant pricing and distance at commit
* time. A sheet that can set its own price is a sheet
* that can under-bill.
*
* Header matching uses the same normalisation as the existing bulk uploader in
* orders/multipleOrders.js, so a file that works there still works here.
*/
/** Lowercase, trim, strip all whitespace — matches multipleOrders.js. */
export const normaliseHeader = (header) =>
header === null || header === undefined ? '' : String(header).trim().toLowerCase().replace(/\s+/g, '');
export const INTAKE_COLUMNS = [
{
key: 'receivername',
label: 'Receiver Name*',
required: true,
example: 'Dhivya',
help: 'Person receiving the delivery.'
},
{
key: 'receiverphone',
label: 'Receiver Phone*',
required: true,
example: '7358856373',
help: '10-digit Indian mobile. Used to match an existing customer.'
},
{
key: 'receiveraddress',
label: 'Receiver Address*',
required: true,
example: 'Gopala Krishna Complex, Joshua St, Puthukudierupu, Nagercoil 629001',
help: 'Full delivery address.'
},
{
key: 'itemdescription',
label: 'Item Description*',
required: true,
example: 'Medicines',
help: 'What is being delivered.'
},
{ key: 'quantity', label: 'Quantity', required: false, default: 1, example: '1', help: 'Whole number. Defaults to 1.' },
{
key: 'collectcash',
label: 'Collect Cash',
required: false,
default: 0,
example: '0',
help: 'Amount to collect on delivery. 0 for pay-later.'
},
{ key: 'receiverlat', label: 'Receiver Latitude', required: false, example: '8.1877496', help: 'Optional. Improves routing.' },
{ key: 'receiverlong', label: 'Receiver Longitude', required: false, example: '77.4241515', help: 'Optional. Improves routing.' },
{ key: 'notes', label: 'Notes', required: false, example: '', help: 'Anything the rider should know.' }
];
export const REQUIRED_KEYS = INTAKE_COLUMNS.filter((c) => c.required).map((c) => c.key);
/** Normalised header → column key, for mapping an uploaded sheet's headers. */
export const HEADER_TO_KEY = INTAKE_COLUMNS.reduce((acc, c) => {
acc[normaliseHeader(c.label)] = c.key;
// Accept the bare key too, so a file exported from this system round-trips.
acc[normaliseHeader(c.key)] = c.key;
return acc;
}, {});
/**
* Aliases for headers people actually send, mapped onto our keys.
*
* The report export and the older bulk uploader each use different names for
* the same value; accepting them costs nothing and saves an operator from
* hand-editing a file. Anything not listed here is reported as an unknown
* column rather than silently dropped.
*/
export const HEADER_ALIASES = {
// from the Orders_Detail report export
deliverycustomer: 'receivername',
deliverycontactno: 'receiverphone',
deliveryaddress: 'receiveraddress',
deliverylat: 'receiverlat',
deliverylong: 'receiverlong',
ordernotes: 'notes',
// from orders/multipleOrders.js
'receivername*': 'receivername',
receiverfulladdress: 'receiveraddress',
receiverlatitude: 'receiverlat',
receiverlongitude: 'receiverlong',
'itemdescription*': 'itemdescription',
collectcash: 'collectcash'
};
/** Resolve any incoming header to a column key, or '' if unrecognised. */
export const headerToKey = (header) => {
const n = normaliseHeader(header);
return HEADER_TO_KEY[n] || HEADER_ALIASES[n] || '';
};
/** Header row plus one filled example row, for the downloadable template. */
export const templateRows = () => [INTAKE_COLUMNS.map((c) => c.label), INTAKE_COLUMNS.map((c) => c.example)];
/**
* Write the template to the operator's machine.
*
* Kept as the only impure function in this module and injected with XLSX so the
* contract above stays testable without touching the DOM.
*/
export const downloadTemplate = async (filename = 'Weekly_Order_Intake_Template.xlsx') => {
const XLSX = await import('xlsx');
const sheet = XLSX.utils.aoa_to_sheet(templateRows());
sheet['!cols'] = INTAKE_COLUMNS.map((c) => ({ wch: Math.max(c.label.length + 2, 16) }));
const book = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(book, sheet, 'Orders');
XLSX.writeFile(book, filename);
};

View File

@@ -0,0 +1,528 @@
/**
* Ops analysis — the grounded layer under the ops agent.
*
* Every function here is pure: order rows in, findings out. No React, no
* network, no localStorage. That is deliberate — an LLM answering questions
* about a day's operations must be handed facts that were computed, not facts
* it inferred from raw rows, and those facts have to be testable on their own.
*
* The rule this module exists to enforce: **never silently "correct" data.**
* Where a field looks wrong (see detectOffsetSkew), we detect and report the
* discrepancy and let the caller opt into a corrected view. A normaliser that
* quietly shifted timestamps would double-correct the moment the backend is
* fixed, and nobody would notice.
*/
// Minutes in the IST offset. Used only as the *hypothesis* detectOffsetSkew
// tests against — it is never applied unconditionally.
export const IST_OFFSET_MIN = 330;
// A row with no orderid is the TOTAL row the CSV export appends (see
// reports/ordersDetails.js). It carries column sums, not an order.
export const isOrderRow = (row) => Boolean(row && String(row.orderid || '').trim());
/** Drop export artifacts (TOTAL row, blank rows) from a fetched/parsed set. */
export const onlyOrders = (rows) => (Array.isArray(rows) ? rows.filter(isOrderRow) : []);
/**
* Minutes-since-midnight of the *literal* clock in a timestamp string.
*
* Deliberately ignores any timezone offset. `deliverydate` arrives as
* "2026-09-02T04:30:00+05:30" while `assigntime` arrives naive; comparing the
* instants would bake in the very offset question we are trying to measure, so
* we compare what an operator would read off the screen instead.
*/
export const wallClockMinutes = (value) => {
if (value === null || value === undefined) return null;
const m = /(\d{1,2}):(\d{2})(?::(\d{2}))?/.exec(String(value));
if (!m) return null;
const h = Number(m[1]);
const min = Number(m[2]);
if (h > 23 || min > 59) return null;
return h * 60 + min + Number(m[3] || 0) / 60;
};
/** Seconds-since-midnight, for clustering where second-level gaps matter. */
export const wallClockSeconds = (value) => {
const mins = wallClockMinutes(value);
return mins === null ? null : Math.round(mins * 60);
};
export const formatMinutes = (mins) => {
if (mins === null || mins === undefined || Number.isNaN(mins)) return '—';
const total = Math.round(mins);
const h = Math.floor(total / 60) % 24;
const m = total % 60;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};
export const median = (nums) => {
const xs = nums.filter((n) => typeof n === 'number' && !Number.isNaN(n)).sort((a, b) => a - b);
if (!xs.length) return null;
const mid = Math.floor(xs.length / 2);
return xs.length % 2 ? xs[mid] : (xs[mid - 1] + xs[mid]) / 2;
};
// Wrap a signed minute delta into (-720, 720] so a slot near midnight compared
// against an assignment just after it reads as a small delta, not ~23 hours.
const wrapDelta = (mins) => {
let d = mins;
while (d > 720) d -= 1440;
while (d <= -720) d += 1440;
return d;
};
// The five lifecycle stamps, in the order they must occur.
export const LIFECYCLE_STAGES = ['assigntime', 'starttime', 'arrivaltime', 'pickuptime', 'deliverytime'];
const STAGE_LABEL = {
assigntime: 'assign',
starttime: 'start',
arrivaltime: 'arrival',
pickuptime: 'pickup',
deliverytime: 'delivery'
};
const has = (v) => v !== null && v !== undefined && String(v).trim() !== '';
/**
* The same value arrives under different names depending on where the rows came
* from, and reading the wrong one fails silently as a zero or a blank rather
* than an error. The orders-detail endpoint sends `ridername` and
* `deliverycharges`; the CSV export re-maps them to `rider` and
* `deliverycharge`. Both are in use, so read both.
*/
const FIELD_ALIASES = {
rider: ['ridername', 'rider'],
charge: ['deliverycharges', 'deliverycharge'],
distance: ['kms', 'actualkms'],
zone: ['deliverysuburb', 'locationsuburb'],
locality: ['deliverylocation', 'deliverysuburb']
};
export const readField = (row, key) => {
const names = FIELD_ALIASES[key] || [key];
for (let i = 0; i < names.length; i += 1) {
if (has(row && row[names[i]])) return row[names[i]];
}
return '';
};
// ============================================================================
// Offset skew — is `deliverydate` labelled +05:30 but carrying a UTC clock?
// ============================================================================
/**
* Compares each order's promised slot against when it was actually assigned.
*
* If the backend serialises a UTC wall clock with an IST offset label, every
* slot reads exactly IST_OFFSET_MIN early, so the median (assign − slot) across
* a day lands near +330 minutes with little spread. Real dispatch lateness does
* not cluster that tightly on that specific number.
*
* Returns `suspected: true` only when the median is close to the offset AND the
* spread is tight AND there are enough orders to mean anything — so a genuinely
* chaotic day is not mistaken for a serialisation bug.
*/
export const detectOffsetSkew = (rows, { toleranceMin = 45, minOrders = 5 } = {}) => {
const deltas = [];
onlyOrders(rows).forEach((o) => {
const slot = wallClockMinutes(o.deliverydate);
const assign = wallClockMinutes(o.assigntime);
if (slot === null || assign === null) return;
deltas.push(wrapDelta(assign - slot));
});
const med = median(deltas);
if (med === null || deltas.length < minOrders) {
return { suspected: false, sampleSize: deltas.length, medianDeltaMin: med, spreadMin: null, suggestedShiftMin: 0 };
}
// Spread as median absolute deviation — robust to the one order that really
// was assigned hours late.
const spread = median(deltas.map((d) => Math.abs(d - med)));
const suspected = Math.abs(med - IST_OFFSET_MIN) <= toleranceMin && spread <= toleranceMin;
return {
suspected,
sampleSize: deltas.length,
medianDeltaMin: Math.round(med),
spreadMin: Math.round(spread),
suggestedShiftMin: suspected ? IST_OFFSET_MIN : 0
};
};
/**
* The promised slot in minutes, optionally shifted by a detected skew.
* `shiftMin` should come from detectOffsetSkew().suggestedShiftMin — pass 0 to
* read the field exactly as the API sent it.
*/
export const slotMinutes = (order, shiftMin = 0) => {
const raw = wallClockMinutes(order && order.deliverydate);
return raw === null ? null : (raw + shiftMin + 1440) % 1440;
};
// ============================================================================
// Bulk close — deliveries stamped in a burst rather than as they happened
// ============================================================================
/**
* Finds runs of orders whose delivery stamps sit implausibly close together.
*
* A rider physically cannot complete drops seconds apart, so a tight run is an
* end-of-day close-out. `descendingByOrderId` is the corroborating tell: a
* human clearing a list works down it in one direction.
*/
export const detectBulkClose = (rows, { maxGapSeconds = 25, minClusterSize = 4 } = {}) => {
const stamped = onlyOrders(rows)
.map((o) => ({ orderid: String(o.orderid), seconds: wallClockSeconds(o.deliverytime), raw: o.deliverytime }))
.filter((o) => o.seconds !== null)
.sort((a, b) => a.seconds - b.seconds);
const clusters = [];
let run = [];
const flush = () => {
if (run.length >= minClusterSize) {
const gaps = run.slice(1).map((o, i) => o.seconds - run[i].seconds);
const ids = run.map((o) => o.orderid);
const sortedDesc = ids.every((id, i) => i === 0 || ids[i - 1] >= id);
const sortedAsc = ids.every((id, i) => i === 0 || ids[i - 1] <= id);
clusters.push({
count: run.length,
orderIds: ids,
firstStamp: run[0].raw,
lastStamp: run[run.length - 1].raw,
spanSeconds: run[run.length - 1].seconds - run[0].seconds,
medianGapSeconds: median(gaps),
monotonicByOrderId: sortedDesc || sortedAsc
});
}
run = [];
};
stamped.forEach((o, i) => {
if (i === 0 || o.seconds - stamped[i - 1].seconds <= maxGapSeconds) {
run.push(o);
} else {
flush();
run = [o];
}
});
flush();
return clusters;
};
// ============================================================================
// Lifecycle capture and event ordering
// ============================================================================
export const lifecycleCapture = (rows) => {
const orders = onlyOrders(rows);
const stages = {};
LIFECYCLE_STAGES.forEach((s) => {
stages[s] = orders.filter((o) => has(o[s])).length;
});
const captured = LIFECYCLE_STAGES.reduce((sum, s) => sum + stages[s], 0);
const expected = orders.length * LIFECYCLE_STAGES.length;
return { stages, captured, expected, ratio: expected ? captured / expected : 0, orderCount: orders.length };
};
/** Lifecycle stamps that go backwards in time within a single order. */
export const detectOutOfOrderEvents = (rows) => {
const violations = [];
onlyOrders(rows).forEach((o) => {
const seen = LIFECYCLE_STAGES.map((s) => ({ stage: s, seconds: has(o[s]) ? wallClockSeconds(o[s]) : null })).filter(
(s) => s.seconds !== null
);
for (let i = 1; i < seen.length; i += 1) {
if (seen[i].seconds < seen[i - 1].seconds) {
violations.push({
orderid: String(o.orderid),
earlier: STAGE_LABEL[seen[i].stage],
later: STAGE_LABEL[seen[i - 1].stage],
earlierValue: o[seen[i].stage],
laterValue: o[seen[i - 1].stage]
});
}
}
});
return violations;
};
// ============================================================================
// Locality hygiene
// ============================================================================
/**
* True when a string carries characters outside the Latin blocks. In this data
* that means Tamil, where one locality is written both as ராமவர்மபுரம் and as
* Ramavarmapuram, and the two never match as strings.
*/
export const hasNonLatinScript = (s) => /[^ -ɏ\s\d\p{P}]/u.test(String(s || ''));
const hasNonLatin = hasNonLatinScript;
const unbalanced = (s) => {
const open = (s.match(/[([{]/g) || []).length;
const close = (s.match(/[)\]}]/g) || []).length;
return open !== close;
};
export const scanLocalities = (rows) => {
const values = onlyOrders(rows)
.map((o) => String(readField(o, 'locality')).trim())
.filter(Boolean);
const byLower = {};
values.forEach((v) => {
const k = v.toLowerCase();
byLower[k] = byLower[k] || new Set();
byLower[k].add(v);
});
return {
nonLatin: [...new Set(values.filter(hasNonLatin))],
unbalanced: [...new Set(values.filter(unbalanced))],
caseVariants: Object.values(byLower)
.filter((set) => set.size > 1)
.map((set) => [...set]),
distinct: Object.keys(byLower).length,
total: values.length
};
};
// ============================================================================
// Rollups
// ============================================================================
const num = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
};
export const zoneRollup = (rows) => {
const map = {};
onlyOrders(rows).forEach((o) => {
const key = String(readField(o, 'zone') || 'Unassigned').trim() || 'Unassigned';
map[key] = map[key] || { zone: key, orders: 0, km: 0, charge: 0 };
map[key].orders += 1;
map[key].km += num(readField(o, 'distance'));
map[key].charge += num(readField(o, 'charge'));
});
return Object.values(map)
.map((z) => ({ ...z, kmPerDrop: z.orders ? z.km / z.orders : 0, chargePerDrop: z.orders ? z.charge / z.orders : 0 }))
.sort((a, b) => b.orders - a.orders);
};
/**
* Headline numbers for the drawer.
*
* `trustworthyDeliveryTimes` counts only orders whose delivery stamp is neither
* part of a bulk-close cluster nor contradicted by its own event ordering —
* which is the number an operator actually needs before reading any SLA.
*/
export const summariseDay = (rows) => {
const orders = onlyOrders(rows);
const skew = detectOffsetSkew(rows);
const clusters = detectBulkClose(rows);
const capture = lifecycleCapture(rows);
const outOfOrder = detectOutOfOrderEvents(rows);
const suspect = new Set();
clusters.forEach((c) => c.orderIds.forEach((id) => suspect.add(id)));
outOfOrder.forEach((v) => suspect.add(v.orderid));
const withDelivery = orders.filter((o) => has(o.deliverytime));
const assignDeltas = orders
.map((o) => {
const slot = slotMinutes(o, skew.suggestedShiftMin);
const assign = wallClockMinutes(o.assigntime);
return slot === null || assign === null ? null : wrapDelta(assign - slot);
})
.filter((d) => d !== null);
return {
orderCount: orders.length,
trustworthyDeliveryTimes: withDelivery.filter((o) => !suspect.has(String(o.orderid))).length,
deliveryStampCount: withDelivery.length,
lifecycleRatio: capture.ratio,
lifecycleCaptured: capture.captured,
lifecycleExpected: capture.expected,
// Rounded: the underlying stamps carry seconds, but a delay reported to an
// operator in fractional minutes is noise, not precision.
medianAssignDelayMin: assignDeltas.length ? Math.round(median(assignDeltas)) : null,
totalKm: orders.reduce((s, o) => s + num(readField(o, 'distance')), 0),
totalCharge: orders.reduce((s, o) => s + num(readField(o, 'charge')), 0),
riders: [...new Set(orders.map((o) => String(readField(o, 'rider')).trim()).filter(Boolean))],
skew
};
};
// ============================================================================
// The scan
// ============================================================================
const SEVERITY_ORDER = { blocking: 0, high: 1, medium: 2, low: 3 };
/**
* Runs every check and returns findings ordered by what they block.
*
* Each finding carries `orderIds` so the UI can highlight the rows a claim came
* from. A finding with no evidence it can point at is not worth showing.
*/
export const scanDataQuality = (rows) => {
const orders = onlyOrders(rows);
const findings = [];
if (!orders.length) return findings;
const rawCount = Array.isArray(rows) ? rows.length : 0;
// 1. Bulk-closed deliveries.
detectBulkClose(rows).forEach((c, i) => {
findings.push({
id: `bulk-close-${i}`,
severity: 'blocking',
title: 'Delivery timestamps were written in bulk, not observed',
detail:
`${c.count} orders were stamped delivered inside ${c.spanSeconds} seconds` +
(c.medianGapSeconds !== null ? `, a median of ${Math.round(c.medianGapSeconds)}s apart` : '') +
(c.monotonicByOrderId ? ', running in order-ID sequence' : '') +
'. That is a list being cleared, not deliveries happening. No SLA, ETA accuracy or rider productivity figure can be derived from this column.',
evidence: [`${c.firstStamp} → ${c.lastStamp}`, `${c.count} orders, span ${c.spanSeconds}s`],
orderIds: c.orderIds,
fix: 'Stamp delivery server-side from the rider action, and rate-limit closes per rider per minute.'
});
});
// 2. Timezone skew.
const skew = detectOffsetSkew(rows);
if (skew.suspected) {
findings.push({
id: 'offset-skew',
severity: 'blocking',
title: 'Promised slots read exactly one IST offset early',
detail:
`Across ${skew.sampleSize} orders the median gap from promised slot to assignment is ${skew.medianDeltaMin} minutes ` +
`(spread ±${skew.spreadMin}), which is the +05:30 offset itself. The likeliest cause is a UTC clock serialised with an ` +
'IST label, so every slot in the console reads five and a half hours early. Confirm against the API before trusting either reading.',
evidence: [`median assign − slot = ${skew.medianDeltaMin} min`, `IST offset = ${IST_OFFSET_MIN} min`, `spread ±${skew.spreadMin} min`],
orderIds: orders.map((o) => String(o.orderid)),
fix: 'Serialise as true UTC with Z, or true IST with +05:30 — and normalise on ingest, before anything reads it.'
});
}
// 3. Lifecycle capture.
const capture = lifecycleCapture(rows);
if (capture.ratio < 0.9) {
const missing = LIFECYCLE_STAGES.filter((s) => capture.stages[s] < capture.orderCount);
findings.push({
id: 'lifecycle-gaps',
severity: 'high',
title: `The rider app records ${Math.round(capture.ratio * 100)}% of lifecycle events`,
detail:
`${capture.captured} of ${capture.expected} expected stamps are present. ` +
`Weakest stages: ${missing.map((s) => STAGE_LABEL[s]).join(', ')}. Cycle-time analysis is not available from this feed at any sample size.`,
evidence: LIFECYCLE_STAGES.map((s) => `${STAGE_LABEL[s].padEnd(9)} ${capture.stages[s]}/${capture.orderCount}`),
orderIds: orders.filter((o) => LIFECYCLE_STAGES.some((s) => !has(o[s]))).map((o) => String(o.orderid)),
fix: 'Instrument the intermediate transitions, then reject writes that arrive out of order.'
});
}
// 4. Event ordering.
const violations = detectOutOfOrderEvents(rows);
if (violations.length) {
findings.push({
id: 'event-order',
severity: 'high',
title: 'Lifecycle stamps run backwards within an order',
detail:
`${violations.length} order(s) record a stage before the stage that must precede it. Even where stamps exist, they cannot be ` +
'used to measure duration.',
evidence: violations
.slice(0, 4)
.map((v) => `${v.orderid}: ${v.earlier} ${v.earlierValue} precedes ${v.later} ${v.laterValue}`),
orderIds: violations.map((v) => v.orderid),
fix: 'Add a monotonic-ordering check at write time so an impossible sequence is rejected, not stored.'
});
}
// 5. Export artifacts.
if (rawCount > orders.length) {
findings.push({
id: 'export-artifact',
severity: 'medium',
title: 'The row set contains non-order rows',
detail:
`${rawCount - orders.length} row(s) carry no orderid — the TOTAL row the CSV export appends. Any pipeline reading the file as ` +
'records gains phantom orders, with the column sums as their values.',
evidence: [`${rawCount} rows in, ${orders.length} actual orders`],
orderIds: [],
fix: 'Move totals to a footer sheet, or drop rows with no orderid on ingest.'
});
}
// 6. Locality hygiene.
const loc = scanLocalities(rows);
const localityIssues = [...loc.nonLatin, ...loc.unbalanced, ...loc.caseVariants.map((v) => v.join(' / '))];
if (localityIssues.length) {
findings.push({
id: 'locality-hygiene',
severity: 'medium',
title: 'Locality is free text and does not normalise',
detail:
`${loc.distinct} distinct spellings across ${loc.total} orders. Mixed scripts, unbalanced brackets and case variants all appear. ` +
'Dispatch buckets zones on this family of fields, so the noise reaches the plan.',
evidence: localityIssues.slice(0, 5),
orderIds: onlyOrders(rows)
.filter((o) => localityIssues.some((v) => v.includes(String(readField(o, 'locality')).trim())))
.map((o) => String(o.orderid)),
fix: 'Resolve localities against a gazetteer at order creation; keep the typed string as a display alias.'
});
}
return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
};
// ============================================================================
// Agent hand-off
// ============================================================================
/**
* The compact, pre-computed fact sheet an LLM should be given instead of raw
* rows — small enough to fit a prompt, and already carrying the conclusions
* that arithmetic can reach on its own.
*
* Keeping this separate from any transport is the point: when the ops-agent
* endpoint lands, it POSTs this object. Nothing here needs to change.
*/
export const buildAgentContext = (rows, meta = {}) => {
const summary = summariseDay(rows);
return {
meta: { generatedAt: new Date().toISOString(), ...meta },
summary: {
orders: summary.orderCount,
riders: summary.riders,
trustworthyDeliveryTimes: summary.trustworthyDeliveryTimes,
lifecycleCaptureRatio: Number(summary.lifecycleRatio.toFixed(3)),
medianAssignDelayMin: summary.medianAssignDelayMin,
totalKm: summary.totalKm,
totalCharge: summary.totalCharge,
timestampsTrustworthy: !summary.skew.suspected && summary.trustworthyDeliveryTimes === summary.deliveryStampCount
},
zones: zoneRollup(rows),
findings: scanDataQuality(rows).map(({ id, severity, title, detail, evidence, orderIds }) => ({
id,
severity,
title,
detail,
evidence,
orderCount: orderIds.length
})),
caveats: [
'Delivery timestamps flagged by a bulk-close finding must not be used for duration or SLA claims.',
'Where an offset-skew finding is present, promised slots are ambiguous; state the assumption used.',
'Rollups over a single day and a single tenant are descriptive, not evidence of a trend.'
]
};
};

View File

@@ -0,0 +1,370 @@
/**
* Tests for the ops analysis layer.
*
* The fixture is the real 2 Sep 2026 day for tenant Bawa Medical, Nagercoil,
* taken from the Orders_Detail export — 12 orders, one rider, plus the TOTAL
* row the CSV export appends. Using real data matters here: every detector in
* opsAnalysis.js exists because of something this specific day exposed, and a
* synthetic fixture would let a detector pass while missing the case it was
* written for.
*
* The most important test in this file is the last one in each detector's
* block: once the backend is fixed, the detector must go QUIET. A detector that
* keeps firing after the fix would have its corrections applied twice.
*/
import {
onlyOrders,
wallClockMinutes,
detectOffsetSkew,
detectBulkClose,
lifecycleCapture,
detectOutOfOrderEvents,
scanLocalities,
zoneRollup,
summariseDay,
scanDataQuality,
buildAgentContext,
IST_OFFSET_MIN
} from './opsAnalysis';
// Shape as the Jupiter API sends it: `deliverydate` is an offset-labelled ISO
// string, the lifecycle stamps are naive local strings.
const order = (orderid, deliverydate, assigntime, deliverytime, extra = {}) => ({
orderid,
tenantname: 'Bawa medical Nagercoil',
rider: 'Sivakumar Subramani',
orderstatus: 'delivered',
deliverydate: `2026-09-02T${deliverydate}:00+05:30`,
assigntime: assigntime ? `2026-09-02 ${assigntime}` : '',
starttime: '',
arrivaltime: '',
pickuptime: '',
deliverytime: deliverytime ? `2026-09-02 ${deliverytime}` : '',
canceltime: '',
...extra
});
const KN = { deliverysuburb: 'King Nagar' };
const WC = { deliverysuburb: 'Weavers Colony' };
const DAY = [
order('908-20246318', '14:00', '19:20:42', '22:59:24', { ...KN, kms: '3', deliverycharge: '60', deliverylocation: 'Parvathipuram' }),
order('908-20246317', '11:30', '19:20:42', '22:59:33', { ...KN, kms: '1', deliverycharge: '60', deliverylocation: 'Puthukudierupu' }),
order('908-20246316', '09:30', '14:36:45', '22:59:42', { ...KN, kms: '3', deliverycharge: '60', deliverylocation: 'CBH ROAD' }),
order('908-20246315', '07:30', '13:26:51', '22:59:51', { ...KN, kms: '4', deliverycharge: '60', deliverylocation: 'NGL' }),
order('908-20246313', '07:30', '13:26:51', '22:59:59', { ...KN, kms: '3', deliverycharge: '60', deliverylocation: 'Erulapappuram' }),
order('908-20246314', '07:30', '13:26:51', '23:00:07', { ...KN, kms: '2', deliverycharge: '60', deliverylocation: 'Ramavarmapuram' }),
order('908-20246312', '07:30', '12:39:45', '23:00:18', { ...WC, kms: '1', deliverycharge: '60', deliverylocation: 'Simon Nagar' }),
order('908-20246308', '05:30', '11:43:03', '23:00:25', { ...WC, kms: '1', deliverycharge: '60', deliverylocation: 'ராமவர்மபுரம்' }),
order('908-20246309', '06:00', '11:43:03', '23:00:34', { ...KN, kms: '6', deliverycharge: '84', deliverylocation: 'Erumbukadu (post' }),
order('908-20246310', '06:00', '11:43:03', '23:00:42', { ...KN, kms: '4', deliverycharge: '60', deliverylocation: 'Kurusady' }),
order('908-20246311', '06:00', '11:43:03', '23:00:50', { ...KN, kms: '5', deliverycharge: '72', deliverylocation: 'NGL' }),
// The only order with intermediate stamps — and they contradict each other:
// arrival and pickup both land before start.
order('908-20246307', '04:30', '10:04:53', '10:50:56', {
...KN,
kms: '3',
deliverycharge: '60',
deliverylocation: 'Udhayamarthandam',
starttime: '2026-09-02 10:50:40',
arrivaltime: '2026-09-02 10:33:25',
pickuptime: '2026-09-02 10:33:38'
})
];
// The TOTAL row the CSV export appends: no orderid, carries the column sums.
const TOTAL_ROW = { orderid: '', ordernotes: 'TOTAL', kms: '36', deliverycharge: '756' };
const RAW_EXPORT = [...DAY, TOTAL_ROW];
const clock = (minutes) => {
const m = Math.round(minutes) % 1440;
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00`;
};
/**
* The same day as it would look after the backend is fixed: slots stored in
* IST, deliveries stamped as they happened, and lifecycle stamps in order.
*
* Deliveries are staggered by index so that four orders sharing a 13:00 slot do
* not land on the same second and trip the bulk-close detector for the wrong
* reason — that detector should stay quiet here because the data is genuinely
* fine, not because the fixture dodged it.
*/
const FIXED_DAY = DAY.map((o, i) => {
const slot = (wallClockMinutes(o.deliverydate) + IST_OFFSET_MIN) % 1440;
const delivered = slot + 40 + i * 7;
const intermediates = o.starttime
? { starttime: `2026-09-02 ${clock(slot + 15)}`, arrivaltime: `2026-09-02 ${clock(slot + 30)}`, pickuptime: `2026-09-02 ${clock(slot + 33)}` }
: {};
return {
...o,
deliverydate: `2026-09-02T${clock(slot)}+05:30`,
assigntime: `2026-09-02 ${clock(slot + 10)}`,
deliverytime: `2026-09-02 ${clock(delivered)}`,
...intermediates
};
});
// ==========================================================================
// Row hygiene
// ==========================================================================
describe('onlyOrders', () => {
it('drops the TOTAL row the CSV export appends', () => {
expect(RAW_EXPORT).toHaveLength(13);
expect(onlyOrders(RAW_EXPORT)).toHaveLength(12);
expect(onlyOrders(RAW_EXPORT).every((o) => o.orderid)).toBe(true);
});
it('tolerates a missing or non-array input', () => {
expect(onlyOrders(undefined)).toEqual([]);
expect(onlyOrders(null)).toEqual([]);
});
});
describe('wallClockMinutes', () => {
it('reads the literal clock, ignoring the timezone label', () => {
// The whole offset question depends on NOT converting here.
expect(wallClockMinutes('2026-09-02T04:30:00+05:30')).toBe(270);
expect(wallClockMinutes('2026-09-02 10:04:53')).toBeCloseTo(604.88, 1);
});
it('returns null for empty and unparseable values', () => {
expect(wallClockMinutes('')).toBeNull();
expect(wallClockMinutes(null)).toBeNull();
expect(wallClockMinutes('not a time')).toBeNull();
});
});
// ==========================================================================
// Offset skew
// ==========================================================================
describe('detectOffsetSkew', () => {
it('flags the day: median assign-minus-slot lands on the IST offset', () => {
const skew = detectOffsetSkew(RAW_EXPORT);
expect(skew.suspected).toBe(true);
expect(skew.sampleSize).toBe(12);
// 343 minutes — 13 past the 330-minute offset.
expect(skew.medianDeltaMin).toBe(343);
expect(skew.suggestedShiftMin).toBe(IST_OFFSET_MIN);
});
it('goes quiet once slots are stored in IST', () => {
const skew = detectOffsetSkew(FIXED_DAY);
expect(skew.suspected).toBe(false);
expect(skew.suggestedShiftMin).toBe(0);
});
it('does not fire on a genuinely chaotic day with a wide spread', () => {
const chaotic = DAY.map((o, i) => ({ ...o, assigntime: `2026-09-02 ${String(6 + i).padStart(2, '0')}:00:00` }));
expect(detectOffsetSkew(chaotic).suspected).toBe(false);
});
it('will not judge from too few orders', () => {
expect(detectOffsetSkew(DAY.slice(0, 3)).suspected).toBe(false);
});
});
// ==========================================================================
// Bulk close
// ==========================================================================
describe('detectBulkClose', () => {
it('finds the 23:00 close-out and excludes the one genuine delivery', () => {
const clusters = detectBulkClose(RAW_EXPORT);
expect(clusters).toHaveLength(1);
const [c] = clusters;
expect(c.count).toBe(11);
expect(c.spanSeconds).toBe(86);
expect(c.medianGapSeconds).toBeLessThanOrEqual(11);
// …307 was delivered at 10:50 and must not be swept into the cluster.
expect(c.orderIds).not.toContain('908-20246307');
});
it('goes quiet when deliveries are spread across the day', () => {
expect(detectBulkClose(FIXED_DAY)).toHaveLength(0);
});
it('respects the minimum cluster size so a genuine double-drop is not flagged', () => {
const twoAtOnce = [
order('A-1', '10:00', '09:00:00', '12:00:00'),
order('A-2', '10:00', '09:00:00', '12:00:05'),
order('A-3', '11:00', '09:00:00', '14:30:00')
];
expect(detectBulkClose(twoAtOnce)).toHaveLength(0);
});
});
// ==========================================================================
// Lifecycle
// ==========================================================================
describe('lifecycleCapture', () => {
it('counts stamps per stage across the day', () => {
const cap = lifecycleCapture(RAW_EXPORT);
expect(cap.orderCount).toBe(12);
expect(cap.stages.assigntime).toBe(12);
expect(cap.stages.deliverytime).toBe(12);
expect(cap.stages.starttime).toBe(1);
expect(cap.stages.arrivaltime).toBe(1);
expect(cap.stages.pickuptime).toBe(1);
expect(cap.captured).toBe(27);
expect(cap.expected).toBe(60);
expect(cap.ratio).toBeCloseTo(0.45, 2);
});
});
describe('detectOutOfOrderEvents', () => {
it('catches arrival and pickup recorded before start on …307', () => {
const v = detectOutOfOrderEvents(RAW_EXPORT);
expect(v.length).toBeGreaterThan(0);
expect(v.every((x) => x.orderid === '908-20246307')).toBe(true);
expect(v[0]).toMatchObject({ earlier: 'arrival', later: 'start' });
});
it('is silent on an order whose stamps run forwards', () => {
const clean = [
order('B-1', '10:00', '09:00:00', '11:00:00', {
starttime: '2026-09-02 09:30:00',
arrivaltime: '2026-09-02 09:45:00',
pickuptime: '2026-09-02 09:50:00'
})
];
expect(detectOutOfOrderEvents(clean)).toEqual([]);
});
});
// ==========================================================================
// Hygiene and rollups
// ==========================================================================
describe('scanLocalities', () => {
it('separates script, bracket and case problems', () => {
const loc = scanLocalities(RAW_EXPORT);
expect(loc.nonLatin).toContain('ராமவர்மபுரம்');
expect(loc.unbalanced).toContain('Erumbukadu (post');
expect(loc.total).toBe(12);
});
});
// The orders-detail endpoint and the CSV export disagree on field names. This
// was found by running the drawer against the live console, where every rider
// read as absent and the day settled at ₹0.
describe('field aliases', () => {
const apiShape = DAY.map(({ rider, deliverycharge, ...rest }) => ({
...rest,
ridername: rider,
deliverycharges: deliverycharge
}));
it('reads rider and charge from the API field names', () => {
const s = summariseDay(apiShape);
expect(s.riders).toEqual(['Sivakumar Subramani']);
expect(s.totalCharge).toBe(756);
expect(s.totalKm).toBe(36);
});
it('still reads the CSV export field names', () => {
const s = summariseDay(DAY);
expect(s.riders).toEqual(['Sivakumar Subramani']);
expect(s.totalCharge).toBe(756);
});
it('rolls up zones identically under either shape', () => {
expect(zoneRollup(apiShape)).toEqual(zoneRollup(DAY));
});
});
describe('zoneRollup', () => {
it('rolls up orders, distance and charge per zone', () => {
const zones = zoneRollup(RAW_EXPORT);
const kn = zones.find((z) => z.zone === 'King Nagar');
const wc = zones.find((z) => z.zone === 'Weavers Colony');
expect(kn).toMatchObject({ orders: 10, km: 34, charge: 636 });
expect(wc).toMatchObject({ orders: 2, km: 2, charge: 120 });
expect(kn.kmPerDrop).toBeCloseTo(3.4, 2);
});
it('totals match the figures the export footer claims', () => {
const zones = zoneRollup(RAW_EXPORT);
expect(zones.reduce((s, z) => s + z.km, 0)).toBe(36);
expect(zones.reduce((s, z) => s + z.charge, 0)).toBe(756);
});
});
// ==========================================================================
// Summary and scan
// ==========================================================================
describe('summariseDay', () => {
it('reports no trustworthy delivery times for this day', () => {
const s = summariseDay(RAW_EXPORT);
expect(s.orderCount).toBe(12);
expect(s.deliveryStampCount).toBe(12);
// 11 bulk-closed, and …307 contradicts its own event order.
expect(s.trustworthyDeliveryTimes).toBe(0);
expect(s.riders).toEqual(['Sivakumar Subramani']);
expect(s.totalKm).toBe(36);
expect(s.totalCharge).toBe(756);
});
it('reports assignment as healthy once the skew is accounted for', () => {
const s = summariseDay(RAW_EXPORT);
expect(s.skew.suspected).toBe(true);
expect(s.medianAssignDelayMin).toBe(13);
});
});
describe('scanDataQuality', () => {
it('ranks blocking findings ahead of hygiene', () => {
const findings = scanDataQuality(RAW_EXPORT);
const ids = findings.map((f) => f.id);
expect(ids).toContain('bulk-close-0');
expect(ids).toContain('offset-skew');
expect(findings[0].severity).toBe('blocking');
const severities = findings.map((f) => f.severity);
expect(severities.indexOf('blocking')).toBeLessThan(severities.indexOf('medium'));
});
it('reports the TOTAL row only when it is actually present', () => {
expect(scanDataQuality(RAW_EXPORT).map((f) => f.id)).toContain('export-artifact');
expect(scanDataQuality(DAY).map((f) => f.id)).not.toContain('export-artifact');
});
it('gives every finding order IDs to point at, or none at all', () => {
scanDataQuality(RAW_EXPORT).forEach((f) => {
expect(Array.isArray(f.orderIds)).toBe(true);
expect(typeof f.fix).toBe('string');
expect(f.fix.length).toBeGreaterThan(0);
});
});
it('returns nothing for an empty set', () => {
expect(scanDataQuality([])).toEqual([]);
expect(scanDataQuality([TOTAL_ROW])).toEqual([]);
});
});
describe('buildAgentContext', () => {
it('marks the day untrustworthy and carries caveats the model must respect', () => {
const ctx = buildAgentContext(RAW_EXPORT, { tenant: 'Bawa medical Nagercoil', day: '2026-09-02' });
expect(ctx.summary.timestampsTrustworthy).toBe(false);
expect(ctx.summary.orders).toBe(12);
expect(ctx.meta.tenant).toBe('Bawa medical Nagercoil');
expect(ctx.zones.length).toBe(2);
expect(ctx.caveats.length).toBeGreaterThan(0);
});
it('carries no raw customer rows into the model context', () => {
const ctx = buildAgentContext(RAW_EXPORT);
const serialised = JSON.stringify(ctx);
expect(serialised).not.toContain('deliverycontactno');
expect(serialised).not.toContain('deliveryaddress');
});
it('reports a fixed day as trustworthy', () => {
expect(buildAgentContext(FIXED_DAY).summary.timestampsTrustworthy).toBe(true);
});
});