595 lines
18 KiB
TypeScript
595 lines
18 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 SupplierRecord extends RecordModel {
|
|
collectionName: "suppliers";
|
|
/** Stored pre-normalized (trim, collapse whitespace, uppercase) — see
|
|
* normalizeSupplierName in purchase-order-schema.ts. */
|
|
name: string;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
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;
|
|
};
|
|
}
|
|
|
|
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;
|
|
/** 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";
|
|
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;
|
|
/** 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;
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
}
|