297 lines
14 KiB
Markdown
297 lines
14 KiB
Markdown
|
|
# 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."
|