-- Pivot Forecast schema install -- Run once against target database: psql -d -f setup_sql/01_schema.sql CREATE SCHEMA IF NOT EXISTS pf; CREATE TABLE IF NOT EXISTS pf.source ( id serial PRIMARY KEY, schema text NOT NULL, tname text NOT NULL, label text, status text NOT NULL DEFAULT 'active', -- active | archived default_layout jsonb, -- Perspective view config used as the per-source default created_at timestamptz NOT NULL DEFAULT now(), created_by text, UNIQUE (schema, tname) ); -- backfill columns for existing installs ALTER TABLE pf.source ADD COLUMN IF NOT EXISTS default_layout jsonb; -- pf.dim_period: run setup_sql/gen_dim_period.sql to create and populate CREATE TABLE IF NOT EXISTS pf.col_meta ( id serial PRIMARY KEY, source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, cname text NOT NULL, label text, role text NOT NULL DEFAULT 'ignore', -- dimension | value | units | date | ignore is_key boolean NOT NULL DEFAULT false, -- true = usable in WHERE slice dim_group text, -- groups functionally dependent columns dim_period_col text, -- pf.dim_period column this dimension derives from in_grain boolean NOT NULL DEFAULT false, -- true = column defines the display grain opos integer, UNIQUE (source_id, cname) ); -- backfill columns for existing installs (must follow the CREATE above) ALTER TABLE pf.col_meta ADD COLUMN IF NOT EXISTS dim_group text; ALTER TABLE pf.col_meta ADD COLUMN IF NOT EXISTS dim_period_col text; ALTER TABLE pf.col_meta ADD COLUMN IF NOT EXISTS in_grain boolean NOT NULL DEFAULT false; CREATE TABLE IF NOT EXISTS pf.version ( id serial PRIMARY KEY, source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE RESTRICT, name text NOT NULL, description text, status text NOT NULL DEFAULT 'open', -- open | closed exclude_iters jsonb NOT NULL DEFAULT '["reference"]'::jsonb, created_at timestamptz NOT NULL DEFAULT now(), created_by text, closed_at timestamptz, closed_by text, UNIQUE (source_id, name) ); CREATE TABLE IF NOT EXISTS pf.log ( id bigserial PRIMARY KEY, version_id integer NOT NULL REFERENCES pf.version(id) ON DELETE CASCADE, pf_user text NOT NULL, stamp timestamptz NOT NULL DEFAULT now(), operation text NOT NULL, -- baseline | reference | scale | recode | clone slice jsonb, params jsonb, note text, tag text -- initiative label, e.g. 'reduce_spend'; groups -- adjustments into a bridge from baseline to current ); -- adding tags to an install that predates them ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS tag text; CREATE INDEX IF NOT EXISTS log_tag_idx ON pf.log (tag) WHERE tag IS NOT NULL; -- seed tags for loads that predate the column: a baseline/reference note is the -- segment's name ('Open Orders', 'Prior Year'), which is exactly what tag holds. -- Adjustment notes are free text, not labels, so they are left alone. UPDATE pf.log SET tag = note WHERE TRUE AND tag IS NULL AND note IS NOT NULL AND note <> '' AND operation IN ('baseline', 'reference'); -- What a segment contributes to, independent of pf_iter. -- -- pf_iter answers "can operations write to these rows"; bucket answers "does this -- belong in the forecast number". Those are not the same question -- Open Orders is -- loaded as reference so nothing adjusts it, yet it is part of the forecast -- so -- neither can be derived from the other. -- -- Free text with suggested values (Forecast / Prior Year / Prior Prior Year / Plan) -- rather than an enum, so a new banner does not need a migration. Blank by default: -- until a segment is labelled, the pivot falls back to showing its own name. ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS bucket text; CREATE INDEX IF NOT EXISTS log_bucket_idx ON pf.log (bucket) WHERE bucket IS NOT NULL; -- Master data for a dim_group: one row per key value, with its sibling columns. -- -- The source is transactional and often a view over all history, so deriving a -- member list from it is both slow and wrong -- slow because it means scanning -- millions of rows, wrong because it can only describe what was transacted and -- has no way to say a part is discontinued or that a new one exists before it -- has sold. This table is the app's own list, refreshed from the source but -- curatable independently of it. CREATE TABLE IF NOT EXISTS pf.dim_member ( source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, dim_group text NOT NULL, -- matches pf.col_meta.dim_group key_value text NOT NULL, -- the is_key column's value attrs jsonb NOT NULL DEFAULT '{}'::jsonb, -- the sibling columns is_active boolean NOT NULL DEFAULT true, source_seen boolean NOT NULL DEFAULT true, -- present in the source at last refresh added_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), refreshed_at timestamptz, PRIMARY KEY (source_id, dim_group, key_value) ); CREATE INDEX IF NOT EXISTS dim_member_active_idx ON pf.dim_member (source_id, dim_group) WHERE is_active; -- generated operation SQL per source, stored after col_meta is configured CREATE TABLE IF NOT EXISTS pf.sql ( id serial PRIMARY KEY, source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, operation text NOT NULL, -- get_data | baseline | reference | scale | recode | clone | undo sql text NOT NULL, generated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (source_id, operation) );