MEGAINFRA-WEBAPP/src/lib/pb-types.ts
DEls ed69b517e8 Integrate agent work and add dashboard attention indicators
Remove repeated Plant, Financial and Workforce shortcut cards. Add site-scoped Plant and Financial sidebar attention status with automatic refresh.

Include accumulated delivery processing, purchase order recovery, supplier reconciliation, plant data and user management changes and audit records.
2026-10-04 21:53:32 +02:00

677 lines
22 KiB
TypeScript

/**
* Typed shapes matching the MEGA INFRASTRUCTURE collections on
* `https://pb.mega.infra.apphub.co.za`. Keep in sync with the live
* schema — re-run `scripts/pb-bootstrap-schema.py` after changes.
*
* `fuel_prices` is additive and lives in its own script
* (`scripts/pb-add-fuel-prices.py`); the app degrades to the
* RATE_PER_LITRE fallback when it is absent.
*/
import type { RecordModel } from "./pb";
import type { BatchStage, CandidateStage } from "./dn-pipeline/stages";
export interface SiteRecord extends RecordModel {
collectionName: "sites";
project_code: string;
name: string;
duration: string;
contract_value: number;
work_hours: number;
hours_per_week: number;
wages_hours_per_day: number;
/** ISO date (YYYY-MM-DD) the project started/starts. Optional. */
job_start_date?: string;
/**
* Decimal degrees. Drives the rain outlook on the Overview page and
* nothing else — a site without them is listed as "no location set"
* rather than being hidden. Added by
* `scripts/pb-add-site-coordinates.py`.
*/
latitude?: number | null;
longitude?: number | null;
expand?: Record<string, never>;
}
export interface SupplierReconciliationRule {
rule_key: string;
version: number;
status: "draft" | "active" | "retired";
financially_active: boolean;
title: string;
supplier_ids: string[];
conditions: Record<string, unknown>;
instructions: string[];
evidence: Array<Record<string, unknown>>;
examples: Array<Record<string, unknown>>;
approved_by: string | null;
approved_at: string | null;
}
export interface SupplierReconciliationKnowledge {
schema_version: 1;
prepared_on: string;
status: "draft" | "active" | "retired";
financially_active: boolean;
rules: SupplierReconciliationRule[];
}
export interface SupplierRecord extends RecordModel {
collectionName: "suppliers";
/** Stored pre-normalized (trim, collapse whitespace, uppercase) — see
* normalizeSupplierName in purchase-order-schema.ts. */
name: string;
/** Optional identifiers printed on supplier documents. */
registration_number?: string;
vat_number?: string;
/** Versioned supplier guidance; drafts are not executable posting policy. */
reconciliation_rules?: SupplierReconciliationKnowledge | null;
}
/**
* One diesel price change. `effective_from` is inclusive — a fuel log is
* priced at the latest row on or before its own date. Created by
* `scripts/pb-add-fuel-prices.py`; read via `loadFuelPrices()`.
*/
export interface FuelPriceRecord extends RecordModel {
collectionName: "fuel_prices";
/** ISO date (YYYY-MM-DD) the price took effect. */
effective_from: string;
price_per_litre: number;
notes?: string;
expand?: Record<string, never>;
}
export type WorkerType = "permanent" | "casual";
export interface EmployeeRecord extends RecordModel {
collectionName: "employees";
site: string; // relation id
name: string;
employee_no: string;
position: string;
rate_per_hour: number;
hours_per_day_standard: number;
worker_type: WorkerType;
expand?: { site?: SiteRecord };
}
export type UnitType = "fuel" | "plant";
export interface VehicleRecord extends RecordModel {
collectionName: "vehicles";
site: string;
description: string;
standard_work_hours: string | number;
fuel_limits: string;
qr_code_id: string;
cost_per_hour: number;
unit_type: UnitType;
/**
* ISO timestamp of the user's most recent acknowledgement of this
* vehicle's "Attention required" status. null/absent when never
* dismissed. Written by PATCH /api/vehicles/[id]/attention-dismiss.
*/
attention_dismissed_at?: string | null;
/**
* Latest GPS fix from a MEGA Clock Fleet scan. Denormalised off
* `fuel_logs` so the map page can render every vehicle in a single
* query, without an N+1 walk back through the scan history. Stays
* null for vehicles that have never been scanned from a kiosk phone
* (e.g. ones only ever imported from a pump sheet). Updated by
* /api/clock/vehicle-scan after every successful fuel_logs write;
* if the PATCH fails the next scan catches up, so a missed write
* is self-healing rather than a hard inconsistency.
*
* `last_scanned_at` is the `date_logged` of the most recent scan
* (UTC midnight anchor, same convention as every other date in the
* schema), not the wall-clock time the scan happened.
*/
last_latitude?: number | null;
last_longitude?: number | null;
last_scanned_at?: string | null;
last_location_accuracy_m?: number | null;
expand?: { site?: SiteRecord };
}
export interface PlantRecord extends VehicleRecord {
// Deprecated alias — Plant has been merged into Vehicles.
}
export interface FixedCostRecord extends RecordModel {
collectionName: "fixed_costs";
site: string;
date_logged: string;
description: string;
cost: number;
notes: string;
is_recurring?: boolean;
expand?: { site?: SiteRecord };
}
export type IncomeClaimStatus = "draft" | "certified" | "paid";
export interface IncomeClaimRecord extends RecordModel {
collectionName: "income_claims";
claim_number: string;
site: string;
period_start: string;
period_end: string;
gross_amount: number;
certified_by: string;
/** ISO date the claim was actually paid. Empty until payment. */
paid_on: string;
status: IncomeClaimStatus;
is_verified: boolean;
notes: string;
expand?: { site?: SiteRecord };
}
/**
* Shared by every document/queue collection's `processing_status`.
* `data_import_batches` (the collection this state machine originally
* lived on) was dropped once purchase_orders, delivery_notes, timesheets,
* fuel_sheets and plant_hours_sheets each grew their own copy of it.
*/
export type BatchStatus =
| "pending"
| "processing"
| "processed"
| "failed"
| "dead";
export interface PurchaseOrderRecord extends RecordModel {
collectionName: "purchase_orders";
/**
* Unique (idx_po_number). Born as a `PENDING-...` placeholder at
* upload time (src/lib/po-placeholder.ts) — a row cannot hold "" here
* because the index is UNIQUE, and two unprocessed uploads would
* collide. Overwritten with the real number once n8n's extraction
* completes.
*/
purchase_order_number: string;
site: string;
supplier: string;
/**
* The authoritative value. `supplier` above is kept as raw OCR
* provenance — this is what everything else reads. Optional: not
* every PO has a match yet.
*/
supplier_ref?: string;
required_by_date: string;
ordered_by: string;
items_count: number;
total_value: number;
purchase_order_file: string;
raw_ocr_data: unknown;
is_verified: boolean;
/**
* SHA-256 of purchase_order_file, stamped at upload. Powers dedup
* against re-uploads of the same bytes, windowed by date_uploaded.
*/
file_hash?: string;
/** Stamped at upload — this is the row's own audit trail, not a copy. */
date_uploaded?: string;
/**
* The row IS the queue item now (see docs/n8n/purchase-order-ingest-implementation.md):
* n8n claims pending rows, extracts via AI, and PATCHes the result in.
* Same state machine data_import_batches used to carry.
*/
processing_status?: BatchStatus;
attempts?: number;
last_error?: string;
next_retry_at?: string;
last_attempt_at?: string;
expand?: { site?: SiteRecord; supplier_ref?: SupplierRecord };
}
export interface PurchaseOrderItemRecord extends RecordModel {
collectionName: "purchase_order_items";
purchase_order: string;
material_code: string;
cost_code: string;
description: string;
qty: number;
unit: string;
rate: number;
disc_percent: number;
vat_percent: number;
amount: number;
expand?: { purchase_order?: PurchaseOrderRecord };
}
/**
* One logged shift: this employee worked these hours on this date. The
* collection was called `timesheets` until the 2026-09-12 rename; that
* name now belongs to the uploaded timesheet document.
*/
export interface TimesheetEntryRecord extends RecordModel {
collectionName: "timesheet_entries";
employee: string;
/** Stamped from the employee's site at import time. Never joined at read time. */
site: string;
date_logged: string;
hours_worked: number;
/** Optional. Empty when captured directly (QR scan) rather than from a sheet. */
timesheet?: string;
expand?: {
employee?: EmployeeRecord;
site?: SiteRecord;
timesheet?: TimesheetRecord;
};
}
/**
* One scan event — the raw audit log behind the MEGA Clock PWA.
* Independent of the derived daily totals in `timesheet_entries`: every
* "in" and every "out" gets its own row here, in scan order.
*/
export interface ClockEventRecord extends RecordModel {
collectionName: "clock_events";
employee: string;
site: string;
direction: "in" | "out";
scanned_at: string;
/** Set only on "out" events — the timesheet_entries row this scan created/updated. */
timesheet_entry?: string;
expand?: {
employee?: EmployeeRecord;
site?: SiteRecord;
timesheet_entry?: TimesheetEntryRecord;
};
}
/** Manual resolution of an unmatched QR IN. The original event is retained. */
export interface ClockMissedOutCorrectionRecord extends RecordModel {
collectionName: "clock_missed_out_corrections";
clock_in_event: string;
employee: string;
site: string;
actual_out_at: string;
reason: string;
recorded_at: string;
timesheet_entry: string;
}
export interface FuelLogRecord extends RecordModel {
collectionName: "fuel_logs";
site: string;
vehicle: string;
date_logged: string;
litres: number;
odo_reading: number;
/**
* "Latest Consumption" value as reported by the pump sheet (typically
* litres/100km or litres/hour). Persisted verbatim from the source
* Excel/CSV rather than recomputed, so the audit trail reflects what
* the fuel team actually filed. Optional — the Aug 2026 sample has
* ~9% empty cells, which we preserve as `undefined` rather than
* fabricating a value. Added in the 2026-08-24 migration.
*/
latest_consumption?: number;
/**
* GPS fix captured at scan time on the kiosk phone (MEGA Clock Fleet
* tab). The spreadsheet import path doesn't have a GPS context, so
* desktop-imported rows will have all three fields null — exactly
* the same posture as `latest_consumption`. Decimal degrees; range
* validation is the route's responsibility, not PB's. Added in the
* 2026-09-22 migration.
*/
latitude?: number | null;
longitude?: number | null;
/** Reported `Position.coords.accuracy` in metres at scan time. */
location_accuracy_m?: number | null;
/** The uploaded pump sheet this row came from. Optional: pre-migration rows have none. */
fuel_sheet?: string;
expand?: {
site?: SiteRecord;
vehicle?: VehicleRecord;
fuel_sheet?: FuelSheetRecord;
};
}
export interface PlantHoursLogRecord extends RecordModel {
collectionName: "plant_hours_log";
date_logged: string;
vehicle_qr: string;
vehicle: string;
site: string;
start_date: string;
end_date: string;
start_time?: string;
end_time?: string;
start_hours: number;
end_hours: number;
hours: number;
notes?: string;
hour_meter_budget: number;
cost_per_hour: number;
value: number;
over_budget: boolean;
hours_mismatch: boolean;
vehicle_unit_type_at_import: UnitType;
/** The uploaded plant hours sheet this row came from. Optional: pre-migration rows have none. */
plant_hours_sheet?: string;
expand?: {
vehicle?: VehicleRecord;
site?: SiteRecord;
plant_hours_sheet?: PlantHoursSheetRecord;
};
}
export interface DeliveryNoteRecord extends RecordModel {
collectionName: "delivery_notes";
delivery_note_number: string;
site: string;
purchase_order: string;
date_received: string;
received_by: string;
slip_image: string;
raw_ocr_data: unknown;
is_verified: boolean;
processing_status?: DeliveryNoteProcessingStatus;
ocr_confidence?: number;
ocr_completed_at?: string;
received_by_name?: string;
document_type?: "delivery_note" | "invoice" | "collection_note" | "unknown";
batch_note?: string;
expand?: {
site?: SiteRecord;
purchase_order?: PurchaseOrderRecord;
batch_note?: DnBatchNoteRecord;
};
}
export type DeliveryNoteProcessingStatus =
| "pending"
| "ocr-complete"
| "auto-verified"
| "needs-review"
| "manual-verified"
| "failed";
/**
* One line on a delivery note. The collection was called
* `received_items` until the 2026-09-12 rename — the old name described
* the receiving event rather than the thing received.
*/
export interface DeliveryNoteItemRecord extends RecordModel {
collectionName: "delivery_note_items";
allocation_key?: string;
source_line_keys?: string[];
booking?: import("./dn-pipeline/line-booking").LineBooking;
delivery_note: string;
/** Optional — empty when OCR could not match the line to a PO item. */
purchase_order_item: string;
/** In the matched PO line's unit. Zero when the line is unmatched or unconvertible. */
qty_received: number;
/** Verbatim quantity from the slip, before any conversion. */
qty_raw: number;
/** Verbatim unit from the slip, e.g. "kg". Empty when the slip showed none. */
unit_raw: string;
/** True when qty_received differs from qty_raw because a conversion was applied. */
unit_converted: boolean;
condition_notes: string;
expand?: {
delivery_note?: DeliveryNoteRecord;
purchase_order_item?: PurchaseOrderItemRecord;
};
}
/**
* The uploaded delivery-note document. One row per file (PDF or
* image) before any page-level work has happened — the inbox item, not
* the ledger row. A `DN_BATCH_NOTES` child row exists for every
* candidate note the splitter found inside it; only after those pass
* every check does a `DELIVERY_NOTES` row get promoted.
*
* Added by `scripts/pb-add-dn-batches.py`.
*/
export type DnBatchStatus =
| "inbox"
| "analysing"
| "needs_attention"
| "processed";
export interface DnBatchRecord extends RecordModel {
collectionName: "dn_batches";
/** The uploaded PDF/image. Empty when only metadata was recorded. */
file: string;
/** SHA-256 of `file`. Powers dedup against re-uploads of the same bytes. */
file_hash: string;
/** Site the delivery belongs to. Required so n8n never has to guess. */
site: string;
/** Upload channel. Empty when the script that wrote the row didn't set it. */
source: "web" | "mobile" | "email" | "";
/** Free-form operator id. Empty when uploaded by an unauthenticated flow. */
uploaded_by: string;
/** Pipeline position. "inbox" until n8n claims it; "processed" once every child note is promoted or blocked. */
status: DnBatchStatus;
/** Total pages in the source file. Set during ingestion; unchanged after. */
page_count: number;
/** How many candidate notes the page splitter pulled out. */
notes_detected: number;
/** Of those, how many became real DELIVERY_NOTES rows. */
notes_promoted: number;
/** Raw segmentation output (per-page notes, bounding boxes, OCR text). Free-form. */
segmentation: unknown;
/** Aggregated check results across the whole batch. Free-form. */
checks: unknown;
/** Separate, revisioned AI proposal; never approved ledger or extraction data. */
reconciliation_preview?: import("./dn-pipeline/ai-preview").ProcessingPreview | null;
/** n8n retry counter. */
attempts: number;
/** ISO timestamp of the most recent n8n claim. Empty until first claim. */
claimed_at: string;
/** Last error message from n8n. Empty when the batch is healthy. */
last_error: string;
/** Set explicitly at upload — PocketBase's `created` is unusable here. */
uploaded_at: string;
open_pages?: unknown;
stage?: BatchStage;
source_count?: number;
processing_completed_at?: string;
capture_session_id?: string;
/** Batch-wide assembly revision; increments for every manual reassembly. */
assembly_revision?: number;
expand?: { site?: SiteRecord };
}
/**
* One candidate note found inside a `DN_BATCHES` row. Stays a candidate
* (status="candidate") until every check passes — only then is a real
* DELIVERY_NOTES row created and `delivery_note` is set. "blocked" notes
* are the ones that failed a check; "promoted" is the terminal state.
*
* Added by `scripts/pb-add-dn-batches.py`.
*/
export type DnBatchNoteStatus = "candidate" | "blocked" | "promoted";
export interface DnBatchNoteRecord extends RecordModel {
collectionName: "dn_batch_notes";
/** Owning DN_BATCHES row. Required — cascade-deletes with the batch. */
batch: string;
/** Position of this note within the batch (0-based). Stable across retries. */
note_index: number;
/** First page of the source PDF/image this note came from. */
page_from: number;
/** Last page of the source PDF/image this note came from. Inclusive. */
page_to: number;
/** Cropped page image (PDF single-page or image) for re-OCR or operator review. */
page_file: string;
/** As printed on the slip. Empty when OCR could not read it. */
delivery_note_number: string;
/** PO number as OCR saw it (any case, any spacing). */
po_number_raw: string;
/** PO number after normalisation (trim, collapse, uppercase). Used for the match lookup. */
po_number_normalised: string;
/** Resolved purchase_orders row. Empty until the normalised PO matches a real PO. */
purchase_order: string;
/** Gross mass (kg) from the slip, when present. */
gross_mass: number;
/** Tare mass (kg) from the slip, when present. */
tare_mass: number;
/** Nett mass (kg) from the slip, when present. */
nett_mass: number;
/** Quantity from the slip in `unit`. Zero when the slip showed none. */
quantity: number;
/** Verbatim unit from the slip, e.g. "kg" or "m³". Empty when absent. */
unit: string;
/** Parsed line items for this note. Free-form until the items extractor is finalised. */
items: unknown;
/** Per-check results (PO match, totals, duplicates, …). Free-form. */
checks: unknown;
/** Promotion state. See DnBatchNoteStatus. */
status: DnBatchNoteStatus;
/** Back-reference to the DELIVERY_NOTES row this note was promoted into. Empty while a candidate or blocked. */
delivery_note: string;
stage?: CandidateStage;
/** Stable staged identity derived from first persisted page plus document identity. */
candidate_key?: string;
assembly_revision?: number;
pages?: unknown;
supplier?: string;
supplier_name_raw?: string;
match_method?: string;
match_evidence?: unknown;
posted_at?: string;
expand?: { batch?: DnBatchRecord; supplier?: SupplierRecord; delivery_note?: DeliveryNoteRecord };
}
export interface DnBatchSourceRecord extends RecordModel {
collectionName: "dn_batch_sources";
batch: string;
file: string;
file_hash: string;
ordinal: number;
mime: string;
original_name: string;
capture_session_id?: string;
delivery_session_id?: string;
captured_at?: string;
latitude?: number;
longitude?: number;
location_accuracy_m?: number;
expand?: { batch?: DnBatchRecord };
}
export interface DnBatchPageRecord extends RecordModel {
collectionName: "dn_batch_pages";
batch: string;
source: string;
source_page: number;
/** Stable batch-wide page number; gaps are intentional when a page fails. */
global_page?: number;
region: number;
image: string;
image_hash: string;
mime: string;
width?: number;
height?: number;
rotation?: number;
read_status?: string;
extraction?: unknown;
schema_version?: string;
model?: string;
prompt_version?: string;
read_error?: string;
manual_group?: string;
expand?: { batch?: DnBatchRecord; source?: DnBatchSourceRecord };
}
export interface SupplierDocumentAliasRecord extends RecordModel {
collectionName: "supplier_document_aliases";
supplier: string;
raw_value: string;
normalised_key: string;
source?: string;
approved_by?: string;
expand?: { supplier?: SupplierRecord };
}
export interface DnReviewEventRecord extends RecordModel {
collectionName: "dn_review_events";
batch: string;
note?: string;
event_key?: string;
action?: string;
before?: unknown;
after?: unknown;
reason?: string;
actor?: string;
expand?: { batch?: DnBatchRecord; note?: DnBatchNoteRecord };
}
export interface DnPipelineLockRecord extends RecordModel {
collectionName: "dn_pipeline_locks";
batch: string;
expires_at: string;
worker: string;
expand?: { batch?: DnBatchRecord };
}
export type DocumentStatus =
| "pending"
| "processing"
| "processed"
| "failed"
| "dead";
/** One row per failed input row, persisted so the operator can see why. */
export interface RowError {
row: number;
reason: string;
}
/**
* The shared contract every uploaded document carries (spec section 4.1).
* One list component and one status helper serve all of them.
*/
export interface UploadedDocument extends RecordModel {
site: string;
source_file: string;
content_type?: string;
file_hash?: string;
date_uploaded: string;
uploaded_by: string;
processing_status: DocumentStatus;
processed_at?: string;
attempts?: number;
last_error?: string;
rows_total?: number;
rows_created?: number;
rows_failed?: number;
row_errors?: RowError[];
expand?: { site?: SiteRecord };
}
/** The uploaded wage/hours spreadsheet. Its rows are TIMESHEET_ENTRIES. */
export interface TimesheetRecord extends UploadedDocument {
collectionName: "timesheets";
}
/**
* The uploaded pump sheet. Its rows are FUEL_LOGS.
*
* `site` is optional on this collection in PocketBase and may come back
* as "" — a pump sheet legitimately spans sites and the row-level site
* stays authoritative. Same for PlantHoursSheetRecord.
*/
export interface FuelSheetRecord extends UploadedDocument {
collectionName: "fuel_sheets";
}
/** The uploaded plant hours sheet. Its rows are PLANT_HOURS_LOG. */
export interface PlantHoursSheetRecord extends UploadedDocument {
collectionName: "plant_hours_sheets";
}
export interface DnItemAliasRecord extends RecordModel {
collectionName: "dn_item_aliases";
supplier_key: string;
slip_description_raw: string;
slip_description_key: string;
material_code: string;
po_description: string;
created_by: string;
times_used: number;
last_used: string;
}