A segment only existed once it had been loaded -- a pf.log entry with forecast rows behind it -- so there was nowhere to hold "this is the cut I want, but the dates need changing first", and rebuilding a scenario meant retyping every segment by hand. pf.load_plan is the definition on its own, with run_logid naming the log entry it produced once it has been run. Not in pf.log deliberately: every row there means a write happened, and unexecuted intent would cost that table its meaning. The FK is ON DELETE SET NULL, so undoing a load returns its plan to unrun rather than taking the definition with it. POST /sources/:id/versions now takes clone_from: settings, version-scoped layouts, and the segments as plans, running none of them. Plans are read from the template's own pf.load_plan rows, or derived from its baseline/reference log entries when it has none -- every version built before this -- since params already stores the filters and the offset. Adjustments are not copied in any form. A scale's target was sized against the rows that were there. Shift years exists because cloning a definition forward a year is the reason to clone one, and doing it by hand across eight segments is where the mistakes come from. Filter values only, and only a bare 20xx: a month reads "04 - Sep" and a part number that looks like a year must not move. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
298 lines
14 KiB
SQL
298 lines
14 KiB
SQL
-- Pivot Forecast schema install
|
|
-- Run once against target database: psql -d <db> -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
|
|
);
|
|
|
|
-- pf.load_plan: a segment's definition, before and apart from having been run.
|
|
--
|
|
-- Until now a segment existed only once it had been loaded -- as a pf.log entry
|
|
-- with forecast rows behind it -- so cloning a version's definition meant
|
|
-- re-running every load, and there was nowhere to hold "this is the cut I want,
|
|
-- but the dates need changing first".
|
|
--
|
|
-- Deliberately not pf.log. That table is the audit log: every row in it means a
|
|
-- write happened, and putting unexecuted intent there would cost it that
|
|
-- meaning. A plan is intent; run_logid is the pf.log entry it produced.
|
|
CREATE TABLE IF NOT EXISTS pf.load_plan (
|
|
id serial PRIMARY KEY,
|
|
version_id integer NOT NULL REFERENCES pf.version(id) ON DELETE CASCADE,
|
|
seq integer NOT NULL DEFAULT 0, -- run order
|
|
kind text NOT NULL, -- baseline | reference
|
|
label text,
|
|
bucket text,
|
|
tag text,
|
|
note text,
|
|
filters jsonb, -- the structured filter builder's own shape
|
|
raw_where text, -- the raw-SQL escape hatch, when used instead
|
|
date_offset text NOT NULL DEFAULT '0 days',
|
|
-- the pf.log entry this plan produced, or null while it is still a plan.
|
|
-- ON DELETE SET NULL so undoing a load returns its plan to unrun rather than
|
|
-- deleting the definition with it.
|
|
run_logid bigint REFERENCES pf.log(id) ON DELETE SET NULL,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
created_by text,
|
|
CHECK (kind IN ('baseline', 'reference'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS load_plan_version ON pf.load_plan (version_id, seq, id);
|