WFDASH-WEBAPP/docs/rep-performance-data-source.md

297 lines
14 KiB
Markdown
Raw Normal View History

# Rep Performance Report — Data Source & Provenance
> **Audience:** anyone who asks where the numbers come from — a manager, an
> auditor, a customer-facing colleague.
> **Scope:** `/reports/rep-performance` (summary, per-rep detail, team
> comparison) and the PDF download.
> **Author context:** WFDash is the operator app (Next.js + PocketBase). This doc
> describes where every figure on the report originates, so the report can be
> defended without reading the code.
---
## TL;DR
The report is **two separate things stitched together**, and it matters that
you can tell them apart:
| | **A — Activity** | **B — Time log** |
|---|---|---|
| What it is | Quotes, orders, delivery notes, customers, companies | "Where the time went", visit map, hours per place |
| Source | **WFDASH itself** (PocketBase) | **Hardwired vehicle tracker** (GPS unit in the truck) |
| Entered by | Reps working in the app; some quotes arrive by email automation | A CSV export from the tracker portal, imported by a manager |
| On a phone? | No — server-side database | No — a fixed vehicle unit, not a personal phone |
| Pages in the PDF | 1, 3, 4, 5, 6 | 2 (and the map behind it) |
**The charts anyone is likely to challenge — quotes loaded, orders placed,
customers visited, companies — are all Section A, and every one of them is a
read-only count of rows that already exist in WFDASH.** Nothing on those pages
is typed in by a person for reporting purposes; the report only counts work
the reps already did in the normal course of business.
The only non-WFDASH input is Section B, and it is confined to two clearly
labelled pages.
---
## 1. Topology — where the data physically comes from
```
WHO ENTERS THE DATA
│
┌───────────┴───────────┐
▼ ▼
Reps working in the app n8n email intake
quotes · orders quotes from RFQ mail
│ │
│ order_items │
│ (cancellation check) │
│ │
└───────────┬───────────┘
▼
┌───────────────────┐
│ delivery_notes │ raised & completed
└─────────┬─────────┘
▼
══════════════════════════════════════════════════════
WFDASH → POCKETBASE (system of record)
quotes · quotes_items · orders · order_items
delivery_notes · customers · companies · users
══════════════════════════════════════════════════════
│
│ read-only counts — nothing is created
▼
┌───────────────────┐
│ REP PERFORMANCE │
│ REPORT │ (this document)
└─────────┬─────────┘
▼
screen · PDF download
```
---
## 2. Section A — Activity metrics (system of record)
Every row below is a **count of existing WFDASH records** inside the selected
date window. The report creates nothing, edits nothing, and nobody enters a
"report number" anywhere.
| Report column | Collection | Field counted | Rule |
|---|---|---|---|
| **Quotes loaded (QL)** | `quotes` | `created` | Any quote created in the window. |
| **Quotes done (QD)** | `quotes` | `created` + `status` | Quote **and** `claimed_by` set **and** status is `emailed`, `approved` or `sent`. |
| **Orders placed (OP)** | `orders` | `created` | Order in the window, **minus** any order with at least one `order_items` row marked `cancelled`. |
| **Delivery notes (DN)** | `delivery_notes` | `created` | DN in the window, excluding `cancelled`. |
| **Orders received (OR)** | `delivery_notes` | `created` + `status` | DN in the window with status `completed`. |
| **Customers / Companies** | `customers`, `companies` | — | Derived from the rows above; customers roll up to the company on the customer record. |
| **Total** | — | — | Plain sum of the five metrics above. Not weighted, not scored. |
**Code of record:** `src/lib/server/rep-performance-service.ts` (the counting
loop is `loadActivity`). The `QD` status list and the order-rep resolver are
imported from `src/lib/server/boss-dashboard-service.ts`, so this report and
the boss dashboard cannot drift apart.
### Date filtering
Every collection is filtered on the same generated window:
```
created >= "<from> 00:00:00.000Z" && created < "<to+1 day> 00:00:00.000Z"
```
The upper bound is pushed out one day so the last day is **inclusive** — a
common off-by-one that would otherwise silently drop activity on the final day
of the month.
---
## 3. Who gets the credit (attribution)
This is the part most worth explaining, because "whose number is it?" is the
usual challenge.
| Metric | Credited to |
|---|---|
| Quotes loaded, Delivery notes, Orders received | The rep who **owns the customer** (`customers.rep`) |
| Quotes done | The rep who **claimed the quote** (`quotes.claimed_by`) |
| Orders placed | The rep on the **order itself** (`orders.rep`) — *not* the customer's owner |
Orders deliberately follow the order's own rep field, matching the boss
dashboard. A rep who is covering someone else's customers shows their orders
under their own name, which is the behaviour the business expects.
### ⚠️ Known quirk worth knowing about
`orders.rep` is a **free-text field** that over the years holds *either* a
PocketBase user id *or* a person's name. The report resolves both: exact user
id first, then a case-insensitive name match. This is not a cosmetic detail —
for Tiaan in September 2026 it is the difference between 1 order and 27:
| How you look it up | Result |
|---|---|
| `orders.rep = "<user id>"` | 1 |
| `orders.rep = "Tiaan"` | 26 |
| **Report total** | **27** |
If someone queries the database themselves and gets a small number, this is
why.
---
## 4. Section B — The time log (vehicle tracker, not WFDASH)
Stated plainly: **the "where the time went" page is not sourced from WFDASH.**
It is sourced from a GPS tracking unit fitted to the vehicle. It is kept in a
separate section of the report for exactly this reason, and it should be
presented differently from Section A if it is challenged.
### 4.1 How it gets in
1. A manager downloads a **"Travel History" CSV** from the tracker's web portal.
2. They upload it on the report page (*Import time log*).
3. The file is parsed and written into PocketBase as `rep_trip_legs` — **one
row per tracker interval, with no interpretation applied at import time.**
```
══════ SEPARATE PROVENANCE — NOT WFDASH ══════
┌────────────────────────────────┐ hardwired unit in the truck,
│ Vehicle GPS tracker │ logs position every few minutes
│ (vehicle registered in │
│ users.tracker_vehicle_id) │
└────────────────────────────────┘
│ manager downloads the CSV from the tracker
│ portal, then uploads it on the report page
▼
┌────────────────────────────────┐ one row per tracker interval,
│ rep_trip_legs │ stored in PocketBase as a copy
│ (raw, un-interpreted) │
└────────────────────────────────┘
│ read-time derivation:
│ work-hours clip · 250 m clustering ·
│ company matching · operator labels
▼
┌────────────────────────────────┐ PDF page 2 + the visit map
│ "Where the time went" │
└────────────────────────────────┘
```
Storing raw and deriving at read time is deliberate: if company coordinates or
weekend rules are corrected tomorrow, historical reports improve immediately
with **no re-import**.
Which vehicle belongs to which rep is recorded on the user
(`users.tracker_vehicle_id`, e.g. `MW13TCGP`), so a CSV cannot be silently
attached to the wrong person.
Re-importing a file **replaces only the dates that file covers**, so importing
October does not destroy September.
### 4.2 What is derived (and therefore arguable)
| Derived value | Rule | Why it exists |
|---|---|---|
| Work hours | 07:30–17:00, local (SAST, UTC+2, no DST) | One global default across the fleet. |
| Weekends | **Excluded** | A truck is often out or parked at weekends for reasons unrelated to selling. One flag flips this back on. |
| Noise filter | Stops under **5 minutes** dropped | Traffic lights, gate checks, GPS drift. Applied at *period* level, so a place survives or drops whole rather than losing a 4-minute sliver. |
| Clustering | Points within **250 m** of each other become one "place" | Keeps neighbouring mine gates apart while absorbing GPS jitter when parked. |
| Place name | Matched against **`companies.coordinates` within 750 m** | `customers.coordinates` is deliberately **ignored** — those are residential addresses, and reporting "visited: a customer's house" would be worse than no name. |
| Unnamed places | Operator-assigned labels, stored in `place_overrides`, matched by proximity (300 m) | Most company records have no coordinates yet, so an unnamed place is a placeholder the manager can name on the map. |
**Consequence to be honest about:** a place only carries a company name if the
company has usable coordinates **or** someone has mapped it manually. Unmapped
places show as a raw location, not as a company. The time log is a strong
signal on *how the time was spent*; it is only as good as the mapping for
*naming* it.
---
## 5. How to verify any number yourself
The report is read-only, so anyone with admin access can reproduce its figures
directly from the database. Worked example — **Tiaan, September 2026**, the
exact period in the sample PDF:
| Report says | Raw PocketBase query | Result |
|---|---|---|
| Orders placed = 27 | `orders` where `rep` = id **or** `"Tiaan"`, created in Sept | 1 + 26 = **27** ✓ |
| Delivery notes = 3 | `delivery_notes` where `customer.rep` = Tiaan, `status != "cancelled"` | **3** ✓ |
| Orders received = 2 | same, `status == "completed"` | **2** ✓ |
| Time log rows = 692 | `rep_trip_legs` where `rep` = Tiaan | **692** ✓ |
The same check works in the WFDASH admin UI, or via the PocketBase admin API
with a filter such as:
```
customer.rep = "54uj39i4juqkh9x" && created >= "2026-09-01 00:00:00.000Z"
&& created < "2026-10-01 00:00:00.000Z" && status != "cancelled"
```
Every metric is a plain count of rows returned by a filter like this one. There
is no spreadsheet step, no manual tally, and no intermediate cache to explain.
---
## 6. Which reps appear
The rep list is **not hardcoded**. Someone appears on the report if they own a
customer, claimed a quote in the period, or carry the `REP` user type. This
means:
- a new rep shows up without a code deploy;
- a rep with no activity in the period drops off naturally;
- accounts matching `test` / `qa` / `demo` are filtered out automatically.
The summary page can additionally hide low-activity reps via the **Min total**
control; this is a display filter only and does not change any underlying total.
---
## 7. Honest limitations
Stated up front so nobody is caught out:
1. **"Orders received" is approximated by completed delivery notes.** The
natural source would be `order_items.delivered_at`, but that field is empty
on every current record, so a completed DN is used instead. If delivery-note
completion is not always entered promptly, this figure runs behind reality.
2. **Order cancellation is coarse.** An order with *any* cancelled line is
excluded entirely, not just the cancelled portion.
3. **The time log's place names depend on manual mapping** (section 4.2).
4. **Work hours are a single fleet-wide default**, not per-rep shifts.
5. **The report reflects the selected period only.** A quote loaded in August
and sent in September counts its *completed* event in September.
---
## 8. Where the code lives
| Concern | File |
|---|---|
| Counting, attribution, company rollups | `src/lib/server/rep-performance-service.ts` |
| Shared status rules (kept in step with boss dashboard) | `src/lib/server/boss-dashboard-service.ts` |
| Tracker CSV parsing | `src/lib/server/travel-history-parser.ts` |
| Time log import + read-time derivation | `src/lib/server/rep-time-log-service.ts` |
| PDF layout (A4 portrait, 6 pages) | `src/lib/pdf/rep-performance-detail-document.tsx` |
| API surface | `src/app/api/v1/reports/rep-performance/**` |
| Schema for tracker data | `scripts/migrations/2026-09-28-create-rep-trip-legs.ts` |
| Schema for operator place labels | `scripts/migrations/2026-09-28-create-place-overrides.ts` |
Both the counting service and the CSV parser are covered by unit tests
(`rep-performance-service.test.ts`, `travel-history-parser.test.ts`), so the
rules in this document are enforced by the build rather than by memory.
---
## 9. One-paragraph answer
> "The report counts records in our own WFDASH database. Quotes loaded, orders
> placed and delivery notes are rows the reps created in the app while doing
> their jobs — we just count them. Customers and companies are read straight
> off those rows. The one exception is the 'where the time went' page, which
> comes from the GPS unit fitted to the truck, exported as a CSV and imported
> manually; it's a separate page for that reason. You can reproduce any number
> in section A yourself by filtering the database on the rep and the date
> range — the queries are in section 5."