updates on the address while creasating the orders and security updates as well
This commit is contained in:
21
.env.production
Normal file
21
.env.production
Normal file
@@ -0,0 +1,21 @@
|
||||
# Committed on purpose.
|
||||
#
|
||||
# Every other env file (.env, .env.development, .env.staging) is gitignored,
|
||||
# so a fresh clone or a build host has none of them. A production build then
|
||||
# leaves REACT_APP_URL undefined, every request becomes the relative path
|
||||
# "undefined/orders/..." and silently resolves against whatever origin the
|
||||
# console is served from instead of the API. That shipped once.
|
||||
#
|
||||
# CRA auto-loads this file for `npm run build`, so the build host needs no
|
||||
# manual setup. `.gitignore` excludes .env.production.local but NOT this file,
|
||||
# which is what makes it the right place for these.
|
||||
#
|
||||
# Nothing secret belongs here: these hostnames are already visible in the
|
||||
# shipped JS bundle. Keep credentials in .env.production.local (gitignored).
|
||||
#
|
||||
# env-cmd (build:dev / build:staging) sets process.env before CRA runs, and
|
||||
# CRA's dotenv does not overwrite existing values, so those builds still win.
|
||||
|
||||
REACT_APP_URL=https://jupiter.nearle.app/live/api/v1
|
||||
REACT_APP_URL2=https://jupiter.nearle.app/live/api/v2
|
||||
REACT_APP_URL3=https://jupiter.nearle.app/live/api/v3
|
||||
165
CLAUDE.md
Normal file
165
CLAUDE.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run start:staging # dev server (loads .env.staging via env-cmd)
|
||||
npm run build:staging # production build — USE THIS, see "Environment" below
|
||||
npm test # CRA/Jest, watch mode
|
||||
CI=true npx react-scripts test --watchAll=false # single run
|
||||
CI=true npx react-scripts test --watchAll=false -t "step ordering" # one test by name
|
||||
CI=true npx react-scripts test --watchAll=false --testPathPattern=api # one file
|
||||
```
|
||||
|
||||
Lint is not wired to a script. ESLint is deliberately disabled in the webpack pipeline
|
||||
(`config-overrides.js` filters out `ESLintWebpackPlugin`; `.env` sets `DISABLE_ESLINT_PLUGIN`),
|
||||
so **lint errors never fail a build** — run ESLint explicitly if you need it.
|
||||
|
||||
## Environment — read this before building
|
||||
|
||||
`REACT_APP_URL` (v1) and `REACT_APP_URL2` (v2) are the Jupiter API bases. They live in
|
||||
`.env`, `.env.development`, and `.env.staging`. **All three files are gitignored**, so a
|
||||
fresh clone or a CI/build host has none of them.
|
||||
|
||||
`npm run build` does *not* load the env-cmd files — only `build:dev` / `build:staging` do.
|
||||
CRA auto-loads `.env` alone. A build missing `REACT_APP_URL` does not fail; it inlines
|
||||
`undefined`, every request becomes the *relative* path `"undefined/orders/..."`, and those
|
||||
silently resolve against whatever origin the console is served from. This shipped to
|
||||
production once and looked like "the API base URL changed to the front-end's own host".
|
||||
|
||||
`config-overrides.js` now hard-fails a production build when `REACT_APP_URL`/`REACT_APP_URL2`
|
||||
are missing. Add any new required var to `REQUIRED_PROD_ENV` there.
|
||||
|
||||
**`REACT_APP_URL3` is referenced but defined nowhere** (`Dispatch.js`, the
|
||||
`getdeliverylogs` fetch). Actual-GPS trails and all Compare deltas silently render nothing
|
||||
as a result. Whether that endpoint is v1 or v2 is unconfirmed.
|
||||
|
||||
## Architecture
|
||||
|
||||
CRA + `react-app-rewired` on the **Mantis** MUI template. The template's own pages/theme are
|
||||
mostly inert scaffolding — all real work lives in `src/pages/nearle/`. Data fetching is
|
||||
TanStack Query v5; maps are Leaflet + OSRM. Redux is vestigial — `store/reducers/index.js`
|
||||
combines only `menu` and `snackbar` (the `auth.js` reducer file is not registered), so no
|
||||
domain data flows through it. Server state belongs in TanStack Query.
|
||||
|
||||
### Auth is localStorage, and it is not enforced
|
||||
|
||||
`login.js` writes `tenantid`, `applocationid`, `locationid`, `userid`, `authname` etc. to
|
||||
localStorage; everything else reads them from there. `AuthGuard`/`GuestGuard` exist but are
|
||||
**commented out in both route files**, so every route is publicly reachable. `App.js` does a
|
||||
soft redirect to `/login` when `authname` is absent and clears localStorage after a 1-hour
|
||||
session window.
|
||||
|
||||
### The API layer has a module-load trap
|
||||
|
||||
`pages/nearle/api/api.js` reads `const tenid = localStorage.getItem('tenantid')` **at module
|
||||
scope**. It is captured once on first import, so a login that happens afterwards leaves every
|
||||
`tenid`-using query pinned to the stale value until a full reload. Prefer reading
|
||||
localStorage inside the query function for anything new.
|
||||
|
||||
Several endpoints bypass the env vars and hardcode absolute hosts — the solver calls
|
||||
(`routes.workolik.com`, `routemate.workolik.com`), `finalCreatedeliveries`, and
|
||||
`fetchRidersLogs`. `finalCreatedeliveries` hardcodes the **live** Jupiter host, so a staging
|
||||
build still commits deliveries to production.
|
||||
|
||||
### Dispatch.js has two personalities
|
||||
|
||||
`pages/nearle/dispatch/Dispatch.js` (~6k lines, plus a ~10k-line CSS file) is one component
|
||||
serving two modes, switched by whether a `data` prop is passed:
|
||||
|
||||
| | standalone `/nearle/dispatch` | embedded (`data` passed) |
|
||||
|---|---|---|
|
||||
| source | live API polling | a solver response |
|
||||
| `shouldFetchLive` | `true` | `false` |
|
||||
| default `viewMode` | `riders` | `zones` |
|
||||
| header / batch / date bar | shown | hidden |
|
||||
|
||||
Both paths converge on the same shape: `{ zones: [{ zone_name, riders: [{ rider_id,
|
||||
rider_name, orders[] }] }], zone_summary[], details[] }`. The live path builds it in the
|
||||
`liveData` memo by re-bucketing flat delivery rows by `deliverysuburb`; the embedded path
|
||||
receives it already shaped. **Anything that produces data for Dispatch must match that
|
||||
shape** — that is what `normaliseAssignResponse` in `api.js` exists to guarantee.
|
||||
|
||||
View modes: By Location / By Zone / By Rider / Active / Rider Info. "Active" is the live-ops
|
||||
view — only riders whose latest GPS log is `active`/`pending`, collapsed to their single
|
||||
in-progress delivery, polling deliveries every 15s.
|
||||
|
||||
Orders bucket into three fixed **batches** (Morning <08:00, Afternoon 09:00–12:30, Evening
|
||||
16:00–19:00) on `assigntime`. Gaps between them are intentional. Layout persists under
|
||||
`dispatch.slots.v8`; bump the key and add the old one to `LEGACY_SLOTS_STORAGE_KEYS` when the
|
||||
shape changes. The operator-editable slot editor and the status-wise time-field dropdown are
|
||||
both commented out in the JSX, not deleted.
|
||||
|
||||
Shared constants and pure helpers live in `dispatchShared.js` specifically to avoid a circular
|
||||
import between `Dispatch.js` and its children (`CompareDataPanel`, `ActiveSection`).
|
||||
|
||||
### Assign flow: two clicks, two endpoints
|
||||
|
||||
```
|
||||
/nearle/orders "Assign Orders (N)"
|
||||
→ POST https://routes.workolik.com/api/v1/optimization/nagercoil/riderassign
|
||||
(fixed-rider Nagercoil solver; body {deliveries:[...]}, or {} to let the server
|
||||
pull created orders itself. Empty result → info snackbar, no navigation.)
|
||||
→ navigate('/nearle/dispatch/preview', { state: { dispatchPreviewData, ... } })
|
||||
|
||||
/nearle/dispatch/preview "Assign Orders" ← this is the commit
|
||||
→ POST https://jupiter.nearle.app/live/api/v1/deliveries/createdeliveries
|
||||
→ navigate('/nearle/deliveries')
|
||||
```
|
||||
|
||||
The first click writes nothing — it only produces a preview. `Preview.js` deliberately wipes
|
||||
`history.state.usr` after consuming the response so a reload cannot resurrect a stale plan
|
||||
(it bounces to `/nearle/orders` instead).
|
||||
|
||||
Two de-duplication rules matter here and were both written to fix real duplicate-delivery
|
||||
bugs: `extractRiders` de-dupes by `orderid` across zones (one rider legitimately appears once
|
||||
per delivery suburb), and `applyReconcileResponse` wipes a rider's orders from *every* zone
|
||||
before re-placing them.
|
||||
|
||||
`normaliseAssignResponse` assigns `step` **per rider across the whole response**, before zone
|
||||
bucketing. Numbering within each zone instead would restart the sequence at every suburb and
|
||||
misrepresent the solver's ordering on map pins and route lines.
|
||||
|
||||
## Dead code that looks live
|
||||
|
||||
Several features are fully implemented but unreachable. Do not assume a feature works because
|
||||
the code exists:
|
||||
|
||||
- **Compare mode** (`CompareDataPanel`, compare map, delta engine, Kalman/OSRM track
|
||||
snapping) — `compareOpen` only becomes `true` via `pendingCompareRef.current`, which nothing
|
||||
ever sets. There is no Compare button in the JSX.
|
||||
- **Analysis view** (`#dispatch-analysis`, batch efficiency) — `setTopView` is never called,
|
||||
so `topView` is permanently `'live'`. Its tenant is also hardcoded (`ANALYSIS_TENANT_ID = 916`).
|
||||
- **Preview.js reconcile / re-optimise / CSV export / tuning** — `handleReconcile`,
|
||||
`handleCreateDelivery`, `tabValue`, `csvExportData`, `tuningTypes` and their imports each
|
||||
appear exactly once (their own declaration). The render is now just a header plus an
|
||||
embedded `<Dispatch>`. `reconcileSteps` and `createOptimisationDeliveries` remain wired in
|
||||
`api.js` but nothing invokes them.
|
||||
- **`/nearle/orders/preview`** (`OrdersPreview.js`, ~770 lines) — routed, but nothing
|
||||
navigates to it. Superseded by the dispatch Preview.
|
||||
- **Rider push notification** — `Preview.js` only calls `notifyRider` when
|
||||
`stateData.rider?.userfcmtoken` exists, and `orders.js` never passes a `rider` key. The
|
||||
assigned rider is never notified.
|
||||
|
||||
## Tests
|
||||
|
||||
Two suites, 67 tests:
|
||||
|
||||
- `src/pages/nearle/api/api.test.js` — `normaliseAssignResponse` across every response shape,
|
||||
`createNagercoilDeliveries` request wiring, and the `!data?.details?.length` contract that
|
||||
`orders.js` branches on. `axios` is mocked with a module factory (CRA automock breaks on
|
||||
`response.data`).
|
||||
- `src/utils/route-guard/authState.test.js` — session validity, which gates every protected
|
||||
route. Covers the 1-hour boundary, a missing start time (accepts, rather than logging the
|
||||
user out) and storage throwing (fails closed).
|
||||
|
||||
Component tests do not exist. The large page components have heavy provider requirements
|
||||
(react-query, notistack, router, theme), so prefer testing extracted pure logic in `api.js` /
|
||||
`dispatchShared.js` over mounting them.
|
||||
|
||||
## Conventions
|
||||
|
||||
Prettier: single quotes, no trailing comma, 140 print width, 2-space indent. Files use CRLF —
|
||||
multi-line string matching in scripts needs `\r?\n`.
|
||||
274
IMPLEMENTATION_PLAN.md
Normal file
274
IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Weekly Order Intake — Implementation Plan
|
||||
|
||||
**Status:** Draft, awaiting decisions in §7
|
||||
**Date:** 2026-09-04
|
||||
**Scope:** `dailygrubs_console` (+ one new Jupiter endpoint from phase 2)
|
||||
**Goal:** A weekly spreadsheet of orders is turned into created orders automatically, with agent assistance at the ambiguous steps.
|
||||
|
||||
---
|
||||
|
||||
## 1. Summary
|
||||
|
||||
The requested outcome is achievable, but **not from the file currently being used as the example.**
|
||||
`Orders_Detail_2026-09-02_073552.xlsx` is a *report of orders that already happened*, not an
|
||||
order intake sheet. Phase 0 therefore defines a real intake template; everything else builds on it.
|
||||
|
||||
The build is six phases. **Phases 0–2 write nothing.** The first real order is created in phase 3,
|
||||
behind an operator commit button. Full autonomy is phase 5, after idempotency is proven in phase 4.
|
||||
|
||||
---
|
||||
|
||||
## 2. Why the attached file cannot be the input
|
||||
|
||||
### 2.1 It records outcomes, not intent
|
||||
|
||||
Every distinguishing column is a result: `orderstatus=delivered`, `deliverytime`, `rider`,
|
||||
`assigntime`, `kms`, `deliverycharge`. Re-submitting it would re-create twelve deliveries that
|
||||
were already ridden and already charged.
|
||||
|
||||
### 2.2 It fails the console's existing upload contract
|
||||
|
||||
`src/pages/nearle/orders/multipleOrders.js` maps incoming headers through `headerMap` and marks
|
||||
six columns required (suffix `*`). Measured against the 12 data rows of the export:
|
||||
|
||||
| Upload column | Required | Report field | Filled | Status |
|
||||
|---|---|---|---|---|
|
||||
| `sendername*` | yes | `locationname` | 12/12 | Mappable |
|
||||
| `senderphone*` | yes | `locationcontactno` | 12/12 | Mappable |
|
||||
| `senderaddress*` | yes | `Pickupaddress` | **0/12** | Present but empty |
|
||||
| `receivername*` | yes | `deliverycustomer` | 12/12 | Mappable |
|
||||
| `receiveralternatephone*` | yes | — | — | **Absent** |
|
||||
| `itemdescription*` | yes | — | 1/12 | **Absent** |
|
||||
| `receiverphone` | no | `deliverycontactno` | 12/12 | Mappable |
|
||||
| `receiverfulladdress` | no | `deliveryaddress` | 12/12 | Mappable |
|
||||
| `receiverlatitude` | no | `deliverylat` | 12/12 | Mappable |
|
||||
| `receiverlongitude` | no | `deliverylong` | 12/12 | Mappable |
|
||||
| `pickupdate(yyyy-mmm-dd)` | no | `deliverydate` | 12/12 | Timezone-suspect — see §6 |
|
||||
| `Quantity` | no | — | — | Absent |
|
||||
|
||||
Two required columns are absent outright and one is blank on every row. The *underlying customer
|
||||
data* is all present, so a mapping is possible — it just needs a purpose-built template.
|
||||
|
||||
### 2.3 Orders attach to customer records, not to addresses
|
||||
|
||||
This is the constraint that decides the architecture. `POST /orders/createorders` takes an array
|
||||
in which every element carries `customerid` **and** `deliveryid`. A row of free text is not enough:
|
||||
each line must resolve to an existing customer, or one must be created first. This is why the
|
||||
current UI says *"Press Continue to add as drop customers"* between upload and creation.
|
||||
|
||||
### 2.4 Three values never come from any sheet
|
||||
|
||||
| Value | Source |
|
||||
|---|---|
|
||||
| `applocationid`, `partnerid`, `locationid`, `moduleid`, pickup lat/long | The selected pickup location record |
|
||||
| `deliverytime` | The pickup slot chosen for the batch |
|
||||
| `deliverycharge`, `orderamount`, `ordervalue` | Computed from tenant pricing (`gettenantpricing`) and distance |
|
||||
|
||||
On the sample data the pricing rule fits **₹60 up to 4 km, then ₹12/km** (6 km → ₹84, 5 km → ₹72),
|
||||
but that is *inferred from one day and one tenant*, not read from configuration. Never copy a
|
||||
charge from the sheet.
|
||||
|
||||
> **Consequence:** the weekly file supplies *who and what*. The pickup point, the slot and the
|
||||
> money are supplied by the run. Automation must bind them explicitly rather than inherit them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Where an agent earns its place
|
||||
|
||||
Most of this pipeline is not an AI problem. Parsing, validating, pricing and posting are
|
||||
deterministic and must stay that way — they are the steps where a confident wrong answer costs
|
||||
money.
|
||||
|
||||
| Deterministic — no model | Agent — proposes, never commits |
|
||||
|---|---|
|
||||
| Parse XLSX/CSV, drop `TOTAL` rows | **Customer resolution** — match name + phone + address to an existing record, or propose a new one |
|
||||
| Validate required columns, reject early | **Address normalisation** — split free text into locality / landmark / pincode, including Tamil-script entries |
|
||||
| Compute charge from tenant pricing | **Anomaly triage** — explain in operator language why a row looks wrong |
|
||||
| Duplicate detection by run key | |
|
||||
| Build and POST the `createorders` payload | |
|
||||
| Reuse `scanDataQuality` as a pre-commit gate | |
|
||||
|
||||
**The rule:** the agent returns a proposal with a confidence score and its reasoning.
|
||||
Deterministic code applies a threshold. Anything below it goes to the operator queue rather than
|
||||
into the batch. **A model never writes an order.**
|
||||
|
||||
---
|
||||
|
||||
## 4. Files
|
||||
|
||||
| File | Action |
|
||||
|---|---|
|
||||
| `src/pages/nearle/ai/intakeTemplate.js` | new — column contract + downloadable template |
|
||||
| `src/pages/nearle/ai/intakeParser.js` | new — parse, validate, classify rows |
|
||||
| `src/pages/nearle/ai/intakeParser.test.js` | new |
|
||||
| `src/pages/nearle/ai/customerResolver.js` | new — phone match, agent seam |
|
||||
| `src/pages/nearle/ai/customerResolver.test.js` | new |
|
||||
| `src/pages/nearle/ai/runKey.js` | new — idempotency |
|
||||
| `src/pages/nearle/ai/runKey.test.js` | new |
|
||||
| `src/pages/nearle/ai/buildOrderPayload.js` | new — the `createorders` array |
|
||||
| `src/pages/nearle/ai/buildOrderPayload.test.js` | new |
|
||||
| `src/pages/nearle/orders/WeeklyIntake.js` | new — review & commit screen |
|
||||
| `src/pages/nearle/api/api.js` | modify — `fetchTenantCustomers`, `createOrdersBatch`, `proposeCustomerMatches` |
|
||||
| `src/routes/MainRoutes.js` | modify — add `nearle/orders/weekly-intake` |
|
||||
| `src/menu-items/nearle.js` | modify — menu entry under Orders |
|
||||
|
||||
Existing modules reused: `opsAnalysis.js` (`readField`, `onlyOrders`, `scanDataQuality`),
|
||||
`xlsx` and `papaparse` (already dependencies).
|
||||
|
||||
---
|
||||
|
||||
## 5. Phases
|
||||
|
||||
Each phase is useful on its own. Nothing writes an order until phase 3.
|
||||
|
||||
### Phase 0 — Define the intake template
|
||||
|
||||
`intakeTemplate.js` holds one contract; everything else derives from it.
|
||||
|
||||
```js
|
||||
export const INTAKE_COLUMNS = [
|
||||
{ key: 'receivername', label: 'Receiver Name*', required: true },
|
||||
{ key: 'receiverphone', label: 'Receiver Phone*', required: true },
|
||||
{ key: 'receiveraddress', label: 'Receiver Address*', required: true },
|
||||
{ key: 'itemdescription', label: 'Item Description*', required: true },
|
||||
{ key: 'quantity', label: 'Quantity', required: false, default: 1 },
|
||||
{ key: 'collectcash', label: 'Collect Cash', required: false, default: 0 },
|
||||
{ key: 'receiverlat', label: 'Receiver Latitude', required: false },
|
||||
{ key: 'receiverlong', label: 'Receiver Longitude', required: false },
|
||||
{ key: 'notes', label: 'Notes', required: false }
|
||||
];
|
||||
|
||||
export const downloadTemplate = () => { /* xlsx, already a dependency */ };
|
||||
```
|
||||
|
||||
Note what is deliberately **absent**: no sender columns (that is the batch's pickup location),
|
||||
no delivery date (that is the slot), no charge (computed). Those are the §2.4 values.
|
||||
|
||||
**Acceptance:** template downloads and round-trips through the phase 1 parser with zero findings.
|
||||
**Gate:** nothing else starts until the column list is agreed and one real sample week exists.
|
||||
|
||||
### Phase 1 — Parser and validator, dry run only
|
||||
|
||||
```js
|
||||
parseIntakeFile(sheetRows) -> {
|
||||
missingColumns: string[],
|
||||
unknownColumns: string[], // fail loudly — see §6 "silent field drift"
|
||||
rows: [{ index, raw, normalised, status, reasons: string[] }],
|
||||
summary: { total, ok, needsReview, rejected }
|
||||
}
|
||||
```
|
||||
|
||||
`status` is `'ok' | 'needs-review' | 'rejected'`.
|
||||
|
||||
- **Rejected:** missing required field, unparseable phone, coordinates outside the tenant radius.
|
||||
- **Needs review:** no coordinates, non-Latin locality, suspected duplicate within the same file.
|
||||
|
||||
Pure module, no React and no network, tested the way `opsAnalysis.js` is.
|
||||
|
||||
**Acceptance:** run a real week through it and read the report. Zero writes.
|
||||
|
||||
### Phase 2 — Customer resolution
|
||||
|
||||
```js
|
||||
resolveCustomers(parsedRows, existingCustomers, { proposeFn, threshold = 0.85 })
|
||||
-> [{ index, match: { customerid, confidence, method }, proposedNew }]
|
||||
```
|
||||
|
||||
`method` is `'phone-exact' | 'agent' | 'none'`.
|
||||
|
||||
1. Deterministic exact phone match against `customers/gettenantcustomers` — no model, no cost.
|
||||
2. Only unmatched rows go to `proposeFn`, the injected agent call. Injection keeps tests offline.
|
||||
3. Below `threshold` → `method: 'none'` → operator queue.
|
||||
|
||||
**Acceptance:** measure match accuracy against a week whose correct answers are already known,
|
||||
before resolution is allowed to influence anything.
|
||||
|
||||
### Phase 3 — Review and commit screen
|
||||
|
||||
```js
|
||||
buildCreateOrdersPayload({ resolvedRows, pickupLocation, pickupSlot, pricing }) -> object[]
|
||||
```
|
||||
|
||||
Emits exactly the shape `multipleOrders.js` posts today: `configid: 9`, `paymenttype: 42`,
|
||||
`paymentstatus: 1`, `orderstatus: 'created'`, `deliverytype: 'B'`, `itemcount: 1`, plus
|
||||
`customerid`/`deliveryid` from phase 2 and the location fields from the selected pickup record.
|
||||
|
||||
`WeeklyIntake.js` renders the proposed batch (resolved customer, address, charge, flags), takes
|
||||
the pickup location and slot, and commits via `POST /orders/createorders`.
|
||||
|
||||
**This is the first phase that creates real orders.** Ship behind a role check (§7.5).
|
||||
|
||||
### Phase 4 — Scheduling and idempotency
|
||||
|
||||
```js
|
||||
runKeyFor(row, { tenantid, weekStart })
|
||||
// sha256(tenantid | weekStart | normalisedPhone | normalisedAddress | itemdescription)
|
||||
```
|
||||
|
||||
Persisted server-side. `createorders` performs **no deduplication of its own**, so this layer is
|
||||
the only thing between a retry and double-billing a customer.
|
||||
|
||||
**Acceptance:** deliberately re-run the previous week's file and confirm zero new orders.
|
||||
This is the single most important test in the build.
|
||||
|
||||
### Phase 5 — Supervised autonomy
|
||||
|
||||
Auto-commit only rows that are `ok`, resolved at or above threshold, and unflagged. Everything
|
||||
else waits in the queue and the operator receives a digest rather than a surprise. Requires a
|
||||
kill switch and an audit row per created order naming the run that produced it.
|
||||
|
||||
**Gate:** enable only after several consecutive clean weeks at phase 4.
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks and guardrails
|
||||
|
||||
| Risk | Why it is real here | Guardrail |
|
||||
|---|---|---|
|
||||
| **Duplicate orders** | Same file re-sent, or a retry after timeout. `createorders` does not dedupe. | Persisted run key per row; replay is a no-op (phase 4). |
|
||||
| **Wrong customer** | Fuzzy names (`HAMEEZ RAMEEZ ` has a trailing space); one locality appears as both `ராமவர்மபுரம்` and `Ramavarmapuram`. | Exact phone match first; agent proposals below threshold never auto-commit. |
|
||||
| **Bad geocode** | Addresses carry plus-codes and free text; the pickup store itself resolves to two precisions (`8.1841265` and `8.184126`). | Reject rows whose coordinates fall outside the tenant's service radius. |
|
||||
| **Wrong slot / date** | The `deliverydate` timezone defect is unresolved and reads 5½ hours early. | Operator sets the slot per batch; never inherit a date from a sheet until the backend is fixed. |
|
||||
| **Silent field drift** | Already observed in this codebase: the API sends `ridername`/`deliverycharges` while the CSV export writes `rider`/`deliverycharge`. Both fail as blanks and zeros, not errors. | Validate the parsed shape; fail loudly on unknown or missing columns. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Open decisions
|
||||
|
||||
Defaults below are what the plan currently assumes. Only #1 changes the design materially.
|
||||
|
||||
1. **What is actually in the weekly file** — recurring standing orders for the same customers, or
|
||||
a fresh list each week?
|
||||
*Assumed: fresh list.* Standing orders would make phase 2 nearly free.
|
||||
2. **One pickup location or several** — this tenant has two (Bawa Medical / King Nagar, and
|
||||
Bawaa Medicals 2 / Weavers Colony). If one file mixes both, the template needs a per-row sender
|
||||
column instead of one choice per batch.
|
||||
*Assumed: one location per batch, operator-selected.*
|
||||
3. **How the file arrives** — console upload is simplest and needs no new infrastructure; a
|
||||
watched folder or mailbox is more automatic and more to build and secure.
|
||||
*Assumed: console upload.*
|
||||
4. **Where the agent runs** — inside `backend_jupiter`, or a separate DailyGrubs service it calls.
|
||||
Phases 0–1 do not depend on the answer.
|
||||
*Assumed: a new Jupiter endpoint.*
|
||||
5. **Who may commit a batch** — auth in this console is localStorage, and `AuthGuard`/`GuestGuard`
|
||||
are currently commented out in both route files. A screen that creates real orders needs this
|
||||
settled first.
|
||||
*Assumed: a role check, to be specified.*
|
||||
|
||||
---
|
||||
|
||||
## 8. Sequencing note
|
||||
|
||||
Phases 0–2 write nothing and depend on none of the open decisions except #1, so they can begin
|
||||
immediately. Phase 3 must not ship before decision #5.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — sources
|
||||
|
||||
- Upload contract: `src/pages/nearle/orders/multipleOrders.js` (`headerMap`, `handleFileDirectUpload`)
|
||||
- Payload shape: `createorders()` in the same file → `POST ${REACT_APP_URL}/orders/createorders`
|
||||
- Customer lookup: `customers/gettenantcustomers`, `customers/search`
|
||||
- Pricing: `tenants/gettenantpricing`
|
||||
- Data-quality findings reused from: `src/pages/nearle/ai/opsAnalysis.js`
|
||||
- Sample analysed: `Orders_Detail_2026-09-02_073552.xlsx` — 13 rows, 12 orders plus one `TOTAL` row
|
||||
430
ai-agent-demo.html
Normal file
430
ai-agent-demo.html
Normal file
@@ -0,0 +1,430 @@
|
||||
<title>Dispatch Ops Agent</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap">
|
||||
<style>
|
||||
:root{
|
||||
--paper:#f2f5f4; --surface:#ffffff; --surface-2:#e9edec; --sunken:#dfe5e3;
|
||||
--ink:#12201e; --ink-2:#41534f; --ink-3:#6d807c; --rule:#d3dbd9; --rule-2:#c0cbc8;
|
||||
--accent:#0a6b62; --accent-soft:#d9eae7; --on-accent:#ffffff;
|
||||
--crit:#b3261e; --crit-soft:#fae3e1; --warn:#9a5b00; --warn-soft:#f8e9d2;
|
||||
--ok:#2c6b45; --ok-soft:#dcece2;
|
||||
--shadow:0 1px 2px rgba(18,32,30,.06), 0 6px 18px -10px rgba(18,32,30,.28);
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--paper:#0e1615; --surface:#152220; --surface-2:#1d2c29; --sunken:#0a110f;
|
||||
--ink:#e6ecea; --ink-2:#a6b5b1; --ink-3:#7b8b87; --rule:#263936; --rule-2:#334a46;
|
||||
--accent:#4fbfaf; --accent-soft:#12312d; --on-accent:#06201d;
|
||||
--crit:#f08a80; --crit-soft:#3a1a17; --warn:#e0a558; --warn-soft:#33240f;
|
||||
--ok:#79c79a; --ok-soft:#122e20;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--paper:#0e1615; --surface:#152220; --surface-2:#1d2c29; --sunken:#0a110f;
|
||||
--ink:#e6ecea; --ink-2:#a6b5b1; --ink-3:#7b8b87; --rule:#263936; --rule-2:#334a46;
|
||||
--accent:#4fbfaf; --accent-soft:#12312d; --on-accent:#06201d;
|
||||
--crit:#f08a80; --crit-soft:#3a1a17; --warn:#e0a558; --warn-soft:#33240f;
|
||||
--ok:#79c79a; --ok-soft:#122e20;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{
|
||||
margin:0; background:var(--paper); color:var(--ink);
|
||||
font-family:"IBM Plex Sans",system-ui,-apple-system,"Segoe UI",sans-serif;
|
||||
font-size:14px; line-height:1.55; -webkit-font-smoothing:antialiased;
|
||||
}
|
||||
h1,h2,h3{font-family:Archivo,"IBM Plex Sans",system-ui,sans-serif; text-wrap:balance; margin:0}
|
||||
.mono{font-family:"IBM Plex Mono",ui-monospace,"Cascadia Mono",monospace; font-variant-numeric:tabular-nums}
|
||||
code{font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:.92em}
|
||||
.shell{max-width:1280px; margin:0 auto; padding:0 22px 60px}
|
||||
|
||||
.top{border-bottom:1px solid var(--rule); background:var(--surface)}
|
||||
.top-in{max-width:1280px; margin:0 auto; padding:16px 22px; display:flex; flex-wrap:wrap; gap:18px; align-items:center; justify-content:space-between}
|
||||
.brand{display:flex; align-items:center; gap:11px}
|
||||
.mark{width:30px;height:30px;border-radius:8px;background:var(--accent);display:grid;place-items:center;color:var(--on-accent);font-family:Archivo;font-weight:700;font-size:12px;flex:none}
|
||||
.brand b{font-family:Archivo;font-weight:700;font-size:15px;letter-spacing:-.01em;display:block;line-height:1.25}
|
||||
.brand span{color:var(--ink-3);font-size:11px;letter-spacing:.06em;text-transform:uppercase}
|
||||
.ctx{display:flex;flex-wrap:wrap;margin:0;border:1px solid var(--rule);border-radius:9px;overflow:hidden;background:var(--surface)}
|
||||
.ctx>div{padding:5px 13px;border-left:1px solid var(--rule)}
|
||||
.ctx>div:first-child{border-left:0}
|
||||
.ctx dt{font-size:9.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--ink-3);margin:0}
|
||||
.ctx dd{margin:1px 0 0;font-size:12.5px;font-weight:500}
|
||||
|
||||
.banner{display:block;margin:20px 0 24px;padding:11px 14px;border:1px solid var(--rule-2);border-radius:9px;background:var(--surface-2);color:var(--ink-2);font-size:12.5px}
|
||||
.banner b{color:var(--ink)}
|
||||
|
||||
.verdict{margin-bottom:26px;border:1px solid var(--rule);border-left:4px solid var(--crit);border-radius:10px;background:var(--surface);box-shadow:var(--shadow);overflow:hidden}
|
||||
.verdict-hd{padding:17px 20px 15px}
|
||||
.verdict-hd h1{font-size:23px;letter-spacing:-.015em;line-height:1.25;max-width:32ch}
|
||||
.verdict-hd p{margin:8px 0 0;color:var(--ink-2);max-width:74ch}
|
||||
.tiles{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--rule)}
|
||||
.tile{padding:12px 20px 14px;border-left:1px solid var(--rule)}
|
||||
.tile:first-child{border-left:0}
|
||||
.tile .k{font-size:9.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--ink-3)}
|
||||
.tile .v{font-family:Archivo;font-weight:600;font-size:26px;letter-spacing:-.02em;line-height:1.2;margin-top:2px;font-variant-numeric:tabular-nums}
|
||||
.tile .v em{font-style:normal;font-size:15px;color:var(--ink-3)}
|
||||
.tile .s{font-size:11.5px;color:var(--ink-3)}
|
||||
.v.bad{color:var(--crit)} .v.warnc{color:var(--warn)} .v.okc{color:var(--ok)}
|
||||
@media(max-width:820px){.tiles{grid-template-columns:repeat(2,1fr)}.tile:nth-child(3){border-left:0}.tile:nth-child(n+3){border-top:1px solid var(--rule)}}
|
||||
|
||||
.cols{display:grid;grid-template-columns:minmax(0,1fr) 370px;gap:26px;align-items:start}
|
||||
@media(max-width:1080px){.cols{grid-template-columns:minmax(0,1fr)}}
|
||||
section{margin-bottom:30px}
|
||||
.eyebrow{font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);font-weight:600;font-family:Archivo}
|
||||
h2{font-size:17px;letter-spacing:-.01em;margin:3px 0}
|
||||
.sub{color:var(--ink-2);font-size:13px;margin:5px 0 14px;max-width:74ch}
|
||||
|
||||
.finds{display:flex;flex-direction:column;gap:1px;background:var(--rule);border:1px solid var(--rule);border-radius:10px;overflow:hidden}
|
||||
.find{background:var(--surface);border:0;border-left:3px solid var(--rule-2);padding:13px 16px;text-align:left;width:100%;font:inherit;color:inherit;cursor:pointer;display:block;transition:background .13s}
|
||||
.find:hover{background:var(--surface-2)}
|
||||
.find:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.find[data-sev="crit"]{border-left-color:var(--crit)}
|
||||
.find[data-sev="high"]{border-left-color:var(--warn)}
|
||||
.find[data-sev="med"]{border-left-color:var(--ink-3)}
|
||||
.find[aria-pressed="true"]{background:var(--surface-2)}
|
||||
.find-top{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
|
||||
.sev{font-family:"IBM Plex Mono",monospace;font-size:9.5px;font-weight:600;letter-spacing:.08em;padding:2px 6px;border-radius:4px;text-transform:uppercase;flex:none}
|
||||
.sev.crit{background:var(--crit-soft);color:var(--crit)}
|
||||
.sev.high{background:var(--warn-soft);color:var(--warn)}
|
||||
.sev.med{background:var(--surface-2);color:var(--ink-2)}
|
||||
.find h3{font-size:14px;font-weight:600;flex:1;min-width:220px}
|
||||
.find p{margin:7px 0 0;color:var(--ink-2);font-size:12.8px;max-width:78ch}
|
||||
.ev{margin-top:9px;padding:8px 10px;background:var(--sunken);border-radius:6px;font-family:"IBM Plex Mono",monospace;font-size:11.5px;line-height:1.6;color:var(--ink-2);overflow-x:auto;white-space:pre;font-variant-numeric:tabular-nums}
|
||||
.fix{margin-top:9px;font-size:12.5px;color:var(--ink)}
|
||||
.fix b{font-family:Archivo;font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--accent);margin-right:8px}
|
||||
|
||||
.chartwrap{border:1px solid var(--rule);border-radius:10px;background:var(--surface);padding:13px 8px 4px;overflow-x:auto;box-shadow:var(--shadow)}
|
||||
.legend{display:flex;gap:15px;flex-wrap:wrap;padding:0 12px 9px;font-size:11.5px;color:var(--ink-2)}
|
||||
.legend i{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;vertical-align:-1px}
|
||||
.legend i.hollow{background:var(--paper);box-shadow:inset 0 0 0 1.6px var(--ink-3)}
|
||||
|
||||
.tblwrap{border:1px solid var(--rule);border-radius:10px;overflow:auto;background:var(--surface);box-shadow:var(--shadow);max-height:520px}
|
||||
table{border-collapse:collapse;width:100%;min-width:740px;font-size:12.5px}
|
||||
th{font-family:Archivo;font-weight:600;font-size:9.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--ink-3);text-align:left;padding:9px 12px;border-bottom:1px solid var(--rule);background:var(--surface-2);position:sticky;top:0;white-space:nowrap}
|
||||
th.num{text-align:right}
|
||||
td{padding:8px 12px;border-bottom:1px solid var(--rule);white-space:nowrap}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.num{text-align:right;font-variant-numeric:tabular-nums}
|
||||
tbody tr{transition:background .18s}
|
||||
tbody tr.hl{background:var(--accent-soft)}
|
||||
.chip{display:inline-block;font-family:"IBM Plex Mono",monospace;font-size:10.5px;padding:1px 6px;border-radius:4px;background:var(--surface-2);color:var(--ink-2)}
|
||||
.chip.bad{background:var(--crit-soft);color:var(--crit)}
|
||||
.chip.good{background:var(--ok-soft);color:var(--ok)}
|
||||
.dim{color:var(--ink-3)}
|
||||
|
||||
.agent{position:sticky;top:16px;border:1px solid var(--rule);border-radius:12px;background:var(--surface);box-shadow:var(--shadow);overflow:hidden;display:flex;flex-direction:column;max-height:calc(100vh - 32px)}
|
||||
.agent-hd{padding:11px 15px;border-bottom:1px solid var(--rule);background:var(--surface-2);display:flex;gap:9px;align-items:center}
|
||||
.pulse{width:7px;height:7px;border-radius:50%;background:var(--ok);flex:none;box-shadow:0 0 0 3px var(--ok-soft)}
|
||||
.agent-hd b{font-family:Archivo;font-size:13px;font-weight:600;display:block;line-height:1.3}
|
||||
.agent-hd span{font-size:11px;color:var(--ink-3)}
|
||||
.thread{padding:14px 15px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;flex:1;min-height:300px}
|
||||
.msg{font-size:13px;line-height:1.55}
|
||||
.msg.you{align-self:flex-end;max-width:88%;background:var(--accent);color:var(--on-accent);padding:7px 12px;border-radius:12px 12px 3px 12px}
|
||||
.msg.bot .who{font-family:Archivo;font-size:9.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--accent);font-weight:600;margin-bottom:5px}
|
||||
.msg.bot p{margin:0 0 8px}
|
||||
.msg.bot p:last-of-type{margin-bottom:0}
|
||||
.msg.bot code{background:var(--sunken);padding:1px 4px;border-radius:3px}
|
||||
.cite{margin-top:9px;font-size:11px;color:var(--ink-3);border-left:2px solid var(--rule-2);padding-left:8px;line-height:1.45}
|
||||
.enter{animation:rise .22s ease-out both}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
|
||||
.asks{border-top:1px solid var(--rule);padding:10px 15px 13px;background:var(--surface-2)}
|
||||
.asks .lbl{font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-3);font-family:Archivo;font-weight:600;margin-bottom:8px}
|
||||
.asks .row{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.ask{font:inherit;font-size:12px;padding:5px 10px;border:1px solid var(--rule-2);background:var(--surface);color:var(--ink);border-radius:14px;cursor:pointer;transition:.13s}
|
||||
.ask:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.ask:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.ask[disabled]{opacity:.38;cursor:default;border-color:var(--rule)}
|
||||
|
||||
.build{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;background:var(--rule);border:1px solid var(--rule);border-radius:10px;overflow:hidden}
|
||||
@media(max-width:880px){.build{grid-template-columns:1fr}}
|
||||
.blk{background:var(--surface);padding:15px 17px}
|
||||
.blk .step{font-family:"IBM Plex Mono",monospace;font-size:11px;color:var(--accent);font-weight:600}
|
||||
.blk h3{font-size:14px;font-weight:600;margin:3px 0 9px}
|
||||
.blk ul{margin:0;padding-left:16px;color:var(--ink-2);font-size:12.5px}
|
||||
.blk li{margin-bottom:6px}
|
||||
.blk li:last-child{margin-bottom:0}
|
||||
.blk code{color:var(--ink)}
|
||||
footer{margin-top:28px;padding-top:15px;border-top:1px solid var(--rule);font-size:11.5px;color:var(--ink-3)}
|
||||
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
|
||||
<header class="top">
|
||||
<div class="top-in">
|
||||
<div class="brand">
|
||||
<div class="mark">DG</div>
|
||||
<div><b>Dispatch Ops Agent</b><span>DailyGrubs Console · demo</span></div>
|
||||
</div>
|
||||
<dl class="ctx mono">
|
||||
<div><dt>Tenant</dt><dd>Bawa Medical, Nagercoil</dd></div>
|
||||
<div><dt>Day</dt><dd>2 Sep 2026</dd></div>
|
||||
<div><dt>Riders</dt><dd>1</dd></div>
|
||||
<div><dt>Orders</dt><dd>12</dd></div>
|
||||
<div><dt>Source</dt><dd>Orders_Detail export</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="shell">
|
||||
|
||||
<p class="banner"><b>Demo.</b> Every figure below is computed from your real <code>Orders_Detail_2026-09-02</code> export — 12 orders, one rider, one day. The agent replies are scripted for this walkthrough; nothing calls a model yet. Customer names and phone numbers are left out.</p>
|
||||
|
||||
<div class="verdict">
|
||||
<div class="verdict-hd">
|
||||
<h1>This day’s delivery times were never measured. They were typed in at 23:00.</h1>
|
||||
<p>Eleven of the twelve orders were marked delivered inside an 86-second window, 8–11 seconds apart. The twelfth has real stamps, but they contradict each other. Before an agent can answer anything about lateness, ETAs or rider productivity, this one field has to become real — otherwise it will answer confidently and be wrong.</p>
|
||||
</div>
|
||||
<div class="tiles">
|
||||
<div class="tile"><div class="k">Usable delivery times</div><div class="v bad mono">0<em> / 12</em></div><div class="s">bulk-closed 22:59–23:00</div></div>
|
||||
<div class="tile"><div class="k">Lifecycle capture</div><div class="v warnc mono">45<em>%</em></div><div class="s">27 of 60 expected stamps</div></div>
|
||||
<div class="tile"><div class="k">Slot → assignment</div><div class="v okc mono">+13<em> min</em></div><div class="s">median, after TZ correction</div></div>
|
||||
<div class="tile"><div class="k">Day settled</div><div class="v mono">₹756</div><div class="s">36 km · 2 pickup points</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cols">
|
||||
<div>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Evidence</div>
|
||||
<h2>One day, three clocks</h2>
|
||||
<p class="sub">Promised slot, assignment and delivery for each order. Slots are drawn corrected by +5:30 — see the second finding. The wall on the right is the problem.</p>
|
||||
<div class="chartwrap">
|
||||
<div class="legend">
|
||||
<span><i class="hollow"></i>Promised slot</span>
|
||||
<span><i style="background:var(--accent)"></i>Assigned to rider</span>
|
||||
<span><i style="background:var(--crit)"></i>Marked delivered</span>
|
||||
<span><i style="background:var(--ok)"></i>Genuine lifecycle — 1 order</span>
|
||||
</div>
|
||||
<div id="chart"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Findings — click one to highlight its rows below</div>
|
||||
<h2>What the agent found</h2>
|
||||
<p class="sub">Ranked by what they block, not by how bad they look. The first two invalidate every time-based number in the console; the rest are hygiene that will bite as volume grows.</p>
|
||||
<div class="finds" id="finds"></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Records</div>
|
||||
<h2>The twelve orders</h2>
|
||||
<p class="sub">Δ is assignment minus corrected slot. Stamps counts how many of the five lifecycle events — assign, start, arrival, pickup, delivery — the rider app actually recorded.</p>
|
||||
<div class="tblwrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Order</th><th>Slot IST</th><th>Assigned</th><th class="num">Δ</th><th>Delivered</th>
|
||||
<th>Zone</th><th>Locality</th><th class="num">Km</th><th class="num">₹</th><th>Stamps</th>
|
||||
</tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Getting there</div>
|
||||
<h2>What it takes to make this real</h2>
|
||||
<p class="sub">You already own most of the machinery. The gap is not a model — it is a trustworthy event stream and somewhere in the console to put the answer.</p>
|
||||
<div class="build">
|
||||
<div class="blk">
|
||||
<span class="step">01</span>
|
||||
<h3>Fix the clock first</h3>
|
||||
<ul>
|
||||
<li>Stamp <code>deliverytime</code> server-side from the rider’s delivery action, not from a screen cleared at night</li>
|
||||
<li>Reject more than ~3 closes per rider per minute, and log every rejection</li>
|
||||
<li>Emit <code>deliverydate</code> as true UTC with <code>Z</code>, or true IST with <code>+05:30</code> — not today’s mix of both</li>
|
||||
<li>Backfill nothing. Treat everything before the fix as unmeasured</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="blk">
|
||||
<span class="step">02</span>
|
||||
<h3>Build it inside Jupiter</h3>
|
||||
<ul>
|
||||
<li>One read-only, tool-calling agent that belongs to DailyGrubs — its own service, no borrowed runtime</li>
|
||||
<li>Tools, not raw SQL: <code>get_orders</code>, <code>get_rider_logs</code>, <code>zone_summary</code>, <code>data_quality_scan</code></li>
|
||||
<li>Scope every tool by <code>tenantid</code> server-side, from the session — never from a model argument</li>
|
||||
<li>Keep this day as a regression fixture. The correct answer is “these timestamps are fabricated”</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="blk">
|
||||
<span class="step">03</span>
|
||||
<h3>Land it in the console</h3>
|
||||
<ul>
|
||||
<li>A drawer on <code>/nearle/reports</code> and <code>/nearle/dispatch</code>, opened from the existing header</li>
|
||||
<li>TanStack Query against a new <code>/ai/ask</code> on Jupiter. Never call Anthropic from the browser</li>
|
||||
<li>Every answer cites the orders it read, as in the panel. An uncited claim is a bug</li>
|
||||
<li>Read-only in v1. Reassigning riders is a separate project, with a confirm step</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<div class="agent">
|
||||
<div class="agent-hd">
|
||||
<span class="pulse"></span>
|
||||
<div><b>Ask about this day</b><span>12 orders · 1 rider · read-only</span></div>
|
||||
</div>
|
||||
<div class="thread" id="thread">
|
||||
<div class="msg you">Did we deliver on time yesterday?</div>
|
||||
<div class="msg bot">
|
||||
<div class="who">Ops Agent</div>
|
||||
<p>I can’t tell you, and neither can the report — the delivery times aren’t real.</p>
|
||||
<p>Eleven of the 12 were stamped delivered between <code>22:59:24</code> and <code>23:00:50</code>, 8–11 seconds apart. That is someone closing a list at the end of the day, not eleven deliveries. The twelfth records delivery 16 seconds after its own start.</p>
|
||||
<p>What I can tell you: assignment was healthy. Median gap from slot to assignment was 13 minutes.</p>
|
||||
<div class="cite">Read: 12 rows · deliverytime, assigntime, deliverydate</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asks">
|
||||
<div class="lbl">Try asking</div>
|
||||
<div class="row" id="asks"></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer>Built from <span class="mono">Orders_Detail_2026-09-02_073552</span> · 13 rows in: 12 orders plus one TOTAL row · all times IST</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var O=[
|
||||
{id:"908-20246307",slot:"10:00",assign:"10:04:53",dev:"10:50:56",km:3,rs:60,zone:"King Nagar",loc:"Udhayamarthandam",st:5},
|
||||
{id:"908-20246308",slot:"11:00",assign:"11:43:03",dev:"23:00:25",km:1,rs:60,zone:"Weavers Colony",loc:"ராமவர்மபுரம்",st:2},
|
||||
{id:"908-20246309",slot:"11:30",assign:"11:43:03",dev:"23:00:34",km:6,rs:84,zone:"King Nagar",loc:"Erumbukadu (post",st:2},
|
||||
{id:"908-20246310",slot:"11:30",assign:"11:43:03",dev:"23:00:42",km:4,rs:60,zone:"King Nagar",loc:"Kurusady",st:2},
|
||||
{id:"908-20246311",slot:"11:30",assign:"11:43:03",dev:"23:00:50",km:5,rs:72,zone:"King Nagar",loc:"NGL",st:2},
|
||||
{id:"908-20246312",slot:"13:00",assign:"12:39:45",dev:"23:00:18",km:1,rs:60,zone:"Weavers Colony",loc:"Simon Nagar",st:2},
|
||||
{id:"908-20246313",slot:"13:00",assign:"13:26:51",dev:"22:59:59",km:3,rs:60,zone:"King Nagar",loc:"Erulapappuram",st:2},
|
||||
{id:"908-20246314",slot:"13:00",assign:"13:26:51",dev:"23:00:07",km:2,rs:60,zone:"King Nagar",loc:"Ramavarmapuram",st:2},
|
||||
{id:"908-20246315",slot:"13:00",assign:"13:26:51",dev:"22:59:51",km:4,rs:60,zone:"King Nagar",loc:"NGL",st:2},
|
||||
{id:"908-20246316",slot:"15:00",assign:"14:36:45",dev:"22:59:42",km:3,rs:60,zone:"King Nagar",loc:"CBH ROAD",st:2},
|
||||
{id:"908-20246317",slot:"17:00",assign:"19:20:42",dev:"22:59:33",km:1,rs:60,zone:"King Nagar",loc:"Puthukudierupu",st:2},
|
||||
{id:"908-20246318",slot:"19:30",assign:"19:20:42",dev:"22:59:24",km:3,rs:60,zone:"King Nagar",loc:"Parvathipuram",st:2}
|
||||
];
|
||||
function hrs(t){var p=t.split(":").map(Number);return p[0]+p[1]/60+(p[2]||0)/3600}
|
||||
function dmin(o){return Math.round((hrs(o.assign)-hrs(o.slot))*60)}
|
||||
|
||||
(function(){
|
||||
var W=760,PL=106,PR=728,T0=9.4,T1=23.7,RH=25,TOP=22;
|
||||
var H=TOP+O.length*RH+40;
|
||||
function x(h){return PL+(h-T0)/(T1-T0)*(PR-PL)}
|
||||
var s='<svg viewBox="0 0 '+W+' '+H+'" width="100%" style="display:block;min-width:640px" role="img" aria-label="Timeline showing promised slot, assignment and delivery for twelve orders. All twelve deliveries are stamped just before 23:00.">';
|
||||
for(var h=10;h<=23;h++){
|
||||
s+='<line x1="'+x(h).toFixed(1)+'" y1="'+TOP+'" x2="'+x(h).toFixed(1)+'" y2="'+(TOP+O.length*RH)+'" stroke="var(--rule)" stroke-width="1" fill="none"/>';
|
||||
if(h%2===0||h===23)s+='<text x="'+x(h).toFixed(1)+'" y="'+(TOP+O.length*RH+16)+'" fill="var(--ink-3)" font-size="10" font-family="IBM Plex Mono, monospace" text-anchor="middle">'+String(h)+':00</text>';
|
||||
}
|
||||
O.forEach(function(o,i){
|
||||
var y=TOP+i*RH+RH/2, xs=x(hrs(o.slot)), xa=x(hrs(o.assign)), xd=x(hrs(o.dev)), real=(o.st===5);
|
||||
s+='<text x="'+(PL-11)+'" y="'+(y+3.5)+'" fill="var(--ink-2)" font-size="10.5" font-family="IBM Plex Mono, monospace" text-anchor="end">'+o.id.slice(4)+'</text>';
|
||||
s+='<line x1="'+PL+'" y1="'+y+'" x2="'+PR+'" y2="'+y+'" stroke="var(--rule)" stroke-width="1" stroke-dasharray="2 4" fill="none"/>';
|
||||
s+='<line x1="'+Math.min(xa,xd).toFixed(1)+'" y1="'+y+'" x2="'+Math.max(xa,xd).toFixed(1)+'" y2="'+y+'" stroke="'+(real?'var(--ok)':'var(--crit)')+'" stroke-width="'+(real?2.5:1.5)+'" opacity="'+(real?0.85:0.3)+'" fill="none"/>';
|
||||
s+='<circle cx="'+xs.toFixed(1)+'" cy="'+y+'" r="4" fill="var(--paper)" stroke="var(--ink-3)" stroke-width="1.6"/>';
|
||||
s+='<circle cx="'+xa.toFixed(1)+'" cy="'+y+'" r="4" fill="var(--accent)"/>';
|
||||
s+='<circle cx="'+xd.toFixed(1)+'" cy="'+y+'" r="4.5" fill="'+(real?'var(--ok)':'var(--crit)')+'"/>';
|
||||
});
|
||||
var xw=x(23);
|
||||
s+='<line x1="'+xw.toFixed(1)+'" y1="'+(TOP-4)+'" x2="'+xw.toFixed(1)+'" y2="'+(TOP+O.length*RH+2)+'" stroke="var(--crit)" stroke-width="1" stroke-dasharray="3 3" opacity="0.5" fill="none"/>';
|
||||
s+='<text x="'+(xw-7).toFixed(1)+'" y="'+(TOP-8)+'" fill="var(--crit)" font-size="10" font-family="Archivo, sans-serif" font-weight="600" text-anchor="end">11 deliveries stamped in 86s →</text>';
|
||||
s+='</svg>';
|
||||
document.getElementById("chart").innerHTML=s;
|
||||
})();
|
||||
|
||||
document.getElementById("rows").innerHTML=O.map(function(o){
|
||||
var d=dmin(o), late=Math.abs(d)>30, real=(o.st===5);
|
||||
return '<tr data-id="'+o.id+'"><td class="mono">'+o.id+'</td>'+
|
||||
'<td class="mono dim">'+o.slot+'</td><td class="mono">'+o.assign+'</td>'+
|
||||
'<td class="num mono" style="color:'+(late?'var(--warn)':'var(--ink-2)')+'">'+(d>0?'+':'')+d+'m</td>'+
|
||||
'<td><span class="chip '+(real?'good':'bad')+'">'+o.dev+'</span></td>'+
|
||||
'<td>'+o.zone+'</td><td class="dim">'+o.loc+'</td>'+
|
||||
'<td class="num mono">'+o.km+'</td><td class="num mono">'+o.rs+'</td>'+
|
||||
'<td><span class="chip '+(real?'good':'bad')+'">'+o.st+'/5</span></td></tr>';
|
||||
}).join("");
|
||||
|
||||
var F=[
|
||||
{sev:"crit",t:"Delivery timestamps are written in bulk, not observed",
|
||||
b:"Eleven orders, one 86-second window, spaced 8 to 11 seconds apart — a rider cannot complete drops nine seconds apart. The twelfth is no better: its delivery lands 16 seconds after its own start. No SLA, no ETA accuracy and no rider productivity figure can be derived from this column — and any agent that reads it will report nonsense with total confidence.",
|
||||
e:"22:59:24 …318\n22:59:33 …317 Δ 9s\n22:59:42 …316 Δ 9s\n ⋮ (8 more, 8–11s apart)\n23:00:50 …311",
|
||||
f:"Stamp delivery server-side from the rider’s action. Rate-limit closes per rider per minute.",
|
||||
ids:["908-20246308","908-20246309","908-20246310","908-20246311","908-20246312","908-20246313","908-20246314","908-20246315","908-20246316","908-20246317","908-20246318"]},
|
||||
{sev:"crit",t:"deliverydate is labelled +05:30 but the wall clock is UTC",
|
||||
b:"The raw field reads 04:30 for an order assigned at 10:04. Add 5:30 and every slot lands within minutes of its assignment, so the offset is real and the string is double-labelled. Read as-is, every promised time in the console is five and a half hours early.",
|
||||
e:"raw 2026-09-02T04:30:00+05:30\nassign 10:04:53\n+5:30 10:00 → assigned +5m ✓",
|
||||
f:"Emit true UTC with Z, or true IST with +05:30. Normalise on ingest, before anything reads it.",
|
||||
ids:O.map(function(o){return o.id})},
|
||||
{sev:"high",t:"The rider app records 2 of 5 lifecycle events",
|
||||
b:"start, arrival and pickup are empty on 11 of 12 orders — 27 of 60 expected stamps, 45%. The one order that has them is internally impossible: arrival at 10:33:25 and pickup at 10:33:38 both precede start at 10:50:40, and delivery lands 16 seconds after start. Cycle-time analysis is not available from this feed at any sample size.",
|
||||
e:"assign 12/12\nstart 1/12\narrival 1/12\npickup 1/12\ndelivery 12/12 ← 11 fabricated\n──────────────\ntotal 27/60 = 45%",
|
||||
f:"Instrument the four intermediate transitions, then add a monotonic-ordering check at write time.",
|
||||
ids:["908-20246307"]},
|
||||
{sev:"med",t:"Locality is free text, and one place appears twice in two scripts",
|
||||
b:"ராமவர்மபுரம் and Ramavarmapuram are the same locality. Erumbukadu (post carries an unclosed parenthesis. Casing is inconsistent. Dispatch buckets zones on deliverysuburb, so this leaks straight into the plan.",
|
||||
e:"ராமவர்மபுரம் ≡ Ramavarmapuram\n\"Erumbukadu (post\" unbalanced\nNGL / CBH ROAD casing",
|
||||
f:"Resolve localities against a gazetteer at order creation; keep the typed string as a display alias.",
|
||||
ids:["908-20246308","908-20246314","908-20246309"]},
|
||||
{sev:"med",t:"The export puts a TOTAL row inside the data",
|
||||
b:"Row 14 has a blank orderid and carries the column sums. The sums are right — 36 km and ₹756 — but any pipeline reading the file as records gains a thirteenth phantom order with a 36 km trip. It already fooled the first pass of this analysis.",
|
||||
e:"row 14: orderid = \"\"\n ordernotes = TOTAL\n kms = 36 deliverycharge = 756",
|
||||
f:"Move totals to a footer sheet, or drop rows with no orderid on ingest.",
|
||||
ids:[]},
|
||||
{sev:"med",t:"Pickup geometry is inconsistent for a fixed store",
|
||||
b:"Bawa Medical resolves to both 8.1841265 and 8.184126 — the same point at two precisions — and pickupsuburb is blank on all twelve rows while locationsuburb is populated. Distance and clustering both key off these fields.",
|
||||
e:"8.1841265, 77.4318002 Bawa medical\n8.184126 , 77.4318002 same store\n8.1798997, 77.4195841 Bawaa medicals 2\npickupsuburb blank × 12",
|
||||
f:"Snap pickup coordinates to the stored location record instead of copying them onto each order.",
|
||||
ids:[]}
|
||||
];
|
||||
document.getElementById("finds").innerHTML=F.map(function(f,i){
|
||||
var label=f.sev==="crit"?"blocking":(f.sev==="high"?"high":"medium");
|
||||
return '<button class="find" data-sev="'+f.sev+'" data-i="'+i+'" aria-pressed="false">'+
|
||||
'<span class="find-top"><span class="sev '+f.sev+'">'+label+'</span><h3>'+f.t+'</h3></span>'+
|
||||
'<p>'+f.b+'</p><div class="ev">'+f.e+'</div>'+
|
||||
'<div class="fix"><b>Fix</b>'+f.f+'</div></button>';
|
||||
}).join("");
|
||||
|
||||
var active=-1;
|
||||
document.getElementById("finds").addEventListener("click",function(e){
|
||||
var btn=e.target.closest(".find"); if(!btn)return;
|
||||
var i=+btn.dataset.i, on=(i!==active); active=on?i:-1;
|
||||
document.querySelectorAll(".find").forEach(function(b){b.setAttribute("aria-pressed",String(+b.dataset.i===active))});
|
||||
var set={}; if(on)F[i].ids.forEach(function(id){set[id]=1});
|
||||
document.querySelectorAll("#rows tr").forEach(function(tr){tr.classList.toggle("hl",!!set[tr.dataset.id])});
|
||||
});
|
||||
|
||||
var QA=[
|
||||
{q:"Was the rider actually late?",
|
||||
a:'<p>Not on assignment. Once the timezone is corrected, the median gap from promised slot to assignment is <strong>13 minutes</strong>, and nine of twelve were assigned inside 30.</p>'+
|
||||
'<p>Two ran wide: <code>…317</code> at +140 minutes, and <code>…316</code> assigned 24 minutes <em>before</em> its slot. Both were single-order slots late in the day.</p>'+
|
||||
'<p>Whether the delivery itself was late is unanswerable, for the reason in the first finding.</p>',
|
||||
c:"Read: assigntime, deliverydate corrected +5:30 · 12 rows"},
|
||||
{q:"What is the delivery charge rule?",
|
||||
a:'<p>Nobody told me, but the twelve rows only fit one rule: <strong>₹60 up to 4 km, then ₹12 for each further km.</strong></p>'+
|
||||
'<p>It holds exactly. 6 km → ₹84, 5 km → ₹72, everything else flat ₹60. Day total ₹756 over 36 km, matching the export footer.</p>'+
|
||||
'<p>Treat it as a hypothesis: it comes from one day and one tenant, so a slab above 6 km would be invisible here.</p>',
|
||||
c:"Derived: kms × deliverycharge · 12 rows · no pricing config consulted"},
|
||||
{q:"Which zone costs most per drop?",
|
||||
a:'<p>King Nagar carries 10 of 12 orders, 34 km and ₹636 — <strong>3.4 km and ₹63.60 per drop.</strong></p>'+
|
||||
'<p>Weavers Colony has 2 orders, 2 km and ₹120, both from the second pickup point at Bawaa Medicals 2.</p>'+
|
||||
'<p>I would not act on this. At 12 orders the whole difference is two short trips; ask me again after three weeks.</p>',
|
||||
c:"Grouped: deliverysuburb × kms, deliverycharge · 12 rows"},
|
||||
{q:"What should I fix first?",
|
||||
a:'<p>The delivery timestamp, and nothing else until it is real. Every question you actually want answered — are we late, is one rider enough, which zone is bleeding — reads that field.</p>'+
|
||||
'<p>Second the timezone, because it is a one-line fix that silently corrupts every slot shown in the console.</p>'+
|
||||
'<p>The other four are hygiene. They will hurt at 200 orders a day. They are not hurting at 12.</p>',
|
||||
c:"Ranked by downstream dependency · 6 findings"}
|
||||
];
|
||||
var thread=document.getElementById("thread"), asks=document.getElementById("asks");
|
||||
asks.innerHTML=QA.map(function(x,i){return '<button class="ask" data-i="'+i+'">'+x.q+'</button>'}).join("");
|
||||
asks.addEventListener("click",function(e){
|
||||
var b=e.target.closest(".ask"); if(!b||b.disabled)return;
|
||||
var x=QA[+b.dataset.i]; b.disabled=true;
|
||||
thread.insertAdjacentHTML("beforeend",'<div class="msg you enter">'+x.q+'</div>');
|
||||
thread.scrollTop=thread.scrollHeight;
|
||||
setTimeout(function(){
|
||||
thread.insertAdjacentHTML("beforeend",'<div class="msg bot enter"><div class="who">Ops Agent</div>'+x.a+'<div class="cite">'+x.c+'</div></div>');
|
||||
thread.scrollTop=thread.scrollHeight;
|
||||
},380);
|
||||
});
|
||||
</script>
|
||||
@@ -7,7 +7,7 @@ const WorkBoxPlugin = require('workbox-webpack-plugin');
|
||||
// served from. That produced a deploy where the API base appeared to be the
|
||||
// front-end's own origin. Only enforced for production builds; `npm start`
|
||||
// is left alone.
|
||||
const REQUIRED_PROD_ENV = ['REACT_APP_URL', 'REACT_APP_URL2'];
|
||||
const REQUIRED_PROD_ENV = ['REACT_APP_URL', 'REACT_APP_URL2', 'REACT_APP_URL3'];
|
||||
|
||||
function assertRequiredEnv() {
|
||||
if (process.env.NODE_ENV !== 'production') return;
|
||||
|
||||
@@ -4,75 +4,441 @@ import { Autocomplete, TextField, CircularProgress } from '@mui/material';
|
||||
import { debounce } from '@mui/material/utils';
|
||||
import { OpenLocationCode } from 'open-location-code';
|
||||
|
||||
// Free OSM Nominatim geocoder — replaces Google Places Autocomplete /
|
||||
// react-geocode across the console. No API key required. Nominatim's usage
|
||||
// policy caps the public endpoint at ~1 req/sec, so every caller here is
|
||||
// debounced (500ms) and cancels stale in-flight predictions.
|
||||
// Nagercoil & Kanyakumari District geographic boundary constraints
|
||||
const NAGERCOIL_COORDS = { lat: 8.1833, lng: 77.4119 };
|
||||
const KANYAKUMARI_BOUNDS = {
|
||||
minLat: 8.04,
|
||||
maxLat: 8.42,
|
||||
minLng: 77.15,
|
||||
maxLng: 77.58
|
||||
};
|
||||
const KANYAKUMARI_BBOX_PHOTON = '77.15,8.04,77.58,8.42'; // minLon,minLat,maxLon,maxLat
|
||||
const KANYAKUMARI_VIEWBOX_NOMINATIM = '77.15,8.42,77.58,8.04'; // left,top,right,bottom
|
||||
|
||||
const PHOTON_SEARCH_URL = 'https://photon.komoot.io/api';
|
||||
const NOMINATIM_SEARCH_URL = 'https://nominatim.openstreetmap.org/search';
|
||||
const NOMINATIM_REVERSE_URL = 'https://nominatim.openstreetmap.org/reverse';
|
||||
|
||||
// Strict boundary validator ensuring suggestions never show outside Kanyakumari District
|
||||
export const isWithinKanyakumariDistrict = (lat, lon) => {
|
||||
const nLat = Number(lat);
|
||||
const nLon = Number(lon);
|
||||
if (!Number.isFinite(nLat) || !Number.isFinite(nLon)) return false;
|
||||
return (
|
||||
nLat >= KANYAKUMARI_BOUNDS.minLat &&
|
||||
nLat <= KANYAKUMARI_BOUNDS.maxLat &&
|
||||
nLon >= KANYAKUMARI_BOUNDS.minLng &&
|
||||
nLon <= KANYAKUMARI_BOUNDS.maxLng
|
||||
);
|
||||
};
|
||||
|
||||
// Curated Nagercoil & Kanyakumari local areas dictionary for instant 0ms sublocality match
|
||||
const NAGERCOIL_LOCALITIES = [
|
||||
{
|
||||
name: 'Kurusady',
|
||||
display_name: 'Kurusady, Ramanputhoor Road, Nagercoil, Tamil Nadu 629004, India',
|
||||
lat: '8.1691',
|
||||
lon: '77.4247',
|
||||
suburb: 'Kurusady',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629004'
|
||||
},
|
||||
{
|
||||
name: 'Kurusady Junction',
|
||||
display_name: 'Kurusady Junction, Chettikulam - Ramanputhoor Rd, Nagercoil, Tamil Nadu 629004, India',
|
||||
lat: '8.1695',
|
||||
lon: '77.4251',
|
||||
suburb: 'Kurusady',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629004'
|
||||
},
|
||||
{
|
||||
name: 'Ramanputhoor',
|
||||
display_name: 'Ramanputhoor, Nagercoil, Kanyakumari District, Tamil Nadu 629004, India',
|
||||
lat: '8.1685',
|
||||
lon: '77.4265',
|
||||
suburb: 'Ramanputhoor',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629004'
|
||||
},
|
||||
{
|
||||
name: 'Vadasery',
|
||||
display_name: 'Vadasery, Christopher Bus Stand Area, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1906',
|
||||
lon: '77.4325',
|
||||
suburb: 'Vadasery',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Chettikulam',
|
||||
display_name: 'Chettikulam, Nagercoil, Kanyakumari District, Tamil Nadu 629002, India',
|
||||
lat: '8.1755',
|
||||
lon: '77.4290',
|
||||
suburb: 'Chettikulam',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629002'
|
||||
},
|
||||
{
|
||||
name: 'Chettikulam Junction',
|
||||
display_name: 'Chettikulam Junction, Nagercoil, Tamil Nadu 629002, India',
|
||||
lat: '8.1748',
|
||||
lon: '77.4316',
|
||||
suburb: 'Chettikulam',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629002'
|
||||
},
|
||||
{
|
||||
name: 'Parvathipuram',
|
||||
display_name: 'Parvathipuram, Nagercoil, Kanyakumari District, Tamil Nadu 629003, India',
|
||||
lat: '8.1950',
|
||||
lon: '77.4080',
|
||||
suburb: 'Parvathipuram',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629003'
|
||||
},
|
||||
{
|
||||
name: 'Asaripallam',
|
||||
display_name: 'Asaripallam, Medical College Area, Nagercoil, Tamil Nadu 629201, India',
|
||||
lat: '8.1850',
|
||||
lon: '77.3970',
|
||||
suburb: 'Asaripallam',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629201'
|
||||
},
|
||||
{
|
||||
name: 'Vetturnimadam',
|
||||
display_name: 'Vetturnimadam, Nagercoil, Kanyakumari District, Tamil Nadu 629003, India',
|
||||
lat: '8.1920',
|
||||
lon: '77.4190',
|
||||
suburb: 'Vetturnimadam',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629003'
|
||||
},
|
||||
{
|
||||
name: 'Kottar',
|
||||
display_name: 'Kottar, Railway Station Road, Nagercoil, Tamil Nadu 629002, India',
|
||||
lat: '8.1720',
|
||||
lon: '77.4360',
|
||||
suburb: 'Kottar',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629002'
|
||||
},
|
||||
{
|
||||
name: 'Meenakshipuram',
|
||||
display_name: 'Meenakshipuram, Anna Bus Stand Area, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1830',
|
||||
lon: '77.4310',
|
||||
suburb: 'Meenakshipuram',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Suchindram',
|
||||
display_name: 'Suchindram, Temple Road, Kanyakumari District, Tamil Nadu 629704, India',
|
||||
lat: '8.1560',
|
||||
lon: '77.4640',
|
||||
suburb: 'Suchindram',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629704'
|
||||
},
|
||||
{
|
||||
name: 'Chunkankadai',
|
||||
display_name: 'Chunkankadai, St. Xaviers Catholic College Area, Nagercoil, Tamil Nadu 629003, India',
|
||||
lat: '8.2040',
|
||||
lon: '77.3870',
|
||||
suburb: 'Chunkankadai',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629003'
|
||||
},
|
||||
{
|
||||
name: 'Putheri',
|
||||
display_name: 'Putheri, Nagercoil, Kanyakumari District, Tamil Nadu 629001, India',
|
||||
lat: '8.2060',
|
||||
lon: '77.4300',
|
||||
suburb: 'Putheri',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Ozhuginasery',
|
||||
display_name: 'Ozhuginasery, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1880',
|
||||
lon: '77.4410',
|
||||
suburb: 'Ozhuginasery',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Elankadai',
|
||||
display_name: 'Elankadai, Kottar, Nagercoil, Tamil Nadu 629002, India',
|
||||
lat: '8.1630',
|
||||
lon: '77.4420',
|
||||
suburb: 'Elankadai',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629002'
|
||||
},
|
||||
{
|
||||
name: 'Tower Junction',
|
||||
display_name: 'Tower Junction, Clock Tower Area, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1810',
|
||||
lon: '77.4300',
|
||||
suburb: 'Nagercoil Town',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Derik Junction',
|
||||
display_name: 'Derik Junction, Cape Road, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1760',
|
||||
lon: '77.4350',
|
||||
suburb: 'Nagercoil Town',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Collectorate',
|
||||
display_name: 'Collectorate Office Area, Nagercoil, Tamil Nadu 629001, India',
|
||||
lat: '8.1850',
|
||||
lon: '77.4240',
|
||||
suburb: 'Collectorate',
|
||||
city: 'Nagercoil',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629001'
|
||||
},
|
||||
{
|
||||
name: 'Marthandam',
|
||||
display_name: 'Marthandam, Kanyakumari District, Tamil Nadu 629165, India',
|
||||
lat: '8.3033',
|
||||
lon: '77.2286',
|
||||
suburb: 'Marthandam',
|
||||
city: 'Marthandam',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629165'
|
||||
},
|
||||
{
|
||||
name: 'Thuckalay',
|
||||
display_name: 'Thuckalay, Kanyakumari District, Tamil Nadu 629175, India',
|
||||
lat: '8.2500',
|
||||
lon: '77.3300',
|
||||
suburb: 'Thuckalay',
|
||||
city: 'Thuckalay',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629175'
|
||||
},
|
||||
{
|
||||
name: 'Colachel',
|
||||
display_name: 'Colachel, Kanyakumari District, Tamil Nadu 629251, India',
|
||||
lat: '8.1755',
|
||||
lon: '77.2582',
|
||||
suburb: 'Colachel',
|
||||
city: 'Colachel',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629251'
|
||||
},
|
||||
{
|
||||
name: 'Karungal',
|
||||
display_name: 'Karungal, Kanyakumari District, Tamil Nadu 629157, India',
|
||||
lat: '8.2580',
|
||||
lon: '77.2450',
|
||||
suburb: 'Karungal',
|
||||
city: 'Karungal',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629157'
|
||||
},
|
||||
{
|
||||
name: 'Thingalnagar',
|
||||
display_name: 'Thingalnagar (Monday Market), Kanyakumari District, Tamil Nadu 629802, India',
|
||||
lat: '8.2120',
|
||||
lon: '77.2980',
|
||||
suburb: 'Thingalnagar',
|
||||
city: 'Thingalnagar',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629802'
|
||||
},
|
||||
{
|
||||
name: 'Monday Market',
|
||||
display_name: 'Monday Market, Thingalnagar, Kanyakumari District, Tamil Nadu 629802, India',
|
||||
lat: '8.2120',
|
||||
lon: '77.2980',
|
||||
suburb: 'Thingalnagar',
|
||||
city: 'Thingalnagar',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629802'
|
||||
},
|
||||
{
|
||||
name: 'Kanyakumari',
|
||||
display_name: 'Kanyakumari, Beach Road, Tamil Nadu 629702, India',
|
||||
lat: '8.0883',
|
||||
lon: '77.5385',
|
||||
suburb: 'Kanyakumari',
|
||||
city: 'Kanyakumari',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629702'
|
||||
},
|
||||
{
|
||||
name: 'Thovalai',
|
||||
display_name: 'Thovalai, Flower Market Area, Kanyakumari District, Tamil Nadu 629302, India',
|
||||
lat: '8.2430',
|
||||
lon: '77.5020',
|
||||
suburb: 'Thovalai',
|
||||
city: 'Thovalai',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629302'
|
||||
},
|
||||
{
|
||||
name: 'Boothapandi',
|
||||
display_name: 'Boothapandi, Kanyakumari District, Tamil Nadu 629852, India',
|
||||
lat: '8.2610',
|
||||
lon: '77.4640',
|
||||
suburb: 'Boothapandi',
|
||||
city: 'Boothapandi',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629852'
|
||||
},
|
||||
{
|
||||
name: 'Aralvaimozhi',
|
||||
display_name: 'Aralvaimozhi, Kanyakumari District, Tamil Nadu 629301, India',
|
||||
lat: '8.2470',
|
||||
lon: '77.5250',
|
||||
suburb: 'Aralvaimozhi',
|
||||
city: 'Aralvaimozhi',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629301'
|
||||
},
|
||||
{
|
||||
name: 'Anjugramam',
|
||||
display_name: 'Anjugramam, Kanyakumari District, Tamil Nadu 629401, India',
|
||||
lat: '8.1360',
|
||||
lon: '77.5280',
|
||||
suburb: 'Anjugramam',
|
||||
city: 'Anjugramam',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629401'
|
||||
},
|
||||
{
|
||||
name: 'Agasteeswaram',
|
||||
display_name: 'Agasteeswaram, Kanyakumari District, Tamil Nadu 629701, India',
|
||||
lat: '8.1180',
|
||||
lon: '77.5120',
|
||||
suburb: 'Agasteeswaram',
|
||||
city: 'Agasteeswaram',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629701'
|
||||
},
|
||||
{
|
||||
name: 'Kaliyakkavilai',
|
||||
display_name: 'Kaliyakkavilai, Kanyakumari District, Tamil Nadu 629153, India',
|
||||
lat: '8.3240',
|
||||
lon: '77.1620',
|
||||
suburb: 'Kaliyakkavilai',
|
||||
city: 'Kaliyakkavilai',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629153'
|
||||
},
|
||||
{
|
||||
name: 'Kulasekharam',
|
||||
display_name: 'Kulasekharam, Kanyakumari District, Tamil Nadu 629161, India',
|
||||
lat: '8.3580',
|
||||
lon: '77.3000',
|
||||
suburb: 'Kulasekharam',
|
||||
city: 'Kulasekharam',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629161'
|
||||
},
|
||||
{
|
||||
name: 'Padmanabhapuram',
|
||||
display_name: 'Padmanabhapuram, Palace Area, Kanyakumari District, Tamil Nadu 629175, India',
|
||||
lat: '8.2510',
|
||||
lon: '77.3270',
|
||||
suburb: 'Padmanabhapuram',
|
||||
city: 'Padmanabhapuram',
|
||||
state: 'Tamil Nadu',
|
||||
postcode: '629175'
|
||||
}
|
||||
];
|
||||
|
||||
// Builds a Google-Places-shaped `address_components` array (each entry
|
||||
// `{ long_name, short_name, types: [] }`) from Nominatim's flat `address`
|
||||
// object, so existing call sites' `place.address_components.forEach(...)`
|
||||
// type-switch parsing keeps working unchanged.
|
||||
// `{ long_name, short_name, types: [] }`) from flat or Nominatim address objects.
|
||||
const buildAddressComponents = (addr = {}) => {
|
||||
const components = [];
|
||||
const push = (longName, types) => {
|
||||
if (longName) components.push({ long_name: longName, short_name: longName, types });
|
||||
if (longName) components.push({ long_name: String(longName).trim(), short_name: String(longName).trim(), types });
|
||||
};
|
||||
push(addr.house_number, ['street_number']);
|
||||
push(addr.road || addr.pedestrian, ['route']);
|
||||
push(addr.suburb || addr.neighbourhood || addr.quarter, ['sublocality_level_1', 'sublocality']);
|
||||
push(addr.city_district || addr.county, ['administrative_area_level_3']);
|
||||
push(addr.city || addr.town || addr.village, ['locality']);
|
||||
push(addr.state, ['administrative_area_level_1']);
|
||||
push(addr.country, ['country']);
|
||||
push(addr.postcode, ['postal_code']);
|
||||
push(addr.house_number || addr.housenumber, ['street_number']);
|
||||
push(addr.road || addr.street || addr.pedestrian, ['route']);
|
||||
push(addr.suburb || addr.neighbourhood || addr.quarter || addr.district, [
|
||||
'sublocality_level_1',
|
||||
'sublocality',
|
||||
'administrative_area_level_3'
|
||||
]);
|
||||
push(addr.city_district || addr.county, ['administrative_area_level_2']);
|
||||
push(addr.city || addr.town || addr.village || 'Nagercoil', ['locality']);
|
||||
push(addr.state || 'Tamil Nadu', ['administrative_area_level_1']);
|
||||
push(addr.country || 'India', ['country']);
|
||||
push(addr.postcode || addr.postal_code, ['postal_code']);
|
||||
return components;
|
||||
};
|
||||
|
||||
// Shapes a Nominatim result close enough to a Google Places `place` object
|
||||
// (formatted_address, geometry.location.lat()/lng(), address_components)
|
||||
// that call sites need only mechanical edits, not rewrites.
|
||||
export const toPlace = (result) => ({
|
||||
formatted_address: result.display_name,
|
||||
name: result.display_name?.split(',')[0] || '',
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => parseFloat(result.lat),
|
||||
lng: () => parseFloat(result.lon)
|
||||
}
|
||||
},
|
||||
address_components: buildAddressComponents(result.address)
|
||||
});
|
||||
// Shapes a geocoder result to a Google Places `place` object
|
||||
export const toPlace = (result) => {
|
||||
const lat = parseFloat(result.lat || result.latitude || 0);
|
||||
const lng = parseFloat(result.lon || result.lng || result.longitude || 0);
|
||||
const name = result.name || result.display_name?.split(',')[0] || '';
|
||||
const addr = result.address || {
|
||||
suburb: result.suburb || '',
|
||||
city: result.city || 'Nagercoil',
|
||||
state: result.state || 'Tamil Nadu',
|
||||
postcode: result.postcode || ''
|
||||
};
|
||||
|
||||
// Nominatim requests have no built-in timeout — a stalled connection (proxy
|
||||
// silently dropping packets, an overloaded public endpoint) would otherwise
|
||||
// leave `fetch` pending forever, so callers see the spinner spin with nothing
|
||||
// ever happening. This bounds every request to REQUEST_TIMEOUT_MS.
|
||||
const REQUEST_TIMEOUT_MS = 8000;
|
||||
let formatted = result.display_name || name;
|
||||
if (name && formatted.startsWith(name + ', ')) {
|
||||
formatted = formatted.slice(name.length + 2);
|
||||
}
|
||||
|
||||
return {
|
||||
formatted_address: formatted,
|
||||
name: name,
|
||||
geometry: {
|
||||
location: {
|
||||
lat: () => (Number.isFinite(lat) ? lat : 0),
|
||||
lng: () => (Number.isFinite(lng) ? lng : 0)
|
||||
}
|
||||
},
|
||||
address_components: buildAddressComponents(addr)
|
||||
};
|
||||
};
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 6000;
|
||||
const fetchWithTimeout = (url, options) => {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timer));
|
||||
};
|
||||
|
||||
// Nominatim (and Google Places, previously) both do full-text search against
|
||||
// a database of named places — neither understands Plus Codes (e.g.
|
||||
// "5CM7+8Q7"), because a Plus Code isn't a name to look up, it's lat/lng
|
||||
// encoded directly into the string. Decoding it is a pure local computation
|
||||
// via the Open Location Code algorithm, so this resolves reliably even where
|
||||
// OSM has zero data for the area (small towns, unlisted businesses).
|
||||
const olc = new OpenLocationCode();
|
||||
const PLUS_CODE_CHARS = '23456789CFGHJMPQRVWX';
|
||||
const PLUS_CODE_RE = new RegExp(`[${PLUS_CODE_CHARS}]{2,8}\\+[${PLUS_CODE_CHARS}]{2,3}`, 'i');
|
||||
|
||||
// A decoded Plus Code is just coordinates — on its own it has no street/
|
||||
// locality name to show the operator, only the code itself. Reverse-geocoding
|
||||
// those coordinates fills in the human-readable side (road, suburb, city...)
|
||||
// the same way a normal search result would, so the option in the dropdown
|
||||
// reads as an actual address instead of a bare code. Best-effort: if Nominatim
|
||||
// has no data for that exact point either, callers fall back to the code text.
|
||||
async function reverseGeocode(lat, lon) {
|
||||
try {
|
||||
const params = new URLSearchParams({ lat: String(lat), lon: String(lon), format: 'json', addressdetails: '1' });
|
||||
@@ -86,13 +452,17 @@ async function reverseGeocode(lat, lon) {
|
||||
}
|
||||
}
|
||||
|
||||
// Short codes (the common case — "5CM7+8Q7" rather than the full
|
||||
// "6JWV5CM7+8Q7") only encode an offset; recovering the actual location
|
||||
// needs a nearby reference point, which is what `bias` (the operator's
|
||||
// tenant/app location) provides. Returns a Nominatim-result-shaped object
|
||||
// (same shape `toPlace()` expects) so it can flow through the exact same
|
||||
// path as a real search hit, or null if no code is present / it can't be
|
||||
// resolved without a reference point.
|
||||
// Extracts door/house number prefix if present (e.g., '262,', 'No. 262,', 'Flat 4B,', 'D.No 12/4,', '262 Kurusady')
|
||||
export const extractDoorPrefix = (text) => {
|
||||
if (!text) return { doorNo: '', cleanQuery: '' };
|
||||
const trimmed = text.trim();
|
||||
const match = trimmed.match(/^((?:no\.?|d\.?no\.?|flat|door|plot|#)?\s*[\w\d\-\/]+)(?:,\s*|\s+)(.+)$/i);
|
||||
if (match) {
|
||||
return { doorNo: match[1].trim(), cleanQuery: match[2].trim() };
|
||||
}
|
||||
return { doorNo: '', cleanQuery: trimmed };
|
||||
};
|
||||
|
||||
const plusCodeToResult = async (text, bias) => {
|
||||
const match = text?.match(PLUS_CODE_RE);
|
||||
if (!match) return null;
|
||||
@@ -101,19 +471,14 @@ const plusCodeToResult = async (text, bias) => {
|
||||
try {
|
||||
let fullCode = code;
|
||||
if (olc.isShort(code)) {
|
||||
const refLat = Number(bias?.lat);
|
||||
const refLng = Number(bias?.lng);
|
||||
// (0,0) isn't a plausible tenant location — it's what `Number('')` and
|
||||
// `Number(undefined)` half-produce (empty string coerces to 0), so treat
|
||||
// it as "no reference point yet" rather than decoding against Null Island.
|
||||
if (!Number.isFinite(refLat) || !Number.isFinite(refLng) || (refLat === 0 && refLng === 0)) return null;
|
||||
const refLat = Number(bias?.lat) || NAGERCOIL_COORDS.lat;
|
||||
const refLng = Number(bias?.lng) || NAGERCOIL_COORDS.lng;
|
||||
if (!Number.isFinite(refLat) || !Number.isFinite(refLng)) return null;
|
||||
fullCode = olc.recoverNearest(code, refLat, refLng);
|
||||
}
|
||||
if (!olc.isFull(fullCode)) return null;
|
||||
const { latitudeCenter, longitudeCenter } = olc.decode(fullCode);
|
||||
// The decoded centroid is exact — keep it as the authoritative coordinate
|
||||
// even though `reverse` below carries its own (possibly snapped-to-nearest-
|
||||
// feature) lat/lon; only its display_name/address are what we want.
|
||||
if (!isWithinKanyakumariDistrict(latitudeCenter, longitudeCenter)) return null;
|
||||
const reverse = await reverseGeocode(latitudeCenter, longitudeCenter);
|
||||
return {
|
||||
...reverse,
|
||||
@@ -128,80 +493,202 @@ const plusCodeToResult = async (text, bias) => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildParams = (query, bias) => {
|
||||
const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5' });
|
||||
// bias.lat/lng arrive as strings from the tenant-location API. `-` coerces to
|
||||
// number automatically, but `+` on a string concatenates instead of adding
|
||||
// (e.g. "8.17445" + 0.5 === "8.174450.5"), producing a malformed, non-numeric
|
||||
// viewbox that Nominatim rejects with 400 Bad Request on every single search.
|
||||
const lat = Number(bias?.lat);
|
||||
const lng = Number(bias?.lng);
|
||||
if (Number.isFinite(lat) && Number.isFinite(lng)) {
|
||||
const d = 0.5; // ~55km box around the operator's zone — soft bias, not a hard filter
|
||||
params.set('viewbox', `${lng - d},${lat + d},${lng + d},${lat - d}`);
|
||||
// Search Photon (Fast, typo-tolerant OpenStreetMap geocoder strictly within Kanyakumari District)
|
||||
const searchPhoton = async (query, bias) => {
|
||||
try {
|
||||
const lat = Number(bias?.lat) || NAGERCOIL_COORDS.lat;
|
||||
const lng = Number(bias?.lng) || NAGERCOIL_COORDS.lng;
|
||||
const { doorNo, cleanQuery } = extractDoorPrefix(query);
|
||||
const searchQuery = cleanQuery || query;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: searchQuery,
|
||||
limit: '15',
|
||||
lang: 'en',
|
||||
lat: String(lat),
|
||||
lon: String(lng),
|
||||
bbox: KANYAKUMARI_BBOX_PHOTON
|
||||
});
|
||||
|
||||
const res = await fetchWithTimeout(`${PHOTON_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
if (!data?.features || !Array.isArray(data.features)) return [];
|
||||
|
||||
return data.features
|
||||
.filter((item) => {
|
||||
const [lon, latCoord] = item.geometry?.coordinates || [null, null];
|
||||
return isWithinKanyakumariDistrict(latCoord, lon);
|
||||
})
|
||||
.map((item, idx) => {
|
||||
const p = item.properties || {};
|
||||
const [lon, latCoord] = item.geometry?.coordinates || [null, null];
|
||||
const finalHouseNumber = doorNo || p.housenumber || '';
|
||||
|
||||
const parts = [
|
||||
finalHouseNumber ? `${finalHouseNumber}, ${p.name || ''}`.trim() : p.name,
|
||||
p.street ? `${!finalHouseNumber && p.housenumber ? p.housenumber + ' ' : ''}${p.street}` : '',
|
||||
p.district || p.suburb || p.neighbourhood || p.locality,
|
||||
p.city || 'Nagercoil',
|
||||
p.state || 'Tamil Nadu',
|
||||
p.postcode,
|
||||
'India'
|
||||
].filter(Boolean);
|
||||
|
||||
const uniqueParts = parts.filter((part, i, arr) => arr.indexOf(part) === i);
|
||||
const formatted = uniqueParts.join(', ');
|
||||
|
||||
return {
|
||||
place_id: `photon-${idx}-${latCoord}-${lon}`,
|
||||
name: finalHouseNumber ? `${finalHouseNumber}, ${p.name || ''}`.trim() : p.name || '',
|
||||
display_name: formatted || p.name || '',
|
||||
lat: String(latCoord),
|
||||
lon: String(lon),
|
||||
address: {
|
||||
house_number: finalHouseNumber,
|
||||
suburb: p.district || p.suburb || p.neighbourhood || p.locality || '',
|
||||
city: p.city || 'Nagercoil',
|
||||
state: p.state || 'Tamil Nadu',
|
||||
postcode: p.postcode || '',
|
||||
country: 'India'
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return params;
|
||||
};
|
||||
|
||||
const searchOnce = async (query, bias) => {
|
||||
const params = buildParams(query, bias);
|
||||
params.set('limit', '1');
|
||||
const res = await fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return null;
|
||||
const results = await res.json();
|
||||
return results?.length ? results[0] : null;
|
||||
// Search Nominatim strictly bounded to Kanyakumari District
|
||||
const searchNominatim = async (query, bias) => {
|
||||
try {
|
||||
const { doorNo, cleanQuery } = extractDoorPrefix(query);
|
||||
const searchQuery = cleanQuery || query;
|
||||
const isLocalQuery =
|
||||
searchQuery.toLowerCase().includes('nagercoil') ||
|
||||
searchQuery.toLowerCase().includes('kanyakumari') ||
|
||||
searchQuery.toLowerCase().includes('kanniyakumari');
|
||||
const enrichedQuery = isLocalQuery ? searchQuery : `${searchQuery} Nagercoil`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: enrichedQuery,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
limit: '8',
|
||||
countrycodes: 'in',
|
||||
viewbox: KANYAKUMARI_VIEWBOX_NOMINATIM,
|
||||
bounded: '1'
|
||||
});
|
||||
|
||||
const res = await fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return [];
|
||||
const results = await res.json();
|
||||
if (!Array.isArray(results)) return [];
|
||||
|
||||
return results
|
||||
.filter((item) => isWithinKanyakumariDistrict(item.lat, item.lon))
|
||||
.map((item) => {
|
||||
if (!doorNo) return item;
|
||||
return {
|
||||
...item,
|
||||
display_name: `${doorNo}, ${item.display_name}`,
|
||||
address: {
|
||||
...item.address,
|
||||
house_number: doorNo || item.address?.house_number || ''
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Filter local Nagercoil / Kanyakumari knowledge base with door number and tokenized matching
|
||||
const matchLocalNagercoil = (query) => {
|
||||
if (!query || query.trim().length < 2) return [];
|
||||
const raw = query.trim();
|
||||
const rawLower = raw.toLowerCase();
|
||||
const { doorNo, cleanQuery } = extractDoorPrefix(raw);
|
||||
const cleanLower = cleanQuery.toLowerCase();
|
||||
const tokens = cleanLower.split(/[\s,]+/).filter((t) => t.length >= 2);
|
||||
|
||||
return NAGERCOIL_LOCALITIES.filter((loc) => {
|
||||
const nameLower = loc.name.toLowerCase();
|
||||
const displayLower = loc.display_name.toLowerCase();
|
||||
const suburbLower = loc.suburb.toLowerCase();
|
||||
|
||||
// 1. Direct query inside locality name or display name (e.g. query='kuru')
|
||||
if (nameLower.includes(rawLower) || displayLower.includes(rawLower) || suburbLower.includes(rawLower)) {
|
||||
return true;
|
||||
}
|
||||
// 2. Query contains locality name (e.g. query='262, kurusady, nagercoil')
|
||||
if (rawLower.includes(nameLower) || cleanLower.includes(nameLower)) {
|
||||
return true;
|
||||
}
|
||||
// 3. Any token in query matches locality name
|
||||
if (tokens.some((t) => nameLower.includes(t) || suburbLower.includes(t))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}).map((loc, idx) => {
|
||||
const formatted = doorNo ? `${doorNo}, ${loc.display_name}` : loc.display_name;
|
||||
return {
|
||||
place_id: `nagercoil-local-${idx}-${doorNo ? `${doorNo}-` : ''}${loc.name}`,
|
||||
name: doorNo ? `${doorNo}, ${loc.name}` : loc.name,
|
||||
display_name: formatted,
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
address: {
|
||||
house_number: doorNo || '',
|
||||
suburb: loc.suburb,
|
||||
city: loc.city,
|
||||
state: loc.state,
|
||||
postcode: loc.postcode,
|
||||
country: 'India'
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Standalone forward-geocode helper — replaces `Geocode.fromAddress(address)`
|
||||
// for call sites that resolve a plain text address without a predictions
|
||||
// dropdown (e.g. re-geocoding on submit). Returns a `toPlace`-shaped object,
|
||||
// or null if nothing matched.
|
||||
//
|
||||
// Unlike Google's geocoder, Nominatim only matches when every term in the
|
||||
// query belongs to the same indexed record — it has no "closest approximate
|
||||
// match" behaviour. A door number + unlisted micro-locality/street name (very
|
||||
// common for Indian addresses, e.g. "60, Keezha Raman Puthoor, ...") makes
|
||||
// the whole query match nothing, even though the city/postcode later in the
|
||||
// string resolves fine on its own. So on a miss, retry with the leading
|
||||
// (most specific, least likely to be indexed) comma-separated segments
|
||||
// dropped one at a time until something matches — worst case this lands on
|
||||
// just "<city> <postcode>", an approximate pin instead of no pin at all.
|
||||
export async function geocodeAddress(address, { bias } = {}) {
|
||||
if (!address) return null;
|
||||
const plusCodeResult = await plusCodeToResult(address, bias);
|
||||
if (plusCodeResult) return toPlace(plusCodeResult);
|
||||
if (plusCodeResult && isWithinKanyakumariDistrict(plusCodeResult.lat, plusCodeResult.lon)) {
|
||||
return toPlace(plusCodeResult);
|
||||
}
|
||||
|
||||
// 1. Check local Nagercoil dictionary first
|
||||
const localMatches = matchLocalNagercoil(address);
|
||||
if (localMatches.length > 0) {
|
||||
return toPlace(localMatches[0]);
|
||||
}
|
||||
|
||||
// 2. Query Photon with Kanyakumari district bbox
|
||||
const photonResults = await searchPhoton(address, bias);
|
||||
if (photonResults.length > 0) {
|
||||
return toPlace(photonResults[0]);
|
||||
}
|
||||
|
||||
// 3. Fallback to Nominatim multi-segment query
|
||||
const segments = address
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const queries = segments.length > 1 ? segments.map((_, i) => segments.slice(i).join(', ')) : [address];
|
||||
try {
|
||||
for (const query of queries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const result = await searchOnce(query, bias);
|
||||
if (result) return toPlace(result);
|
||||
}
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('geocodeAddress error:', err);
|
||||
return null;
|
||||
|
||||
for (const query of queries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const nomResults = await searchNominatim(query, bias);
|
||||
if (nomResults?.length && isWithinKanyakumariDistrict(nomResults[0].lat, nomResults[0].lon)) return toPlace(nomResults[0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Address search-as-you-type — replaces Google Places Autocomplete
|
||||
// (usePlacesWidget / window.google.maps.places.Autocomplete /
|
||||
// AutocompleteService) across the console. Renders as an MUI Autocomplete
|
||||
// with freeSolo so the operator can still submit free text.
|
||||
//
|
||||
// Props:
|
||||
// value / onChange(text) — controlled free-text input value
|
||||
// onPlaceSelected(place) — fired when a suggestion is chosen; `place`
|
||||
// is shaped like a Google Places `place`
|
||||
// bias { lat, lng } — optional soft bias toward the operator's zone
|
||||
const AddressAutocomplete = ({
|
||||
id,
|
||||
label,
|
||||
placeholder = 'Search address',
|
||||
placeholder = 'Search address or locality (e.g. Kurusady, Vadasery)',
|
||||
value,
|
||||
onChange,
|
||||
onPlaceSelected,
|
||||
@@ -224,26 +711,74 @@ const AddressAutocomplete = ({
|
||||
|
||||
const fetchPredictions = useMemo(
|
||||
() =>
|
||||
debounce((query, bias1, callback) => {
|
||||
if (!query) {
|
||||
debounce(async (query, bias1, callback) => {
|
||||
if (!query || query.trim().length < 2) {
|
||||
callback([]);
|
||||
return;
|
||||
}
|
||||
const params = buildParams(query, bias1);
|
||||
const searchPromise = fetchWithTimeout(`${NOMINATIM_SEARCH_URL}?${params.toString()}`, { headers: { Accept: 'application/json' } })
|
||||
.then((res) => (res.ok ? res.json() : []))
|
||||
.catch(() => []);
|
||||
const plusCodePromise = plusCodeToResult(query, bias1).catch(() => null);
|
||||
Promise.all([searchPromise, plusCodePromise]).then(([results, plusCodeResult]) => {
|
||||
callback(plusCodeResult ? [plusCodeResult, ...(results || [])] : results || []);
|
||||
|
||||
const trimmed = query.trim();
|
||||
|
||||
// 1. Instant local Nagercoil / Kanyakumari suggestions (0ms)
|
||||
const localResults = matchLocalNagercoil(trimmed);
|
||||
|
||||
// 2. Plus Code check
|
||||
const plusCodePromise = plusCodeToResult(trimmed, bias1).catch(() => null);
|
||||
|
||||
// 3. Photon fast autocomplete (bounded to Kanyakumari District)
|
||||
const photonPromise = searchPhoton(trimmed, bias1);
|
||||
|
||||
// 4. Nominatim bounded fallback
|
||||
const nominatimPromise = searchNominatim(trimmed, bias1);
|
||||
|
||||
const [plusCodeResult, photonResults, nomResults] = await Promise.all([
|
||||
plusCodePromise,
|
||||
photonPromise,
|
||||
nominatimPromise
|
||||
]);
|
||||
|
||||
const combined = [];
|
||||
if (plusCodeResult && isWithinKanyakumariDistrict(plusCodeResult.lat, plusCodeResult.lon)) {
|
||||
combined.push(plusCodeResult);
|
||||
}
|
||||
|
||||
// Prioritize local Nagercoil dictionary matches
|
||||
localResults.forEach((item) => {
|
||||
if (isWithinKanyakumariDistrict(item.lat, item.lon)) {
|
||||
combined.push(item);
|
||||
}
|
||||
});
|
||||
}, 500),
|
||||
|
||||
// Add Photon results (already verified to be inside district)
|
||||
(photonResults || []).forEach((item) => {
|
||||
if (!isWithinKanyakumariDistrict(item.lat, item.lon)) return;
|
||||
const isDuplicate = combined.some(
|
||||
(c) =>
|
||||
c.display_name?.toLowerCase() === item.display_name?.toLowerCase() ||
|
||||
(Math.abs(Number(c.lat) - Number(item.lat)) < 0.001 && Math.abs(Number(c.lon) - Number(item.lon)) < 0.001)
|
||||
);
|
||||
if (!isDuplicate) combined.push(item);
|
||||
});
|
||||
|
||||
// Add Nominatim results (already verified to be inside district)
|
||||
(nomResults || []).forEach((item) => {
|
||||
if (!isWithinKanyakumariDistrict(item.lat, item.lon)) return;
|
||||
const isDuplicate = combined.some(
|
||||
(c) =>
|
||||
c.display_name?.toLowerCase() === item.display_name?.toLowerCase() ||
|
||||
(Math.abs(Number(c.lat) - Number(item.lat)) < 0.001 && Math.abs(Number(c.lon) - Number(item.lon)) < 0.001)
|
||||
);
|
||||
if (!isDuplicate) combined.push(item);
|
||||
});
|
||||
|
||||
callback(combined.slice(0, 8));
|
||||
}, 250),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
if (!inputValue) {
|
||||
if (!inputValue || inputValue.trim().length < 2) {
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
return undefined;
|
||||
@@ -252,7 +787,7 @@ const AddressAutocomplete = ({
|
||||
fetchPredictions(inputValue, bias, (results) => {
|
||||
if (!active) return;
|
||||
setLoading(false);
|
||||
setOptions(results);
|
||||
setOptions(results || []);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
@@ -269,14 +804,14 @@ const AddressAutocomplete = ({
|
||||
sx={sx}
|
||||
options={options}
|
||||
filterOptions={(x) => x}
|
||||
getOptionLabel={(option) => (typeof option === 'string' ? option : option.display_name || '')}
|
||||
isOptionEqualToValue={(option, val) => option.place_id === val?.place_id}
|
||||
getOptionLabel={(option) => (typeof option === 'string' ? option : option.display_name || option.name || '')}
|
||||
isOptionEqualToValue={(option, val) => option.place_id === val?.place_id || option.display_name === val?.display_name}
|
||||
noOptionsText={
|
||||
loading
|
||||
? 'Searching…'
|
||||
: inputValue.trim().length >= 3
|
||||
? 'No match found — drop the business name/Plus Code, or try just City + Postcode'
|
||||
: 'Type an address to search'
|
||||
? 'Searching Nagercoil & Kanyakumari District…'
|
||||
: inputValue.trim().length >= 2
|
||||
? 'No exact match found in Nagercoil / Kanyakumari — you can continue typing the full address'
|
||||
: 'Type an address or locality in Nagercoil / Kanyakumari'
|
||||
}
|
||||
inputValue={inputValue}
|
||||
onInputChange={(event, newInputValue) => {
|
||||
@@ -285,7 +820,7 @@ const AddressAutocomplete = ({
|
||||
}}
|
||||
onChange={(event, selected) => {
|
||||
if (selected && typeof selected !== 'string') {
|
||||
setInputValue(selected.display_name || '');
|
||||
setInputValue(selected.display_name || selected.name || '');
|
||||
onPlaceSelected?.(toPlace(selected));
|
||||
}
|
||||
}}
|
||||
@@ -316,3 +851,4 @@ const AddressAutocomplete = ({
|
||||
};
|
||||
|
||||
export default AddressAutocomplete;
|
||||
|
||||
|
||||
@@ -65,6 +65,13 @@ const nearle = {
|
||||
url: '/nearle/orders',
|
||||
icon: AiOutlineDashboard
|
||||
},
|
||||
{
|
||||
id: 'weeklyIntake',
|
||||
title: <FormattedMessage id="WeeklyIntake" />,
|
||||
type: 'item',
|
||||
url: '/nearle/orders/weekly-intake',
|
||||
icon: TbListDetails
|
||||
},
|
||||
{
|
||||
id: 'deliveries',
|
||||
title: <FormattedMessage id="Deliveries" />,
|
||||
|
||||
374
src/pages/nearle/ai/OpsAgentDrawer.js
Normal file
374
src/pages/nearle/ai/OpsAgentDrawer.js
Normal 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;
|
||||
147
src/pages/nearle/ai/OpsAgentDrawer.test.js
Normal file
147
src/pages/nearle/ai/OpsAgentDrawer.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
255
src/pages/nearle/ai/intakeParser.js
Normal file
255
src/pages/nearle/ai/intakeParser.js
Normal 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.`;
|
||||
};
|
||||
278
src/pages/nearle/ai/intakeParser.test.js
Normal file
278
src/pages/nearle/ai/intakeParser.test.js
Normal 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.');
|
||||
});
|
||||
});
|
||||
129
src/pages/nearle/ai/intakeTemplate.js
Normal file
129
src/pages/nearle/ai/intakeTemplate.js
Normal 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);
|
||||
};
|
||||
528
src/pages/nearle/ai/opsAnalysis.js
Normal file
528
src/pages/nearle/ai/opsAnalysis.js
Normal 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.'
|
||||
]
|
||||
};
|
||||
};
|
||||
370
src/pages/nearle/ai/opsAnalysis.test.js
Normal file
370
src/pages/nearle/ai/opsAnalysis.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -329,8 +329,11 @@ export const fetchRidersLogs = async ({ queryKey }) => {
|
||||
// riderSearch || ''
|
||||
// }`
|
||||
// );
|
||||
// v2 deliberately (the v1 route returns a different shape) -- but via the
|
||||
// configured base rather than a hardcoded host, so a non-production build
|
||||
// does not read from live.
|
||||
const riderLogsResponse = await axios.get(
|
||||
`https://jupiter.nearle.app/live/api/v2/partners/getriderlogs/?applocationid=${appId}&fromdate=${startdate || ''}&todate=${
|
||||
`${process.env.REACT_APP_URL2}/partners/getriderlogs/?applocationid=${appId}&fromdate=${startdate || ''}&todate=${
|
||||
startdate || ''
|
||||
}&keyword=${riderSearch || ''}`
|
||||
);
|
||||
@@ -404,7 +407,10 @@ export const finalCreatedeliveries = async (deliveryData) => {
|
||||
userid: toInt(d.userid),
|
||||
rider_id: toInt(d.rider_id)
|
||||
}));
|
||||
const response = await axios.post(`https://jupiter.nearle.app/live/api/v1/deliveries/createdeliveries`, deliveries);
|
||||
// Use the configured base, NOT a hardcoded host: this call commits real
|
||||
// deliveries, and hardcoding the live host meant a staging build wrote to
|
||||
// production.
|
||||
const response = await axios.post(`${process.env.REACT_APP_URL}/deliveries/createdeliveries`, deliveries);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
282
src/pages/nearle/orders/WeeklyIntake.js
Normal file
282
src/pages/nearle/orders/WeeklyIntake.js
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Weekly Order Intake — dry run (phases 0 and 1).
|
||||
*
|
||||
* Upload the weekly sheet, see exactly what would happen to every row. This
|
||||
* screen deliberately has **no commit control**: it reads a file, runs
|
||||
* parseIntakeFile, and reports. Creating orders is phase 3, and it needs
|
||||
* customer resolution and a role check before it can exist.
|
||||
*
|
||||
* The value of shipping the read-only half first is that an operator can fix a
|
||||
* bad week's file before anything is at stake, and we learn what real files
|
||||
* look like before writing the code that commits them.
|
||||
*/
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { Box, Button, Chip, Paper, Stack, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Tooltip, Typography } from '@mui/material';
|
||||
import { MdCloudUpload, MdDownload, MdErrorOutline, MdCheckCircle, MdWarningAmber, MdRestartAlt } from 'react-icons/md';
|
||||
import Papa from 'papaparse';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
import { INTAKE_COLUMNS, downloadTemplate } from '../ai/intakeTemplate';
|
||||
import { describeResult, parseIntakeFile, STATUS } from '../ai/intakeParser';
|
||||
import { OpenToast } from 'components/nearle_components/OpenToast';
|
||||
|
||||
const BRAND = '#662582';
|
||||
const BRAND_LIGHT = '#9255AB';
|
||||
const DT = {
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
borderSubtle: '#e2e8f0',
|
||||
surface: '#ffffff',
|
||||
surfaceAlt: '#f8fafc'
|
||||
};
|
||||
|
||||
const STATUS_META = {
|
||||
[STATUS.OK]: { label: 'Ready', color: '#2c6b45', bg: '#dcece2', Icon: MdCheckCircle },
|
||||
[STATUS.REVIEW]: { label: 'Review', color: '#8a5600', bg: '#f8ead2', Icon: MdWarningAmber },
|
||||
[STATUS.REJECTED]: { label: 'Rejected', color: '#b3261e', bg: '#fae4e2', Icon: MdErrorOutline }
|
||||
};
|
||||
|
||||
// Bawa Medical, King Nagar. Phase 3 reads this from the selected pickup
|
||||
// location instead of a constant — until then the radius check needs an origin.
|
||||
const DEFAULT_HUB = { lat: 8.1841265, lng: 77.4318002 };
|
||||
const DEFAULT_RADIUS_KM = 25;
|
||||
|
||||
const Tile = ({ label, value, tone }) => (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ flex: '1 1 120px', minWidth: 120, px: 1.75, py: 1.25, 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: 26, fontWeight: 700, lineHeight: 1.2, color: tone || DT.textPrimary, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
export default function WeeklyIntake() {
|
||||
const [result, setResult] = useState(null);
|
||||
const [filename, setFilename] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const opentoast = (msg, variant, time) => OpenToast(msg, variant, time);
|
||||
|
||||
const run = (rows, name) => {
|
||||
const parsed = parseIntakeFile(rows, { serviceCentre: DEFAULT_HUB, serviceRadiusKm: DEFAULT_RADIUS_KM });
|
||||
setResult(parsed);
|
||||
setFilename(name);
|
||||
const summary = describeResult(parsed);
|
||||
opentoast(summary, parsed.missingColumns.length || parsed.summary.rejected ? 'warning' : 'success', 4000);
|
||||
};
|
||||
|
||||
const onFile = (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const lower = file.name.toLowerCase();
|
||||
const isCSV = lower.endsWith('.csv');
|
||||
const isExcel = lower.endsWith('.xls') || lower.endsWith('.xlsx');
|
||||
if (!isCSV && !isExcel) {
|
||||
opentoast('Upload a .csv, .xls or .xlsx file.', 'warning', 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCSV) {
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (res) => run(res.data || [], file.name),
|
||||
error: (err) => opentoast(`Could not read the file: ${err.message}`, 'error', 4000)
|
||||
});
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const wb = XLSX.read(e.target.result, { type: 'array' });
|
||||
run(XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { defval: '' }), file.name);
|
||||
} catch (err) {
|
||||
opentoast(`Could not read the workbook: ${err.message}`, 'error', 4000);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
// Allow re-selecting the same file after an edit.
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
setResult(null);
|
||||
setFilename('');
|
||||
};
|
||||
|
||||
const s = result?.summary;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Paper elevation={0} sx={{ p: { xs: 1.5, md: 2 }, mb: 1.5, borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, bgcolor: '#fff' }}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} alignItems={{ md: 'center' }} justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 20, fontWeight: 800, color: DT.textPrimary, lineHeight: 1.25 }}>Weekly Order Intake</Typography>
|
||||
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary }}>
|
||||
{filename ? `Checked ${filename}` : 'Dry run — check a weekly file before anything is created'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Tooltip title="Download the blank intake template" arrow>
|
||||
<Button
|
||||
onClick={() => downloadTemplate()}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disableElevation
|
||||
startIcon={<MdDownload size={15} />}
|
||||
sx={{
|
||||
height: 34, px: 1.5, borderRadius: 1.5, textTransform: 'none', fontSize: 12.5, fontWeight: 700,
|
||||
bgcolor: '#fff', borderColor: DT.borderSubtle, color: DT.textPrimary,
|
||||
'&:hover': { bgcolor: '#fff', borderColor: BRAND, color: BRAND }
|
||||
}}
|
||||
>
|
||||
Template
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{result && (
|
||||
<Button
|
||||
onClick={reset}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
disableElevation
|
||||
startIcon={<MdRestartAlt size={15} />}
|
||||
sx={{
|
||||
height: 34, px: 1.5, borderRadius: 1.5, textTransform: 'none', fontSize: 12.5, fontWeight: 700,
|
||||
bgcolor: '#fff', borderColor: DT.borderSubtle, color: DT.textPrimary,
|
||||
'&:hover': { bgcolor: '#fff', borderColor: BRAND, color: BRAND }
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => inputRef.current?.click()}
|
||||
variant="contained"
|
||||
size="small"
|
||||
disableElevation
|
||||
startIcon={<MdCloudUpload size={15} />}
|
||||
sx={{
|
||||
height: 34, px: 1.75, borderRadius: 1.5, textTransform: 'none', fontSize: 12.5, fontWeight: 700,
|
||||
bgcolor: BRAND, '&:hover': { bgcolor: BRAND_LIGHT }
|
||||
}}
|
||||
>
|
||||
Check a file
|
||||
</Button>
|
||||
<input ref={inputRef} type="file" accept=".csv,.xls,.xlsx" onChange={onFile} data-testid="intake-file" style={{ display: 'none' }} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ px: 1.75, py: 1.25, mb: 1.5, borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, borderLeft: `3px solid ${BRAND}`, bgcolor: '#fff' }}
|
||||
>
|
||||
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary, lineHeight: 1.6 }}>
|
||||
<strong style={{ color: DT.textPrimary }}>This screen never creates orders.</strong> It reads the file and reports what would
|
||||
happen to each row. Creating orders needs customer matching and a pickup slot, which are not built yet.
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{!result ? (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{ p: 4, borderRadius: 2, border: '1px dashed', borderColor: DT.borderSubtle, bgcolor: '#fff', textAlign: 'center' }}
|
||||
>
|
||||
<MdCloudUpload size={32} color={DT.textMuted} />
|
||||
<Typography sx={{ fontSize: 15, fontWeight: 700, color: DT.textPrimary, mt: 1 }}>No file checked yet</Typography>
|
||||
<Typography sx={{ fontSize: 13, color: DT.textSecondary, mt: 0.5, maxWidth: 480, mx: 'auto' }}>
|
||||
Upload the week’s CSV or Excel file. Required columns are{' '}
|
||||
{INTAKE_COLUMNS.filter((c) => c.required).map((c) => c.label).join(', ')}.
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Tile label="Rows" value={s.total} />
|
||||
<Tile label="Ready" value={s.ok} tone={STATUS_META[STATUS.OK].color} />
|
||||
<Tile label="Need review" value={s.needsReview} tone={STATUS_META[STATUS.REVIEW].color} />
|
||||
<Tile label="Rejected" value={s.rejected} tone={STATUS_META[STATUS.REJECTED].color} />
|
||||
</Stack>
|
||||
|
||||
{result.missingColumns.length > 0 && (
|
||||
<Paper elevation={0} sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: '#f3c6c2', borderLeft: '3px solid #b3261e', bgcolor: '#fff' }}>
|
||||
<Typography sx={{ fontSize: 13, fontWeight: 700, color: '#b3261e' }}>Missing required columns</Typography>
|
||||
<Typography sx={{ fontSize: 12.5, color: DT.textSecondary, mt: 0.25 }}>
|
||||
{result.missingColumns.join(', ')} — no row can be created until these are present.
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{result.unknownColumns.length > 0 && (
|
||||
<Paper elevation={0} sx={{ p: 1.5, borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, borderLeft: '3px solid #8a5600', bgcolor: '#fff' }}>
|
||||
<Typography sx={{ fontSize: 13, fontWeight: 700, color: '#8a5600' }}>
|
||||
{result.unknownColumns.length} column{result.unknownColumns.length === 1 ? '' : 's'} not recognised — ignored
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: 12, color: DT.textSecondary, mt: 0.25, wordBreak: 'break-word' }}>
|
||||
{result.unknownColumns.join(', ')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<TableContainer component={Paper} elevation={0} sx={{ borderRadius: 2, border: '1px solid', borderColor: DT.borderSubtle, bgcolor: '#fff' }}>
|
||||
<Table size="small" sx={{ minWidth: 820 }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ bgcolor: DT.surfaceAlt }}>
|
||||
{['Row', 'Status', 'Receiver', 'Phone', 'Address', 'Item', 'Qty', 'What needs attention'].map((h) => (
|
||||
<TableCell key={h} sx={{ fontSize: 10, letterSpacing: 0.5, textTransform: 'uppercase', color: DT.textMuted, fontWeight: 700 }}>
|
||||
{h}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{result.rows.map((row) => {
|
||||
const meta = STATUS_META[row.status];
|
||||
const n = row.normalised;
|
||||
return (
|
||||
<TableRow key={row.index} hover>
|
||||
<TableCell sx={{ fontSize: 12, color: DT.textMuted, fontVariantNumeric: 'tabular-nums' }}>{row.sheetRow}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={meta.label}
|
||||
size="small"
|
||||
sx={{ height: 20, fontSize: 10.5, fontWeight: 700, bgcolor: meta.bg, color: meta.color, '& .MuiChip-label': { px: 0.9 } }}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: 12.5, fontWeight: 600 }}>{n.receivername || <span style={{ color: DT.textMuted }}>—</span>}</TableCell>
|
||||
<TableCell sx={{ fontSize: 12.5, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{n.receiverphone || <span style={{ color: DT.textMuted }}>—</span>}
|
||||
</TableCell>
|
||||
<TableCell sx={{ fontSize: 12, color: DT.textSecondary, maxWidth: 240 }}>{n.receiveraddress || '—'}</TableCell>
|
||||
<TableCell sx={{ fontSize: 12.5 }}>{n.itemdescription || <span style={{ color: DT.textMuted }}>—</span>}</TableCell>
|
||||
<TableCell sx={{ fontSize: 12.5, fontVariantNumeric: 'tabular-nums' }}>{n.quantity ?? '—'}</TableCell>
|
||||
<TableCell sx={{ fontSize: 12, color: DT.textSecondary, maxWidth: 300 }}>
|
||||
{row.reasons.length === 0 ? (
|
||||
<span style={{ color: DT.textMuted }}>Nothing</span>
|
||||
) : (
|
||||
row.reasons.map((r, i) => (
|
||||
<Box key={i} sx={{ mb: i < row.reasons.length - 1 ? 0.5 : 0 }}>
|
||||
<Box component="span" sx={{ color: r.level === 'reject' ? '#b3261e' : '#8a5600', fontWeight: 700, mr: 0.5 }}>
|
||||
{r.level === 'reject' ? 'Blocked:' : 'Check:'}
|
||||
</Box>
|
||||
{r.message}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,7 @@ import { CSVExport } from 'components/third-party/ReactTable';
|
||||
import Loader from 'components/Loader';
|
||||
import { useDebounce } from 'components/nearle_components/useDebounce';
|
||||
import MapWithRoute from './mapWithRoute';
|
||||
import OpsAgentDrawer from '../ai/OpsAgentDrawer';
|
||||
|
||||
// ============================================================================
|
||||
// Design tokens — shared with the rest of the redesigned operator pages.
|
||||
@@ -304,6 +305,10 @@ export default function OrdersDetails() {
|
||||
const [totalAmount, settotalAmount] = useState(0);
|
||||
const [searchword, setSearchword] = useState('');
|
||||
|
||||
// Ops Agent — reads the rows already loaded here, so it inherits whatever
|
||||
// date range and status filter the operator has applied.
|
||||
const [opsOpen, setOpsOpen] = useState(false);
|
||||
|
||||
// Density toggle — operators choose how many rows to surface per screen.
|
||||
// Default 'compact' (rows ≈ 32px) so the table starts dense like Linear / Vercel.
|
||||
const [density, setDensity] = useState(() => {
|
||||
@@ -732,6 +737,37 @@ export default function OrdersDetails() {
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Check this range for data-quality problems before trusting its numbers" arrow>
|
||||
<Button
|
||||
onClick={() => setOpsOpen(true)}
|
||||
disableElevation
|
||||
variant="outlined"
|
||||
size="small"
|
||||
startIcon={<MdInsights size={15} />}
|
||||
sx={{
|
||||
height: 32,
|
||||
px: 1.25,
|
||||
borderRadius: 1.5,
|
||||
textTransform: 'none',
|
||||
fontSize: 12.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.1,
|
||||
bgcolor: '#fff',
|
||||
borderColor: DT.borderSubtle,
|
||||
color: DT.textPrimary,
|
||||
'&:hover': {
|
||||
bgcolor: '#fff',
|
||||
borderColor: BRAND,
|
||||
color: BRAND,
|
||||
boxShadow: `0 0 0 3px ${ring(BRAND)}`
|
||||
},
|
||||
'&:focus-visible': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
|
||||
}}
|
||||
>
|
||||
Ops Agent
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Download — CSVExport already renders its own Button; style it directly so
|
||||
only ONE button paints in the action bar. Matches the contained brand look. */}
|
||||
<Box
|
||||
@@ -1628,6 +1664,19 @@ export default function OrdersDetails() {
|
||||
</Box>
|
||||
)}
|
||||
</Dialog>
|
||||
|
||||
{/* ============================================= || Ops Agent Drawer || ============================================= */}
|
||||
<OpsAgentDrawer
|
||||
open={opsOpen}
|
||||
onClose={() => setOpsOpen(false)}
|
||||
rows={rows}
|
||||
totalAvailable={statusCount}
|
||||
meta={{
|
||||
tenant: rows[0]?.tenantname,
|
||||
range: startdate === enddate ? dayjs(startdate).format('DD MMM YYYY') : `${dayjs(startdate).format('DD MMM')} – ${dayjs(enddate).format('DD MMM YYYY')}`,
|
||||
status: currentStatus
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
// project import
|
||||
// import GuestGuard from 'utils/route-guard/GuestGuard';
|
||||
import GuestGuard from 'utils/route-guard/GuestGuard';
|
||||
import CommonLayout from 'layout/CommonLayout';
|
||||
import Loadable from 'components/Loadable';
|
||||
|
||||
@@ -22,9 +22,9 @@ const LoginRoutes = {
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
// <GuestGuard>
|
||||
<GuestGuard>
|
||||
<CommonLayout />
|
||||
// </GuestGuard>
|
||||
</GuestGuard>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -33,6 +33,7 @@ const CreateCustomer = Loadable(lazy(() => import('pages/nearle/clients/createCu
|
||||
|
||||
const Createorder1 = Loadable(lazy(() => import('pages/nearle/orders/createorder1')));
|
||||
const MultipleOrders = Loadable(lazy(() => import('pages/nearle/orders/multipleOrders')));
|
||||
const WeeklyIntake = Loadable(lazy(() => import('pages/nearle/orders/WeeklyIntake')));
|
||||
|
||||
const OrderSummary = Loadable(lazy(() => import('pages/nearle/reports/orderSummary')));
|
||||
const OrdersDetails = Loadable(lazy(() => import('pages/nearle/reports/ordersDetails')));
|
||||
@@ -53,9 +54,9 @@ const MainRoutes = {
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
// <AuthGuard>
|
||||
<MainLayout />
|
||||
// </AuthGuard>
|
||||
<AuthGuard>
|
||||
<MainLayout />
|
||||
</AuthGuard>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
@@ -76,6 +77,10 @@ const MainRoutes = {
|
||||
path: 'create/grouporders',
|
||||
element: <MultipleOrders />
|
||||
},
|
||||
{
|
||||
path: 'weekly-intake',
|
||||
element: <WeeklyIntake />
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
element: <Createorder1 />
|
||||
|
||||
@@ -2,29 +2,32 @@ import PropTypes from 'prop-types';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
// project import
|
||||
import useAuth from 'hooks/useAuth';
|
||||
import { isSessionValid } from './authState';
|
||||
|
||||
// ==============================|| AUTH GUARD ||============================== //
|
||||
|
||||
// Blocks a protected route when there is no valid session.
|
||||
//
|
||||
// Previously this used useAuth(), which throws unless JWTProvider wraps the
|
||||
// tree -- and that provider is commented out in App.js. That is why the guard
|
||||
// was disabled in the route files rather than fixed, leaving every route
|
||||
// publicly reachable. It now reads the same localStorage session the rest of
|
||||
// the app uses.
|
||||
//
|
||||
// Children are withheld while unauthenticated so a protected page cannot
|
||||
// paint (and fire its data queries) during the redirect.
|
||||
const AuthGuard = ({ children }) => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const ok = isSessionValid();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) {
|
||||
navigate('login', {
|
||||
state: {
|
||||
from: location.pathname
|
||||
},
|
||||
replace: true
|
||||
});
|
||||
navigate('login', { replace: true });
|
||||
if (!ok) {
|
||||
navigate('/login', { state: { from: location.pathname }, replace: true });
|
||||
}
|
||||
}, [isLoggedIn, navigate, location]);
|
||||
}, [ok, navigate, location.pathname]);
|
||||
|
||||
return children;
|
||||
return ok ? children : null;
|
||||
};
|
||||
|
||||
AuthGuard.propTypes = {
|
||||
|
||||
@@ -2,28 +2,30 @@ import PropTypes from 'prop-types';
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
// project import
|
||||
import { APP_DEFAULT_PATH } from 'config';
|
||||
import useAuth from 'hooks/useAuth';
|
||||
import { isSessionValid } from './authState';
|
||||
|
||||
// ==============================|| GUEST GUARD ||============================== //
|
||||
|
||||
// Keeps an already-signed-in user off the login page, sending them back to
|
||||
// wherever AuthGuard bounced them from (or the default landing path).
|
||||
//
|
||||
// Reads the same localStorage session as AuthGuard -- see authState.js for why
|
||||
// this no longer uses useAuth().
|
||||
const GuestGuard = ({ children }) => {
|
||||
const { isLoggedIn } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const ok = isSessionValid();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
navigate(location?.state?.from ? location?.state?.from : APP_DEFAULT_PATH, {
|
||||
state: {
|
||||
from: ''
|
||||
},
|
||||
replace: true
|
||||
});
|
||||
if (ok) {
|
||||
navigate(location?.state?.from || APP_DEFAULT_PATH, { state: { from: '' }, replace: true });
|
||||
}
|
||||
}, [isLoggedIn, navigate, location]);
|
||||
}, [ok, navigate, location]);
|
||||
|
||||
// Unlike AuthGuard, render children regardless: a signed-in user seeing the
|
||||
// login form for one frame is harmless, whereas blanking it would leave a
|
||||
// signed-out user staring at nothing if the redirect ever failed.
|
||||
return children;
|
||||
};
|
||||
|
||||
|
||||
25
src/utils/route-guard/authState.js
Normal file
25
src/utils/route-guard/authState.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Single source of truth for "is this session still valid".
|
||||
//
|
||||
// The app authenticates via localStorage (login.js writes authname/tenantid/
|
||||
// applocationid/...), NOT via the JWTContext this template shipped with --
|
||||
// JWTProvider is commented out in App.js, so useAuth() throws. The guards
|
||||
// therefore read localStorage directly, using the same 1-hour window App.js
|
||||
// already enforces on its interval, so both agree on when a session is over.
|
||||
|
||||
export const SESSION_MAX_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
export const isSessionValid = () => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
if (!localStorage.getItem('authname')) return false;
|
||||
const started = Number(localStorage.getItem('sessionStartTime'));
|
||||
// A missing/unparseable start time means a session written before this
|
||||
// check existed. Treat it as valid rather than logging the user out --
|
||||
// App.js stamps one on mount.
|
||||
if (!Number.isFinite(started) || started <= 0) return true;
|
||||
return Date.now() - started < SESSION_MAX_AGE_MS;
|
||||
} catch (e) {
|
||||
// Private mode or blocked storage: fail closed.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
70
src/utils/route-guard/authState.test.js
Normal file
70
src/utils/route-guard/authState.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Session-validity tests. This function now gates every protected route, so
|
||||
* a wrong answer either locks out a legitimate operator or leaves the console
|
||||
* publicly reachable.
|
||||
*/
|
||||
|
||||
import { isSessionValid, SESSION_MAX_AGE_MS } from './authState';
|
||||
|
||||
const setSession = (authname, startedMsAgo) => {
|
||||
localStorage.clear();
|
||||
if (authname !== null) localStorage.setItem('authname', authname);
|
||||
if (startedMsAgo !== undefined) localStorage.setItem('sessionStartTime', String(Date.now() - startedMsAgo));
|
||||
};
|
||||
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
describe('isSessionValid', () => {
|
||||
it('rejects when nothing is stored', () => {
|
||||
expect(isSessionValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when authname is absent even if a start time exists', () => {
|
||||
localStorage.setItem('sessionStartTime', String(Date.now()));
|
||||
expect(isSessionValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a fresh session', () => {
|
||||
setSession('operator', 1000);
|
||||
expect(isSessionValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a session just inside the window', () => {
|
||||
setSession('operator', SESSION_MAX_AGE_MS - 5000);
|
||||
expect(isSessionValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a session past the window', () => {
|
||||
setSession('operator', SESSION_MAX_AGE_MS + 1000);
|
||||
expect(isSessionValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects exactly at the boundary', () => {
|
||||
setSession('operator', SESSION_MAX_AGE_MS);
|
||||
expect(isSessionValid()).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts when the start time is missing, rather than logging the user out', () => {
|
||||
// A session written before this check existed. App.js stamps one on mount.
|
||||
localStorage.setItem('authname', 'operator');
|
||||
expect(isSessionValid()).toBe(true);
|
||||
});
|
||||
|
||||
it.each([['not-a-number'], [''], ['0'], ['-1']])('accepts an unparseable start time (%p)', (value) => {
|
||||
localStorage.setItem('authname', 'operator');
|
||||
localStorage.setItem('sessionStartTime', value);
|
||||
expect(isSessionValid()).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when storage throws', () => {
|
||||
const spy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('SecurityError: storage blocked');
|
||||
});
|
||||
expect(isSessionValid()).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('matches the 1-hour window App.js enforces', () => {
|
||||
expect(SESSION_MAX_AGE_MS).toBe(60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
283
weekly-order-intake-plan.html
Normal file
283
weekly-order-intake-plan.html
Normal file
@@ -0,0 +1,283 @@
|
||||
<title>Weekly Order Intake</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500;6..72,600&family=Public+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap">
|
||||
<style>
|
||||
:root{
|
||||
--paper:#f6f5f8; --surface:#ffffff; --surface-2:#eeecf2; --sunken:#e6e3ec;
|
||||
--ink:#1a1526; --ink-2:#4a4459; --ink-3:#7b7389; --rule:#dcd8e4; --rule-2:#c8c2d4;
|
||||
--brand:#662582; --brand-2:#9255ab; --brand-soft:#f0e7f5; --on-brand:#ffffff;
|
||||
--crit:#b3261e; --crit-soft:#fae4e2; --warn:#8a5600; --warn-soft:#f8ead2;
|
||||
--ok:#2c6b45; --ok-soft:#dceae2;
|
||||
--shadow:0 1px 2px rgba(26,21,38,.05), 0 8px 22px -14px rgba(26,21,38,.3);
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root:not([data-theme="light"]){
|
||||
--paper:#131019; --surface:#1c1826; --surface-2:#262032; --sunken:#0e0c13;
|
||||
--ink:#ece9f1; --ink-2:#b0a8bf; --ink-3:#847b93; --rule:#332c40; --rule-2:#443b53;
|
||||
--brand:#c79ade; --brand-2:#a679c4; --brand-soft:#2a1b35; --on-brand:#1c0f24;
|
||||
--crit:#f0938a; --crit-soft:#3b1a17; --warn:#dca85c; --warn-soft:#332510;
|
||||
--ok:#7cc79c; --ok-soft:#132f21;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.8);
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"]{
|
||||
--paper:#131019; --surface:#1c1826; --surface-2:#262032; --sunken:#0e0c13;
|
||||
--ink:#ece9f1; --ink-2:#b0a8bf; --ink-3:#847b93; --rule:#332c40; --rule-2:#443b53;
|
||||
--brand:#c79ade; --brand-2:#a679c4; --brand-soft:#2a1b35; --on-brand:#1c0f24;
|
||||
--crit:#f0938a; --crit-soft:#3b1a17; --warn:#dca85c; --warn-soft:#332510;
|
||||
--ok:#7cc79c; --ok-soft:#132f21;
|
||||
--shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.8);
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);font-family:"Public Sans",system-ui,-apple-system,"Segoe UI",sans-serif;font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased}
|
||||
h1,h2,h3{font-family:Newsreader,Georgia,serif;font-weight:600;text-wrap:balance;margin:0;letter-spacing:-.005em}
|
||||
.mono,code{font-family:"JetBrains Mono",ui-monospace,monospace;font-variant-numeric:tabular-nums}
|
||||
code{font-size:.87em;background:var(--sunken);padding:1px 5px;border-radius:4px}
|
||||
.wrap{max-width:920px;margin:0 auto;padding:0 24px 72px}
|
||||
|
||||
header.top{border-bottom:1px solid var(--rule);background:var(--surface)}
|
||||
.top-in{max-width:920px;margin:0 auto;padding:20px 24px;display:flex;flex-wrap:wrap;gap:14px;align-items:baseline;justify-content:space-between}
|
||||
.top-in h1{font-size:21px}
|
||||
.top-in .sub{font-size:12px;color:var(--ink-3);letter-spacing:.05em;text-transform:uppercase;font-weight:600;font-family:"Public Sans",sans-serif}
|
||||
|
||||
.verdict{margin:26px 0 34px;border:1px solid var(--rule);border-left:4px solid var(--crit);border-radius:10px;background:var(--surface);box-shadow:var(--shadow);padding:20px 22px}
|
||||
.verdict h2{font-size:24px;line-height:1.3;max-width:30ch}
|
||||
.verdict p{margin:10px 0 0;color:var(--ink-2);max-width:72ch}
|
||||
.verdict p:last-child{margin-bottom:0}
|
||||
|
||||
section{margin-bottom:40px}
|
||||
.eyebrow{font-size:10.5px;letter-spacing:.11em;text-transform:uppercase;color:var(--brand);font-weight:700;margin-bottom:5px}
|
||||
h2.sec{font-size:22px;margin-bottom:6px}
|
||||
h3{font-size:16.5px;margin:22px 0 6px}
|
||||
p.lead{color:var(--ink-2);max-width:72ch;margin:0 0 16px}
|
||||
p{max-width:72ch}
|
||||
ul,ol{max-width:72ch;padding-left:20px;color:var(--ink-2)}
|
||||
li{margin-bottom:6px}
|
||||
li strong{color:var(--ink)}
|
||||
|
||||
.tblwrap{border:1px solid var(--rule);border-radius:10px;overflow-x:auto;background:var(--surface);box-shadow:var(--shadow)}
|
||||
table{border-collapse:collapse;width:100%;min-width:640px;font-size:13.5px}
|
||||
th{font-family:"Public Sans",sans-serif;font-size:10px;letter-spacing:.07em;text-transform:uppercase;color:var(--ink-3);font-weight:700;text-align:left;padding:10px 14px;background:var(--surface-2);border-bottom:1px solid var(--rule);white-space:nowrap}
|
||||
td{padding:9px 14px;border-bottom:1px solid var(--rule);vertical-align:top}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td.st{white-space:nowrap;font-weight:600;font-size:12.5px}
|
||||
.ok{color:var(--ok)} .no{color:var(--crit)} .wn{color:var(--warn)}
|
||||
.req{display:inline-block;font-family:"JetBrains Mono",monospace;font-size:9.5px;font-weight:600;padding:1px 5px;border-radius:3px;background:var(--crit-soft);color:var(--crit);margin-left:6px;vertical-align:1px}
|
||||
|
||||
.split{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--rule);border:1px solid var(--rule);border-radius:10px;overflow:hidden}
|
||||
@media(max-width:760px){.split{grid-template-columns:1fr}}
|
||||
.half{background:var(--surface);padding:16px 18px}
|
||||
.half h3{margin:0 0 4px;font-size:15.5px}
|
||||
.half .tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase;font-weight:700;margin-bottom:8px}
|
||||
.half ul{padding-left:18px;margin:0;font-size:13.5px}
|
||||
|
||||
.phases{display:flex;flex-direction:column;gap:1px;background:var(--rule);border:1px solid var(--rule);border-radius:10px;overflow:hidden}
|
||||
.phase{background:var(--surface);padding:16px 18px;display:grid;grid-template-columns:auto 1fr;gap:16px}
|
||||
.pnum{font-family:"JetBrains Mono",monospace;font-size:12px;font-weight:600;color:var(--brand);padding-top:2px}
|
||||
.phase h3{margin:0 0 3px;font-size:16px}
|
||||
.phase .gate{font-size:12.5px;color:var(--ink-2);margin-top:8px;padding-top:8px;border-top:1px dashed var(--rule-2)}
|
||||
.phase .gate b{font-family:"Public Sans",sans-serif;font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--brand);margin-right:7px}
|
||||
.phase ul{margin:6px 0 0;font-size:13.5px;padding-left:18px}
|
||||
|
||||
.callout{border:1px solid var(--rule);border-left:3px solid var(--warn);background:var(--surface);border-radius:8px;padding:13px 16px;margin:16px 0;font-size:13.5px;color:var(--ink-2)}
|
||||
.callout b{color:var(--ink)}
|
||||
.callout.brand{border-left-color:var(--brand)}
|
||||
|
||||
.decisions{counter-reset:d;list-style:none;padding:0;margin:0}
|
||||
.decisions li{counter-increment:d;background:var(--surface);border:1px solid var(--rule);border-radius:9px;padding:13px 16px 13px 46px;position:relative;margin-bottom:8px;color:var(--ink-2);font-size:14px;max-width:none}
|
||||
.decisions li::before{content:counter(d);position:absolute;left:16px;top:13px;font-family:"JetBrains Mono",monospace;font-size:12px;font-weight:600;color:var(--brand)}
|
||||
.decisions li b{color:var(--ink);display:block;font-family:"Public Sans",sans-serif}
|
||||
|
||||
footer{margin-top:36px;padding-top:16px;border-top:1px solid var(--rule);font-size:12.5px;color:var(--ink-3)}
|
||||
@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
|
||||
<header class="top">
|
||||
<div class="top-in">
|
||||
<h1>Weekly Order Intake</h1>
|
||||
<span class="sub">DailyGrubs Console · implementation plan</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<div class="verdict">
|
||||
<h2>The attached file cannot be the weekly input. It is a report of orders that already happened.</h2>
|
||||
<p>Every column in <code>Orders_Detail_2026-09-02</code> is an <em>outcome</em>: <code>orderstatus=delivered</code>, <code>deliverytime</code>, <code>rider</code>, <code>assigntime</code>, <code>kms</code>. Feeding it back would re-create twelve deliveries that were already made, already ridden and already charged.</p>
|
||||
<p>It also fails the console’s existing upload contract outright. Of the six columns the bulk uploader marks required, <strong>two are absent from the report entirely and one is blank on all twelve rows.</strong> This is fixable — the underlying customer data is all there — but it needs a purpose-built intake template, and that is decision one below.</p>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Analysis · the gap</div>
|
||||
<h2 class="sec">Report export vs. the upload contract</h2>
|
||||
<p class="lead">The console already accepts CSV/XLSX bulk uploads via <code>orders/multipleOrders.js</code>, which maps incoming headers through a fixed <code>headerMap</code> and rejects the file if a required column is missing. Here is that contract against what the report actually provides.</p>
|
||||
<div class="tblwrap">
|
||||
<table>
|
||||
<thead><tr><th>Upload column</th><th>Report field</th><th>Filled</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="mono">sendername<span class="req">req</span></td><td class="mono">locationname</td><td class="mono">12/12</td><td class="st ok">Mappable</td></tr>
|
||||
<tr><td class="mono">senderphone<span class="req">req</span></td><td class="mono">locationcontactno</td><td class="mono">12/12</td><td class="st ok">Mappable</td></tr>
|
||||
<tr><td class="mono">senderaddress<span class="req">req</span></td><td class="mono">Pickupaddress</td><td class="mono">0/12</td><td class="st wn">Present but empty</td></tr>
|
||||
<tr><td class="mono">receivername<span class="req">req</span></td><td class="mono">deliverycustomer</td><td class="mono">12/12</td><td class="st ok">Mappable</td></tr>
|
||||
<tr><td class="mono">receiveralternatephone<span class="req">req</span></td><td class="mono">—</td><td class="mono">—</td><td class="st no">Absent</td></tr>
|
||||
<tr><td class="mono">itemdescription<span class="req">req</span></td><td class="mono">—</td><td class="mono">1/12</td><td class="st no">Absent</td></tr>
|
||||
<tr><td class="mono">receiverphone</td><td class="mono">deliverycontactno</td><td class="mono">12/12</td><td class="st ok">Mappable</td></tr>
|
||||
<tr><td class="mono">receiverfulladdress</td><td class="mono">deliveryaddress</td><td class="mono">12/12</td><td class="st ok">Mappable</td></tr>
|
||||
<tr><td class="mono">receiverlatitude</td><td class="mono">deliverylat</td><td class="mono">12/12</td><td class="st ok">Mappable</td></tr>
|
||||
<tr><td class="mono">receiverlongitude</td><td class="mono">deliverylong</td><td class="mono">12/12</td><td class="st ok">Mappable</td></tr>
|
||||
<tr><td class="mono">pickupdate</td><td class="mono">deliverydate</td><td class="mono">12/12</td><td class="st wn">Timezone-suspect</td></tr>
|
||||
<tr><td class="mono">Quantity</td><td class="mono">—</td><td class="mono">—</td><td class="st no">Absent</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Analysis · the harder constraint</div>
|
||||
<h2 class="sec">Orders attach to customer records, not to addresses</h2>
|
||||
<p class="lead">This is the part that decides the architecture. <code>createorders</code> posts an array in which every element carries <code>customerid</code> and <code>deliveryid</code>. A row of free text is not enough — each line of the weekly file has to <em>resolve to a customer that exists</em>, or one has to be created first. That is exactly why the current UI says “Press Continue to add as drop customers” between upload and creation.</p>
|
||||
<p class="lead">Three more values are never in any sheet and must come from elsewhere at commit time:</p>
|
||||
<ul>
|
||||
<li><strong>The pickup location</strong> — <code>applocationid</code>, <code>partnerid</code>, <code>locationid</code>, <code>moduleid</code> and the pickup coordinates all come from the selected location record.</li>
|
||||
<li><strong>The pickup slot</strong> — chosen per batch, and it drives <code>deliverytime</code>.</li>
|
||||
<li><strong>The charge</strong> — computed from tenant pricing and distance, never copied from the sheet. On this data the rule fits ₹60 up to 4 km then ₹12/km, but that is inferred, not configuration.</li>
|
||||
</ul>
|
||||
<div class="callout"><b>Consequence:</b> the weekly file supplies <em>who and what</em>. The pickup point, the slot and the money are supplied by the run, not the sheet. Any automation has to bind them explicitly rather than inherit them by accident.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Design</div>
|
||||
<h2 class="sec">Where an agent earns its place — and where it does not</h2>
|
||||
<p class="lead">Most of this pipeline is not an AI problem. Parsing, validating, pricing and posting are deterministic and must stay that way, because they are the steps where a confident wrong answer costs money. The agent belongs at the three genuinely ambiguous joints.</p>
|
||||
<div class="split">
|
||||
<div class="half">
|
||||
<div class="tag" style="color:var(--ink-3)">Deterministic — no model</div>
|
||||
<ul>
|
||||
<li>Parse XLSX/CSV, drop <code>TOTAL</code> rows</li>
|
||||
<li>Validate required columns, reject early</li>
|
||||
<li>Compute charge from tenant pricing</li>
|
||||
<li>Duplicate detection by run key</li>
|
||||
<li>Build and POST the <code>createorders</code> payload</li>
|
||||
<li>Reuse <code>scanDataQuality</code> as a pre-commit gate</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="half">
|
||||
<div class="tag" style="color:var(--brand)">Agent — proposes, never commits</div>
|
||||
<ul>
|
||||
<li><strong>Customer resolution:</strong> match name + phone + address to an existing record, or propose a new one</li>
|
||||
<li><strong>Address normalisation:</strong> split free text into locality/landmark/pincode, including Tamil-script entries</li>
|
||||
<li><strong>Anomaly triage:</strong> explain why a row looks wrong, in operator language</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="callout brand"><b>The rule:</b> the agent returns a proposal with a confidence score and its reasoning. Deterministic code applies a threshold. Anything below it goes to the operator queue rather than into the batch. A model never writes an order.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Build</div>
|
||||
<h2 class="sec">Phases</h2>
|
||||
<p class="lead">Ordered by dependency — each phase is useful on its own, and nothing writes an order until phase 4.</p>
|
||||
<div class="phases">
|
||||
<div class="phase">
|
||||
<div class="pnum">00</div>
|
||||
<div>
|
||||
<h3>Define the intake template</h3>
|
||||
<p style="margin:0;color:var(--ink-2);font-size:13.5px">A real order-intake sheet: sender, receiver, phone, address, item, quantity, requested date, collect-cash. Published as a downloadable template from the console so the weekly file is generated against a known contract instead of being whatever someone exported.</p>
|
||||
<div class="gate"><b>Gate</b>Nothing else starts until the column list is agreed and a sample week exists.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase">
|
||||
<div class="pnum">01</div>
|
||||
<div>
|
||||
<h3>Parser and validator, dry-run only</h3>
|
||||
<ul>
|
||||
<li>Pure module beside <code>opsAnalysis.js</code>, same testing approach</li>
|
||||
<li>Per-row result: <code>ok</code> / <code>needs-review</code> / <code>rejected</code>, each with a reason</li>
|
||||
<li>Reuses the existing <code>scanDataQuality</code> findings</li>
|
||||
</ul>
|
||||
<div class="gate"><b>Gate</b>Run a real week through it and read the report. Zero writes.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase">
|
||||
<div class="pnum">02</div>
|
||||
<div>
|
||||
<h3>Customer resolution</h3>
|
||||
<ul>
|
||||
<li>Deterministic first: exact phone match against <code>gettenantcustomers</code></li>
|
||||
<li>Agent second, only for what did not match exactly</li>
|
||||
<li>Every proposal carries confidence + rationale; below threshold goes to the queue</li>
|
||||
</ul>
|
||||
<div class="gate"><b>Gate</b>Measure match accuracy on a known week before it influences anything.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase">
|
||||
<div class="pnum">03</div>
|
||||
<div>
|
||||
<h3>Review and commit screen</h3>
|
||||
<ul>
|
||||
<li>Proposed batch shown as a table: resolved customer, address, charge, flags</li>
|
||||
<li>Operator picks pickup location and slot — the values the sheet cannot supply</li>
|
||||
<li>One commit button, posting to <code>orders/createorders</code></li>
|
||||
</ul>
|
||||
<div class="gate"><b>Gate</b>This is the first phase that creates real orders. Ship it behind a role check.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase">
|
||||
<div class="pnum">04</div>
|
||||
<div>
|
||||
<h3>Scheduling and idempotency</h3>
|
||||
<ul>
|
||||
<li>Weekly trigger: watched folder, mailbox, or an upload endpoint — see decision 3</li>
|
||||
<li><strong>Run key per row</strong> = hash(tenant, week, receiver phone, address, item). Persisted.</li>
|
||||
<li>A replayed file is a no-op. The endpoint has no dedupe of its own, so this layer must.</li>
|
||||
</ul>
|
||||
<div class="gate"><b>Gate</b>Deliberately re-run last week’s file and confirm zero new orders.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="phase">
|
||||
<div class="pnum">05</div>
|
||||
<div>
|
||||
<h3>Supervised autonomy</h3>
|
||||
<ul>
|
||||
<li>Auto-commit only rows that are fully resolved, unflagged and above threshold</li>
|
||||
<li>Anything else waits in the queue; the operator gets a digest, not a surprise</li>
|
||||
<li>Kill switch, and an audit row per created order naming the run that made it</li>
|
||||
</ul>
|
||||
<div class="gate"><b>Gate</b>Enable only after several consecutive clean weeks at phase 4.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Risk</div>
|
||||
<h2 class="sec">What will go wrong, and the guardrail for each</h2>
|
||||
<div class="tblwrap">
|
||||
<table>
|
||||
<thead><tr><th>Risk</th><th>Why it is real here</th><th>Guardrail</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>Duplicate orders</strong></td><td>The same file re-sent, or a retry after a timeout. <code>createorders</code> does not dedupe.</td><td>Persisted run key per row; replay is a no-op.</td></tr>
|
||||
<tr><td><strong>Wrong customer</strong></td><td>Fuzzy matching on names like <code>HAMEEZ RAMEEZ</code> with trailing spaces, and two scripts for one locality.</td><td>Exact phone match first; agent proposals below threshold never auto-commit.</td></tr>
|
||||
<tr><td><strong>Bad geocode</strong></td><td>Addresses carry plus-codes and free text; the store itself resolves to two precisions.</td><td>Reject rows whose coordinates fall outside the tenant’s service radius.</td></tr>
|
||||
<tr><td><strong>Wrong slot / date</strong></td><td>The <code>deliverydate</code> timezone defect is unresolved and reads 5½ hours early.</td><td>Operator sets the slot per batch; never inherit a date from the sheet until the backend is fixed.</td></tr>
|
||||
<tr><td><strong>Silent field drift</strong></td><td>Already observed: the API sends <code>ridername</code>/<code>deliverycharges</code>, the export writes <code>rider</code>/<code>deliverycharge</code>. Both fail as blanks and zeros, not errors.</td><td>Validate the parsed shape and fail loudly on an unknown or missing column.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="eyebrow">Blocked on you</div>
|
||||
<h2 class="sec">Decisions needed before phase 0</h2>
|
||||
<ol class="decisions">
|
||||
<li><b>What is actually in the weekly file?</b> Recurring standing orders for the same customers, or a fresh list each week? The first makes customer resolution nearly free; the second makes it the hard part.</li>
|
||||
<li><b>One pickup location, or several?</b> This tenant has two — Bawa Medical (King Nagar) and Bawaa Medicals 2 (Weavers Colony). If a single file mixes both, it needs a sender column per row rather than one choice per batch.</li>
|
||||
<li><b>How does the file arrive?</b> Operator upload in the console is simplest and needs no new infrastructure. A watched folder or mailbox is more automatic and more to build and secure.</li>
|
||||
<li><b>Where does the agent run?</b> Still open from before: inside <code>backend_jupiter</code>, or a separate DailyGrubs service it calls. Nothing in phases 0–1 depends on the answer, so building can start regardless.</li>
|
||||
<li><b>Who may commit a batch?</b> Auth in this console is localStorage and the route guards are currently commented out. A screen that creates real orders needs that settled first.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<footer>Analysis from <span class="mono">Orders_Detail_2026-09-02_073552.xlsx</span> — 13 rows, 12 orders plus one TOTAL row · upload contract read from <span class="mono">orders/multipleOrders.js</span> · payload shape from <span class="mono">POST /orders/createorders</span></footer>
|
||||
</div>
|
||||
Reference in New Issue
Block a user