MEGAINFRA-WEBAPP/docs/data-model.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

19 KiB
Raw Blame History

Data model

Every PocketBase collection that backs the app, with fields, relations, and the site-attribution strategy each one uses.

Source of truth for types: src/lib/pb-types.ts (13 collection interfaces). Source of truth for the live schema: scripts/pb-bootstrap-schema.py. Production instance: https://pb.mega.infra.apphub.co.za.


1. Entity-relationship diagram

erDiagram
    SITES ||--o{ EMPLOYEES : "site (relation)"
    SITES ||--o{ VEHICLES : "site (relation)"
    SITES ||--o{ FIXED_COSTS : "site (relation)"
    SITES ||--o{ INCOME_CLAIMS : "site (relation)"
    SITES ||--o{ PURCHASE_ORDERS : "site (relation)"
    SITES ||--o{ DELIVERY_NOTES : "site (relation)"
    SITES ||--o{ FUEL_LOGS : "site (relation)"
    SITES ||--o{ PLANT_HOURS_LOG : "site (relation)"

    EMPLOYEES ||--o{ TIMESHEETS : "employee (relation)"
    VEHICLES ||--o{ FUEL_LOGS : "vehicle (relation)"
    VEHICLES ||--o{ PLANT_HOURS_LOG : "vehicle (relation)"
    DATA_IMPORT_BATCHES ||--o{ FUEL_LOGS : "batch_reference (relation)"
    DATA_IMPORT_BATCHES ||--o{ TIMESHEETS : "batch_reference (relation)"
    DATA_IMPORT_BATCHES ||--o{ PLANT_HOURS_LOG : "batch_reference (loose)"

    PURCHASE_ORDERS ||--|{ PURCHASE_ORDER_ITEMS : "purchase_order (cascade)"
    PURCHASE_ORDERS ||--o{ DELIVERY_NOTES : "purchase_order"
    DELIVERY_NOTES ||--|{ RECEIVED_ITEMS : "delivery_note (cascade)"
    PURCHASE_ORDER_ITEMS ||--o{ RECEIVED_ITEMS : "purchase_order_item"

The PLANT collection is intentionally absent from the diagram — it is a deprecated legacy alias for the unified VEHICLES collection (src/lib/pb-types.ts:58-60).


2. Site attribution strategy, per collection

This is the table to read first. The question "where does this row belong?" has three different answers depending on which collection you're in.

Collection site relation project_code text Attribution source Drift risk
sites n/a required, unique The site row itself None — the unique index is authoritative
employees ✅ ✅ Both, but site is canonical for metrics; project_code is denormalised at create time Medium — changing a worker's site later leaves project_code stale
vehicles ✅ ✅ Both, but site is the relation; project_code is denormalised at create time High — fuel logs are filtered by log.site, not by the current vehicle's site
fixed_costs ✅ ✅ site is the relation; project_code is denormalised Low — the import routes always stamp both from the same source
income_claims ✅ ✅ Same as fixed_costs Low
purchase_orders ✅ ✅ Same as fixed_costs Low
delivery_notes ✅ ✅ Same as fixed_costs Low
fuel_logs ✅ ✅ site is filled when the source row has a site; project_code is the canonical fallback when the site is missing Medium — see multi-site-assessment.md §5
plant_hours_log ✅ required ✅ required Always set from the vehicle at import time Lower than fuel_logs because required
timesheets ❌ ❌ Joined via employees.site at read time — timesheets has no site or project_code of its own High — site attribution is a per-employee property that can move
purchase_order_items ❌ ❌ Inherited through purchase_order → site None — never queried directly by site
received_items ❌ ❌ Inherited through delivery_note → site None — same
data_import_batches ❌ ❌ Inferred from processed_record_id + the processed record's collection n/a

Reading the table. Every collection except the two joining-tables and data_import_batches carries both a site relation and a project_code string. The only collection that carries neither is timesheets — and that's the collection whose site attribution drifts the most, because it's synthesised at read time from the employee record.


3. The project_code denormalisation

project_code is a free-text identifier like MEG-001 or HILLS-2026 that uniquely names a contract. It is the human-readable label that appears on every report and filter dropdown.

The schema carries it on every child collection as a denormalised copy of the parent site's project_code. It is not a foreign key — it is just a string, written at insert time, never updated when the parent site changes.

Why it exists. The chat agent and several older reports filter by project_code (text) rather than expanding the site relation (expand: "site" would force a join the dashboard pages don't want). It is also what the import routes use as the canonical key when a row's site relation is empty.

The drift problem. If you change a site's project_code in PocketBase, every existing fuel_logs, fixed_costs, employees, vehicles, purchase_orders, delivery_notes, and income_claims row keeps the old string forever. Code that joins through the site relation sees the new value; code that filters on project_code text sees the old.

scripts/pb-relax-rules.py:24 and the PATCH /api/sites/[id] route (src/app/api/sites/[id]/route.ts:23-28) deliberately refuse to let you change project_code on an existing site — that is exactly the rule designed to prevent drift. The POST /api/sites route also enforces a 64-char ceiling and uniqueness via the idx_sites_project_code index (scripts/pb-bootstrap-schema.py:84).

What this means in practice: the denormalisation is stable as long as no one re-creates a site with the same name and a different code. The biggest remaining risk is bulk imports that resolve site by name and stamp the wrong project_code onto historical rows — see multi-site-assessment.md §4.


4. Collection reference

Each entry below lists every typed field, the relation direction, and any notable constraints. Optional/legacy fields are marked.

4.1 sites

The site is the unit of business. One row per construction contract.

Field Type Required Notes
project_code text ✅ Unique (idx_sites_project_code). Locked after creation.
name text optional Human label, e.g. "The Hills"
duration text optional Free-text duration ("8 months", "52 weeks") — parsed by parseDurationMonths
contract_value number optional Rand value, no currency field
work_hours number optional Total project work hours
hours_per_week number optional Standard workweek for the site
wages_hours_per_day number optional Standard wage day length
job_start_date date optional ISO date; the projection helper treats this as the authoritative start when present

4.2 employees

Field Type Notes
project_code text Denormalised from site.project_code at create time
site relation → sites The site the worker is currently assigned to
name, employee_no, position text employee_no is the cross-system payroll id; uppercased on import (src/app/api/timesheets/import/route.ts:289)
rate_per_hour number Drives payroll burn
hours_per_day_standard number 9 by default for auto-created casuals
worker_type select permanent or casual

4.3 vehicles (unified — includes plant)

Field Type Notes
project_code text Denormalised
site relation → sites Current assignment
description text e.g. "Toyota Hilux 2.4 GD-6"
standard_work_hours text/number Free-text on schema; numeric in practice. Used by the plant utilisation calc
fuel_limits text Free-form like "8.5 L/h", "6.5 km/L" — parsed by parseFuelLimit (src/lib/fuel-variance.ts)
qr_code_id text The clean identifier the fuel importer matches against. NOT unique in schema — see multi-site-assessment §5
cost_per_hour number Drives plant utilisation value
unit_type select fuel or plant (default fuel). Splits the bucket between Fleet and Plant pages
attention_dismissed_at date When the user dismissed the vehicle's "over fuel limit" alert from the fuel page

The legacy plant collection is a deprecated alias kept around for old importers. New plant items go in vehicles with unit_type="plant".

4.4 fixed_costs

Monthly or one-off costs. is_recurring is the partition key.

Field Type Notes
project_code text Denormalised
site relation → sites
date_logged date Drives the monthly tile and trend charts
description, notes text
cost number Rand value
is_recurring bool true (or missing) → monthly bucket; false → one-off bucket. Partitioned by partitionFixedCosts (src/lib/partition-fixed-costs.ts)

4.5 income_claims

Field Type Notes
claim_number text The contractor's IPN / claim ref
project_code text Denormalised
site relation → sites
period_start, period_end date The period the claim covers
gross_amount number Rand
certified_by text Signature line on the cert
paid_on date Empty until the claim is actually paid — drives the income series
status select draft / certified / paid
is_verified bool Verified by a human
notes text

4.6 purchase_orders

Field Type Notes
purchase_order_number text Unique (idx_po_number). Normalised to uppercase with "PO" prefix stripped before insert (src/lib/purchase-order-schema.ts)
project_code text Denormalised
site relation → sites Resolved by matchSite at ingest time
supplier text
required_by_date date When the materials are needed
ordered_by text Who placed it
items_count, total_value number Denormalised roll-ups for table views
purchase_order_file file The original PDF/image; mime allowlist enforced by the upload route
raw_ocr_data json Full OCR payload + ingest metadata
is_verified bool Human-verified flag

4.7 purchase_order_items

Joins POs to line items. cascadeDelete: true on the parent relation (scripts/pb-bootstrap-schema.py:132).

Field Type Notes
purchase_order relation → purchase_orders Required parent
material_code, cost_code text
description text The free-text the OCR matches against in the DN line
qty number Ordered quantity
qty_outstanding number Decreased as received_items roll up — but the canonical fulfilment % is computed from received_items directly (see /dashboard/purchasing page and the orphan-id note in src/app/dashboard/purchasing/page.tsx:52-72)
unit, rate, disc_percent, vat_percent, amount The amount field is the computed total, not stored separately

4.8 timesheets

The collection with no site and no project_code. Site is resolved through employeeRates at every read site.

Field Type Notes
employee relation → employees
batch_reference relation → data_import_batches
date_logged date
hours_worked number Decimal

4.9 fuel_logs

Field Type Notes
project_code text Denormalised, falls back to vehicle.project_code when the source row has no site
site relation → sites Optional — empty when the source row had no site column
vehicle relation → vehicles Required by the schema but the importer tolerates a missing relation and falls back to QR
batch_reference relation → data_import_batches
date_logged date ISO datetime; importer anchors at UTC midnight
litres number Required
odo_reading number Required — used for variance calculation
latest_consumption number Optional. Empty cells preserved as undefined rather than 0 (intentional, see comment at src/lib/pb-types.ts:173-181)

4.10 plant_hours_log

The most disciplined collection. Every site-attribution field is required.

Field Type Notes
date_logged, start_date, end_date date
vehicle_qr text The QR text — part of the composite unique index below
vehicle relation → vehicles Required (minSelect=1)
site relation → sites Required (minSelect=1)
project_code text Required
start_hours, end_hours, hours number
hour_meter_budget, cost_per_hour, value number value = hours × cost_per_hour
over_budget, hours_mismatch bool Computed at import time
vehicle_unit_type_at_import select Snapshot of the vehicle's unit_type at the moment of import — see the note in multi-site-assessment §5 about why this discipline should extend to site
batch_reference relation Optional

Unique index: (vehicle_qr, start_date). Prevents accidental double-imports of the same week for the same vehicle (scripts/pb-bootstrap-schema.py:257). Surfaced as a friendly "duplicate: vehicle_qr=X start_date=Y" error in the import response.

4.11 delivery_notes

Field Type Notes
delivery_note_number text Optional; OCR may fill it in
project_code text Denormalised
site relation → sites Resolved at upload time
purchase_order relation → purchase_orders Filled by OCR (exact → normalised → Levenshtein) or by manual assign-po route
date_received date
received_by text The uploader's identity — currently always "web-upload"
slip_image file The photo/PDF, served via /api/delivery-notes/:id/slip to n8n
raw_ocr_data json Full OCR payload + match metadata
processing_status select pending → ocr-complete → auto-verified / needs-review / manual-verified / failed
ocr_confidence number 0..100 integer
ocr_completed_at date
received_by_name text Signature OCR'd from the slip
is_verified bool Final flip from auto-verified or manual-verified

4.12 received_items

Joins DNs to PO line items. cascadeDelete: true on the parent DN.

Field Type Notes
delivery_note relation → delivery_notes Required parent
purchase_order_item relation → purchase_order_items Optional — can be empty for items the OCR couldn't match, or for orphans where the PO item was deleted between fetch and create
qty_received number
condition_notes text Free-text audit trail (e.g. "OCR 92% · desc: Cement 50kg · unit: bag")

4.13 data_import_batches

The audit-trail spine. Every uploaded file — Excel, CSV, PDF, image — has a batch row that tracks its lifecycle.

Field Type Notes
batch_type select timesheets / fuel_logs / purchase_orders / delivery_notes / plant_hours
content_type text MIME of the original file
original_file file The uploaded bytes; mime-restricted per batch_type
date_uploaded date
uploaded_by text Currently always "web-import" or "web-upload"
status select pending / processing / processed / failed / dead
attempts number Incremented on each retry
last_error text Last failure reason
last_attempt_at date
next_retry_at date
processed_at date When the batch reached processed
processed_record_id text The id of the record this batch created (PO id, DN id, etc.)
file_hash text SHA-256 of the original file; the dedup window for PO re-uploads uses this index

5. Indexes and constraints summary

Index Where Purpose
idx_sites_project_code sites(project_code) UNIQUE Prevents duplicate site codes; enforced on the POST /api/sites route via the idx_sites_project_code name match (src/app/api/sites/route.ts:64)
idx_po_number purchase_orders(purchase_order_number) UNIQUE Prevents duplicate PO numbers — works alongside the AI extraction dedup by purchase_order_number
idx_plant_hours_log_qr_start plant_hours_log(vehicle_qr, start_date) UNIQUE Prevents accidental double-imports of the same week for the same vehicle; surfaced as duplicate: reason in the importer
idx_batches_file_hash data_import_batches(file_hash, status) Used by the PO upload dedup window (src/app/api/purchase-orders/upload/route.ts:80-84)

No collection outside the above has a server-enforced unique constraint. In particular, vehicles.qr_code_id and vehicles.project_code are not unique — see multi-site-assessment §5.


6. PocketBase API rules

scripts/pb-relax-rules.py writes the ruleset used in production. For every collection:

  • listRule, viewRule, createRule → @request.auth.id != '' (any authenticated user)
  • updateRule, deleteRule → null (superuser only)

The collection list is the full set of 13 — there is no collection where clients can update or delete records through the API. The /api/records/[collection]/[id]/verify route hand-picks two collections (purchase_orders, delivery_notes) for the is_verified toggle, but it runs server-side as pbAdmin(), so it is not constrained by these rules.

The only routes that respect the API rules at all are the (currently unused) browser-side pb client and the n8n file-proxy route /api/delivery-notes/:id/slip, which uses the admin token in its Authorization header. The PB layer never blocks the Next.js server.


7. Schema drift

The pb-bootstrap-schema.py script is the canonical schema definition. The following migrations exist on disk and are not represented in the script:

Migration Purpose
migrate-delivery-notes-ocr.py Adds the processing_status / ocr_* fields and the OCR webhook hooks to existing DNs
migrate-plant-hours-backfill-vehicle.py Backfills plant_hours_log.vehicle relations from vehicle_qr for pre-relation rows
scripts/migrations/* (other) One-off patches for specific schema or data issues

Any time the schema changes, the new field must be added to both pb-bootstrap-schema.py (so a fresh PB starts in the right shape) and src/lib/pb-types.ts (so the TypeScript code agrees). Forgetting either side is a known foot-gun.