Files
mkd/backend/schema.sql
2026-02-04 00:17:04 +05:00

920 lines
48 KiB
SQL
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- Создание базы данных (выполните один раз под суперпользователем postgres):
-- CREATE DATABASE mkd_control_center WITH ENCODING 'UTF8';
-- Далее подключитесь к базе mkd_control_center и выполните это тело файла.
-- ========= УЧАСТКИ =========
CREATE TABLE IF NOT EXISTS districts (
id VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL,
manager_name TEXT NOT NULL
);
-- ========= ДОМА =========
-- Для упрощения: вся структура Building хранится в одном JSONB-поле data.
CREATE TABLE IF NOT EXISTS buildings (
id VARCHAR(50) PRIMARY KEY,
data JSONB NOT NULL
);
-- ========= ЗАЯВКИ =========
CREATE TYPE doma_application_status AS ENUM ('new', 'in_progress', 'deferred', 'done', 'canceled');
CREATE TABLE IF NOT EXISTS applications (
id BIGSERIAL PRIMARY KEY,
number TEXT NOT NULL,
status doma_application_status NOT NULL,
description TEXT NOT NULL,
address TEXT NOT NULL,
apartment TEXT NOT NULL,
client_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deadline_at TIMESTAMPTZ NOT NULL,
performer_name TEXT,
employee_id VARCHAR(50) REFERENCES employees(id) ON DELETE SET NULL,
building_id VARCHAR(50) REFERENCES buildings(id) ON DELETE SET NULL,
is_overdue BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_applications_building ON applications(building_id);
CREATE INDEX IF NOT EXISTS idx_applications_employee ON applications(employee_id);
CREATE INDEX IF NOT EXISTS idx_applications_performer ON applications(performer_name);
CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status);
CREATE INDEX IF NOT EXISTS idx_applications_overdue ON applications(is_overdue);
CREATE INDEX IF NOT EXISTS idx_applications_deadline ON applications(deadline_at);
-- ========= ТЕСТОВЫЕ ДАННЫЕ =========
-- Демо данные удалены. Создавайте участки и дома через интерфейс приложения.
-- INSERT INTO districts (id, name, manager_name) VALUES
-- ('d-1', 'Участок №1 (Центральный)', 'Смирнов А.В.'),
-- ('d-2', 'Участок №2 (Заречный)', 'Петров Б.Г.')
-- ON CONFLICT (id) DO NOTHING;
-- INSERT INTO buildings (id, data) VALUES ...
-- ON CONFLICT (id) DO NOTHING;
-- INSERT INTO applications (number, status, description, address, apartment, client_name, created_at, deadline_at, performer_name)
-- VALUES ...
-- ON CONFLICT DO NOTHING;
-- ========= ФИНАНСОВЫЕ ДАННЫЕ ИЗ 1С =========
-- Настройки маппинга колонок CSV/XLSX (создаем первой, т.к. на неё ссылается financial_reports)
CREATE TABLE IF NOT EXISTS financial_report_mappings (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT FALSE,
column_mappings JSONB NOT NULL, -- { "source_column": "target_field", ... }
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Метаданные загруженных отчетов
CREATE TABLE IF NOT EXISTS financial_reports (
id BIGSERIAL PRIMARY KEY,
filename TEXT NOT NULL,
file_type VARCHAR(10) NOT NULL CHECK (file_type IN ('CSV', 'XLSX')),
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
uploaded_by TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'processing' CHECK (status IN ('processing', 'completed', 'failed', 'partial')),
total_rows INTEGER,
processed_rows INTEGER DEFAULT 0,
error_rows INTEGER DEFAULT 0,
error_log JSONB,
mapping_id BIGINT REFERENCES financial_report_mappings(id) ON DELETE SET NULL
);
-- Финансовые данные по домам
CREATE TABLE IF NOT EXISTS building_financial_data (
id BIGSERIAL PRIMARY KEY,
building_id VARCHAR(50) NOT NULL REFERENCES buildings(id) ON DELETE CASCADE,
report_id BIGINT REFERENCES financial_reports(id) ON DELETE SET NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
period_type VARCHAR(10) NOT NULL CHECK (period_type IN ('month', 'quarter', 'year')),
-- Доходы
total_income NUMERIC(15, 2) DEFAULT 0,
income_by_items JSONB, -- { "item_name": amount, ... }
-- Расходы
total_expenses NUMERIC(15, 2) DEFAULT 0,
expenses_by_items JSONB, -- { "item_name": amount, ... }
-- Баланс
balance NUMERIC(15, 2) DEFAULT 0,
-- Дополнительные данные
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(building_id, period_start, period_end, period_type)
);
CREATE INDEX IF NOT EXISTS idx_building_financial_data_building ON building_financial_data(building_id);
CREATE INDEX IF NOT EXISTS idx_building_financial_data_period ON building_financial_data(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_building_financial_data_report ON building_financial_data(report_id);
-- ========= ОФИС: ЗАЯВКИ НА РЕМОНТ ТЕХНИКИ =========
CREATE TYPE office_equipment_type AS ENUM ('pc', 'laptop', 'air_conditioner', 'printer', 'other');
CREATE TYPE repair_request_status AS ENUM (
'new',
'search_contractor', -- Поиск подрядчика
'agreed_with_contractor', -- Договорились с подрядчиком
'waiting_delivery', -- Ожидание поставки
'taken_for_repair', -- Увезли на ремонт
'self_repair', -- Ремонт самостоятельно
'in_progress', -- В работе
'completed', -- Выполнена
'canceled' -- Отменена
);
-- Справочник оборудования
CREATE TABLE IF NOT EXISTS office_equipment (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
type office_equipment_type NOT NULL,
brand TEXT,
model TEXT,
serial_number TEXT UNIQUE,
assigned_to TEXT, -- Имя сотрудника
purchase_date DATE,
warranty_until DATE,
next_maintenance_date DATE, -- Дата следующего ТО
condition VARCHAR(20) NOT NULL DEFAULT 'good' CHECK (condition IN ('good', 'fair', 'poor')),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Заявки на ремонт
CREATE TABLE IF NOT EXISTS office_repair_requests (
id BIGSERIAL PRIMARY KEY,
equipment_id BIGINT NOT NULL REFERENCES office_equipment(id) ON DELETE CASCADE,
requester_name TEXT NOT NULL,
description TEXT NOT NULL,
priority VARCHAR(10) NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
status repair_request_status NOT NULL DEFAULT 'new',
assigned_to TEXT, -- Исполнитель
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
canceled_at TIMESTAMPTZ,
cancel_reason TEXT,
solution TEXT, -- Решение/что было сделано
expected_return_date DATE, -- Ожидаемая дата возврата из ремонта
attachments TEXT[], -- Пути к файлам (фото проблемы и т.д.)
comments JSONB, -- [{ "author": "name", "text": "comment", "created_at": "timestamp" }]
is_paid BOOLEAN DEFAULT FALSE, -- Платный ремонт
cost NUMERIC(10, 2) DEFAULT 0, -- Стоимость ремонта
cost_estimated BOOLEAN DEFAULT FALSE, -- Стоимость предварительная (требуется диагностика)
invoice_id BIGINT, -- ID счета на оплату из финансов (если создан)
invoice_url TEXT, -- Ссылка на счет
waiting_delivery_deadline TEXT, -- Примерный срок при ожидание поставки
waiting_delivery_contacts TEXT, -- Контакты для уточнения поставки
taken_for_repair_deadline TEXT, -- Срок при увезли на ремонт
taken_for_repair_contacts TEXT, -- Контакты при увезли на ремонт
agreed_contractor_price NUMERIC(10, 2) -- Цена при договорились с подрядчиком
);
CREATE INDEX IF NOT EXISTS idx_repair_requests_equipment ON office_repair_requests(equipment_id);
CREATE INDEX IF NOT EXISTS idx_repair_requests_status ON office_repair_requests(status);
CREATE INDEX IF NOT EXISTS idx_repair_requests_created ON office_repair_requests(created_at DESC);
-- История перемещений и событий по оборудованию
CREATE TYPE office_equipment_history_type AS ENUM ('purchase', 'issue', 'transfer', 'repair', 'write_off');
CREATE TABLE IF NOT EXISTS office_equipment_history (
id BIGSERIAL PRIMARY KEY,
equipment_id BIGINT NOT NULL REFERENCES office_equipment(id) ON DELETE CASCADE,
event_type office_equipment_history_type NOT NULL,
event_date DATE NOT NULL,
assigned_to TEXT, -- кому выдано / кому передано (для issue, transfer)
assigned_from TEXT, -- от кого (для transfer)
reason TEXT, -- причина (для repair, write_off)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_equipment_history_equipment ON office_equipment_history(equipment_id);
CREATE INDEX IF NOT EXISTS idx_equipment_history_date ON office_equipment_history(event_date DESC);
-- ========= ОФИС: БАЗА ЗНАНИЙ =========
-- Категории базы знаний
CREATE TABLE IF NOT EXISTS knowledge_base_categories (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
parent_id BIGINT REFERENCES knowledge_base_categories(id) ON DELETE CASCADE,
description TEXT,
icon TEXT,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_kb_categories_parent ON knowledge_base_categories(parent_id);
-- Статьи базы знаний
CREATE TABLE IF NOT EXISTS knowledge_base_articles (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
category_id BIGINT REFERENCES knowledge_base_categories(id) ON DELETE SET NULL,
content TEXT NOT NULL, -- Markdown или HTML
content_type VARCHAR(10) NOT NULL DEFAULT 'markdown' CHECK (content_type IN ('markdown', 'html')),
author TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
parent_version_id BIGINT REFERENCES knowledge_base_articles(id) ON DELETE SET NULL, -- Для версионирования
is_published BOOLEAN DEFAULT TRUE,
tags TEXT[],
attachments TEXT[], -- Пути к прикрепленным файлам
view_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_kb_articles_category ON knowledge_base_articles(category_id);
CREATE INDEX IF NOT EXISTS idx_kb_articles_slug ON knowledge_base_articles(slug);
CREATE INDEX IF NOT EXISTS idx_kb_articles_published ON knowledge_base_articles(is_published, published_at DESC);
-- ========= ОФИС: СОВЕЩАНИЯ И ПЕРЕГОВОРНЫЕ =========
-- Переговорные комнаты
CREATE TABLE IF NOT EXISTS meeting_rooms (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
capacity INTEGER NOT NULL,
location TEXT,
equipment TEXT[], -- ['projector', 'whiteboard', 'video_conference']
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Совещания
CREATE TABLE IF NOT EXISTS meetings (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
organizer TEXT NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
room_id BIGINT REFERENCES meeting_rooms(id) ON DELETE SET NULL,
participants TEXT[] NOT NULL, -- Список участников
agenda TEXT, -- Повестка дня
notes TEXT, -- Заметки/протокол
conclusions TEXT, -- Заключения совещания
status VARCHAR(20) NOT NULL DEFAULT 'scheduled' CHECK (status IN ('scheduled', 'in_progress', 'completed', 'canceled')),
reminder_sent BOOLEAN DEFAULT FALSE,
reminder_time TIMESTAMPTZ, -- Когда отправлять напоминание
attachments TEXT[], -- Прикрепленные файлы
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_time > start_time)
);
CREATE INDEX IF NOT EXISTS idx_meetings_room ON meetings(room_id);
CREATE INDEX IF NOT EXISTS idx_meetings_time ON meetings(start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_meetings_status ON meetings(status);
-- Бронирования переговорных (отдельная таблица для истории)
CREATE TABLE IF NOT EXISTS meeting_bookings (
id BIGSERIAL PRIMARY KEY,
room_id BIGINT NOT NULL REFERENCES meeting_rooms(id) ON DELETE CASCADE,
meeting_id BIGINT REFERENCES meetings(id) ON DELETE CASCADE,
booked_by TEXT NOT NULL,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
purpose TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'completed', 'canceled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_time > start_time)
-- Примечание: проверка на пересечение времени выполняется на уровне приложения
-- в API endpoint для предотвращения конфликтов бронирований
);
CREATE INDEX IF NOT EXISTS idx_meeting_bookings_room ON meeting_bookings(room_id);
CREATE INDEX IF NOT EXISTS idx_meeting_bookings_time ON meeting_bookings(start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_meeting_bookings_status ON meeting_bookings(status);
-- ========= ОФИС: ЗАЯВКИ НА ТМЦ (КАНЦТОВАРЫ, ХОЗ. НУЖДЫ) =========
CREATE TABLE IF NOT EXISTS office_supply_requests (
id BIGSERIAL PRIMARY KEY,
requester_name TEXT NOT NULL, -- Сотрудник, создавший заявку
category VARCHAR(50) NOT NULL, -- 'stationery', 'household', 'food', 'other'
item_name TEXT NOT NULL,
quantity INTEGER DEFAULT 1,
issued_quantity INTEGER DEFAULT 0, -- Количество выданного сотруднику
unit VARCHAR(20) DEFAULT 'шт.',
amount NUMERIC(10, 2) DEFAULT 0,
priority VARCHAR(10) NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
status VARCHAR(20) NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'approved', 'ordered', 'received', 'canceled', 'archived', 'collected')),
approved_by TEXT, -- Кто одобрил
approved_at TIMESTAMPTZ,
ordered_at TIMESTAMPTZ,
received_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_supply_requests_status ON office_supply_requests(status);
CREATE INDEX IF NOT EXISTS idx_supply_requests_requester ON office_supply_requests(requester_name);
CREATE INDEX IF NOT EXISTS idx_supply_requests_created ON office_supply_requests(created_at DESC);
-- ========= ОФИС: ЗАКАЗЫ =========
-- Заказы создаются из заявок на ТМЦ, могут включать несколько заявок
-- Заказ может ждать счет, иметь несколько предложений от поставщиков
CREATE TABLE IF NOT EXISTS office_orders (
id BIGSERIAL PRIMARY KEY,
order_number TEXT UNIQUE NOT NULL, -- Номер заказа (ORD-YYYY-MMDD-XXXX)
title TEXT NOT NULL, -- Название заказа
description TEXT, -- Описание заказа
status VARCHAR(20) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'waiting_quote', 'quotes_received', 'approved', 'ordered', 'received', 'canceled')),
total_amount NUMERIC(10, 2) DEFAULT 0, -- Общая сумма заказа
supplier_name TEXT, -- Название поставщика
supplier_contact TEXT, -- Контакты поставщика
invoice_id BIGINT, -- ID счета на оплату из финансов (если создан)
invoice_url TEXT, -- Ссылка на счет
expected_date DATE, -- Ожидаемая дата получения
received_date DATE, -- Дата получения
notes TEXT,
created_by TEXT NOT NULL, -- Кто создал заказ
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Связь заказов с заявками (многие ко многим)
CREATE TABLE IF NOT EXISTS office_order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES office_orders(id) ON DELETE CASCADE,
request_id BIGINT NOT NULL REFERENCES office_supply_requests(id) ON DELETE CASCADE,
quantity INTEGER DEFAULT 1, -- Количество в заказе (может отличаться от заявки)
unit_price NUMERIC(10, 2) DEFAULT 0, -- Цена за единицу в заказе
total_price NUMERIC(10, 2) DEFAULT 0, -- Общая цена позиции
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(order_id, request_id)
);
-- Предложения от поставщиков для заказа
CREATE TABLE IF NOT EXISTS office_order_quotes (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES office_orders(id) ON DELETE CASCADE,
supplier_name TEXT NOT NULL,
supplier_contact TEXT,
total_amount NUMERIC(10, 2) NOT NULL,
quote_file_url TEXT, -- Файл с коммерческим предложением
notes TEXT,
is_selected BOOLEAN DEFAULT FALSE, -- Выбранное предложение
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_orders_status ON office_orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_created ON office_orders(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_order_items_order ON office_order_items(order_id);
CREATE INDEX IF NOT EXISTS idx_order_items_request ON office_order_items(request_id);
CREATE INDEX IF NOT EXISTS idx_order_quotes_order ON office_order_quotes(order_id);
-- ========= ОФИС: СКЛАД =========
CREATE TABLE IF NOT EXISTS office_inventory (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
category VARCHAR(50), -- 'stationery', 'household', 'food', 'other'
quantity NUMERIC(10, 2) NOT NULL DEFAULT 0,
unit VARCHAR(20) NOT NULL DEFAULT 'шт.',
min_threshold NUMERIC(10, 2) NOT NULL DEFAULT 0,
last_restock DATE,
last_restock_by TEXT, -- Кто пополнил
location TEXT, -- Место хранения
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_office_inventory_category ON office_inventory(category);
CREATE INDEX IF NOT EXISTS idx_office_inventory_name ON office_inventory(name);
-- ========= ОФИС: ДОКУМЕНТООБОРОТ =========
CREATE TABLE IF NOT EXISTS office_documents (
id BIGSERIAL PRIMARY KEY,
reg_number TEXT NOT NULL UNIQUE, -- Регистрационный номер
title TEXT NOT NULL,
correspondent TEXT NOT NULL, -- Корреспондент
document_type VARCHAR(20) NOT NULL CHECK (document_type IN ('incoming', 'outgoing')),
status VARCHAR(20) NOT NULL DEFAULT 'registered' CHECK (status IN ('registered', 'processed', 'sent', 'archived')),
date DATE NOT NULL,
assigned_to TEXT, -- Кому назначен
tracking_number TEXT, -- Трек-номер для отправленных
file_url TEXT, -- Путь к файлу
notes TEXT,
created_by TEXT NOT NULL, -- Кто создал/зарегистрировал
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_office_documents_reg_number ON office_documents(reg_number);
CREATE INDEX IF NOT EXISTS idx_office_documents_status ON office_documents(status);
CREATE INDEX IF NOT EXISTS idx_office_documents_type ON office_documents(document_type);
CREATE INDEX IF NOT EXISTS idx_office_documents_date ON office_documents(date DESC);
CREATE INDEX IF NOT EXISTS idx_office_documents_created_by ON office_documents(created_by);
-- ========= HR: СОТРУДНИКИ =========
CREATE TYPE employee_status AS ENUM ('active', 'vacation', 'inactive');
CREATE TYPE messenger_type AS ENUM ('Max', 'Telegram');
-- Основная таблица сотрудников
CREATE TABLE IF NOT EXISTS employees (
id VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL, -- ФИО
position TEXT NOT NULL, -- Должность
phone TEXT NOT NULL, -- Номер телефона
status employee_status NOT NULL DEFAULT 'active',
salary NUMERIC(10, 2) NOT NULL,
assigned_district_id VARCHAR(50) REFERENCES districts(id) ON DELETE SET NULL,
manager_id VARCHAR(50) REFERENCES employees(id) ON DELETE SET NULL, -- Руководитель сотрудника
birth_date DATE,
photo_url TEXT,
registration_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_employees_district ON employees(assigned_district_id);
CREATE INDEX IF NOT EXISTS idx_employees_status ON employees(status);
CREATE INDEX IF NOT EXISTS idx_employees_manager ON employees(manager_id);
-- ========= СТАТИСТИКА ПРОИЗВОДИТЕЛЬНОСТИ СОТРУДНИКОВ =========
CREATE TABLE IF NOT EXISTS employee_performance_stats (
id BIGSERIAL PRIMARY KEY,
employee_name TEXT NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Статистика по заявкам
total_assigned INTEGER NOT NULL DEFAULT 0, -- Всего заявок назначено
total_completed INTEGER NOT NULL DEFAULT 0, -- Выполнено заявок
total_overdue INTEGER NOT NULL DEFAULT 0, -- Просрочено заявок
total_in_progress INTEGER NOT NULL DEFAULT 0, -- В работе
total_deferred INTEGER NOT NULL DEFAULT 0, -- Отложено
-- Расчётные показатели
completion_rate NUMERIC(5, 2) NOT NULL DEFAULT 0, -- Процент выполнения (с учётом общего контекста)
overdue_rate NUMERIC(5, 2) NOT NULL DEFAULT 0, -- Процент просрочек
performance_score NUMERIC(5, 2) NOT NULL DEFAULT 0, -- Общий рейтинг производительности (0-100)
-- Связи
district_id VARCHAR(50) REFERENCES districts(id) ON DELETE SET NULL,
-- Метаданные
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_name, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_performance_stats_employee ON employee_performance_stats(employee_name);
CREATE INDEX IF NOT EXISTS idx_performance_stats_district ON employee_performance_stats(district_id);
CREATE INDEX IF NOT EXISTS idx_performance_stats_period ON employee_performance_stats(period_start, period_end);
CREATE INDEX IF NOT EXISTS idx_performance_stats_score ON employee_performance_stats(performance_score DESC);
-- ========= СТАТИСТИКА ПО УЧАСТКАМ =========
CREATE TABLE IF NOT EXISTS district_performance_stats (
id BIGSERIAL PRIMARY KEY,
district_id VARCHAR(50) NOT NULL REFERENCES districts(id) ON DELETE CASCADE,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
-- Статистика по заявкам
total_applications INTEGER NOT NULL DEFAULT 0,
total_completed INTEGER NOT NULL DEFAULT 0,
total_overdue INTEGER NOT NULL DEFAULT 0,
total_in_progress INTEGER NOT NULL DEFAULT 0,
-- Расчётные показатели
completion_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
overdue_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
average_score NUMERIC(5, 2) NOT NULL DEFAULT 0, -- Средний рейтинг сотрудников участка
-- Метаданные
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(district_id, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_district_stats_district ON district_performance_stats(district_id);
CREATE INDEX IF NOT EXISTS idx_district_stats_period ON district_performance_stats(period_start, period_end);
-- Логины мессенджеров
CREATE TABLE IF NOT EXISTS employee_messenger_logins (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
messenger messenger_type NOT NULL,
login TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id, messenger)
);
CREATE INDEX IF NOT EXISTS idx_messenger_logins_employee ON employee_messenger_logins(employee_id);
-- HR данные: Паспортные данные
CREATE TABLE IF NOT EXISTS employee_passport_data (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
series TEXT NOT NULL,
number TEXT NOT NULL,
issued_by TEXT NOT NULL,
issued_date DATE NOT NULL,
registration_address TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id)
);
CREATE INDEX IF NOT EXISTS idx_passport_data_employee ON employee_passport_data(employee_id);
-- HR данные: Трудовая книжка
CREATE TABLE IF NOT EXISTS employee_labor_books (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
number TEXT NOT NULL,
series TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id)
);
CREATE INDEX IF NOT EXISTS idx_labor_books_employee ON employee_labor_books(employee_id);
-- Записи в трудовой книжке
CREATE TABLE IF NOT EXISTS labor_book_entries (
id BIGSERIAL PRIMARY KEY,
labor_book_id BIGINT NOT NULL REFERENCES employee_labor_books(id) ON DELETE CASCADE,
date DATE NOT NULL,
organization TEXT NOT NULL,
position TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_labor_book_entries_labor_book ON labor_book_entries(labor_book_id);
-- Заказ справок
CREATE TYPE certificate_status AS ENUM ('requested', 'issued', 'ready');
CREATE TABLE IF NOT EXISTS employee_certificates (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
type TEXT NOT NULL, -- Тип справки (2-НДФЛ, справка с места работы и т.д.)
requested_date DATE NOT NULL,
issued_date DATE,
status certificate_status NOT NULL DEFAULT 'requested',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_certificates_employee ON employee_certificates(employee_id);
CREATE INDEX IF NOT EXISTS idx_certificates_status ON employee_certificates(status);
-- Прочие документы
CREATE TABLE IF NOT EXISTS employee_other_documents (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
name TEXT NOT NULL,
type TEXT NOT NULL,
date DATE NOT NULL,
file_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_other_documents_employee ON employee_other_documents(employee_id);
-- Типовые документы HR (печать/шаблоны в разделе Кадры)
CREATE TABLE IF NOT EXISTS hr_template_documents (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
file_path TEXT NOT NULL,
original_filename TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_hr_template_documents_created ON hr_template_documents(created_at DESC);
-- Бухгалтерская информация
CREATE TABLE IF NOT EXISTS employee_accounting_data (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
inn TEXT, -- ИНН
snils TEXT, -- СНИЛС
bank_name TEXT, -- Название банка
bank_account TEXT, -- Расчетный счет
correspondent_account TEXT, -- Корреспондентский счет
bik TEXT, -- БИК
tax_id TEXT, -- КПП (если есть)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id)
);
CREATE INDEX IF NOT EXISTS idx_accounting_data_employee ON employee_accounting_data(employee_id);
-- Характеристики договора
CREATE TABLE IF NOT EXISTS employee_contracts (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
contract_type TEXT NOT NULL, -- Тип договора (трудовой, ГПХ, срочный и т.д.)
contract_number TEXT, -- Номер договора
start_date DATE NOT NULL, -- Дата начала
end_date DATE, -- Дата окончания (NULL для бессрочного)
probation_period_days INTEGER, -- Испытательный срок в днях
work_schedule TEXT, -- График работы (полный день, частичная занятость и т.д.)
work_mode TEXT, -- Режим работы (офис, удаленно, гибрид)
contract_terms TEXT, -- Дополнительные условия договора
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_contracts_employee ON employee_contracts(employee_id);
CREATE INDEX IF NOT EXISTS idx_contracts_dates ON employee_contracts(start_date, end_date);
-- ========= HR: ОТПУСКА =========
CREATE TABLE IF NOT EXISTS employee_vacations (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
days_count INTEGER NOT NULL, -- Количество дней отпуска
vacation_type TEXT NOT NULL DEFAULT 'annual', -- Тип отпуска (annual - ежегодный, unpaid - без сохранения зарплаты, study - учебный и т.д.)
status VARCHAR(20) NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'approved', 'active', 'completed', 'canceled', 'rejected')),
approved_by TEXT, -- Кто утвердил
approved_at TIMESTAMPTZ, -- Дата утверждения
notes TEXT, -- Примечания
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_vacations_employee ON employee_vacations(employee_id);
CREATE INDEX IF NOT EXISTS idx_vacations_dates ON employee_vacations(start_date, end_date);
CREATE INDEX IF NOT EXISTS idx_vacations_status ON employee_vacations(status);
-- ========= HR: БОЛЬНИЧНЫЕ =========
CREATE TABLE IF NOT EXISTS employee_sick_leaves (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
start_date DATE NOT NULL,
end_date DATE, -- Может быть NULL, если больничный еще не закрыт
days_count INTEGER, -- Количество дней (рассчитывается автоматически)
sick_leave_number TEXT, -- Номер больничного листа
diagnosis TEXT, -- Диагноз (если указан)
medical_institution TEXT, -- Медицинское учреждение
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'canceled')),
closed_at TIMESTAMPTZ, -- Дата закрытия больничного
expected_return_date DATE, -- Предварительная дата выхода
notes TEXT, -- Примечания
file_url TEXT, -- Ссылка на отсканированный больничный лист
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sick_leaves_employee ON employee_sick_leaves(employee_id);
CREATE INDEX IF NOT EXISTS idx_sick_leaves_dates ON employee_sick_leaves(start_date, end_date);
CREATE INDEX IF NOT EXISTS idx_sick_leaves_status ON employee_sick_leaves(status);
-- ========= HR: УВОЛЬНЕНИЯ =========
CREATE TYPE termination_status AS ENUM ('initiated', 'in_progress', 'completed', 'canceled');
CREATE TABLE IF NOT EXISTS employee_terminations (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
termination_date DATE NOT NULL, -- Дата увольнения
reason TEXT NOT NULL, -- Причина увольнения
initiated_by TEXT NOT NULL, -- Кто инициировал увольнение
initiated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
status termination_status NOT NULL DEFAULT 'initiated',
-- Договор на увольнение
termination_contract_number TEXT, -- Номер договора на увольнение
termination_contract_date DATE, -- Дата договора
termination_contract_file_url TEXT, -- Файл договора
-- Расчеты
final_settlement_amount NUMERIC(10, 2), -- Сумма окончательного расчета
unused_vacation_days INTEGER, -- Неиспользованные дни отпуска
compensation_amount NUMERIC(10, 2), -- Компенсация за неиспользованный отпуск
severance_pay NUMERIC(10, 2), -- Выходное пособие
other_payments NUMERIC(10, 2), -- Прочие выплаты
deductions NUMERIC(10, 2), -- Удержания
settlement_document_number TEXT, -- Номер документа расчета
settlement_document_date DATE, -- Дата документа расчета
settlement_document_file_url TEXT, -- Файл документа расчета
-- Дополнительная информация
notes TEXT, -- Примечания
completed_at TIMESTAMPTZ, -- Дата завершения процедуры увольнения
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_terminations_employee ON employee_terminations(employee_id);
CREATE INDEX IF NOT EXISTS idx_terminations_date ON employee_terminations(termination_date);
CREATE INDEX IF NOT EXISTS idx_terminations_status ON employee_terminations(status);
-- ========= HR: ОТГУЛЫ И ПРОГУЛЫ =========
CREATE TABLE IF NOT EXISTS employee_absences (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
absence_type VARCHAR(20) NOT NULL CHECK (absence_type IN ('day_off', 'absence', 'late', 'early_leave')), -- Тип: отгул, прогул, опоздание, ранний уход
start_date DATE NOT NULL,
end_date DATE, -- Может быть NULL для однодневных отгулов
start_time TIME, -- Время начала (для опозданий и ранних уходов)
end_time TIME, -- Время окончания (для опозданий и ранних уходов)
days_count DECIMAL(5, 2) NOT NULL DEFAULT 1.0, -- Количество дней (может быть дробным для части дня)
reason TEXT, -- Причина отсутствия
requires_approval BOOLEAN NOT NULL DEFAULT TRUE, -- Требуется ли согласование
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected', 'canceled')), -- Статус согласования
approved_by TEXT, -- Кто утвердил (руководитель)
approved_at TIMESTAMPTZ, -- Дата и время утверждения
approved_signature TEXT, -- Подпись руководителя (текст или путь к файлу)
rejected_by TEXT, -- Кто отклонил
rejected_at TIMESTAMPTZ, -- Дата отклонения
rejection_reason TEXT, -- Причина отклонения
notes TEXT, -- Примечания
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (end_date IS NULL OR end_date >= start_date)
);
CREATE INDEX IF NOT EXISTS idx_absences_employee ON employee_absences(employee_id);
CREATE INDEX IF NOT EXISTS idx_absences_dates ON employee_absences(start_date, end_date);
CREATE INDEX IF NOT EXISTS idx_absences_type ON employee_absences(absence_type);
CREATE INDEX IF NOT EXISTS idx_absences_status ON employee_absences(status);
-- Обновляем таблицу отпусков для поддержки согласования
ALTER TABLE employee_vacations
ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS approved_signature TEXT,
ADD COLUMN IF NOT EXISTS rejected_by TEXT,
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS rejection_reason TEXT;
-- Обновляем таблицу больничных для поддержки согласования (если требуется)
ALTER TABLE employee_sick_leaves
ADD COLUMN IF NOT EXISTS requires_approval BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS approved_by TEXT,
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS approved_signature TEXT;
-- ========= ПРОФИЛИ ПОЛЬЗОВАТЕЛЕЙ ПОРТАЛА =========
-- Профили пользователей портала
-- Важно: профиль не может быть без сотрудника (employee_id NOT NULL)
-- Но сотрудник может быть без профиля (связь необязательная со стороны employees)
CREATE TABLE IF NOT EXISTS portal_users (
id BIGSERIAL PRIMARY KEY,
employee_id VARCHAR(50) NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
email TEXT,
login TEXT UNIQUE,
photo_url TEXT, -- Путь к загруженному фото
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(employee_id) -- Один профиль на одного сотрудника
);
CREATE INDEX IF NOT EXISTS idx_portal_users_employee ON portal_users(employee_id);
CREATE INDEX IF NOT EXISTS idx_portal_users_login ON portal_users(login);
-- Изменяем таблицу employees: photo_url теперь хранит путь к загруженному файлу
-- (или может быть NULL, если фото берется из профиля пользователя)
-- ========= HR: ВАКАНСИИ =========
-- Создание типа vacancy_status (с проверкой существования)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'vacancy_status') THEN
CREATE TYPE vacancy_status AS ENUM ('urgent', 'active', 'paused', 'closed');
END IF;
END$$;
-- Таблица вакансий
CREATE TABLE IF NOT EXISTS vacancies (
id VARCHAR(50) PRIMARY KEY,
position TEXT NOT NULL, -- Название должности
department TEXT NOT NULL, -- Отдел
status vacancy_status NOT NULL DEFAULT 'active',
salary TEXT, -- Вилка зарплаты (например, "55 000 - 65 000 ₽")
description TEXT NOT NULL, -- Описание вакансии
requirements TEXT, -- Требования к кандидату
conditions TEXT, -- Условия работы
responsibilities TEXT, -- Обязанности
posted_date DATE NOT NULL DEFAULT CURRENT_DATE, -- Дата публикации
closing_date DATE, -- Дата закрытия вакансии
applicants_count INTEGER DEFAULT 0, -- Количество откликов
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_vacancies_status ON vacancies(status);
CREATE INDEX IF NOT EXISTS idx_vacancies_department ON vacancies(department);
CREATE INDEX IF NOT EXISTS idx_vacancies_posted_date ON vacancies(posted_date DESC);
-- ========= HR: КАНДИДАТЫ =========
-- Создание типа candidate_stage (с проверкой существования)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_stage') THEN
CREATE TYPE candidate_stage AS ENUM ('new', 'interview', 'probation', 'hired', 'rejected');
ELSE
-- Добавляем 'probation' если его еще нет
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'probation'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'candidate_stage')
) THEN
ALTER TYPE candidate_stage ADD VALUE IF NOT EXISTS 'probation';
END IF;
-- Удаляем 'offer' если он есть (заменяем на 'probation')
-- Примечание: удаление значений из ENUM в PostgreSQL невозможно напрямую,
-- поэтому оставляем 'offer' для обратной совместимости, но не используем в новом коде
END IF;
END$$;
-- Таблица кандидатов
CREATE TABLE IF NOT EXISTS candidates (
id VARCHAR(50) PRIMARY KEY,
name TEXT NOT NULL, -- ФИО кандидата
position TEXT NOT NULL, -- Позиция, на которую претендует
vacancy_id VARCHAR(50) REFERENCES vacancies(id) ON DELETE SET NULL, -- Связь с вакансией
stage candidate_stage NOT NULL DEFAULT 'new',
phone TEXT NOT NULL,
email TEXT,
resume_url TEXT, -- Ссылка на резюме
cover_letter TEXT, -- Сопроводительное письмо
interview_date TIMESTAMPTZ, -- Дата собеседования
interview_notes TEXT, -- Заметки с собеседования
offer_salary NUMERIC(10, 2), -- Предложенная зарплата
offer_date DATE, -- Дата предложения
hired_date DATE, -- Дата найма
rejected_reason TEXT, -- Причина отказа
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_candidates_vacancy ON candidates(vacancy_id);
CREATE INDEX IF NOT EXISTS idx_candidates_stage ON candidates(stage);
CREATE INDEX IF NOT EXISTS idx_candidates_position ON candidates(position);
-- ========= HR: СОБЫТИЯ КАНДИДАТОВ =========
-- Создание типа candidate_event_type
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_event_type') THEN
CREATE TYPE candidate_event_type AS ENUM (
'call', -- Созвон
'interview_1', -- Первое собеседование
'interview_2', -- Второе собеседование
'interview_3', -- Третье собеседование
'test_task', -- Тестовое задание
'offer', -- Оффер
'offer_accepted', -- Оффер принят
'offer_rejected', -- Оффер отклонен
'probation_start', -- Начало испытательного срока
'hired', -- Трудоустроен
'rejected', -- Отклонен
'other' -- Другое
);
END IF;
END$$;
-- Создание типа candidate_event_result
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'candidate_event_result') THEN
CREATE TYPE candidate_event_result AS ENUM ('success', 'failed', 'pending', 'cancelled');
END IF;
END$$;
-- Таблица событий кандидата
CREATE TABLE IF NOT EXISTS candidate_events (
id VARCHAR(50) PRIMARY KEY,
candidate_id VARCHAR(50) NOT NULL REFERENCES candidates(id) ON DELETE CASCADE,
event_type candidate_event_type NOT NULL,
event_date TIMESTAMPTZ NOT NULL,
notes TEXT, -- Заметки о событии
result candidate_event_result DEFAULT 'pending', -- Результат события
interviewer TEXT, -- Кто проводил (для собеседований)
location TEXT, -- Место проведения (офис, онлайн и т.д.)
duration_minutes INTEGER, -- Длительность в минутах
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_candidate_events_candidate ON candidate_events(candidate_id);
CREATE INDEX IF NOT EXISTS idx_candidate_events_type ON candidate_events(event_type);
CREATE INDEX IF NOT EXISTS idx_candidate_events_date ON candidate_events(event_date DESC);