MEGAINFRA-WEBAPP/docs/data-flows.md
DEls 08ed89d9c8 feat(dashboard): enhance operations dashboard with rain outlook and coordinate support
Updated the operations dashboard to include a rain outlook feature, providing users with a 7-day forecast based on site coordinates. Added latitude and longitude fields to the site form for better weather data integration. Enhanced the fuel log and site management functionalities to utilize dynamic fuel pricing based on historical data. Improved overall data handling and user experience across various dashboard components.
2026-09-06 19:59:52 +02:00

376 lines
18 KiB
Markdown

# Data flows
> End-to-end ingestion pipelines with sequence diagrams and the exact site-attribution strategy each one uses.
Every importer writes into a `data_import_batches` row, which carries the
original file for the audit trail and the resulting `processed_record_id`.
Three of the pipelines are synchronous (the file is parsed and inserted in
the same request) and two are async (a separate worker, n8n, processes
them and POSTs results back).
---
## 1. Timesheet import (synchronous)
**Route:** `POST /api/timesheets/import` (`src/app/api/timesheets/import/route.ts`)
**Input:** `multipart/form-data` with `file` (xlsx / xls / csv) plus optional `siteId` and `createMissingEmployees` form fields
**Output:** `{ batchId, totalRows, imported, skipped, errors, detectedFormat, createdEmployees }`
**Triggers:** `ImportTimesheetsButton` on the Workforce page
**Format auto-detection** (the route walks every sheet, first match wins):
1. **Hills Casual Matrix** — `looksLikeCasualMatrix(rows)` (`src/lib/timesheet-casual-parse.ts`) → one long row per (employee, day). Hours are `HH:MM`, "Absent" cells are skipped, names with leading commas are cleaned.
2. **Permanent wide** — `parseHillsTimesheet` (`src/lib/timesheet-grid-parse.ts`) → one row per employee with daily hours across columns.
3. **Long format** — header row `employee_no, date, hours_worked` for both CSV and XLSX fallback.
**Pipeline:**
```mermaid
sequenceDiagram
participant U as User (browser)
participant R as /api/timesheets/import
participant P as PocketBase
U->>R: multipart { file, siteId?, createMissingEmployees? }
R->>R: parseFile() → uniform long rows
alt createMissingEmployees && siteId
R->>P: getFullList(employees, fields="id,employee_no")
R->>P: create(missing employees, casual, site=siteId)
end
R->>P: create(data_import_batches, batch_type=timesheets, original_file)
R->>P: getFullList(employees, fields="id,employee_no")
loop for each parsed row
R->>P: create(timesheets, employee=lookup[row.employee_no])
end
R-->>U: { batchId, totalRows, imported, skipped, errors, ... }
```
**Site attribution.** Timesheets do not carry a `site` field. The route
writes the row against the employee record; site attribution happens at
*read time* in the metrics helpers by joining through
`employeeRates.get(timesheet.employee).site`. This is the only
collection in the system with no `site` or `project_code` on the row itself.
**Failure behaviour.**
- Unknown `employee_no` (and `createMissingEmployees` not set) → row errored, import continues.
- Per-row `pb.collection("timesheets").create` failure → row errored, import continues.
- File parse failure → 400 with a top-level `error` and `details`.
---
## 2. Fuel log import (synchronous)
**Route:** `POST /api/fuel-logs/import` (`src/app/api/fuel-logs/import/route.ts`)
**Input:** `multipart/form-data` with `file` (xlsx or csv)
**Output:** `{ batchId, totalRows, imported, skipped, errors, qrMatched, descMatched }`
**Triggers:** `ImportFuelLogsButton` on the Fleet page
**Expected columns** (case-insensitive, alias-resolved by `resolveColumn` in `src/lib/fuel-import-parse.ts`):
| Primary | Legacy aliases |
| --- | --- |
| `Date Logged` | `date`, `date_logged` |
| `Vehicle QR` | `vehicle_qr`, `qr`, `qr_code_id` |
| `Vehicle` | `vehicle_description`, `description` |
| `Km's/ Hours` | `odo_reading`, `odometer`, `hour_meter` |
| `Litres Pumped` | `litres`, `liters` |
| `Current Site` | `site`, `current_site` |
| `Latest Consumption` | `latest_consumption`, `consumption` (optional) |
**Pipeline:**
```mermaid
sequenceDiagram
participant U as User (browser)
participant R as /api/fuel-logs/import
participant P as PocketBase
U->>R: multipart { file }
R->>R: SheetJS parse → rowsRaw
R->>P: create(data_import_batches, batch_type=fuel_logs, original_file, status=processed)
R->>P: getFullList(vehicles, fields="id,description,project_code,qr_code_id")
R->>P: getFullList(sites, fields="id,name,project_code")
R-->>R: vehicleByQr, vehicleByDesc, siteByName maps
loop for each row
R->>R: parseDate, toNumber
R->>R: vehicle = vehicleByQr[qr] ?? vehicleByDesc[desc]
R->>R: site = siteByName[row.site.toLowerCase()]
R->>R: projectCode = site?.projectCode ?? vehicle.projectCode
R->>P: create(fuel_logs, site, vehicle, project_code, ...)
end
R-->>U: { batchId, totalRows, imported, skipped, errors, qrMatched, descMatched }
```
**Site attribution strategy.** Two-tier match:
1. The row's `Current Site` cell is matched case-insensitive against
`sites.name`. If found, the site relation and its `project_code` are
stamped on the row.
2. If the row has no site cell (legacy CSVs), the row's `project_code`
is set to the matched vehicle's `project_code` and the `site` relation
is left **empty**. The fuel log exists but isn't tied to a site by
relation — the only link is the text `project_code`.
This is the seam where **site attribution goes wrong** as the fleet grows —
see [multi-site-assessment §4](./multi-site-assessment.md#4-site-resolution-by-name-string).
Two sites with near-identical names, a renamed site, or a vehicle moved
between sites all produce silently wrong attribution.
**Failure behaviour.**
- Unknown vehicle (no QR match, no description match) → row errored, import continues.
- Invalid date / odo / litres → row errored, import continues.
- PB insert failure → row errored, import continues; the importer extracts
the field-level validation message via `formatPbError` (lines 65-78).
---
## 3. Plant hours import (synchronous)
**Route:** `POST /api/plant-hours/import` (`src/app/api/plant-hours/import/route.ts`)
**Input:** `multipart/form-data` with `file` (xlsx or csv, max 10 MB)
**Output:** `{ batchId, totalRows, imported, skipped, mismatched, warnings, errors }`
**Orchestrator:** `importPlantHours` in `src/lib/plant-hours-import.ts`
**Triggers:** `ImportPlantHoursButton` on the Plant page
**Pipeline:**
```mermaid
sequenceDiagram
participant U as User
participant R as /api/plant-hours/import
participant O as importPlantHours (pure)
participant P as PocketBase
U->>R: multipart { file }
R->>R: SheetJS parse
R->>P: create(data_import_batches, batch_type=plant_hours, original_file, status=processed)
R->>P: getFullList(vehicles, fields="id,description,project_code,qr_code_id,unit_type,site,cost_per_hour", perPage=5000)
R->>P: getFullList(sites, fields="id,name,project_code", perPage=5000)
R->>O: importPlantHours(rowsRaw, batchId, { vehicles, sites })
O->>O: resolve each row by qr_code_id (or desc fallback)
O->>O: compute hours, value, over_budget, hours_mismatch
O->>O: snapshot vehicle_unit_type_at_import
loop for each prepped row
R->>P: create(plant_hours_log, ...)
alt duplicate (vehicle_qr, start_date)
P-->>R: 400 validation_not_unique
R->>R: record as "duplicate: vehicle_qr=X start_date=Y"
end
end
R-->>U: { batchId, totalRows, imported, skipped, mismatched, warnings, errors }
```
**Site attribution.** The orchestrator pulls the site from the matched
vehicle (`vehicle.site`) and stamps both `site` and `project_code` on
the row. This is the **only collection where `site` is required
(`minSelect=1`)** — every plant_hours_log row has a non-null site.
**Composite unique index.** `idx_plant_hours_log_qr_start` on
`(vehicle_qr, start_date)` blocks accidental double-imports of the same
week for the same vehicle. The route surfaces the PB-level rejection as
a friendly `duplicate: ...` error per row, not a batch-level failure.
See [multi-site-assessment §5](./multi-site-assessment.md#5-vehicles-plant-and-employees-moving-between-sites) for the cross-site implication
of this index.
**Failure behaviour.** Per-row. The orchestrator collects row-level
errors and warnings; the route appends PB insert errors. The batch
itself is never aborted by one bad row.
**Exception report.** `GET /api/plant-hours/exception-report/[batchId]`
reconstructs a per-row exception CSV by re-parsing the source file
from the batch's `original_file` and matching against the imported rows
in `plant_hours_log.filter('batch_reference = "<batchId>"')`. Used to
re-export a corrected sheet when the operator needs to fix and re-import.
---
## 4. Purchase order upload → ingest (async via n8n)
Three routes cooperate. The browser never talks to n8n directly.
**Routes:**
- `POST /api/purchase-orders/upload` — `src/app/api/purchase-orders/upload/route.ts`
- `POST /api/purchase-orders/process-batch` — called by n8n — `src/app/api/purchase-orders/process-batch/route.ts`
- `GET /api/data-import-batches/[id]/status` — poll target for the browser
**Auth model.** All three are server-side; only `process-batch` requires
the shared `N8N_INGEST_SECRET` header. The other two run as the
PocketBase superuser.
**Pipeline:**
```mermaid
sequenceDiagram
participant U as User
participant W as /api/purchase-orders/upload
participant P as PocketBase
participant N as n8n (PO ingest workflow)
participant B as /api/purchase-orders/process-batch
participant B2 as /api/data-import-batches/:id/status
U->>W: multipart { file (PDF/image) }
W->>W: SHA-256 the bytes
W->>P: getFullList(batches, file_hash=X && status=processed && date_uploaded >= 30d_ago)
alt duplicate
W-->>U: 200 { duplicate: true, existing_batch_id, ... }
else new
W->>P: create(data_import_batches, batch_type=purchase_orders, status=pending, file_hash)
W-->>U: 202 { batch_id, status: queued }
end
N->>P: poll /api/files/data_import_batches/:id/<filename> (admin auth)
N->>N: AI Agent extracts PO fields + line items
N->>B: POST { batch_id, extracted } + x-ingest-secret
B->>P: getOne(batch) — must be in 'processing'
B->>P: getFullList(purchase_orders, filter=po_number=...) — dedup
B->>B: matchSite(extracted.project_code) → siteId
B->>P: download batch.original_file via /api/files
B->>P: create(purchase_orders, file=..., site=siteId, raw_ocr_data, is_verified=false)
loop for each line item
B->>P: create(purchase_order_items, purchase_order=poRecord.id)
end
alt dedup branch
B->>P: update(batch, status=processed, processed_record_id=existing.id) // keep original_file
else success branch
B->>P: update(batch, status=processed, processed_record_id=poRecord.id, original_file="")
end
U->>B2: GET /api/data-import-batches/:id/status
B2-->>U: { id, status, processed_record_id, processed_record_label, ... }
```
**Three dedup layers, in order:**
1. **Hash dedup** at upload time — SHA-256 of the file bytes, matched
against batches in the last 30 days with `status=processed`. Returns
`200 { duplicate: true }` before any new batch row is written.
2. **AI-extracted PO number dedup** at process time —
`getFullList(purchase_orders, filter=po_number="...")`. If found,
the existing PO id is recorded on the batch but **the duplicate file
is kept on the batch row** for audit (`clearFile: false`).
3. **PB unique index** `idx_po_number` on
`purchase_orders(purchase_order_number)` — the final backstop. If
the AI dedup misses, this rejects the insert.
**Site attribution strategy.** `matchSite(pb, payload.project_code)` in
`src/lib/purchase-order-schema.ts`:
1. Exact match on `sites.project_code`.
2. Fuzzy match `project_code ~ "X"` (PB's `~` operator does case-insensitive substring).
3. If both miss, the PO is stored with `site=null` and a warning is
surfaced: `unmatched_project_code: !siteId` is written into
`raw_ocr_data`. The `unmatched_project_code` flag is the operator's
signal that a manual assignment is needed.
**Failure behaviour.** Per the `process-batch` header comment, the route
distinguishes `retryable: true` (5xx with a transient signal — n8n
retries) from `retryable: false` (400 — n8n marks the batch `dead` and
surfaces it in the dashboard). Network blips and PB write conflicts are
retryable; validation failures are not.
---
## 5. Delivery note upload → OCR result (async via n8n)
**Routes:**
- `POST /api/delivery-notes/upload` — `src/app/api/delivery-notes/upload/route.ts`
- `GET /api/delivery-notes/[id]/slip` — authenticated file proxy for n8n
- `POST /api/delivery-notes/[id]/ocr-result` — webhook from n8n — `src/app/api/delivery-notes/[id]/ocr-result/route.ts`
- `POST /api/delivery-notes/[id]/assign-po` — manual fallback
- `POST /api/records/[collection]/[id]/verify` — manual verify toggle
**Pipeline:**
```mermaid
sequenceDiagram
participant U as User
participant U2 as /api/delivery-notes/upload
participant P as PocketBase
participant N as n8n (DN OCR workflow)
participant S as /api/delivery-notes/:id/slip
participant R as /api/delivery-notes/:id/ocr-result
participant A as /api/delivery-notes/:id/assign-po
U->>U2: multipart { file, site, delivery_note_number? }
U2->>P: getOne(sites, fields="project_code")
U2->>P: create(delivery_notes, slip_image=file, site, project_code, processing_status=pending)
U2->>P: create(data_import_batches, batch_type=delivery_notes, processed_record_id=dn.id)
U2-->>U: 201 { id, siteId, processingStatus: pending }
N->>S: GET (x-ingest-secret)
S->>P: getOne(delivery_notes, fields="slip_image")
S->>P: GET /api/files/delivery_notes/:id/<filename>
S-->>N: file bytes
N->>N: AI Agent OCR: po_number, dn_number, items, confidence
N->>R: POST { po_number, delivery_note_number, date_received, received_by_name, confidence, items, raw_ocr } + x-ingest-secret
R->>P: getOne(delivery_notes, fields="id,purchase_order,...")
R->>R: PO match (exact → normalised → Levenshtein)
R->>P: getFullList(purchase_order_items, filter=purchase_order='<poId>')
R->>R: resolveOcrTier(confidence, poResolved, itemsVerifiable)
alt tier = auto
R->>P: update(delivery_notes, processing_status=auto-verified, is_verified=true, ocr_confidence, ocr_completed_at, purchase_order, raw_ocr_data, ...)
loop for each item
R->>P: create(received_items, delivery_note, purchase_order_item=validatedId, qty_received, condition_notes)
end
else tier = needs-review
R->>P: update(delivery_notes, processing_status=needs-review, is_verified=false, ...)
R->>P: delete + recreate received_items (idempotent)
else tier = manual
R->>P: update(delivery_notes, processing_status=needs-review, ...) — no received_items
end
R->>P: update(data_import_batches, status=processed, ...)
opt operator fixes
U->>A: POST { po: <poId> } (manual fallback)
A->>P: getOne(purchase_orders, id)
A->>P: update(delivery_notes, purchase_order=poId, processing_status=manual-verified, is_verified=true)
end
```
**Three-tier OCR result** (rules in `src/lib/ocr-result-tier.ts`):
| Confidence | PO resolved | Items verifiable | Tier | Effect |
| --- | --- | --- | --- | --- |
| ≥ 0.8 | ✅ | ✅ | `auto` | `is_verified=true`, `processing_status=auto-verified`, received_items created with PO links |
| ≥ 0.8 | ❌ | n/a | `needs-review` (downgraded) | `is_verified=false`, no auto-link — orphan DNs are never auto-verified |
| ≥ 0.8 | ✅ | ❌ (local fetch empty but n8n supplied ids) | `needs-review` (downgraded) | We don't claim auto when we can't cross-check the links |
| 0.5..0.79 | any | any | `needs-review` | `is_verified=false`, received_items created without PO links |
| < 0.5 | any | any | `manual` | `is_verified=false`, **no received_items** — the operator must capture manually |
**PO match priority:** exact `purchase_order_number` → normalised
(strip non-alnum, lowercase) → Levenshtein (single-char edit, threshold
0.75). The chosen path + score is recorded in
`raw_ocr_data.po_match_method` / `po_match_score` / `po_resolved_number`
so the exception inbox can show "matched by Levenshtein, 0.9" rather
than a silent "no match".
**Site attribution strategy.** Site is stamped at upload time from the
form field (`src/app/api/delivery-notes/upload/route.ts:26`); the OCR
callback does not change it. The PO the OCR picks is what links the
delivery to a site indirectly (PO.site). For DNs uploaded without a
site, the route 400s.
**Idempotency.** If the OCR webhook is replayed, the route
**deletes** the existing `received_items` for this DN and recreates them
within the same call. This avoids stale matches if the OCR rerun finds
different items. The DN update is patch-style so missing fields
(delivery_note_number, date_received) are never overwritten by missing
data.
**Failure behaviour.** If the DN update itself fails, the route marks
the batch `failed` so the operator dashboard reflects the broken state
instead of leaving it pending forever. If a single `received_items`
create fails with a 400 (FK race — the PO item was deleted between
fetch and create), the route retries with the link dropped and
annotates the row as `link dropped: orphan id`.
---
## 6. Cross-cutting behaviours
- **Multipart limits.** All upload routes check `file.size` before parsing.
PO upload enforces mime allowlist via `ALLOWED_MIME`; DN upload accepts
any image/pdf. Plant hours import caps at 10 MB.
- **Auth headers.** `N8N_INGEST_SECRET` is checked at the top of every
n8n-callbackable route (`process-batch`, `ocr-result`, `slip` proxy).
The browser-facing routes do not check any auth.
- **PB ID validation.** All `[id]` path params use the PB ID pattern
(`/^[a-z0-9]{15}$/i`) to close the filter-injection sink before the
route does `filter: "<field>='<id>'"`.
- **Audit trail.** Every batch keeps the original file via
`data_import_batches.original_file`. The PO route clears this field
on success (the file is moved to the PO row); it keeps the bytes on
the dedup path. The fuel/plant/timesheet routes never clear the file.