Territory filtering was deferred from v1, so every account saw and could write every row. With the sales team about to adjust their own territories that is the thing standing in the way, and it is also why a rep would wait fifteen seconds to load 2.7M rows to work on a few thousand. The list lives on pf.app_user.territory with is_admin beside it, and col_meta.is_territory marks which column of a source the values belong to -- flagged rather than named in code, so a second source can be divided by something other than a sales rep. Fail closed: buildTerritoryClause returns FALSE for an empty list or an unflagged source. An account nobody configured sees nothing, rather than everything because a column was left null. Built from the session, never the request. That is what separates it from `scope`, which the browser sends and should: a filter the user chose belongs in the payload, a permission cannot come from the thing it restrains. It is ANDed on last, where nothing in the request can undo it. Enforced on /data (the cursor and the count behind X-Row-Count), on /agg before the GROUP BY since the territory column need not be in the grain, on every operation through sliceUnits, and on the value completion endpoint -- which reads the source table, so without it a dropdown enumerates every customer and rep in the business to someone shown none of their rows. Undo is gated by owner rather than territory: it removes an entry's rows wholesale, so half-undoing one would leave a state nothing describes. Recode refuses to set the territory column unless you are an admin, since moving a row between territories is reassignment, not forecasting. ./pf.sh gains set-territory, set-admin and orphan-territory. The last lists territory values no account owns -- work under one is invisible to everybody but an admin, which a typo causes easily and nothing in the app reveals. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
187 lines
9.0 KiB
SQL
187 lines
9.0 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;
|
|
|
|
-- 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)
|
|
);
|