729 lines
25 KiB
Python
Executable File
729 lines
25 KiB
Python
Executable File
from contextlib import contextmanager
|
|
import time
|
|
from typing import Iterator
|
|
|
|
from psycopg.rows import dict_row
|
|
import psycopg
|
|
|
|
from .config import DATABASE_URL
|
|
|
|
|
|
SCHEMA_SQL = """
|
|
CREATE TABLE IF NOT EXISTS members (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
employee_id TEXT,
|
|
company TEXT,
|
|
rank TEXT,
|
|
role TEXT,
|
|
department TEXT,
|
|
grp TEXT,
|
|
division TEXT,
|
|
team TEXT,
|
|
cell TEXT,
|
|
work_status TEXT,
|
|
work_time TEXT,
|
|
phone TEXT,
|
|
email TEXT,
|
|
seat_label TEXT,
|
|
photo_url TEXT,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS member_overrides (
|
|
id SERIAL PRIMARY KEY,
|
|
employee_id TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
company TEXT,
|
|
rank TEXT,
|
|
role TEXT,
|
|
department TEXT,
|
|
grp TEXT,
|
|
division TEXT,
|
|
team TEXT,
|
|
cell TEXT,
|
|
work_status TEXT,
|
|
work_time TEXT,
|
|
phone TEXT,
|
|
email TEXT,
|
|
seat_label TEXT,
|
|
photo_url TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS member_retirements (
|
|
id SERIAL PRIMARY KEY,
|
|
employee_id TEXT,
|
|
name TEXT NOT NULL,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS member_aliases (
|
|
id SERIAL PRIMARY KEY,
|
|
alias_name TEXT NOT NULL UNIQUE,
|
|
canonical_name TEXT NOT NULL,
|
|
employee_id TEXT,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS seat_maps (
|
|
id SERIAL PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
image_url TEXT NOT NULL,
|
|
source_type TEXT NOT NULL DEFAULT 'image',
|
|
source_url TEXT,
|
|
preview_svg TEXT,
|
|
view_box_min_x DOUBLE PRECISION,
|
|
view_box_min_y DOUBLE PRECISION,
|
|
view_box_width DOUBLE PRECISION,
|
|
view_box_height DOUBLE PRECISION,
|
|
image_width INTEGER,
|
|
image_height INTEGER,
|
|
grid_rows INTEGER NOT NULL,
|
|
grid_cols INTEGER NOT NULL,
|
|
cell_gap INTEGER NOT NULL DEFAULT 0,
|
|
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS seat_positions (
|
|
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
|
|
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
|
|
seat_slot_id INTEGER,
|
|
row_index INTEGER NOT NULL DEFAULT 0,
|
|
col_index INTEGER NOT NULL DEFAULT 0,
|
|
seat_label TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS seat_slots (
|
|
id SERIAL PRIMARY KEY,
|
|
seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE,
|
|
slot_key TEXT NOT NULL,
|
|
label TEXT NOT NULL,
|
|
x DOUBLE PRECISION NOT NULL,
|
|
y DOUBLE PRECISION NOT NULL,
|
|
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
|
|
layer_name TEXT NOT NULL DEFAULT 'chair',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (seat_map_id, slot_key)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_import_batches (
|
|
id SERIAL PRIMARY KEY,
|
|
source_key TEXT NOT NULL UNIQUE,
|
|
source_name TEXT NOT NULL,
|
|
source_path TEXT NOT NULL,
|
|
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
row_count INTEGER NOT NULL DEFAULT 0,
|
|
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_raw_organization_rows (
|
|
id SERIAL PRIMARY KEY,
|
|
batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE,
|
|
row_index INTEGER NOT NULL,
|
|
row_json JSONB NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_raw_mh_rows (
|
|
id SERIAL PRIMARY KEY,
|
|
batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE,
|
|
row_index INTEGER NOT NULL,
|
|
row_json JSONB NOT NULL,
|
|
row_values_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_raw_mh_pm_rows (
|
|
id SERIAL PRIMARY KEY,
|
|
batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE,
|
|
row_index INTEGER NOT NULL,
|
|
row_values_json JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_raw_payment_rows (
|
|
id SERIAL PRIMARY KEY,
|
|
batch_id INTEGER NOT NULL REFERENCES integration_import_batches(id) ON DELETE CASCADE,
|
|
row_index INTEGER NOT NULL,
|
|
row_json JSONB NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_projects (
|
|
id SERIAL PRIMARY KEY,
|
|
project_code TEXT NOT NULL UNIQUE,
|
|
project_name TEXT NOT NULL,
|
|
display_name TEXT NOT NULL DEFAULT '',
|
|
intranet_name TEXT NOT NULL DEFAULT '',
|
|
business_area TEXT NOT NULL DEFAULT '',
|
|
business_subarea TEXT NOT NULL DEFAULT '',
|
|
project_nature TEXT NOT NULL DEFAULT '',
|
|
main_category TEXT NOT NULL DEFAULT '',
|
|
middle_category TEXT NOT NULL DEFAULT '',
|
|
sub_category TEXT NOT NULL DEFAULT '',
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_project_aliases (
|
|
id SERIAL PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES integration_projects(id) ON DELETE CASCADE,
|
|
alias_name TEXT NOT NULL,
|
|
alias_type TEXT NOT NULL DEFAULT 'name',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (project_id, alias_name, alias_type)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_project_category_mappings (
|
|
id SERIAL PRIMARY KEY,
|
|
source_key TEXT NOT NULL DEFAULT 'ptj_csv',
|
|
project_name TEXT NOT NULL,
|
|
normalized_project_key TEXT NOT NULL,
|
|
mapped_d1 TEXT NOT NULL DEFAULT '',
|
|
mapped_d2 TEXT NOT NULL DEFAULT '',
|
|
mapped_d3 TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (source_key, normalized_project_key)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_project_pm_assignments (
|
|
id SERIAL PRIMARY KEY,
|
|
project_id INTEGER NOT NULL REFERENCES integration_projects(id) ON DELETE CASCADE,
|
|
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
|
pm_name TEXT NOT NULL,
|
|
source_label TEXT NOT NULL DEFAULT 'mh_sheet2',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (project_id, source_label)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_work_logs (
|
|
id SERIAL PRIMARY KEY,
|
|
work_date DATE NOT NULL,
|
|
employee_id TEXT NOT NULL,
|
|
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
|
|
member_name TEXT NOT NULL,
|
|
title TEXT NOT NULL DEFAULT '',
|
|
team_category TEXT NOT NULL DEFAULT '',
|
|
team_name TEXT NOT NULL DEFAULT '',
|
|
user_state TEXT NOT NULL DEFAULT '',
|
|
shift_hours NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
|
weekend_late_flag TEXT NOT NULL DEFAULT '',
|
|
review_status TEXT NOT NULL DEFAULT '',
|
|
source_row_index INTEGER NOT NULL DEFAULT 0,
|
|
raw_batch_id INTEGER REFERENCES integration_import_batches(id) ON DELETE SET NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (work_date, employee_id, source_row_index)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_work_log_segments (
|
|
id SERIAL PRIMARY KEY,
|
|
work_log_id INTEGER NOT NULL REFERENCES integration_work_logs(id) ON DELETE CASCADE,
|
|
slot_name TEXT NOT NULL,
|
|
project_id INTEGER REFERENCES integration_projects(id) ON DELETE SET NULL,
|
|
project_code TEXT NOT NULL DEFAULT '',
|
|
project_name TEXT NOT NULL DEFAULT '',
|
|
business_type TEXT NOT NULL DEFAULT '',
|
|
activity_code TEXT NOT NULL DEFAULT '',
|
|
hours NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
|
overtime_hours_raw NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
|
overtime_hours_adjusted NUMERIC(10, 2) NOT NULL DEFAULT 0,
|
|
is_overtime BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_vouchers (
|
|
id SERIAL PRIMARY KEY,
|
|
accounting_company TEXT NOT NULL DEFAULT '',
|
|
claim_date DATE,
|
|
issue_date DATE,
|
|
issue_month TEXT NOT NULL DEFAULT '',
|
|
account_code TEXT NOT NULL DEFAULT '',
|
|
management_account_code TEXT NOT NULL DEFAULT '',
|
|
account_name TEXT NOT NULL DEFAULT '',
|
|
project_id INTEGER REFERENCES integration_projects(id) ON DELETE SET NULL,
|
|
project_code TEXT NOT NULL DEFAULT '',
|
|
project_name TEXT NOT NULL DEFAULT '',
|
|
display_project_name TEXT NOT NULL DEFAULT '',
|
|
intranet_project_name TEXT NOT NULL DEFAULT '',
|
|
business_area TEXT NOT NULL DEFAULT '',
|
|
business_subarea TEXT NOT NULL DEFAULT '',
|
|
planning_dev_sales TEXT NOT NULL DEFAULT '',
|
|
main_category TEXT NOT NULL DEFAULT '',
|
|
middle_category TEXT NOT NULL DEFAULT '',
|
|
sub_category TEXT NOT NULL DEFAULT '',
|
|
department_name TEXT NOT NULL DEFAULT '',
|
|
team_name TEXT NOT NULL DEFAULT '',
|
|
customer_name TEXT NOT NULL DEFAULT '',
|
|
summary_text TEXT NOT NULL DEFAULT '',
|
|
debit_supply_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
|
credit_supply_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
|
expense_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
|
income_amount NUMERIC(14, 2) NOT NULL DEFAULT 0,
|
|
voucher_type TEXT NOT NULL DEFAULT '',
|
|
project_nature TEXT NOT NULL DEFAULT '',
|
|
raw_batch_id INTEGER REFERENCES integration_import_batches(id) ON DELETE SET NULL,
|
|
source_row_index INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS integration_binary_sources (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
source_key TEXT NOT NULL UNIQUE,
|
|
source_name TEXT NOT NULL,
|
|
filename TEXT NOT NULL DEFAULT '',
|
|
mime_type TEXT NOT NULL DEFAULT 'application/octet-stream',
|
|
content BYTEA NOT NULL,
|
|
content_sha256 TEXT NOT NULL DEFAULT '',
|
|
meta_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS history_revisions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
scope TEXT NOT NULL DEFAULT 'organization',
|
|
revision_label TEXT NOT NULL,
|
|
created_by_user_id BIGINT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
note TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS member_versions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
company TEXT NOT NULL DEFAULT '',
|
|
rank TEXT NOT NULL DEFAULT '',
|
|
role TEXT NOT NULL DEFAULT '',
|
|
department TEXT NOT NULL DEFAULT '',
|
|
grp TEXT NOT NULL DEFAULT '',
|
|
division TEXT NOT NULL DEFAULT '',
|
|
team TEXT NOT NULL DEFAULT '',
|
|
cell TEXT NOT NULL DEFAULT '',
|
|
work_status TEXT NOT NULL DEFAULT '',
|
|
work_time TEXT NOT NULL DEFAULT '',
|
|
phone TEXT NOT NULL DEFAULT '',
|
|
email TEXT NOT NULL DEFAULT '',
|
|
photo_url TEXT NOT NULL DEFAULT '',
|
|
valid_from TIMESTAMPTZ NOT NULL,
|
|
valid_to TIMESTAMPTZ,
|
|
revision_no BIGINT NOT NULL,
|
|
changed_by_user_id BIGINT,
|
|
change_reason TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS seat_assignment_versions (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
|
|
seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE,
|
|
seat_slot_id INTEGER REFERENCES seat_slots(id) ON DELETE CASCADE,
|
|
seat_label TEXT NOT NULL DEFAULT '',
|
|
valid_from TIMESTAMPTZ NOT NULL,
|
|
valid_to TIMESTAMPTZ,
|
|
revision_no BIGINT NOT NULL,
|
|
changed_by_user_id BIGINT,
|
|
change_reason TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE SCHEMA IF NOT EXISTS auth;
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.users (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'admin',
|
|
member_id INTEGER NULL REFERENCES members(id) ON DELETE SET NULL,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_from TEXT NOT NULL DEFAULT 'manual',
|
|
last_login_at TIMESTAMPTZ,
|
|
password_changed_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.sessions (
|
|
id UUID PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ,
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.login_audit_logs (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
username TEXT NOT NULL,
|
|
user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
success BOOLEAN NOT NULL,
|
|
failure_reason TEXT,
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
"""
|
|
|
|
MIGRATION_SQL = """
|
|
ALTER TABLE members ADD COLUMN IF NOT EXISTS employee_id TEXT;
|
|
ALTER TABLE members ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
|
|
CREATE TABLE IF NOT EXISTS member_overrides (
|
|
id SERIAL PRIMARY KEY,
|
|
employee_id TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL DEFAULT '',
|
|
company TEXT,
|
|
rank TEXT,
|
|
role TEXT,
|
|
department TEXT,
|
|
grp TEXT,
|
|
division TEXT,
|
|
team TEXT,
|
|
cell TEXT,
|
|
work_status TEXT,
|
|
work_time TEXT,
|
|
phone TEXT,
|
|
email TEXT,
|
|
seat_label TEXT,
|
|
photo_url TEXT,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS member_retirements (
|
|
id SERIAL PRIMARY KEY,
|
|
employee_id TEXT,
|
|
name TEXT NOT NULL,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS member_aliases (
|
|
id SERIAL PRIMARY KEY,
|
|
alias_name TEXT NOT NULL UNIQUE,
|
|
canonical_name TEXT NOT NULL,
|
|
employee_id TEXT,
|
|
note TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_map_id INTEGER REFERENCES seat_maps(id) ON DELETE CASCADE;
|
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_slot_id INTEGER;
|
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS row_index INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS col_index INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS seat_label TEXT;
|
|
ALTER TABLE seat_positions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_type TEXT NOT NULL DEFAULT 'image';
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS source_url TEXT;
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS preview_svg TEXT;
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_x DOUBLE PRECISION;
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_min_y DOUBLE PRECISION;
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_width DOUBLE PRECISION;
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS view_box_height DOUBLE PRECISION;
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_width INTEGER;
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS image_height INTEGER;
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS cell_gap INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE seat_maps ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT FALSE;
|
|
ALTER TABLE seat_maps ALTER COLUMN image_url DROP NOT NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS seat_slots (
|
|
id SERIAL PRIMARY KEY,
|
|
seat_map_id INTEGER NOT NULL REFERENCES seat_maps(id) ON DELETE CASCADE,
|
|
slot_key TEXT NOT NULL,
|
|
label TEXT NOT NULL,
|
|
x DOUBLE PRECISION NOT NULL,
|
|
y DOUBLE PRECISION NOT NULL,
|
|
rotation DOUBLE PRECISION NOT NULL DEFAULT 0,
|
|
layer_name TEXT NOT NULL DEFAULT 'chair',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (seat_map_id, slot_key)
|
|
);
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'seat_positions' AND column_name = 'x'
|
|
) THEN
|
|
EXECUTE 'UPDATE seat_positions SET row_index = COALESCE(y, row_index, 0), col_index = COALESCE(x, col_index, 0) WHERE seat_map_id IS NULL';
|
|
END IF;
|
|
END $$;
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'seat_positions' AND column_name = 'floor_label'
|
|
) THEN
|
|
EXECUTE 'UPDATE seat_positions SET seat_label = COALESCE(seat_label, floor_label) WHERE seat_label IS NULL';
|
|
END IF;
|
|
END $$;
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'integration_raw_mh_rows' AND column_name = 'row_values_json'
|
|
) THEN
|
|
ALTER TABLE integration_raw_mh_rows
|
|
ADD COLUMN row_values_json JSONB NOT NULL DEFAULT '[]'::jsonb;
|
|
END IF;
|
|
END $$;
|
|
|
|
DROP INDEX IF EXISTS seat_positions_map_cell_idx;
|
|
CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_map_cell_idx
|
|
ON seat_positions (seat_map_id, row_index, col_index)
|
|
WHERE seat_map_id IS NOT NULL
|
|
AND seat_slot_id IS NULL;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS member_overrides_employee_id_idx
|
|
ON member_overrides (employee_id);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS member_retirements_name_idx
|
|
ON member_retirements (name);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS member_aliases_alias_name_idx
|
|
ON member_aliases (alias_name);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS seat_positions_slot_idx
|
|
ON seat_positions (seat_slot_id)
|
|
WHERE seat_slot_id IS NOT NULL;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_organization_rows_batch_row_idx
|
|
ON integration_raw_organization_rows (batch_id, row_index);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_mh_rows_batch_row_idx
|
|
ON integration_raw_mh_rows (batch_id, row_index);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_mh_pm_rows_batch_row_idx
|
|
ON integration_raw_mh_pm_rows (batch_id, row_index);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_raw_payment_rows_batch_row_idx
|
|
ON integration_raw_payment_rows (batch_id, row_index);
|
|
|
|
CREATE INDEX IF NOT EXISTS integration_work_logs_employee_idx
|
|
ON integration_work_logs (employee_id, work_date);
|
|
|
|
CREATE INDEX IF NOT EXISTS integration_work_log_segments_project_idx
|
|
ON integration_work_log_segments (project_code, project_name);
|
|
|
|
CREATE INDEX IF NOT EXISTS integration_vouchers_project_idx
|
|
ON integration_vouchers (project_code, project_name);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_project_category_mappings_key_idx
|
|
ON integration_project_category_mappings (source_key, normalized_project_key);
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS integration_binary_sources_source_key_idx
|
|
ON integration_binary_sources (source_key);
|
|
|
|
CREATE INDEX IF NOT EXISTS member_versions_member_time_idx
|
|
ON member_versions (member_id, valid_from, valid_to);
|
|
|
|
CREATE INDEX IF NOT EXISTS seat_assignment_versions_member_time_idx
|
|
ON seat_assignment_versions (member_id, valid_from, valid_to);
|
|
|
|
CREATE INDEX IF NOT EXISTS history_revisions_scope_created_idx
|
|
ON history_revisions (scope, created_at DESC);
|
|
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1
|
|
FROM information_schema.table_constraints
|
|
WHERE constraint_name = 'seat_positions_seat_slot_id_fkey'
|
|
AND table_name = 'seat_positions'
|
|
) THEN
|
|
ALTER TABLE seat_positions
|
|
ADD CONSTRAINT seat_positions_seat_slot_id_fkey
|
|
FOREIGN KEY (seat_slot_id) REFERENCES seat_slots(id) ON DELETE CASCADE;
|
|
END IF;
|
|
END $$;
|
|
|
|
CREATE SCHEMA IF NOT EXISTS auth;
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.users (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
username TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
role TEXT NOT NULL DEFAULT 'admin',
|
|
member_id INTEGER NULL REFERENCES members(id) ON DELETE SET NULL,
|
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
|
created_from TEXT NOT NULL DEFAULT 'manual',
|
|
last_login_at TIMESTAMPTZ,
|
|
password_changed_at TIMESTAMPTZ,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.sessions (
|
|
id UUID PRIMARY KEY,
|
|
user_id BIGINT NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
revoked_at TIMESTAMPTZ,
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS auth.login_audit_logs (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
username TEXT NOT NULL,
|
|
user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
|
success BOOLEAN NOT NULL,
|
|
failure_reason TEXT,
|
|
ip_address INET,
|
|
user_agent TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'admin';
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS member_id INTEGER NULL REFERENCES members(id) ON DELETE SET NULL;
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE;
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS created_from TEXT NOT NULL DEFAULT 'manual';
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
|
ALTER TABLE auth.users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ;
|
|
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ;
|
|
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS ip_address INET;
|
|
ALTER TABLE auth.sessions ADD COLUMN IF NOT EXISTS user_agent TEXT;
|
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_id BIGINT NULL REFERENCES auth.users(id) ON DELETE SET NULL;
|
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS failure_reason TEXT;
|
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS ip_address INET;
|
|
ALTER TABLE auth.login_audit_logs ADD COLUMN IF NOT EXISTS user_agent TEXT;
|
|
|
|
DROP INDEX IF EXISTS entity_change_events_entity_idx;
|
|
DROP TABLE IF EXISTS entity_change_events;
|
|
"""
|
|
|
|
|
|
@contextmanager
|
|
def get_conn() -> Iterator[psycopg.Connection]:
|
|
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
|
|
yield conn
|
|
|
|
|
|
def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None:
|
|
last_error: Exception | None = None
|
|
for _ in range(max_retries):
|
|
try:
|
|
with get_conn() as conn:
|
|
with conn.cursor() as cur:
|
|
cur.execute(SCHEMA_SQL)
|
|
cur.execute(MIGRATION_SQL)
|
|
ensure_history_backfill(cur)
|
|
conn.commit()
|
|
return
|
|
except psycopg.OperationalError as exc:
|
|
last_error = exc
|
|
time.sleep(retry_delay)
|
|
if last_error is not None:
|
|
raise last_error
|
|
|
|
|
|
def ensure_history_backfill(cur) -> None:
|
|
cur.execute(
|
|
"""
|
|
SELECT id
|
|
FROM history_revisions
|
|
WHERE scope = 'organization'
|
|
AND revision_label = 'initial-backfill'
|
|
ORDER BY id ASC
|
|
LIMIT 1
|
|
"""
|
|
)
|
|
row = cur.fetchone()
|
|
if row is None:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO history_revisions (scope, revision_label, note)
|
|
VALUES ('organization', 'initial-backfill', 'Seeded from current members and seat_positions state')
|
|
RETURNING id
|
|
"""
|
|
)
|
|
revision_id = int(cur.fetchone()["id"])
|
|
else:
|
|
revision_id = int(row["id"])
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO member_versions (
|
|
member_id, name, company, rank, role, department, grp, division, team, cell,
|
|
work_status, work_time, phone, email, photo_url,
|
|
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
|
)
|
|
SELECT
|
|
m.id, m.name, COALESCE(m.company, ''), COALESCE(m.rank, ''), COALESCE(m.role, ''),
|
|
COALESCE(m.department, ''), COALESCE(m.grp, ''), COALESCE(m.division, ''), COALESCE(m.team, ''), COALESCE(m.cell, ''),
|
|
COALESCE(m.work_status, ''), COALESCE(m.work_time, ''), COALESCE(m.phone, ''), COALESCE(m.email, ''), COALESCE(m.photo_url, ''),
|
|
TIMESTAMPTZ '1970-01-01 00:00:00+00', NULL, %s, NULL, 'initial-backfill'
|
|
FROM members AS m
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM member_versions mv
|
|
WHERE mv.member_id = m.id
|
|
)
|
|
""",
|
|
(revision_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO seat_assignment_versions (
|
|
member_id, seat_map_id, seat_slot_id, seat_label,
|
|
valid_from, valid_to, revision_no, changed_by_user_id, change_reason
|
|
)
|
|
SELECT
|
|
sp.member_id, sp.seat_map_id, sp.seat_slot_id, COALESCE(sp.seat_label, ''),
|
|
TIMESTAMPTZ '1970-01-01 00:00:00+00', NULL, %s, NULL, 'initial-backfill'
|
|
FROM seat_positions AS sp
|
|
WHERE NOT EXISTS (
|
|
SELECT 1
|
|
FROM seat_assignment_versions sav
|
|
WHERE sav.member_id = sp.member_id
|
|
)
|
|
""",
|
|
(revision_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE member_versions
|
|
SET valid_from = TIMESTAMPTZ '1970-01-01 00:00:00+00'
|
|
WHERE revision_no = %s
|
|
AND change_reason = 'initial-backfill'
|
|
""",
|
|
(revision_id,),
|
|
)
|
|
|
|
cur.execute(
|
|
"""
|
|
UPDATE seat_assignment_versions
|
|
SET valid_from = TIMESTAMPTZ '1970-01-01 00:00:00+00'
|
|
WHERE revision_no = %s
|
|
AND change_reason = 'initial-backfill'
|
|
""",
|
|
(revision_id,),
|
|
)
|