krona-staff/migrations/0006_payroll_foundation.up.sql
Ochenstarik 3464998168
Some checks are pending
ci / verify (push) Waiting to run
feat: add secure attendance portal and payroll foundation
2026-09-24 15:59:38 +07:00

302 lines
14 KiB
SQL

-- REQ-001 payroll foundation integrated after the attendance portal.
-- Money is BIGINT in minor units; rates and weights use basis points (10000 = 100%).
CREATE TABLE staff_org_units (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
code TEXT NOT NULL CHECK (code ~ '^[a-z0-9_-]{1,40}$'),
name TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'Asia/Novosibirsk',
external_ref TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
UNIQUE (tenant_id, id)
);
CREATE UNIQUE INDEX staff_org_units_code_idx ON staff_org_units (tenant_id, code)
WHERE deleted_at IS NULL;
CREATE TABLE staff_org_unit_terms (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
org_unit_id VARCHAR(26) NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE,
pool_rule JSONB,
shift_norm_minutes INTEGER NOT NULL DEFAULT 720 CHECK (shift_norm_minutes BETWEEN 60 AND 2880),
rounding_unit_minor BIGINT NOT NULL DEFAULT 100 CHECK (rounding_unit_minor > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
CHECK (effective_to IS NULL OR effective_to >= effective_from)
);
CREATE INDEX staff_org_unit_terms_lookup_idx
ON staff_org_unit_terms (tenant_id, org_unit_id, effective_from DESC);
ALTER TABLE staff_employees
ADD COLUMN primary_org_unit_id VARCHAR(26),
ADD COLUMN external_ref TEXT,
ADD COLUMN hired_on DATE,
ADD COLUMN terminated_on DATE,
ADD CONSTRAINT staff_employees_primary_org_unit_fk
FOREIGN KEY (tenant_id, primary_org_unit_id) REFERENCES staff_org_units (tenant_id, id);
ALTER TABLE staff_attendance_events
ADD COLUMN org_unit_id VARCHAR(26),
ADD COLUMN payload JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD CONSTRAINT staff_attendance_events_org_unit_fk
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id);
CREATE TABLE staff_metric_definitions (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
key TEXT NOT NULL CHECK (key ~ '^[a-z][a-z0-9_]{0,60}$'),
label TEXT NOT NULL,
value_kind TEXT NOT NULL DEFAULT 'money' CHECK (value_kind IN ('money', 'percent', 'number')),
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'module', 'import')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, key)
);
CREATE TABLE staff_daily_metrics (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
org_unit_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
metric_key TEXT NOT NULL CHECK (metric_key ~ '^[a-z][a-z0-9_]{0,60}$'),
value BIGINT NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
breakdown JSONB NOT NULL DEFAULT '{}'::jsonb,
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'module', 'import')),
recorded_by VARCHAR(26) NOT NULL CHECK (recorded_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, org_unit_id, business_date, metric_key),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
FOREIGN KEY (tenant_id, metric_key) REFERENCES staff_metric_definitions (tenant_id, key)
);
CREATE TABLE staff_work_items (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
org_unit_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
item_kind TEXT NOT NULL CHECK (item_kind ~ '^[a-z][a-z0-9_]{0,40}$'),
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'module', 'import')),
external_key TEXT,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id)
);
CREATE INDEX staff_work_items_day_idx ON staff_work_items (tenant_id, employee_id, business_date);
CREATE UNIQUE INDEX staff_work_items_external_idx ON staff_work_items (tenant_id, external_key)
WHERE external_key IS NOT NULL;
CREATE TABLE staff_pay_rules (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
code TEXT NOT NULL CHECK (code ~ '^[a-z][a-z0-9_]{0,40}$'),
version INTEGER NOT NULL CHECK (version > 0),
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
preset_code TEXT,
rule JSONB NOT NULL,
param_declarations JSONB NOT NULL DEFAULT '[]'::jsonb,
pool_weight_bp INTEGER NOT NULL DEFAULT 0 CHECK (pool_weight_bp BETWEEN 0 AND 20000),
effective_from DATE NOT NULL,
effective_to DATE,
approved_by VARCHAR(26) CHECK (approved_by IS NULL OR approved_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
approved_at TIMESTAMPTZ,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, code, version),
CHECK (effective_to IS NULL OR effective_to >= effective_from)
);
CREATE INDEX staff_pay_rules_lookup_idx ON staff_pay_rules (tenant_id, code, effective_from DESC);
CREATE TABLE staff_pay_assignments (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
org_unit_id VARCHAR(26),
pay_rule_id VARCHAR(26) NOT NULL,
effective_from DATE NOT NULL,
effective_to DATE,
params JSONB NOT NULL DEFAULT '{}'::jsonb,
pool_weight_bp INTEGER CHECK (pool_weight_bp IS NULL OR pool_weight_bp BETWEEN 0 AND 20000),
note TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
FOREIGN KEY (tenant_id, pay_rule_id) REFERENCES staff_pay_rules (tenant_id, id),
CHECK (effective_to IS NULL OR effective_to >= effective_from)
);
CREATE INDEX staff_pay_assignments_lookup_idx
ON staff_pay_assignments (tenant_id, employee_id, effective_from DESC);
CREATE TABLE staff_work_sessions (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
org_unit_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
started_at TIMESTAMPTZ,
ended_at TIMESTAMPTZ,
worked_minutes INTEGER NOT NULL DEFAULT 0 CHECK (worked_minutes BETWEEN 0 AND 2880),
source TEXT NOT NULL DEFAULT 'terminal' CHECK (source IN ('terminal', 'manual')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'pending_review', 'confirmed', 'rejected')),
anomaly TEXT,
correction_note TEXT,
confirmed_by VARCHAR(26) CHECK (confirmed_by IS NULL OR confirmed_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
confirmed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, employee_id, org_unit_id, business_date),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
CHECK (ended_at IS NULL OR started_at IS NULL OR ended_at >= started_at)
);
CREATE INDEX staff_work_sessions_review_idx ON staff_work_sessions (tenant_id, business_date DESC, status);
CREATE TABLE staff_payroll_runs (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
org_unit_id VARCHAR(26),
period_start DATE NOT NULL,
period_end DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'calculated', 'approved', 'paid')),
total_accrued_minor BIGINT NOT NULL DEFAULT 0,
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
calculated_at TIMESTAMPTZ,
approved_by VARCHAR(26) CHECK (approved_by IS NULL OR approved_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
approved_at TIMESTAMPTZ,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revision INTEGER NOT NULL DEFAULT 1 CHECK (revision > 0),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
CHECK (period_end >= period_start)
);
CREATE UNIQUE INDEX staff_payroll_runs_period_unit_idx
ON staff_payroll_runs (tenant_id, org_unit_id, period_start) WHERE org_unit_id IS NOT NULL;
CREATE UNIQUE INDEX staff_payroll_runs_period_all_idx
ON staff_payroll_runs (tenant_id, period_start) WHERE org_unit_id IS NULL;
CREATE TABLE staff_payroll_lines (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
run_id VARCHAR(26) NOT NULL,
employee_id VARCHAR(26) NOT NULL,
org_unit_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
pay_rule_id VARCHAR(26) NOT NULL,
rule_code TEXT NOT NULL,
worked_minutes INTEGER NOT NULL DEFAULT 0,
pool_amount_minor BIGINT NOT NULL DEFAULT 0,
pool_divisor_bp INTEGER NOT NULL DEFAULT 0,
pool_share_minor BIGINT NOT NULL DEFAULT 0,
raw_amount_minor BIGINT NOT NULL DEFAULT 0,
amount_minor BIGINT NOT NULL DEFAULT 0,
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
explain JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
UNIQUE (tenant_id, run_id, employee_id, org_unit_id, business_date),
FOREIGN KEY (tenant_id, run_id) REFERENCES staff_payroll_runs (tenant_id, id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, org_unit_id) REFERENCES staff_org_units (tenant_id, id),
FOREIGN KEY (tenant_id, pay_rule_id) REFERENCES staff_pay_rules (tenant_id, id)
);
CREATE INDEX staff_payroll_lines_employee_idx
ON staff_payroll_lines (tenant_id, employee_id, business_date DESC);
CREATE TABLE staff_payouts (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
run_id VARCHAR(26),
paid_on DATE NOT NULL,
amount_minor BIGINT NOT NULL CHECK (amount_minor > 0),
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
method TEXT NOT NULL DEFAULT 'cash' CHECK (method IN ('cash', 'transfer')),
note TEXT,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id),
FOREIGN KEY (tenant_id, run_id) REFERENCES staff_payroll_runs (tenant_id, id)
);
CREATE INDEX staff_payouts_employee_idx ON staff_payouts (tenant_id, employee_id, paid_on DESC);
CREATE TABLE staff_payroll_ledger (
id VARCHAR(26) PRIMARY KEY CHECK (id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
tenant_id VARCHAR(26) NOT NULL CHECK (tenant_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
employee_id VARCHAR(26) NOT NULL,
business_date DATE NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('accrual', 'payout', 'adjustment')),
amount_minor BIGINT NOT NULL,
currency_code CHAR(3) NOT NULL DEFAULT 'RUB',
ref_type TEXT NOT NULL CHECK (ref_type IN ('payroll_run', 'payout', 'manual')),
ref_id VARCHAR(26),
correlation_id VARCHAR(26) NOT NULL CHECK (correlation_id ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
note TEXT,
created_by VARCHAR(26) NOT NULL CHECK (created_by ~ '^[0-9A-HJKMNP-TV-Z]{26}$'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, id),
FOREIGN KEY (tenant_id, employee_id) REFERENCES staff_employees (tenant_id, id)
);
CREATE INDEX staff_payroll_ledger_balance_idx
ON staff_payroll_ledger (tenant_id, employee_id, business_date);
DO $$
DECLARE target_table TEXT;
BEGIN
FOREACH target_table IN ARRAY ARRAY[
'staff_org_units', 'staff_org_unit_terms', 'staff_metric_definitions',
'staff_daily_metrics', 'staff_work_items', 'staff_pay_rules', 'staff_pay_assignments',
'staff_work_sessions', 'staff_payroll_runs', 'staff_payroll_lines', 'staff_payouts'
] LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', target_table);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', target_table);
EXECUTE format(
'CREATE POLICY %I ON %I USING (tenant_id = current_setting(''app.tenant_id'', true))'
|| ' WITH CHECK (tenant_id = current_setting(''app.tenant_id'', true))',
target_table || '_tenant_isolation', target_table
);
END LOOP;
END
$$;
ALTER TABLE staff_payroll_ledger ENABLE ROW LEVEL SECURITY;
ALTER TABLE staff_payroll_ledger FORCE ROW LEVEL SECURITY;
CREATE POLICY staff_payroll_ledger_tenant_select ON staff_payroll_ledger
FOR SELECT USING (tenant_id = current_setting('app.tenant_id', true));
CREATE POLICY staff_payroll_ledger_tenant_insert ON staff_payroll_ledger
FOR INSERT WITH CHECK (
tenant_id = current_setting('app.tenant_id', true)
AND created_by = current_setting('app.user_id', true)
);
GRANT SELECT, INSERT, UPDATE, DELETE ON
staff_org_units, staff_org_unit_terms, staff_metric_definitions, staff_daily_metrics,
staff_work_items, staff_pay_rules, staff_pay_assignments, staff_work_sessions,
staff_payroll_runs, staff_payroll_lines, staff_payouts
TO krona_staff_app;
GRANT SELECT, INSERT ON staff_payroll_ledger TO krona_staff_app;
REVOKE UPDATE, DELETE, TRUNCATE ON staff_payroll_ledger FROM krona_staff_app;