-- 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'); -- Display order for the pivot's segment and bucket columns. -- -- The segment's display name in the pivot, falling back to tag then note. -- -- Separate from both because those have jobs already -- tag groups adjustments -- into initiatives for the bridge, note is free commentary -- and because the -- label carries the sort order. Perspective orders column groups by the value -- string, so a leading "01 - " is how ordering is expressed; putting that in the -- note would put it in every note. ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS label text; -- Vestigial, both of them. They held the ordinal when the "01 - " prefix was -- computed for the pivot rather than typed into label and bucket: bucket_order -- sequenced the bucket columns, log.seq the segments within them. Nothing reads -- either now, and nothing writes them -- kept only because dropping a column is -- not worth a migration to reclaim two that cost nothing. ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS bucket_order jsonb; ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS seq integer; -- 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; -- The names a row falls back to when nobody has named it, per scenario. Null -- means "use the built-in", which is the DISPLAY DEFAULTS block in -- lib/sql_generator.js. Read through a join at query time, not baked into -- pf.sql: those templates are keyed on (source_id, operation) and shared by -- every version of a source. ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS adjustment_segment text; ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS adjustment_bucket text; ALTER TABLE pf.version ADD COLUMN IF NOT EXISTS unlabeled_load text; -- What the entry did, stamped when it did it. -- -- Not a cache: a log entry's forecast rows never change after it is written. -- Only the operation that owns the logid inserts them, and the only thing that -- removes them is undo, which deletes this row too -- so these totals are fixed -- at write time rather than derived from something that can move underneath -- them. The change log was joining the whole forecast table to recompute them -- on every open, 2.5M rows to total a few thousand. -- -- measure_cols records which columns they are denominated in, since the value -- and units roles can be reassigned in col_meta and the numbers would otherwise -- quietly come to mean something else. ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS row_count integer; ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS value_total double precision; ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS units_total double precision; ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS measure_cols jsonb; -- The column an account's territory is expressed in -- the rep, the region, -- whatever this source divides ownership by. Exactly one per source. -- -- Flagged here rather than named in code because it is source-specific, the -- same way the grain is: a second source should not need a code change to be -- scoped by something other than a sales rep. -- -- It does two jobs. The server scopes every read and write to the values on the -- account, and recode refuses to *set* this column unless the account is an -- admin -- moving a row between territories is reassignment, not forecasting. ALTER TABLE pf.col_meta ADD COLUMN IF NOT EXISTS is_territory boolean NOT NULL DEFAULT false; -- The debug path: what the entry was trying to do, and what that became. -- -- Both, deliberately. params records the intent -- the slice, the scope, the -- resolved increments -- and sql_text records the statement as executed, with -- territory and scope already resolved into it. Keeping only one of them -- assumes the translation between them is correct, which is exactly the -- assumption in doubt when the rows look wrong. -- -- env captures the state the intent was executed against that cannot be -- reconstructed afterwards: the territory in force, the version's -- exclude_iters, and when the template was generated. All three are mutable -- rows elsewhere, and nothing remembers what they were. -- -- The template generation is a fingerprint, not a version: it cannot reproduce -- the old template, but it can tell you the entry ran under a different one, -- which is what would otherwise make a replay quietly wrong. ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS env jsonb; ALTER TABLE pf.log ADD COLUMN IF NOT EXISTS sql_text text; -- 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) ); -- pf.layout: named Perspective view configs. -- -- Replaces the per-browser localStorage lists (pf_layouts_v*) and the single -- anonymous pf.source.default_layout. A layout is either `private` -- visible -- only to its owner -- or `published`, visible to everyone on the version and -- writable only by its owner or an admin, the same rule pf.log already uses. CREATE TABLE IF NOT EXISTS pf.layout ( id serial PRIMARY KEY, source_id integer NOT NULL REFERENCES pf.source(id) ON DELETE CASCADE, -- null = applies to every version of the source; that is where the old -- source default lives, and what a new version picks up before anyone has -- published a layout of its own. version_id integer REFERENCES pf.version(id) ON DELETE CASCADE, name text NOT NULL, config jsonb NOT NULL, owner text NOT NULL, visibility text NOT NULL DEFAULT 'private', -- private | published is_default boolean NOT NULL DEFAULT false, -- applied on first load created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), CHECK (visibility IN ('private', 'published')), -- only a published layout can be the default: a private one would be a -- default nobody else could see. CHECK (NOT is_default OR visibility = 'published') ); -- COALESCE rather than the bare column: version_id is nullable, and in a unique -- index NULLs are distinct, so source-wide rows would not be constrained at all. CREATE UNIQUE INDEX IF NOT EXISTS layout_private_name ON pf.layout (source_id, COALESCE(version_id, 0), owner, lower(name)) WHERE visibility = 'private'; CREATE UNIQUE INDEX IF NOT EXISTS layout_published_name ON pf.layout (source_id, COALESCE(version_id, 0), lower(name)) WHERE visibility = 'published'; -- one default per scope CREATE UNIQUE INDEX IF NOT EXISTS layout_one_default ON pf.layout (source_id, COALESCE(version_id, 0)) WHERE is_default; CREATE INDEX IF NOT EXISTS layout_lookup ON pf.layout (source_id, version_id); -- Carry the old single source default across as a published, source-wide row. -- Idempotent: skipped once a default exists for that source. INSERT INTO pf.layout (source_id, version_id, name, config, owner, visibility, is_default) SELECT s.id, NULL, 'Source default', s.default_layout, COALESCE(s.created_by, 'admin'), 'published', true FROM pf.source s WHERE TRUE AND s.default_layout IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM pf.layout l WHERE TRUE AND l.source_id = s.id AND l.version_id IS NULL AND l.is_default );