From ab553526f6af26da58d9985033762d7c6afeb47d Mon Sep 17 00:00:00 2001 From: Nasim Sadat Date: Fri, 30 Oct 2026 14:40:16 +0100 Subject: [PATCH] fuel-db: customer on every row, place and holder for site, licence tables Draft migrations rewritten in place (PD-16, PD-18), with 0004 for the licence tables (PD-20) and rule tests in fuel-db/tests. Dashboards' column list regenerated; coverage_detail selects operator_uid. Checks: tests/run.sh 106 of 106; 18 views, 686 rows, 0 differences against a reference from the same corpus. Codex's fixes to its inspection are not re-inspected. Bands: persistence, Superset. Rules: 6, 8. Co-Authored-By: Claude Opus 5.5 --- petrolyst-insights/dashboards/atoma/SPEC.md | 2 +- .../dashboards/atoma/build_bundle.py | 3 +- .../dashboards/atoma/columns.tsv | 20 +- .../fuel-db/migrations/0001_fm_core.sql | 550 ++++++++++++------ .../migrations/0002_fm_derivations.sql | 102 ++-- .../fuel-db/migrations/0003_bi_views.sql | 297 +++++----- .../fuel-db/migrations/0004_licence.sql | 91 +++ .../fuel-db/migrations/README.md | 85 ++- .../fuel-db/tests/read_only.sql | 36 ++ .../fuel-monitoring/fuel-db/tests/rules.sql | 361 ++++++++++++ stacks/fuel-monitoring/fuel-db/tests/run.sh | 48 ++ 11 files changed, 1197 insertions(+), 398 deletions(-) create mode 100644 stacks/fuel-monitoring/fuel-db/migrations/0004_licence.sql create mode 100644 stacks/fuel-monitoring/fuel-db/tests/read_only.sql create mode 100644 stacks/fuel-monitoring/fuel-db/tests/rules.sql create mode 100755 stacks/fuel-monitoring/fuel-db/tests/run.sh diff --git a/petrolyst-insights/dashboards/atoma/SPEC.md b/petrolyst-insights/dashboards/atoma/SPEC.md index 46054a8..39d7e07 100644 --- a/petrolyst-insights/dashboards/atoma/SPEC.md +++ b/petrolyst-insights/dashboards/atoma/SPEC.md @@ -230,7 +230,7 @@ Physical datasets, one per `public.v_*` view in fuel-db's `public` schema, plus so the opening refill is included) or a bill's AFN amount. Both use only `v_coverage`, `v_delivery` and `v_electricity_cost`. Every dataset has the same -four site columns from `v_site`: `site_uid`, `site_code`, `site_name`, `site_label`. +five columns from `v_site`: `site_uid`, `site_code`, `site_name`, `site_label`, `operator_uid` (the customer; the row filter uses it, PD-16). | Dataset | Grain | Used by | Native filters that apply | |---|---|---|---| diff --git a/petrolyst-insights/dashboards/atoma/build_bundle.py b/petrolyst-insights/dashboards/atoma/build_bundle.py index b89df5e..7021a70 100644 --- a/petrolyst-insights/dashboards/atoma/build_bundle.py +++ b/petrolyst-insights/dashboards/atoma/build_bundle.py @@ -109,7 +109,7 @@ CALCULATED = { # opening visit's delivery is included and the closing visit's excluded. VIRTUAL = { "coverage_detail": ("v_coverage", [("lane_name", "character varying(60)"), ("amount", "character varying(120)")], """\ -SELECT c.site_uid, c.site_code, c.site_name, c.site_label, c.lane, c.status, +SELECT c.site_uid, c.site_code, c.site_name, c.site_label, c.operator_uid, c.lane, c.status, c.start_on, c.end_on, c.start_ts, c.end_ts, regexp_replace(c.lane, '^[0-9]+ ', '') AS lane_name, CASE WHEN c.lane = '1 Stock intervals' THEN @@ -166,6 +166,7 @@ METRICS = { LABELS = { "site_code": "Code", "site_name": "Site name", "site_label": "Site", "site_uid": "Site uid", + "operator_uid": "Customer", "pilot_status": "Pilot status", "baseline_status_label": "Baseline status", "baseline_status": "Baseline status code", "baseline_validity_note": "Validity note", "lph_estimate": "L/h", "lph_method": "L/h method", "lph_window_start": "L/h window start", diff --git a/petrolyst-insights/dashboards/atoma/columns.tsv b/petrolyst-insights/dashboards/atoma/columns.tsv index 8d2a234..ca9ee60 100644 --- a/petrolyst-insights/dashboards/atoma/columns.tsv +++ b/petrolyst-insights/dashboards/atoma/columns.tsv @@ -9,6 +9,7 @@ v_assumption site_uid character varying(12) v_assumption site_code character varying(20) v_assumption site_name character varying(120) v_assumption site_label character varying(170) +v_assumption operator_uid character varying(12) v_assumption code character varying(60) v_assumption topic character varying(30) v_assumption asset_uid character varying(40) @@ -30,6 +31,7 @@ v_baseline_input site_uid character varying(12) v_baseline_input site_code character varying(20) v_baseline_input site_name character varying(120) v_baseline_input site_label character varying(170) +v_baseline_input operator_uid character varying(12) v_baseline_input baseline_code character varying(60) v_baseline_input baseline_status character varying(12) v_baseline_input interval_label character varying(30) @@ -47,6 +49,7 @@ v_baseline_version site_uid character varying(12) v_baseline_version site_code character varying(20) v_baseline_version site_name character varying(120) v_baseline_version site_label character varying(170) +v_baseline_version operator_uid character varying(12) v_baseline_version code character varying(60) v_baseline_version metric character varying(40) v_baseline_version status character varying(12) @@ -75,6 +78,7 @@ v_coverage site_uid character varying(12) v_coverage site_code character varying(20) v_coverage site_name character varying(120) v_coverage site_label character varying(170) +v_coverage operator_uid character varying(12) v_coverage lane character varying(60) v_coverage status character varying(40) v_coverage start_on date @@ -85,6 +89,7 @@ v_data_quality site_uid character varying(12) v_data_quality site_code character varying(20) v_data_quality site_name character varying(120) v_data_quality site_label character varying(170) +v_data_quality operator_uid character varying(12) v_data_quality check_order integer v_data_quality check_code character varying(40) v_data_quality label character varying(80) @@ -94,6 +99,7 @@ v_delivery site_uid character varying(12) v_delivery site_code character varying(20) v_delivery site_name character varying(120) v_delivery site_label character varying(170) +v_delivery operator_uid character varying(12) v_delivery event_key character varying(80) v_delivery delivery_date date v_delivery seq_in_day integer @@ -108,6 +114,7 @@ v_electricity_cost site_uid character varying(12) v_electricity_cost site_code character varying(20) v_electricity_cost site_name character varying(120) v_electricity_cost site_label character varying(170) +v_electricity_cost operator_uid character varying(12) v_electricity_cost bill_month date v_electricity_cost period_start date v_electricity_cost period_end date @@ -122,6 +129,7 @@ v_evidence site_uid character varying(12) v_evidence site_code character varying(20) v_evidence site_name character varying(120) v_evidence site_label character varying(170) +v_evidence operator_uid character varying(12) v_evidence document_key character varying(16) v_evidence title character varying(200) v_evidence kind character varying(20) @@ -133,6 +141,7 @@ v_hour_meter_evidence site_uid character varying(12) v_hour_meter_evidence site_code character varying(20) v_hour_meter_evidence site_name character varying(120) v_hour_meter_evidence site_label character varying(170) +v_hour_meter_evidence operator_uid character varying(12) v_hour_meter_evidence meter_label character varying(40) v_hour_meter_evidence mapped_set character varying(60) v_hour_meter_evidence reading_date date @@ -146,6 +155,7 @@ v_imported_calculation site_uid character varying(12) v_imported_calculation site_code character varying(20) v_imported_calculation site_name character varying(120) v_imported_calculation site_label character varying(170) +v_imported_calculation operator_uid character varying(12) v_imported_calculation cell_ref character varying(40) v_imported_calculation source_label character varying(80) v_imported_calculation formula character varying(200) @@ -160,6 +170,7 @@ v_interval site_uid character varying(12) v_interval site_code character varying(20) v_interval site_name character varying(120) v_interval site_label character varying(170) +v_interval operator_uid character varying(12) v_interval interval_label character varying(30) v_interval start_on date v_interval end_on date @@ -193,6 +204,7 @@ v_kpi_measures site_uid character varying(12) v_kpi_measures site_code character varying(20) v_kpi_measures site_name character varying(120) v_kpi_measures site_label character varying(170) +v_kpi_measures operator_uid character varying(12) v_kpi_measures measure_order integer v_kpi_measures measure character varying(60) v_kpi_measures value numeric(12,2) @@ -205,6 +217,7 @@ v_modelled_lph site_uid character varying(12) v_modelled_lph site_code character varying(20) v_modelled_lph site_name character varying(120) v_modelled_lph site_label character varying(170) +v_modelled_lph operator_uid character varying(12) v_modelled_lph assumption_code character varying(60) v_modelled_lph status character varying(12) v_modelled_lph basis character varying(20) @@ -223,6 +236,7 @@ v_reconciliation site_uid character varying(12) v_reconciliation site_code character varying(20) v_reconciliation site_name character varying(120) v_reconciliation site_label character varying(170) +v_reconciliation operator_uid character varying(12) v_reconciliation interval_label character varying(30) v_reconciliation start_on date v_reconciliation end_on date @@ -242,6 +256,7 @@ v_runtime site_uid character varying(12) v_runtime site_code character varying(20) v_runtime site_name character varying(120) v_runtime site_label character varying(170) +v_runtime operator_uid character varying(12) v_runtime meter_label character varying(40) v_runtime mapped_set character varying(60) v_runtime interval_label character varying(80) @@ -259,6 +274,7 @@ v_site site_uid character varying(12) v_site site_code character varying(20) v_site site_name character varying(120) v_site site_label character varying(170) +v_site operator_uid character varying(12) v_site zone character varying(40) v_site province character varying(40) v_site district character varying(60) @@ -283,9 +299,10 @@ v_spec_reference site_uid character varying(12) v_spec_reference site_code character varying(20) v_spec_reference site_name character varying(120) v_spec_reference site_label character varying(170) +v_spec_reference operator_uid character varying(12) v_spec_reference slot character varying(20) v_spec_reference asset_uid character varying(40) -v_spec_reference asset_kind character varying(10) +v_spec_reference asset_kind character varying(20) v_spec_reference make_model character varying(120) v_spec_reference attribute character varying(40) v_spec_reference load_pct numeric(5,1) @@ -301,6 +318,7 @@ v_stock_reading site_uid character varying(12) v_stock_reading site_code character varying(20) v_stock_reading site_name character varying(120) v_stock_reading site_label character varying(170) +v_stock_reading operator_uid character varying(12) v_stock_reading observed_on date v_stock_reading seq_in_day integer v_stock_reading reading_type character varying(20) diff --git a/stacks/fuel-monitoring/fuel-db/migrations/0001_fm_core.sql b/stacks/fuel-monitoring/fuel-db/migrations/0001_fm_core.sql index 013b1aa..dac7414 100644 --- a/stacks/fuel-monitoring/fuel-db/migrations/0001_fm_core.sql +++ b/stacks/fuel-monitoring/fuel-db/migrations/0001_fm_core.sql @@ -2,7 +2,9 @@ -- DRAFT, not applied. Applied by hand by a loader acting as fuel_owner (README in this folder); -- deploy.sh never touches fuel-db. Design and rationale: fuel-monitoring -- .context/plans/2026-10-24-fuel-db-baseline-dashboard.md (sections cited as "design §n"). --- Order: 0001, 0002, 0003, each in its own transaction. +-- Order: 0001 to 0004, each in its own transaction. +-- Every table but fm.asset_kind carries operator_uid, the customer (PD-16). A child names its +-- parent and the customer together, so it cannot point at another customer's parent. BEGIN; CREATE SCHEMA fm AUTHORIZATION fuel_owner; CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA fm; -- trusted extension; for EXCLUDE on text @@ -10,9 +12,19 @@ CREATE EXTENSION IF NOT EXISTS btree_gist WITH SCHEMA fm; -- trusted extension -- anyway, and the local test applies this file in a session whose search_path is the loader's default. SET LOCAL search_path = fm, pg_catalog, public; +-- Customers ------------------------------------------------------------------------------- +CREATE TABLE fm.operator ( + operator_uid text PRIMARY KEY CHECK (operator_uid ~ '^OPR-[0-9]{4}$'), + name text NOT NULL, + label_issuer text NOT NULL, -- whose labels this customer's people see (holder_alias.issuer) + programme text NOT NULL, -- the programme v_site reports (participation.programme) + timezone text NOT NULL -- for a holder with no place, or a place without its own +); + -- Provenance ------------------------------------------------------------------------------ CREATE TABLE fm.source_document ( sha256 text PRIMARY KEY CHECK (sha256 ~ '^[0-9a-f]{64}$'), + operator_uid text NOT NULL REFERENCES fm.operator, source_family text NOT NULL, -- stable across versions: '-', 'site-yaml:SITE-0001' legacy_key16 text UNIQUE CHECK (legacy_key16 ~ '^[0-9a-f]{16}$'), -- catalog.yaml key kind text NOT NULL CHECK (kind IN ('delivery_form','site_logbook','operator_workbook', @@ -23,12 +35,15 @@ CREATE TABLE fm.source_document ( corpus_path text, -- NULL when held outside the repository transcription_path text, -- the corpus note that transcribes it contains_personal_data boolean NOT NULL, - supersedes_sha256 text UNIQUE REFERENCES fm.source_document, + supersedes_sha256 text UNIQUE, null_reasons jsonb NOT NULL DEFAULT '{}', - CHECK (corpus_path IS NOT NULL OR transcription_path IS NOT NULL) + CHECK (corpus_path IS NOT NULL OR transcription_path IS NOT NULL), + UNIQUE (operator_uid, sha256), + FOREIGN KEY (operator_uid, supersedes_sha256) REFERENCES fm.source_document (operator_uid, sha256) ); CREATE TABLE fm.import_batch ( import_batch_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_kind text NOT NULL CHECK (source_kind IN ('site_yaml','operator_workbook','catalog','review_manifest','baseline_run')), source_path text NOT NULL, source_sha256 text NOT NULL CHECK (source_sha256 ~ '^[0-9a-f]{64}$'), @@ -37,33 +52,86 @@ CREATE TABLE fm.import_batch ( started_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz, status text NOT NULL DEFAULT 'running' CHECK (status IN ('running','loaded','failed')), - report jsonb NOT NULL DEFAULT '{}' -- counts, unmapped paths, conflicts + report jsonb NOT NULL DEFAULT '{}', -- counts, unmapped paths, conflicts + UNIQUE (operator_uid, import_batch_id) ); CREATE TABLE fm.import_record ( import_record_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, logical_key text NOT NULL, -- = source_key: ':delivery:SITE-0001:2026-01-31:1' source_locator text NOT NULL, -- YAML path or sheet!range as read payload jsonb NOT NULL, -- original values, strings as written, 'UNKNOWN' kept payload_sha256 text NOT NULL, - first_batch_id bigint NOT NULL REFERENCES fm.import_batch, - last_seen_batch_id bigint NOT NULL REFERENCES fm.import_batch, - UNIQUE (logical_key, payload_sha256) + first_batch_id bigint NOT NULL, + last_seen_batch_id bigint NOT NULL, + UNIQUE (operator_uid, logical_key, payload_sha256), + UNIQUE (operator_uid, import_record_id), + FOREIGN KEY (operator_uid, first_batch_id) REFERENCES fm.import_batch (operator_uid, import_batch_id), + FOREIGN KEY (operator_uid, last_seen_batch_id) REFERENCES fm.import_batch (operator_uid, import_batch_id) ); --- Identity -------------------------------------------------------------------------------- -CREATE TABLE fm.site ( - site_uid text PRIMARY KEY CHECK (site_uid ~ '^SITE-[0-9]{4}$'), - name text, name_on_plate text, - zone text NOT NULL, province text NOT NULL, district text, - timezone text NOT NULL DEFAULT 'Asia/Kabul', - import_record_id bigint NOT NULL REFERENCES fm.import_record, -- full site.yaml payload - null_reasons jsonb NOT NULL DEFAULT '{}' +-- Identity: asset kinds and assets, places and holders (PD-18) -------------------------------- +CREATE TABLE fm.asset_kind ( -- what an asset does with fuel (PD-18); global, not per customer + kind text PRIMARY KEY, + fuel_role text CHECK (fuel_role IN ('container','consumer','transfer','sensor')) + -- NULL: carries other assets and holds no fuel itself ); -CREATE TABLE fm.site_location ( -- coordinates are claims; the map uses only an accepted one +INSERT INTO fm.asset_kind (kind, fuel_role) VALUES + ('generator','consumer'), ('engine','consumer'), ('tank','container'), ('compartment','container'), + ('vehicle_tank','container'), ('pump','transfer'), ('vehicle',NULL); +CREATE TABLE fm.asset ( + asset_uid text PRIMARY KEY CHECK (asset_uid ~ '^AST-[A-Z0-9-]+$'), + operator_uid text NOT NULL REFERENCES fm.operator, + kind text NOT NULL REFERENCES fm.asset_kind, + make text, model text, serial text, year_of_manufacture smallint, + import_record_id bigint NOT NULL, + null_reasons jsonb NOT NULL DEFAULT '{}', + UNIQUE (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id) +); +CREATE TABLE fm.place ( -- fixed geography; its coordinates are claims (place_location) + place_uid text PRIMARY KEY CHECK (place_uid ~ '^PLC-[0-9]{4,8}$'), + operator_uid text NOT NULL REFERENCES fm.operator, + role text NOT NULL CHECK (role IN ('tower_site','depot','yard','fuel_station')), + zone text NOT NULL, province text NOT NULL, district text, + timezone text, -- NULL: the operator's + import_record_id bigint NOT NULL, + null_reasons jsonb NOT NULL DEFAULT '{}', + UNIQUE (operator_uid, place_uid), + FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id) +); +CREATE TABLE fm.holder ( -- what assets are in or on (PD-18). For a telecom site: one + -- fixed installation at one place. Ids are opaque and permanent + holder_uid text PRIMARY KEY CHECK (holder_uid ~ '^[A-Z]{2,4}-[0-9]{4,7}$'), + operator_uid text NOT NULL REFERENCES fm.operator, + kind text NOT NULL CHECK (kind IN ('fixed_installation','vehicle','vessel','trailer')), + name text, name_on_plate text, + asset_uid text, -- a vehicle is both an asset and a holder + import_record_id bigint NOT NULL, + null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK ((kind = 'vehicle') = (asset_uid IS NOT NULL)), + UNIQUE (operator_uid, holder_uid), + FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id) +); +CREATE TABLE fm.holder_place ( -- where a fixed holder stands; a vehicle's position is telemetry + holder_uid text NOT NULL, + operator_uid text NOT NULL REFERENCES fm.operator, + place_uid text NOT NULL, + valid_from date, valid_to date, -- NULL from = unknown start; NULL to = still there + null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK (valid_from IS NOT NULL OR null_reasons ? 'valid_from'), + EXCLUDE USING gist (holder_uid WITH =, + daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid), + FOREIGN KEY (operator_uid, place_uid) REFERENCES fm.place (operator_uid, place_uid) +); +CREATE TABLE fm.place_location ( -- coordinates are claims; the map uses only an accepted one location_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_key text NOT NULL, -- 'site-yaml:SITE-0001:coordinates' - assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), -- site, lat, lon, precision, basis - site_uid text NOT NULL REFERENCES fm.site, + assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), -- operator, place, lat, lon, precision, basis + place_uid text NOT NULL, latitude numeric(9,6) NOT NULL CHECK (latitude BETWEEN -90 AND 90), longitude numeric(9,6) NOT NULL CHECK (longitude BETWEEN -180 AND 180), precision_m integer, -- e.g. 110 where the source rounds to 0.001 deg @@ -71,91 +139,111 @@ CREATE TABLE fm.site_location ( -- coordinates are claims; the source_note text NOT NULL, -- 'operator site plate photo ' acceptance text NOT NULL DEFAULT 'pending' CHECK (acceptance IN ('pending','accepted','rejected','superseded','retracted')), accepted_on date, accepted_by_role text, acceptance_ref text, -- copied from the matching fm.review row - superseded_by bigint REFERENCES fm.site_location, - import_record_id bigint NOT NULL REFERENCES fm.import_record, + superseded_by bigint, + import_record_id bigint NOT NULL, CHECK (acceptance <> 'accepted' OR (accepted_on IS NOT NULL AND accepted_by_role IS NOT NULL)), - CHECK (acceptance <> 'superseded' OR superseded_by IS NOT NULL) + CHECK (acceptance <> 'superseded' OR superseded_by IS NOT NULL), + UNIQUE (operator_uid, location_id), + FOREIGN KEY (operator_uid, place_uid) REFERENCES fm.place (operator_uid, place_uid), + FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.place_location (operator_uid, location_id), + FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id) ); -CREATE UNIQUE INDEX site_location_live_key ON fm.site_location (source_key) +CREATE UNIQUE INDEX place_location_live_key ON fm.place_location (operator_uid, source_key) WHERE acceptance IN ('pending','accepted','rejected'); -CREATE UNIQUE INDEX site_location_one_accepted ON fm.site_location (site_uid) WHERE acceptance = 'accepted'; -CREATE TABLE fm.site_alias ( - site_alias_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - site_uid text NOT NULL REFERENCES fm.site, +CREATE UNIQUE INDEX place_location_one_accepted ON fm.place_location (place_uid) WHERE acceptance = 'accepted'; +CREATE TABLE fm.holder_alias ( + holder_alias_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, + holder_uid text NOT NULL, label text NOT NULL, issuer text NOT NULL, valid_from date, valid_to date, -- NULL from = unknown start; NULL to = still valid is_primary boolean NOT NULL DEFAULT false, null_reasons jsonb NOT NULL DEFAULT '{}', CHECK (valid_from IS NOT NULL OR null_reasons ? 'valid_from'), - EXCLUDE USING gist (issuer WITH =, label WITH =, + EXCLUDE USING gist (operator_uid WITH =, issuer WITH =, label WITH =, daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&), - EXCLUDE USING gist (site_uid WITH =, issuer WITH =, - daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&) WHERE (is_primary) + EXCLUDE USING gist (holder_uid WITH =, issuer WITH =, + daterange(coalesce(valid_from,'-infinity'::date), valid_to, '[)') WITH &&) WHERE (is_primary), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid) ); -CREATE TABLE fm.pilot_participation ( - site_uid text NOT NULL REFERENCES fm.site, - programme text NOT NULL DEFAULT 'ATOMA OFMS pilot', +CREATE TABLE fm.participation ( + holder_uid text NOT NULL, + operator_uid text NOT NULL REFERENCES fm.operator, + programme text NOT NULL, status text NOT NULL CHECK (status IN ('active','reference','candidate','withdrawn')), valid_from date NOT NULL, valid_to date, decision_ref text, note text, - EXCLUDE USING gist (site_uid WITH =, programme WITH =, daterange(valid_from, valid_to, '[)') WITH &&) + EXCLUDE USING gist (holder_uid WITH =, programme WITH =, daterange(valid_from, valid_to, '[)') WITH &&), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid) ); -CREATE TABLE fm.asset ( - asset_uid text PRIMARY KEY CHECK (asset_uid ~ '^AST-[A-Z0-9-]+$'), - kind text NOT NULL CHECK (kind IN ('generator','tank')), - make text, model text, serial text, year_of_manufacture smallint, - import_record_id bigint NOT NULL REFERENCES fm.import_record, - null_reasons jsonb NOT NULL DEFAULT '{}' -); -CREATE TABLE fm.asset_placement ( +CREATE TABLE fm.asset_placement ( -- in or on a holder, or at a place: exactly one (PD-18) placement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - asset_uid text NOT NULL REFERENCES fm.asset, - site_uid text NOT NULL REFERENCES fm.site, + operator_uid text NOT NULL REFERENCES fm.operator, + asset_uid text NOT NULL, + holder_uid text, + place_uid text, slot_label text NOT NULL, -- 'GEN-01': a position label, not an identity occupied_from date, occupied_to date, -- NULL from = before any record (conservative) earliest_evidence_on date, installed_on date, null_reasons jsonb NOT NULL DEFAULT '{}', + CHECK (num_nonnulls(holder_uid, place_uid) = 1), CHECK (installed_on IS NOT NULL OR null_reasons ? 'installed_on'), EXCLUDE USING gist (asset_uid WITH =, daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&), - EXCLUDE USING gist (site_uid WITH =, slot_label WITH =, - daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&) + EXCLUDE USING gist (holder_uid WITH =, slot_label WITH =, + daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&) WHERE (holder_uid IS NOT NULL), + EXCLUDE USING gist (place_uid WITH =, slot_label WITH =, + daterange(coalesce(occupied_from,'-infinity'::date), occupied_to, '[)') WITH &&) WHERE (place_uid IS NOT NULL), + FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid), + FOREIGN KEY (operator_uid, place_uid) REFERENCES fm.place (operator_uid, place_uid) ); CREATE TABLE fm.runtime_meter ( meter_id text PRIMARY KEY, -- ours, stable: 'MTR-SITE-0001-GENSET1' - site_uid text NOT NULL REFERENCES fm.site, + operator_uid text NOT NULL REFERENCES fm.operator, + holder_uid text NOT NULL, source_label text NOT NULL, -- as written: 'Genset 1', 'R.H' - asset_uid text REFERENCES fm.asset, + asset_uid text, mapping_status text NOT NULL CHECK (mapping_status IN ('confirmed','proposed','unmapped')), mapping_basis text, - UNIQUE (site_uid, source_label), - CHECK ((mapping_status = 'unmapped') = (asset_uid IS NULL)) + UNIQUE (holder_uid, source_label), + CHECK ((mapping_status = 'unmapped') = (asset_uid IS NULL)), + UNIQUE (operator_uid, meter_id), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid), + FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid) ); CREATE TABLE fm.meter_epoch ( epoch_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - meter_id text NOT NULL REFERENCES fm.runtime_meter, + operator_uid text NOT NULL REFERENCES fm.operator, + meter_id text NOT NULL, epoch_no smallint NOT NULL, starts_on date, -- NULL: began before the first reading held start_reason text NOT NULL CHECK (start_reason IN ('first_seen','display_replaced','controller_replaced','reset','set_replaced')), continuity text NOT NULL CHECK (continuity IN ('confirmed','assumed','uncertain')), rollover_at numeric(9,1), - source_sha256 text REFERENCES fm.source_document, note text, - UNIQUE (meter_id, epoch_no) + source_sha256 text, note text, + UNIQUE (meter_id, epoch_no), + UNIQUE (operator_uid, epoch_id), + FOREIGN KEY (operator_uid, meter_id) REFERENCES fm.runtime_meter (operator_uid, meter_id), + FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256) ); -- Operations (claims) --------------------------------------------------------------------- CREATE TABLE fm.visit ( visit_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, visit_key text NOT NULL UNIQUE, -- 'SITE-0001:2026-01-31:1' - site_uid text NOT NULL REFERENCES fm.site, - visit_on date NOT NULL, -- site-local calendar date + holder_uid text NOT NULL, + visit_on date NOT NULL, -- holder-local calendar date seq_in_day smallint NOT NULL DEFAULT 1, time_in_local time, time_out_local time, -- as written; NULL when the source has a date only kind text NOT NULL CHECK (kind IN ('delivery','stock_check','maintenance','survey')), -- descriptive only: views use fm.v_visit_live and the live claims - UNIQUE (site_uid, visit_on, seq_in_day), - UNIQUE (visit_id, site_uid) -- target of site-checked composite foreign keys + UNIQUE (holder_uid, visit_on, seq_in_day), + UNIQUE (operator_uid, visit_id, holder_uid), -- target of holder-checked composite foreign keys + UNIQUE (operator_uid, visit_id), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid) ); -- A visit is never deleted. Once all its claims are superseded or retracted it stays for provenance -- only, and drops out of every analytical view (fm.v_visit_live). @@ -164,42 +252,52 @@ CREATE TABLE fm.visit ( -- superseded_by, review_note, null_reasons CREATE TABLE fm.fuel_movement ( movement_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_key text NOT NULL, -- ':delivery:SITE-0001:2026-01-31:1' assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), event_key text NOT NULL, -- the physical movement: 'SITE-0001:2026-01-31:1:delivery:1'; -- claims sharing it are alternative reports of ONE movement - visit_id bigint NOT NULL REFERENCES fm.visit, - site_uid text NOT NULL REFERENCES fm.site, + visit_id bigint NOT NULL, + holder_uid text NOT NULL, kind text NOT NULL CHECK (kind IN ('delivery','external_in','internal_transfer', 'withdrawal','drain','spill','loss_reported')), - from_tank_uid text REFERENCES fm.asset, to_tank_uid text REFERENCES fm.asset, + from_tank_uid text, to_tank_uid text, source_tank_label text, litres numeric(8,1) CHECK (litres >= 0), litres_state text NOT NULL CHECK (litres_state IN ('stated','converted_from_level','converted_from_mass','not_recorded','illegible')), density_kg_per_l numeric(5,3), unit_price numeric(8,2), price_currency text, - source_sha256 text NOT NULL REFERENCES fm.source_document, + source_sha256 text NOT NULL, record_ref text NOT NULL, -- 'form ' | '!J20' | 'logbook p2 r1' - import_record_id bigint NOT NULL REFERENCES fm.import_record, + import_record_id bigint NOT NULL, claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), - superseded_by bigint REFERENCES fm.fuel_movement, + superseded_by bigint, review_note text, null_reasons jsonb NOT NULL DEFAULT '{}', CHECK ((litres IS NULL) = (litres_state IN ('not_recorded','illegible'))), CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), - CHECK (kind <> 'internal_transfer' OR (from_tank_uid IS NOT NULL AND to_tank_uid IS NOT NULL)) + CHECK (kind <> 'internal_transfer' OR (from_tank_uid IS NOT NULL AND to_tank_uid IS NOT NULL)), + UNIQUE (operator_uid, movement_id), + FOREIGN KEY (operator_uid, visit_id) REFERENCES fm.visit (operator_uid, visit_id), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid), + FOREIGN KEY (operator_uid, from_tank_uid) REFERENCES fm.asset (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, to_tank_uid) REFERENCES fm.asset (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256), + FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id), + FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.fuel_movement (operator_uid, movement_id) ); -CREATE UNIQUE INDEX fuel_movement_live_key ON fm.fuel_movement (source_key) +CREATE UNIQUE INDEX fuel_movement_live_key ON fm.fuel_movement (operator_uid, source_key) WHERE claim_status IN ('asserted','reviewed','disputed'); CREATE INDEX ON fm.fuel_movement (visit_id, event_key); CREATE TABLE fm.stock_observation ( stock_obs_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_key text NOT NULL, assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), - visit_id bigint NOT NULL REFERENCES fm.visit, - site_uid text NOT NULL REFERENCES fm.site, + visit_id bigint NOT NULL, + holder_uid text NOT NULL, scope text NOT NULL CHECK (scope IN ('site_total','tank')), - tank_uid text REFERENCES fm.asset, -- only when the source identifies the tank + tank_uid text, -- only when the source identifies the tank source_tank_label text, -- 'T1' as written, even when unmapped phase text NOT NULL CHECK (phase IN ('before_delivery','after_delivery','standalone')), level_cm numeric(6,1), @@ -208,27 +306,35 @@ CREATE TABLE fm.stock_observation ( 'stated_total','level_only','not_recorded','illegible','placeholder_zero')), resolution_l numeric(6,1), -- ±1 cm of dip at this level, from tank geometry resolution_basis text, - source_sha256 text NOT NULL REFERENCES fm.source_document, + source_sha256 text NOT NULL, record_ref text NOT NULL, - import_record_id bigint NOT NULL REFERENCES fm.import_record, + import_record_id bigint NOT NULL, claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), - superseded_by bigint REFERENCES fm.stock_observation, + superseded_by bigint, review_note text, null_reasons jsonb NOT NULL DEFAULT '{}', CHECK (scope = 'site_total' OR tank_uid IS NOT NULL OR source_tank_label IS NOT NULL), CHECK ((litres IS NULL) = (litres_basis IN ('level_only','not_recorded','illegible','placeholder_zero'))), - CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL) + CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), + UNIQUE (operator_uid, stock_obs_id), + FOREIGN KEY (operator_uid, visit_id) REFERENCES fm.visit (operator_uid, visit_id), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid), + FOREIGN KEY (operator_uid, tank_uid) REFERENCES fm.asset (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256), + FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id), + FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.stock_observation (operator_uid, stock_obs_id) ); -CREATE UNIQUE INDEX stock_obs_live_key ON fm.stock_observation (source_key) +CREATE UNIQUE INDEX stock_obs_live_key ON fm.stock_observation (operator_uid, source_key) WHERE claim_status IN ('asserted','reviewed','disputed'); CREATE INDEX ON fm.stock_observation (visit_id, phase); CREATE TABLE fm.hour_meter_reading ( reading_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_key text NOT NULL, assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), - visit_id bigint NOT NULL REFERENCES fm.visit, - meter_id text NOT NULL REFERENCES fm.runtime_meter, - epoch_id bigint REFERENCES fm.meter_epoch, -- NULL = not yet judged; excluded from runtime + visit_id bigint NOT NULL, + meter_id text NOT NULL, + epoch_id bigint, -- NULL = not yet judged; excluded from runtime source_field text NOT NULL CHECK (source_field IN ('current','previous','logbook_rh','display_photo')), date_basis text NOT NULL CHECK (date_basis IN ('stated','inferred_previous_visit','capture_date_unknown')), hours numeric(9,1), @@ -236,84 +342,109 @@ CREATE TABLE fm.hour_meter_reading ( reading_state text NOT NULL CHECK (reading_state IN ('clear','best_reading','ambiguous','illegible','crossed_out','not_recorded')), alternatives text, -- competing readings of one illegible value, '|'-separated transcribed_by text NOT NULL CHECK (transcribed_by IN ('source_operator','our_reading')), - source_sha256 text NOT NULL REFERENCES fm.source_document, + source_sha256 text NOT NULL, record_ref text NOT NULL, - import_record_id bigint NOT NULL REFERENCES fm.import_record, + import_record_id bigint NOT NULL, claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), - superseded_by bigint REFERENCES fm.hour_meter_reading, + superseded_by bigint, review_note text, null_reasons jsonb NOT NULL DEFAULT '{}', CHECK ((hours IS NULL) = (reading_state IN ('ambiguous','illegible','crossed_out','not_recorded'))), - CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL) + CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), + UNIQUE (operator_uid, reading_id), + FOREIGN KEY (operator_uid, visit_id) REFERENCES fm.visit (operator_uid, visit_id), + FOREIGN KEY (operator_uid, meter_id) REFERENCES fm.runtime_meter (operator_uid, meter_id), + FOREIGN KEY (operator_uid, epoch_id) REFERENCES fm.meter_epoch (operator_uid, epoch_id), + FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256), + FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id), + FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.hour_meter_reading (operator_uid, reading_id) ); -CREATE UNIQUE INDEX hour_reading_live_key ON fm.hour_meter_reading (source_key) +CREATE UNIQUE INDEX hour_reading_live_key ON fm.hour_meter_reading (operator_uid, source_key) WHERE claim_status IN ('asserted','reviewed','disputed'); CREATE INDEX ON fm.hour_meter_reading (visit_id, meter_id); CREATE TABLE fm.runtime_interval ( -- future: controller / ATS logs, telemetry runtime_interval_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_key text NOT NULL, -- live-unique, as for every claim assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), - site_uid text NOT NULL REFERENCES fm.site, - asset_uid text REFERENCES fm.asset, + holder_uid text NOT NULL, + asset_uid text, scope text NOT NULL CHECK (scope IN ('generator','site_any_generator')), started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL CHECK (ended_at > started_at), source_kind text NOT NULL CHECK (source_kind IN ('controller_event_log','ats_log','gateway_telemetry','manual_log')), - source_sha256 text REFERENCES fm.source_document, + source_sha256 text, claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), - superseded_by bigint REFERENCES fm.runtime_interval, + superseded_by bigint, CHECK ((scope = 'generator') = (asset_uid IS NOT NULL)), CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), - EXCLUDE USING gist (site_uid WITH =, scope WITH =, (coalesce(asset_uid,'')) WITH =, source_kind WITH =, - tstzrange(started_at, ended_at) WITH &&) WHERE (claim_status IN ('asserted','reviewed')) + EXCLUDE USING gist (holder_uid WITH =, scope WITH =, (coalesce(asset_uid,'')) WITH =, source_kind WITH =, + tstzrange(started_at, ended_at) WITH &&) WHERE (claim_status IN ('asserted','reviewed')), + UNIQUE (operator_uid, runtime_interval_id), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid), + FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256), + FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.runtime_interval (operator_uid, runtime_interval_id) ); -CREATE UNIQUE INDEX runtime_interval_live_key ON fm.runtime_interval (source_key) +CREATE UNIQUE INDEX runtime_interval_live_key ON fm.runtime_interval (operator_uid, source_key) WHERE claim_status IN ('asserted','reviewed','disputed'); CREATE TABLE fm.engine_fuel_total ( -- future: flow-meter net fuel per period total_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_key text NOT NULL, -- live-unique, as for every claim assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), - asset_uid text NOT NULL REFERENCES fm.asset, + asset_uid text NOT NULL, started_at timestamptz NOT NULL, ended_at timestamptz NOT NULL CHECK (ended_at > started_at), net_fuel_l numeric(10,2) NOT NULL, -- feed − return as the device reports engine_hours numeric(8,2), -- same device, if reported source_kind text NOT NULL CHECK (source_kind IN ('differential_flow_meter','single_flow_meter','controller_fuel_counter')), - source_sha256 text REFERENCES fm.source_document, + source_sha256 text, claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), - superseded_by bigint REFERENCES fm.engine_fuel_total, + superseded_by bigint, CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), EXCLUDE USING gist (asset_uid WITH =, tstzrange(started_at, ended_at) WITH &&) - WHERE (claim_status IN ('asserted','reviewed')) + WHERE (claim_status IN ('asserted','reviewed')), + UNIQUE (operator_uid, total_id), + FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256), + FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.engine_fuel_total (operator_uid, total_id) ); -CREATE UNIQUE INDEX engine_fuel_total_live_key ON fm.engine_fuel_total (source_key) +CREATE UNIQUE INDEX engine_fuel_total_live_key ON fm.engine_fuel_total (operator_uid, source_key) WHERE claim_status IN ('asserted','reviewed','disputed'); CREATE TABLE fm.electricity_bill ( bill_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_key text NOT NULL, assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), - site_uid text NOT NULL REFERENCES fm.site, + holder_uid text NOT NULL, bill_month date NOT NULL CHECK (extract(day FROM bill_month) = 1), -- month as the source assigns it period_start date, period_end date, amount numeric(12,2), currency text, currency_basis text CHECK (currency_basis IN ('stated','inferred')), kwh numeric(10,1), - source_sha256 text NOT NULL REFERENCES fm.source_document, + source_sha256 text NOT NULL, record_ref text NOT NULL, - import_record_id bigint NOT NULL REFERENCES fm.import_record, + import_record_id bigint NOT NULL, claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), - superseded_by bigint REFERENCES fm.electricity_bill, + superseded_by bigint, null_reasons jsonb NOT NULL DEFAULT '{}', CHECK (kwh IS NOT NULL OR null_reasons ? 'kwh'), - CHECK (period_start IS NOT NULL OR null_reasons ? 'period_start') + CHECK (period_start IS NOT NULL OR null_reasons ? 'period_start'), + UNIQUE (operator_uid, bill_id), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid), + FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256), + FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id), + FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.electricity_bill (operator_uid, bill_id) ); -CREATE UNIQUE INDEX bill_live_key ON fm.electricity_bill (source_key) +CREATE UNIQUE INDEX bill_live_key ON fm.electricity_bill (operator_uid, source_key) WHERE claim_status IN ('asserted','reviewed','disputed'); -- Specifications, assumptions, imported calculations ------------------------------------- CREATE TABLE fm.equipment_spec ( -- versioned like a claim: corrections supersede spec_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_key text NOT NULL, -- 'site-yaml:SITE-0001:AST-…:rated_prime_kw' assertion_sha256 text NOT NULL CHECK (assertion_sha256 ~ '^[0-9a-f]{64}$'), - asset_uid text NOT NULL REFERENCES fm.asset, + asset_uid text NOT NULL, attribute text NOT NULL, -- rated_prime_kw, capacity_l, fuel_rate_at_load, … load_pct numeric(5,1), value_num numeric(12,3), value_text text, unit text, @@ -321,22 +452,28 @@ CREATE TABLE fm.equipment_spec ( -- versioned like a claim: corre 'operator_stated','our_inference','geometry')), applicability text NOT NULL DEFAULT 'confirmed' CHECK (applicability IN ('confirmed','conditional','unconfirmed')), applicability_note text, - source_sha256 text REFERENCES fm.source_document, - import_record_id bigint NOT NULL REFERENCES fm.import_record, + source_sha256 text, + import_record_id bigint NOT NULL, claim_status text NOT NULL DEFAULT 'asserted' CHECK (claim_status IN ('asserted','reviewed','disputed','superseded','retracted')), - superseded_by bigint REFERENCES fm.equipment_spec, + superseded_by bigint, CHECK (claim_status <> 'superseded' OR superseded_by IS NOT NULL), - CHECK ((attribute IN ('fuel_rate_at_load','specific_fuel_at_load')) = (load_pct IS NOT NULL)) + CHECK ((attribute IN ('fuel_rate_at_load','specific_fuel_at_load')) = (load_pct IS NOT NULL)), + UNIQUE (operator_uid, spec_id), + FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256), + FOREIGN KEY (operator_uid, import_record_id) REFERENCES fm.import_record (operator_uid, import_record_id), + FOREIGN KEY (operator_uid, superseded_by) REFERENCES fm.equipment_spec (operator_uid, spec_id) ); -CREATE UNIQUE INDEX equipment_spec_live_key ON fm.equipment_spec (source_key) +CREATE UNIQUE INDEX equipment_spec_live_key ON fm.equipment_spec (operator_uid, source_key) WHERE claim_status IN ('asserted','reviewed','disputed'); CREATE TABLE fm.assumption ( -- one row per version; a change is a new version assumption_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, code text NOT NULL UNIQUE, -- 'A-SITE-0001-GEN02-IDLE-v1' fingerprint text NOT NULL CHECK (fingerprint ~ '^[0-9a-f]{64}$'), -- all fields below + sorted members created_xact xid8 NOT NULL DEFAULT pg_current_xact_id(), -- rule 9: members only in this transaction - site_uid text NOT NULL REFERENCES fm.site, - asset_uid text REFERENCES fm.asset, + holder_uid text NOT NULL, + asset_uid text, topic text NOT NULL CHECK (topic IN ('runtime_hours_per_day','generator_idle','meter_coverage', 'tank_linkage','no_unrecorded_movements','density','other')), -- meter_coverage: its members name the exact counters, @@ -347,77 +484,97 @@ CREATE TABLE fm.assumption ( -- one row per version; a chang derived_from_fuel boolean NOT NULL, -- true: never a runtime input to an L/h figure status text NOT NULL CHECK (status IN ('proposed','accepted','rejected','superseded')), accepted_on date, accepted_by_role text, decision_ref text, -- copied from the matching fm.review row - supersedes_id bigint UNIQUE REFERENCES fm.assumption, + supersedes_id bigint UNIQUE, rationale text NOT NULL, contradicted_by text, - source_sha256 text REFERENCES fm.source_document, + source_sha256 text, CHECK (status <> 'accepted' OR (accepted_on IS NOT NULL AND accepted_by_role IS NOT NULL)), CONSTRAINT assumption_no_runtime_from_fuel -- rule 5 CHECK (NOT (derived_from_fuel AND topic = 'runtime_hours_per_day' AND status = 'accepted')), CHECK (topic <> 'generator_idle' OR asset_uid IS NOT NULL), CHECK (topic <> 'meter_coverage' OR asset_uid IS NULL), -- its sets are in assumption_member - EXCLUDE USING gist (site_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&) + EXCLUDE USING gist (holder_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&) WHERE (topic = 'meter_coverage' AND status IN ('proposed','accepted')), EXCLUDE USING gist (asset_uid WITH =, daterange(applies_from, applies_to, '[]') WITH &&) - WHERE (topic = 'generator_idle' AND status IN ('proposed','accepted')) + WHERE (topic = 'generator_idle' AND status IN ('proposed','accepted')), + UNIQUE (operator_uid, assumption_id), + FOREIGN KEY (operator_uid, holder_uid) REFERENCES fm.holder (operator_uid, holder_uid), + FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid), + FOREIGN KEY (operator_uid, supersedes_id) REFERENCES fm.assumption (operator_uid, assumption_id), + FOREIGN KEY (operator_uid, source_sha256) REFERENCES fm.source_document (operator_uid, sha256) ); CREATE TABLE fm.assumption_member ( -- exact membership of a meter_coverage assumption - assumption_id bigint NOT NULL REFERENCES fm.assumption, + assumption_id bigint NOT NULL, + operator_uid text NOT NULL REFERENCES fm.operator, member_role text NOT NULL CHECK (member_role IN ('counter','covered_generator','idle_generator')), - meter_id text REFERENCES fm.runtime_meter, - asset_uid text REFERENCES fm.asset, + meter_id text, + asset_uid text, CHECK ((member_role = 'counter') = (meter_id IS NOT NULL)), - CHECK ((member_role = 'counter') = (asset_uid IS NULL)) + CHECK ((member_role = 'counter') = (asset_uid IS NULL)), + FOREIGN KEY (operator_uid, assumption_id) REFERENCES fm.assumption (operator_uid, assumption_id), + FOREIGN KEY (operator_uid, meter_id) REFERENCES fm.runtime_meter (operator_uid, meter_id), + FOREIGN KEY (operator_uid, asset_uid) REFERENCES fm.asset (operator_uid, asset_uid) ); CREATE UNIQUE INDEX assumption_member_meter ON fm.assumption_member (assumption_id, meter_id) WHERE meter_id IS NOT NULL; CREATE UNIQUE INDEX assumption_member_asset ON fm.assumption_member (assumption_id, asset_uid) WHERE asset_uid IS NOT NULL; -- a set is either covered or idle within one assumption CREATE TABLE fm.imported_calculation ( -- versioned like a claim: a revised formula supersedes calc_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + operator_uid text NOT NULL REFERENCES fm.operator, source_key text NOT NULL, -- ':calc:SITE-0001: