Справочник базы данных (schema v3, 2026-07-27). Все таблицы и все поля схем
coreиhrс объяснениями — сгенерировано из канонического DDL (docs/db/hr-schema.sql, прогнан на чистом PostgreSQL 16) и сверено с ним потаблично. Архитектурный контекст — док 10 §3.8, модель доступа — док 11 §3, решения и история — дизайн-док2026-07-27-hr-postgres-schema-design.md.Итого: 48 таблиц (17
core+ 31hr), 5 view (security_invoker), 2 rollup-функции, 14 enum-типов, 69 триггеров. Пять доменов: D1 Platform · D2 People & Scheduling · D3 Leave & Holidays · D4 Requests & Approvals · D5 Attendance.
Платформенный фундамент staffapp: single-tenant профиль компании (v3 убрал мультитенантную ось), учётные записи и сессии, RBAC-гранты (роль → права, грант в скоупе), единый реестр файлов, append-only аудит, уведомления, журнал Lark-синка, каталог настроек и карта легаси-идентификаторов на время миграции. Состояние доступа сотрудника нигде не хранится — оно всегда вычисляется вьюхой core.v_employee_access (ADR-02: none / suspended / active из наличия аккаунта и флага suspend). Все таблицы домена имеют created_at/updated_at; updated_at поддерживается автоматическим триггером <table>_touch (before update → core.touch_updated_at()), поэтому ниже он указывается одной строкой.
Однострочный профиль компании (структурный синглтон — вторую строку вставить невозможно). Читается всеми экранами для локали/валюты; time_zone закрывает «день» в учёте посещаемости. Сидируется строкой BetterPlace / Asia/Makassar.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор, gen_random_uuid() |
| name | text not null | Название компании |
| time_zone | text not null, default 'Asia/Makassar' | Часовой пояс компании; по нему закрывается день посещаемости |
| default_locale | char(2) not null, default 'en' | Локаль по умолчанию; CHECK: только 'en' или 'id' |
| default_currency | char(3) not null, default 'IDR' | Валюта по умолчанию |
| created_at | timestamptz not null, default now() | Момент создания строки |
| updated_at | timestamptz not null, default now() | Момент последнего изменения (триггер) |
| Правила: |
company_singleton по константе (true) — структурный синглтон: в таблице не может появиться вторая строка.default_locale: значение только 'en' или 'id'.company_touch — before update проставляет updated_at = now().Канонический справочник департаментов: скоуп для RBAC-грантов и настроек, фасет на экранах S03/S14/S19/S34. Пишет админ, читают почти все списки. Мягкое архивирование через archived_at.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| code | citext not null unique | Уникальный код департамента (регистронезависимый) |
| name_en | text not null | Название на английском |
| name_id | text not null | Название на индонезийском |
| archived_at | timestamptz | Момент архивирования; NULL = активен |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Последнее изменение (триггер) |
| Правила: |
code.department_touch — before update проставляет updated_at.Логин-идентичность (ключ — login, не e-mail: у emp-007/emp-008 почты нет, ADR-04). Состояние доступа НЕ хранится — оно производное (core.v_employee_access). Пишет админ через управление доступом; читает auth-слой.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| employee_id | uuid unique, FK → hr.employee (on delete set null) | Привязка аккаунта к сотруднику (кросс-доменный FK из §11); один аккаунт на сотрудника |
| login | citext not null unique | Логин (регистронезависимый), первичный ключ входа |
| citext | Почта; опциональна (ADR-04) | |
| password_hash | text | Хэш пароля; NULL = приглашён, но не активирован |
| is_suspended | boolean not null, default false | Флаг блокировки; suspend отзывает все сессии |
| last_login_at | timestamptz | Последний вход |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
login; unique по employee_id (не более одного аккаунта на сотрудника).user_account_employee_fk → hr.employee, on delete set null (аккаунт переживает удаление карточки).user_account_touch — before update проставляет updated_at.Серверный refresh-токен, одна строка на устройство; цепочка ротации через parent_session_id. Пишет auth-слой при входе/ротации; suspend аккаунта отзывает все сессии.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| user_account_id | uuid not null, FK → core.user_account (cascade) | Чей токен; удаление аккаунта уносит сессии |
| token_hash | bytea not null unique | Хэш токена (сам токен в БД не хранится) |
| parent_session_id | uuid, FK → core.session (set null) | Предыдущая сессия в цепочке ротации |
| user_agent | text | User-Agent устройства |
| ip | inet | IP входа |
| expires_at | timestamptz not null | Срок жизни токена |
| revoked_at | timestamptz | Момент отзыва; NULL = живая |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
token_hash.session_live_idx по user_account_id только для строк с revoked_at is null — быстрый поиск живых сессий аккаунта (и массовый отзыв).session_touch — before update проставляет updated_at.Одноразовый код приглашения/сброса пароля, хранится только хэш. Пишет флоу «invite/reset», читает auth-слой при активации.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| user_account_id | uuid not null, FK → core.user_account (cascade) | Чей код |
| code_hash | bytea not null | Хэш одноразового кода |
| purpose | text not null, default 'reset' | Назначение; CHECK: 'invite' или 'reset' |
| expires_at | timestamptz not null | Срок действия кода |
| used_at | timestamptz | Момент использования; NULL = код ещё жив |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
credential_reset_one_live_key по user_account_id при used_at is null — максимум один живой код на аккаунт.purpose: только 'invite' или 'reset'.credential_reset_touch — before update проставляет updated_at.Набор прав (permission bundle). НЕ должность (это hr.position) и НЕ ячейка user-type экрана S34 (это hr.user_type) — ADR-05. Сидируется четырёхуровневой иерархией: super_admin(100), admin(80), supervisor(50), staff(10).
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| code | citext not null unique | Код роли (super_admin/admin/supervisor/staff) |
| name_en | text not null | Название EN |
| name_id | text not null | Название ID |
| rank | smallint not null | Уровень в иерархии для структурной анти-эскалации: выдать роль с rank >= собственного запрещено на сервисном слое |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
code.role_touch — before update проставляет updated_at.Глобальный каталог атомарных прав; каждый гейт кнопки в UI завязан на код отсюда (например hr.request.decide). Для приложения каталог read-only (insert/update/delete отозваны у staffapp_app — правится только миграциями).
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| code | citext not null unique | Код права, например hr.request.decide |
| description | text not null, default '' | Описание права |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
code.staffapp_app отозваны insert/update/delete — каталог для приложения только на чтение.permission_touch — before update проставляет updated_at.Маппинг роли на права; редактируется супер-админом через настройки «admin powers» (specs/11-security-operations.md §3) — «grants-as-data». Сид: super_admin и admin получают полный операционный набор, supervisor/staff — усечённые.
| Поле | Тип | Смысл |
|---|---|---|
| role_id | uuid not null, FK → core.role (cascade) | Роль |
| permission_id | uuid not null, FK → core.permission (cascade) | Право |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
(role_id, permission_id) — одно право одной роли выдаётся один раз.role_permission_touch — before update проставляет updated_at.Выдача роли пользователю ВНУТРИ скоупа — строка «supervisor видит свою команду». Фильтрацию по скоупу авторитетно резолвит app-слой. Скоуп-типы (enum core.role_scope): company, department, location, subordinates.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| user_account_id | uuid not null, FK → core.user_account (cascade) | Кому выдана роль |
| role_id | uuid not null, FK → core.role (cascade) | Какая роль |
| scope_type | core.role_scope not null, default 'company' | Тип скоупа: company / department / location / subordinates |
| scope_department_id | uuid, FK → core.department (cascade) | Департамент-скоуп; обязателен ровно при scope_type='department' |
| scope_location_id | uuid, FK → hr.work_location (restrict, §11) | Локация-скоуп; обязательна ровно при scope_type='location'; локацию с грантами удалить нельзя |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
scope_type = 'department' тогда и только тогда, когда scope_department_id заполнен.scope_type = 'location' тогда и только тогда, когда scope_location_id заполнен.(user_account_id, role_id, scope_type, scope_department_id, scope_location_id) — дубликат гранта (в т.ч. с NULL-скоупами) невозможен.user_role_account_idx по user_account_id — выборка грантов пользователя.user_role_touch — before update проставляет updated_at.Единый реестр блобов: фото сотрудников, вложения заявок, фото-доказательства чек-инов. Уникальность checksum = идемпотентная загрузка; пара owner_kind/owner_id — единая поверхность для будущего сборщика сирот (review 2).
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| storage_key | text not null unique | Ключ в объектном хранилище |
| original_name | text not null | Исходное имя файла |
| mime_type | text not null | MIME-тип |
| byte_size | bigint not null | Размер в байтах; CHECK: >= 0 |
| checksum_sha256 | bytea not null | SHA-256 содержимого |
| uploaded_by_user_account_id | uuid, FK → core.user_account (set null) | Кто загрузил; переживает удаление аккаунта |
| owner_kind | text | Тип сущности-владельца (для sweep'а сирот) |
| owner_id | uuid | Идентификатор владельца (для sweep'а сирот) |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
storage_key.file_dedup_key по (checksum_sha256, byte_size) — дедупликация: повторная загрузка того же содержимого идемпотентна.byte_size >= 0.file_owner_idx по (owner_kind, owner_id) — поверхность для сборщика сирот.file_touch — before update проставляет updated_at.Append-only «кто/что/когда» по всем доменам. Пишет app-слой на каждое значимое действие; читается по паре (сущность, время) — JSONB GIN сознательно НЕ создан (review 2). Чистка возможна только под ролью staffapp_retention.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| actor_user_account_id | uuid | Актор; намеренно БЕЗ FK — лог переживает удаление субъекта |
| action | core.audit_action not null | Действие: create/update/delete/archive/restore/login/logout/grant/revoke/sync |
| entity_type | text not null | Тип затронутой сущности |
| entity_id | uuid | Идентификатор сущности |
| before | jsonb | Снимок до изменения |
| after | jsonb | Снимок после изменения |
| redacted_fields | text[] not null, default '{}' | Список полей, вымаранных из снимков |
| correlation_id | uuid | Сквозной идентификатор операции |
| created_at | timestamptz not null | Момент записи |
| updated_at | timestamptz not null | Служебный (update всё равно запрещён) |
| Правила: |
audit_log_append_only — before update/delete вызывает core.forbid_mutation(): мутация разрешена только членам роли staffapp_retention, иначе ошибка 42501.staffapp_app — «ремень и подтяжки» вместе с триггером.audit_log_created_brin по created_at — дешёвый скан по времени.audit_log_entity_idx по (entity_type, entity_id, created_at desc) — история конкретной сущности.audit_log_touch — before update проставляет updated_at (практически недостижим из-за append-only).Каталог нотифицируемых событий и их каналов по умолчанию. Read-only для приложения (грант отозван). Сид: request.submitted, request.decided, request.needs_action, attendance.corrected.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| code | citext not null unique | Код события (на него ссылается core.notification.type_code) |
| name_en | text not null | Название EN |
| name_id | text not null | Название ID |
| default_channels | core.notification_channel[] not null, default '{in_app}' | Каналы по умолчанию: in_app / lark / email |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
code.staffapp_app отозваны insert/update/delete — каталог правится только миграциями.notification_type_touch — before update проставляет updated_at.Один факт на получателя; текст НЕ хранится — app рендерит из payload (аргументы для next-intl) в локали читателя. Пофасетная доставка по каналам отложена до дизайна мессенджера S49.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| type_code | citext not null, FK → core.notification_type (code) | Тип события |
| recipient_employee_id | uuid, FK → hr.employee (restrict, §11) | Получатель-сотрудник; сотрудника с уведомлениями удалить нельзя |
| recipient_user_account_id | uuid, FK → core.user_account (cascade) | Получатель-аккаунт; удаление аккаунта уносит его уведомления |
| payload | jsonb not null, default '{}' | Аргументы рендера для next-intl, не готовое предложение |
| read_at | timestamptz | Момент прочтения; NULL = непрочитано |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
recipient_employee_id / recipient_user_account_id заполнен.notification_unread_idx по (recipient_employee_id, created_at desc) при read_at is null — лента непрочитанных.notification_touch — before update проставляет updated_at.Справочник конфигурационных ключей с типом и дефолтом (Q-17 «Departments Settings»). Read-only для приложения. Сид: attendance.late_grace_minutes, leave.min_notice_days, attendance.require_photo, attendance.geofence_enforced.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| key | citext not null unique | Ключ настройки (на него ссылается core.setting.key) |
| value_type | text not null | Тип значения; CHECK: boolean / integer / numeric / text / json |
| default_value | jsonb | Значение по умолчанию |
| description | text not null, default '' | Описание ключа |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
key.value_type: только boolean / integer / numeric / text / json.staffapp_app отозваны insert/update/delete — каталог правится только миграциями.setting_definition_touch — before update проставляет updated_at.Действующее значение конфигурации: строка с department_id = NULL — общекомпанийский дефолт, строка с департаментом — переопределение для него. Пишет админ на экране настроек, читают движки (посещаемость, отпуска).
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| key | citext not null, FK → core.setting_definition (key) | Какой ключ настроен |
| department_id | uuid, FK → core.department (cascade) | Скоуп: NULL = вся компания, иначе override департамента |
| value | jsonb not null | Значение |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
(key, department_id) — один ключ имеет максимум одно значение на скоуп, включая единственный общекомпанийский дефолт.setting_touch — before update проставляет updated_at.Журнал здоровья синхронизации: одна строка на запуск employee-синка из Lark (v3: заменил собой «универсальную интеграционную платформу»). Пишет sync-джоб, читает админский экран здоровья синка.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| started_at | timestamptz not null, default now() | Старт запуска |
| finished_at | timestamptz | Финиш; NULL = ещё идёт |
| status | text not null, default 'running' | Статус; CHECK: running / success / error |
| created_count | int not null, default 0 | Сколько сотрудников создано |
| updated_count | int not null, default 0 | Сколько обновлено |
| flagged_count | int not null, default 0 | Сколько помечено на разбор |
| error | text | Текст ошибки при status='error' |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
status: только running / success / error.lark_sync_run_touch — before update проставляет updated_at.Соответствие «emp-001 → uuid» на время поэкранного переезда со старых данных на API. Таблица временная: будет удалена, когда последний экран перейдёт на API.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор |
| entity_type | text not null | Тип сущности (пространство легаси-идентификаторов) |
| legacy_id | text not null | Старый строковый идентификатор (например emp-001) |
| entity_id | uuid not null | Новый uuid сущности |
| created_at | timestamptz not null | Создание |
| updated_at | timestamptz not null | Изменение (триггер) |
| Правила: |
(entity_type, legacy_id) — один легаси-идентификатор маппится ровно в одну сущность.legacy_id_map_touch — before update проставляет updated_at.Таблицы домена (12): справочники hr.position, hr.user_type, hr.employment_type, hr.work_location; расписания hr.work_schedule, hr.work_schedule_day, hr.shift_template; люди hr.employee, hr.employment, hr.employee_work_location; датированные смены hr.shift, hr.shift_assignment. На каждой таблице домена стоит авто-триггер <table>_touch (before update → core.touch_updated_at()), проставляющий updated_at = now(); ниже он указан один раз на таблицу без повторения деталей.
Используемые enum-типы домена: hr.employee_source = 'lark' | 'manual'; hr.employee_status = 'active' | 'inactive'; hr.location_direction = 'check_in' | 'check_out'.
Справочник должностей — колонка «Role» на экране S34. Это НЕ авторизация: права доступа живут в core.role (ADR-05). Пишет админ из таблицы персонала, читают экраны персонала и hr.employment.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| code | citext NOT NULL UNIQUE | Машинный код должности, регистронезависимый, уникальный |
| name_en | text NOT NULL | Название на английском |
| name_id | text NOT NULL | Название на индонезийском |
| archived_at | timestamptz NULL | Мягкое удаление: NULL = активна, иначе момент архивации |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, default now(), обновляется триггером |
Правила:
id; UNIQUE по code.position_touch — before update, проставляет updated_at.Справочник Staff/Manager/Admin — ячейка «User Type» на S34, расширяется прямо из редактора ячейки. Читается hr.employee.user_type_id.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| code | citext NOT NULL UNIQUE | Машинный код типа, регистронезависимый, уникальный |
| name_en | text NOT NULL | Название на английском |
| name_id | text NOT NULL | Название на индонезийском |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id; UNIQUE по code. Поля archived_at нет.user_type_touch — before update, проставляет updated_at.Справочник типов трудовых отношений: PKWTT / PKWT / probation / contractor. Читается hr.employment.employment_type_id.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| code | citext NOT NULL UNIQUE | Машинный код, регистронезависимый, уникальный |
| name_en | text NOT NULL | Название на английском |
| name_id | text NOT NULL | Название на индонезийском |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id; UNIQUE по code. Поля archived_at нет.employment_type_touch — before update, проставляет updated_at.Вручную редактируемая геоточка для check-in/check-out (геофенс). Мягкое удаление через archived_at; лейблы продолжают отображать заархивированные строки (review 3). Читается табелем (валидация геозоны), hr.employee_work_location, hr.employment, hr.shift.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| location_no | bigint identity | Сквозной номер, generated always as identity |
| reference | text generated, UNIQUE | Человекочитаемый код LOC-0001: 'LOC-' || lpad(location_no, 4, '0'), stored |
| name | text NOT NULL default '' | Название; пустое по умолчанию — паттерн «сначала пустая строка, потом правка», поэтому CHECK на btrim намеренно нет (review 3) |
| latitude | numeric(9,6) NULL | Широта центра геозоны |
| longitude | numeric(9,6) NULL | Долгота центра геозоны |
| radius_m | integer NOT NULL default 100 | Радиус геозоны в метрах, CHECK > 0 |
| unit_id | uuid NULL | Задел: станет FK на core.unit, когда появится домен недвижимости (E01); сейчас без FK |
| archived_at | timestamptz NULL | Мягкое удаление: NULL = активна |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id; UNIQUE по generated-полю reference.radius_m > 0.work_location_touch — before update, проставляет updated_at.Именованная недельная рота (экран S32). Новое расписание и его СЕМЬ строк hr.work_schedule_day обязаны вставляться в ОДНОЙ транзакции: триггер полноты — DEFERRED и срабатывает на COMMIT. Привязывается к сотруднику через hr.employee.work_schedule_id.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| schedule_no | bigint identity | Сквозной номер, generated always as identity |
| reference | text generated, UNIQUE | Код WSC-0001: 'WSC-' || lpad(schedule_no, 4, '0'), stored |
| name | text NOT NULL default '' | Название; пустое допустимо (blank-row-then-edit) |
| role_label | text NULL | Подсказка для массового назначения (bulk-assign hint), больше ни на что не влияет |
| archived_at | timestamptz NULL | Мягкое удаление: NULL = активно |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id; UNIQUE по reference.work_schedule_name_key по name при условии btrim(name) <> '' — непустые имена уникальны, пустые черновики могут дублироваться.work_schedule_complete — after insert, DEFERRABLE INITIALLY DEFERRED: на COMMIT проверяет через hr.work_schedule_check_complete(), что у расписания ровно 7 строк дней, иначе исключение.work_schedule_touch — before update, проставляет updated_at.Один день недели роты; ровно 7 строк на расписание (гарантируется отложенным триггером). break_minutes — неоплачиваемый перерыв, который снапшотится в табель (attendance).
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| work_schedule_id | uuid NOT NULL FK | → hr.work_schedule(id), ON DELETE CASCADE — дни умирают вместе с расписанием |
| iso_weekday | smallint NOT NULL | День недели ISO, CHECK between 1 and 7 (1=Пн … 7=Вс) |
| is_day_off | boolean NOT NULL default false | Выходной ли этот день недели |
| start_time | time NULL | Начало рабочего дня; NULL только у выходного |
| end_time | time NULL | Конец рабочего дня; NULL только у выходного |
| break_minutes | integer NOT NULL default 0 | Неоплачиваемый перерыв в минутах, CHECK >= 0; снапшотится на attendance |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id; UNIQUE (work_schedule_id, iso_weekday) — один ряд на день недели.is_day_off = (start_time is null and end_time is null) — выходной ⇔ оба времени NULL, рабочий день ⇔ оба заданы.work_schedule_day_complete — after insert/update/delete, DEFERRABLE INITIALLY DEFERRED: на COMMIT проверяет, что у родительского расписания по-прежнему ровно 7 дней (та же hr.work_schedule_check_complete(); если расписание уже удалено — молча пропускает).work_schedule_day_touch — before update, проставляет updated_at.Именованный пресет смены (пикер на S38). Ночная смена 19:00→04:00 легальна: crosses_midnight — generated-флаг, paid_minutes — длительность за вычетом перерыва.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| name | text NOT NULL default '' | Название пресета; пустое допустимо |
| start_time | time NOT NULL | Начало смены |
| end_time | time NOT NULL | Конец смены (может быть «раньше» начала — переход через полночь) |
| break_minutes | integer NOT NULL default 0 | Неоплачиваемый перерыв, CHECK >= 0 |
| crosses_midnight | boolean generated stored | end_time <= start_time — смена пересекает полночь |
| paid_minutes | integer generated stored | hr.span_minutes(start_time, end_time) - break_minutes — оплачиваемые минуты нетто (span_minutes добавляет 1440 при переходе через полночь) |
| archived_at | timestamptz NULL | Мягкое удаление: NULL = активен |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id.shift_template_name_key по name при btrim(name) <> '' — непустые имена уникальны.shift_template_touch — before update, проставляет updated_at.Человек: идентичность, фото, рота. Поля archived_at нет — ось жизненного цикла это status. Сотрудники никогда не удаляются физически: исторические FK стоят как RESTRICT. У search_text НАМЕРЕННО нет trigram-индекса (≈650 строк, review 2). Пишут админ-экран S34 и Lark-синхронизация; читает почти всё приложение.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| employee_no | text NOT NULL UNIQUE | Табельный номер emp-001 — колонка ID на S34 |
| source | hr.employee_source NOT NULL default 'manual' | Откуда заведён: lark (синхронизация) или manual |
| full_name | text NOT NULL default '' | Полное имя; пустое допустимо (blank-row-then-edit) |
| citext NULL | E-mail, регистронезависимый | |
| phone | text NULL | Телефон |
| photo_file_id | uuid NULL FK | → core.file(id), ON DELETE SET NULL — фото сотрудника |
| status | hr.employee_status NOT NULL default 'active' | active / inactive; согласован с hr.employment триггером (см. ниже) |
| user_type_id | uuid NULL FK | → hr.user_type(id) — тип пользователя (Staff/Manager/Admin) |
| work_schedule_id | uuid NULL FK | → hr.work_schedule(id), ON DELETE SET NULL — каноническая привязка к недельной роте (решение Q-20) |
| lark_id | text NULL | Идентификатор в Lark |
| lark_id_origin | text NOT NULL default 'manual' | CHECK in ('sync','manual'). Инвариант review 3, хранимый как колонка: Lark-sync НИКОГДА не должен перезаписывать lark_id, чей origin = manual (правка ячейки Lark ID на S34 пишет origin=manual) |
| search_text | text generated stored | lower(full_name || ' ' || employee_no || ' ' || coalesce(email, '')) — строка для поиска |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id; UNIQUE по employee_no.employee_lark_id_key по lark_id при lark_id is not null — непустые Lark ID уникальны, NULL можно у многих.lark_id_origin in ('sync','manual').employee_status_idx по status; employee_user_type_idx по user_type_id.employee_status_consistent — after insert or update of status, DEFERRABLE INITIALLY DEFERRED: через hr.employee_status_matches_employment() запрещает состояние «сотрудник inactive, но у него есть открытая строка hr.employment (end_date IS NULL)»; ошибка с errcode check_violation.employee_touch — before update, проставляет updated_at.Темпоральный контракт (ADR-03): период работы сотрудника с департаментом, должностью, типом занятости, руководителем и локацией. ЕДИНСТВЕННАЯ легальная запись для inline-правки Department/Role/Supervisor на S34: UPDATE текущей строки SET end_date = current_date - 1, затем INSERT новой записи — обе в ОДНОЙ транзакции (review 3). Свободный текст из UI резолвится через справочники core.department / hr.position.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| employee_id | uuid NOT NULL FK | → hr.employee(id), ON DELETE RESTRICT — история не даёт удалить сотрудника |
| start_date | date NOT NULL | Начало периода назначения |
| end_date | date NULL | Конец периода; NULL = текущее (открытое) назначение |
| department_id | uuid NULL FK | → core.department(id) — департамент в этот период |
| position_id | uuid NULL FK | → hr.position(id) — должность в этот период |
| employment_type_id | uuid NULL FK | → hr.employment_type(id) — тип занятости (PKWTT/PKWT/…) |
| manager_employee_id | uuid NULL FK | → hr.employee(id) — руководитель (колонка Supervisor на S34) |
| work_location_id | uuid NULL FK | → hr.work_location(id) — основная рабочая локация периода |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id.end_date is null or end_date >= start_date.employment_no_overlap (gist, btree_gist): employee_id with =, daterange(start_date, end_date, '[]') with && — периоды одного сотрудника не могут пересекаться (включительные границы; открытый end_date = бесконечность).employment_employee_idx (employee_id, start_date desc) — история по сотруднику; covering-индексы employment_dept_lookup_idx (department_id, employee_id) include (end_date, start_date), employment_pos_lookup_idx (position_id, employee_id) include (…), employment_mgr_lookup_idx (manager_employee_id, employee_id) include (…) — обратные выборки по департаменту/должности/руководителю.employment_status_consistent — after insert or update of end_date, DEFERRABLE INITIALLY DEFERRED: та же проверка согласованности со статусом сотрудника (открытое назначение у inactive запрещено).employment_touch — before update, проставляет updated_at.Право «можно отмечаться здесь», раздельно по направлению check-in / check-out (ячейки Check In / Check Out на S34). Читается валидацией табеля.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| employee_id | uuid NOT NULL FK | → hr.employee(id), ON DELETE RESTRICT |
| work_location_id | uuid NOT NULL FK | → hr.work_location(id), ON DELETE CASCADE — разрешения умирают вместе с локацией |
| direction | hr.location_direction NOT NULL | Направление: check_in или check_out |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id; UNIQUE (employee_id, work_location_id, direction) — одно разрешение на сотрудника-локацию-направление.employee_work_location_touch — before update, проставляет updated_at.Датированное переопределение недельной роты (экран S38). Модель кардинальности ЗАФИКСИРОВАНА: смена — исключение (~1k строк/год), а не норма — см. ADR-14. Может создаваться из шаблона; параметры шаблона штампуются в строку.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| shift_no | bigint identity | Сквозной номер, generated always as identity |
| reference | text generated, UNIQUE | Код SHF-00001: 'SHF-' || lpad(shift_no, 5, '0'), stored |
| shift_template_id | uuid NULL FK | → hr.shift_template(id), ON DELETE SET NULL — из какого пресета создана |
| name | text NOT NULL default '' | Название смены; пустое допустимо |
| work_date | date NOT NULL | Дата смены |
| start_time | time NOT NULL | Начало смены |
| end_time | time NOT NULL | Конец смены |
| break_minutes | integer NOT NULL default 0 | Неоплачиваемый перерыв, CHECK >= 0; review 3 — штампуется из шаблона и не теряется при удалении шаблона |
| crosses_midnight | boolean generated stored | end_time <= start_time — переход через полночь |
| work_location_id | uuid NULL FK | → hr.work_location(id), ON DELETE SET NULL — переопределение геозоны для этой смены |
| note | text NULL | Произвольная заметка |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id; UNIQUE по reference.(id, work_date) — не бизнес-ограничение, а мишень для композитного «date-pinning» FK из hr.shift_assignment.shift_date_idx (work_date, work_location_id) — выборка смен по дате/локации.shift_touch — before update, проставляет updated_at.Кто выходит в конкретную датированную смену. 0 строк = открытая (никому не назначенная) смена. UNIQUE (employee_id, work_date) делает структурно недостижимым состояние «две пересекающиеся смены молча потерялись».
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Первичный ключ, default gen_random_uuid() |
| shift_id | uuid NOT NULL | Смена; часть композитного FK ниже (отдельного одиночного FK нет) |
| work_date | date NOT NULL | Дата смены — денормализована и «пришпилена» композитным FK: не может разойтись с hr.shift.work_date |
| employee_id | uuid NOT NULL FK | → hr.employee(id), ON DELETE RESTRICT |
| created_at | timestamptz NOT NULL | Момент создания, default now() |
| updated_at | timestamptz NOT NULL | Момент последнего изменения, триггер |
Правила:
id; UNIQUE (shift_id, employee_id) — сотрудник назначается на смену не более одного раза.(employee_id, work_date) — структурно одна смена на сотрудника в день (reviews 1+3); сплит-смены потребуют hr.attendance_segment и сначала продуктового решения.(shift_id, work_date) → hr.shift (id, work_date) ON UPDATE CASCADE ON DELETE CASCADE — перенос даты смены каскадно обновляет назначения, удаление смены удаляет их.shift_assignment_shift_idx по shift_id — назначения конкретной смены.shift_assignment_touch — before update, проставляет updated_at.Домен покрывает каталог видов отпуска, квотные схемы (политики), годовые начисления, заявления на отпуск с посуточной «росписью», вложения-доказательства и именованные календари праздников. Статусами заявлений владеет конверт hr.request (домен Requests, ADR-07); здесь статус только зеркалируется. На каждой таблице домена стоит служебный триггер <table>_touch (before update: updated_at := now(), ставится bootstrap-блоком §16) — ниже отдельно не повторяется.
Каталог видов отпуска и порядок «чипов» на экране /hr/leave. Пишет админ в настройках HR; читают заявления (hr.leave_request), чипы инбокса (hr.request_type.leave_type_id) и посещаемость (hr.attendance_record копирует category). Сидируется 4 строками: annual, holiday, sick, other.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ, gen_random_uuid(). |
| code | citext NOT NULL UNIQUE | Машинный код вида (case-insensitive), напр. annual, sick. |
| name_en | text NOT NULL | Название по-английски. |
| name_id | text NOT NULL | Название по-индонезийски. |
| category | hr.leave_category NOT NULL | Репортинговая корзина (annual/sick/holiday/unpaid/other); копируется на attendance-строку триггером (ADR-10). |
| tracks_balance | boolean NOT NULL DEFAULT false | Ведётся ли баланс; false → в колонках баланса выводится длинное тире. |
| requires_attachment | boolean NOT NULL DEFAULT false | Требуется ли вложение к заявлению (напр. справка для sick). |
| sort_order | int NOT NULL DEFAULT 100 | Порядок чипа на /hr/leave. |
| archived_at | timestamptz NULL | Метка архивации; архивный вид не принимает новых заявлений (триггер на hr.leave_request). |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено (триггер _touch). |
Правила:
id; UNIQUE по code.leave_type_touch, на самой таблице нет — запрет заявлений на архивный вид реализован триггером leave_request_type_not_archived на hr.leave_request.Квота «как данные»: сколько дней в год даёт вид отпуска и по какому методу начисляется. Пишет админ; читают hr.grant_entitlements() (годовое начисление) и вью hr.leave_balance. Версионирование — архивировать старую строку и вставить новую (две живые политики на один вид размножали бы баланс и строки инбокса — review 1 CRITICAL). Сид: «Annual Leave 12 (UU 13/2003)» и «Holiday Leave 15».
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ. |
| leave_type_id | uuid NOT NULL FK → hr.leave_type (RESTRICT) | Вид отпуска, чью квоту описывает схема. |
| name | text NOT NULL UNIQUE | Человекочитаемое имя схемы. |
| days_per_year | numeric(4,1) NOT NULL CHECK >= 0 | Дней в год; 12 = норма UU 13/2003. |
| accrual_method | hr.accrual_method NOT NULL DEFAULT 'upfront_annual' | Метод начисления: upfront_annual (вся норма сразу) или monthly_worked (по отработанным месяцам — см. accrued_days в hr.leave_balance). |
| min_service_months | int NOT NULL DEFAULT 0 | Минимальный стаж (месяцев) для права на схему. |
| carry_over_max_days | numeric(4,1) NOT NULL DEFAULT 0 | Максимум дней переноса на следующий год. |
| carry_over_expiry_months | int NULL | Через сколько месяцев сгорает перенос (NULL — не сгорает). |
| effective_from | date NOT NULL DEFAULT current_date | С какой даты схема действует. |
| archived_at | timestamptz NULL | Архивация = «закрыть версию»; архивные схемы не участвуют в начислении. |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено. |
Правила:
id; UNIQUE по name.leave_policy_one_live_per_type_key на (leave_type_id) WHERE archived_at IS NULL — не больше ОДНОЙ живой политики на вид отпуска.days_per_year >= 0.leave_policy_touch.M:N «кто на какой схеме». Пишет админ; читает hr.grant_entitlements() при годовом начислении.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ. |
| leave_policy_id | uuid NOT NULL FK → hr.leave_policy (CASCADE) | Схема; удаление схемы уносит назначения. |
| employee_id | uuid NOT NULL FK → hr.employee (RESTRICT) | Сотрудник на схеме. |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено. |
Правила:
(leave_policy_id, employee_id) — сотрудник в схеме не дублируется.leave_policy_assignee_touch.Грант на сотрудника × схему × год — СНИМОК, а не указатель: правка политики задним числом не переписывает уже выданный год. Пишется функцией hr.grant_entitlements(p_year) (идемпотентный январский job: entitled_days = days_per_year живой политики, accrual_start_date = 1 января, только активные сотрудники; ON CONFLICT DO NOTHING). Читает вью hr.leave_balance и hr.leave_balance_asof().
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ. |
| employee_id | uuid NOT NULL FK → hr.employee (RESTRICT) | Кому начислено. |
| leave_policy_id | uuid NOT NULL FK → hr.leave_policy (RESTRICT) | По какой схеме (ссылка для трассировки; числа — снимок). |
| period_year | int NOT NULL CHECK 2000..2100 | Год периода. |
| entitled_days | numeric(4,1) NOT NULL | Начислено дней на год (снимок days_per_year на момент гранта). |
| carried_over_days | numeric(4,1) NOT NULL DEFAULT 0 | Перенесено с прошлого года. |
| carry_over_expires_on | date NULL | Дата сгорания переноса; после неё hr.leave_balance считает перенос нулём. |
| accrual_start_date | date NOT NULL | Старт начисления (для monthly_worked — от неё считаются целые месяцы). |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено. |
Правила:
(employee_id, leave_policy_id, period_year) — один грант на сотрудника×схему×год (на него же опирается идемпотентность grant_entitlements).period_year BETWEEN 2000 AND 2100.leave_entitlement_touch.Заявление (регистр S22), только целые дни (v3: механика полудней удалена). Создаёт сотрудник; решение принимается на конверте hr.request (ADR-07), а статус сюда зеркалируется триггером — прямой UPDATE статуса отвергается. Со стороны конверта: hr.request.leave_request_id FK RESTRICT + партиальный уникальный индекс request_leave_request_key — ровно один конверт на заявление.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ. |
| leave_no | bigint GENERATED ALWAYS AS IDENTITY | Сквозной номер-счётчик. |
| reference | text GENERATED ('LV-' || lpad(leave_no,6,'0')) STORED UNIQUE | Человекочитаемый номер вида LV-000123. |
| employee_id | uuid NOT NULL FK → hr.employee (RESTRICT) | Заявитель. |
| leave_type_id | uuid NOT NULL FK → hr.leave_type (RESTRICT) | Вид отпуска. |
| status | hr.request_status NOT NULL DEFAULT 'pending' | pending/approved/rejected/cancelled; ЗЕРКАЛО статуса конверта hr.request, напрямую не пишется. |
| date_from | date NOT NULL | Начало диапазона. |
| date_to | date NOT NULL | Конец диапазона. |
| total_days | integer NOT NULL DEFAULT 0 | Считается триггером: число списываемых дней (counts_against_balance) в hr.leave_day. |
| comment | text NOT NULL DEFAULT '' | Комментарий заявителя (в инбоксе — fallback для requester_comment). |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено. |
Правила:
date_to >= date_from.(id, employee_id) — мишень составного FK из hr.leave_day: день всегда привязан к сотруднику своего заявления (review 1).leave_request_no_overlap: employee_id = и daterange(date_from, date_to, '[]') && при status IN ('pending','approved') — живые заявления одного сотрудника не пересекаются по датам.reference (генерируемый столбец).leave_request_employee_idx на (employee_id, status).leave_request_status_is_mirrored (before update of status): отвергает смену статуса при pg_trigger_depth() = 1 — писать статус может только триггерный каскад с конверта.leave_request_needs_envelope (deferred, after insert / update of status): к коммиту у заявления обязан существовать конверт hr.request — «одобренный отпуск без строки в инбоксе» недостижим.leave_request_type_not_archived (before insert / update of leave_type_id): заявление на архивный вид отпуска отвергается.leave_request_touch; total_days обновляет триггер leave_day_recount на hr.leave_day.«Разворот» заявления по датам: одна строка = один целый день; ЭТО и есть леджер баланса (его читают hr.leave_balance, hr.leave_balance_asof(), hr.resolve_working_day(), вью hr.non_working_day). Перестраивается функцией hr.leave_request_rebuild_days(): генерирует даты диапазона, день не списывается (counts_against_balance = false), если внутри диапазона праздник из календаря сотрудника или выходной его недельной роты; save-путь S42 обязан перегонять её для живых заявлений поверх изменённых дат.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ. |
| leave_request_id | uuid NOT NULL | Родительское заявление (часть составного FK ниже). |
| employee_id | uuid NOT NULL FK → hr.employee (RESTRICT) | Сотрудник; через составной FK гарантированно совпадает с сотрудником заявления. |
| leave_date | date NOT NULL | Календарная дата дня отпуска. |
| counts_against_balance | boolean NOT NULL DEFAULT true | Списывается ли день с баланса; false для праздника/ротного выходного внутри диапазона. |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено. |
Правила:
(leave_request_id, leave_date) — дата в заявлении один раз.(leave_request_id, employee_id) → hr.leave_request (id, employee_id) ON DELETE CASCADE — день принадлежит именно сотруднику заявления (review 1); удаление заявления уносит дни.employee_id → hr.employee RESTRICT.leave_day_balance_idx на (employee_id, leave_date) WHERE counts_against_balance — балансовые выборки; индекс leave_day_request_idx на (leave_request_id).leave_day_in_range (deferred, after insert / update of leave_date, leave_request_id): день обязан лежать внутри [date_from, date_to] родителя; отложен до коммита, потому что rebuild двигает и диапазон, и дни.leave_day_recount (after insert/update/delete): пересчитывает leave_request.total_days как count списываемых дней.leave_day_touch.Файлы-доказательства заявления (surat dokter и т.п.). Пишет заявитель при подаче; на карточке запроса всплывают через hr.request_inbox (union с hr.request_attachment неотпускных запросов).
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ. |
| leave_request_id | uuid NOT NULL FK → hr.leave_request (CASCADE) | Чьё заявление; удаление заявления уносит связки. |
| file_id | uuid NOT NULL FK → core.file (RESTRICT) | Сам файл в файловом реестре; файл нельзя удалить, пока привязан. |
| position | int NOT NULL DEFAULT 0 | Порядок вложений на карточке. |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено. |
Правила:
(leave_request_id, file_id) — файл к заявлению один раз.leave_request_attachment_touch.Именованный годовой набор праздников (экран S42). Пишет админ; читают резолвер hr.resolve_working_day(), rebuild дней отпуска и вью. Правка года у заполненного календаря: SET CONSTRAINTS public_holiday_schedule_fk DEFERRED, обновить год родителя И сдвинуть даты детей в ОДНОЙ транзакции. Удаление при существующих датах блокируется (FK без каскада) — вместо этого архивировать: списанная история должна оставаться объяснимой.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ. |
| holiday_no | bigint GENERATED ALWAYS AS IDENTITY | Сквозной счётчик. |
| reference | text GENERATED ('HOL-' || lpad(holiday_no,4,'0')) STORED UNIQUE | Номер вида HOL-0001. |
| name | text NOT NULL DEFAULT '' | Имя набора (может быть пустым). |
| calendar_year | int NOT NULL CHECK 2000..2100 | Календарный год набора. |
| scope | hr.holiday_scope NOT NULL DEFAULT 'company' | Охват: national/regional/company. |
| archived_at | timestamptz NULL | Архивация вместо удаления. |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено. |
Правила:
(id, calendar_year) — мишень составного FK из hr.public_holiday (жёстко связывает даты с годом родителя).holiday_schedule_name_key на (name, calendar_year) WHERE btrim(name) <> '' — непустые имена уникальны в пределах года.calendar_year BETWEEN 2000 AND 2100; UNIQUE по reference.holiday_schedule_touch.Одна датированная строка праздника внутри календаря. Читается резолвером рабочего дня (высший приоритет источника) и rebuild'ом дней отпуска (праздник внутри диапазона не списывается).
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ. |
| holiday_schedule_id | uuid NOT NULL | Родительский календарь (часть составного FK). |
| calendar_year | int NOT NULL | Дублированный год — вторая нога составного FK, синхронизирует дату с годом родителя. |
| holiday_date | date NOT NULL | Дата праздника. |
| name_en | text NOT NULL DEFAULT '' | Название по-английски. |
| name_id | text NOT NULL DEFAULT '' | Название по-индонезийски. |
| is_cuti_bersama | boolean NOT NULL DEFAULT false | Метка cuti bersama — «совместный отпуск», который клиент может списывать с годовой квоты (открытый вопрос Q4). |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено. |
Правила:
(holiday_schedule_id, holiday_date) — дата в календаре один раз.public_holiday_year_chk: extract(year from holiday_date) = calendar_year — дата не выпадает из года календаря.public_holiday_schedule_fk (holiday_schedule_id, calendar_year) → hr.holiday_schedule (id, calendar_year), DEFERRABLE INITIALLY IMMEDIATE, без каскада: никакого тихого cascade-удаления списанной истории, а редактор года S42 работает через отложенную проверку (reviews 1+3).public_holiday_touch.M:N «календарь → люди» — именно поэтому у hr.employee нет колонки holiday_schedule_id. Пишет админ; читают резолвер, rebuild дней и вью hr.non_working_day / hr.employee_current.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Ключ. |
| holiday_schedule_id | uuid NOT NULL FK → hr.holiday_schedule (RESTRICT) | Какой календарь назначен. |
| employee_id | uuid NOT NULL FK → hr.employee (RESTRICT) | Кому назначен. |
| created_at | timestamptz NOT NULL DEFAULT now() | Создано. |
| updated_at | timestamptz NOT NULL DEFAULT now() | Обновлено. |
Правила:
(holiday_schedule_id, employee_id) — назначение не дублируется.holiday_schedule_assignee_touch.hr.non_working_day (security_invoker): единая join-поверхность для посещаемости — union трёх КОНЕЧНЫХ источников нерабочих дней: праздники по назначенным календарям (kind='public_holiday'), одобренные дни отпуска ('leave'), одобренные day-off ('day_off'); ротные выходные бесконечны по датам и решаются hr.resolve_working_day().hr.leave_balance (security_invoker): годовой баланс по сотруднику × виду отпуска × году; entitlement'ы агрегируются ДО join'а (защита от fan-out, review 1 CRITICAL); перенос обнуляется после carry_over_expires_on (привязка к периоду); accrued_days: upfront_annual → вся норма, monthly_worked → least(норма, целых месяцев от accrual_start_date); taken_days/pending_days — счёт списываемых строк hr.leave_day по approved/pending; remaining_days = entitled + carried − taken; available_days = remaining − pending. Целые дни (v3). Это цифра года, не «на дату».hr.leave_balance_asof(employee, leave_type, asof): месячная цифра S14 — entitlement as-of-года минус списанные дни с начала года по дату.hr.grant_entitlements(year): идемпотентный январский грант-job (см. hr.leave_entitlement).hr.leave_request_rebuild_days(id): разворот заявления в hr.leave_day (см. там же).Механика конверта. Любая заявка сотрудника — это строка-«конверт» hr.request (номер REQ-000123), которая владеет статусом и никогда не несёт типоспецифичных полей; полезная нагрузка живёт в 1:1-таблицах (day_off_request, attendance_correction_request) или в hr.leave_request из домена отпусков. Статус-машина — data-driven: разрешённые переходы лежат в белом списке hr.request_status_transition (глобальная закрытая машина, read-only для приложения), триггер отклоняет любой переход вне списка, а на переходах с requires_note = true — в сиде это pending→rejected (отказ), rejected→pending и approved→cancelled — обязательна непустая причина: канонический путь hr.request_decide() падает с ясной ошибкой прямо на statement'е, а отложенный триггер страхует то же на COMMIT. Маршрутизация одобрения — single-hop через request_type.approver_role_id: NULL означает «решает текущий супервайзер заявителя», конкретная роль — «решают члены этой роли»; многошаговый роутинг сознательно отложен (ADR-16). Для leave-заявок конверт — единственный владелец статуса: триггер request_sync_leave односторонне зеркалирует его в hr.leave_request.status, а прямой UPDATE статуса на стороне leave отклоняется (ADR-07).
Enum'ы домена: hr.request_status = pending | approved | rejected | cancelled; hr.request_handler = leave | day_off | attendance_correction.
Справочник «одна строка = один чип» на экране /hr/requests; leave-чипы указывают на тип отпуска, чью квоту тратят — таксономия отпусков не дублируется. Читается всеми экранами заявок, редактируется админкой настроек. Сид: 4 leave-чипа (annual/holiday/sick/other, approver_role_id NULL → супервайзер) + day_off и attendance_correction (аппрувер — роль admin).
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор типа. |
| code | citext, unique | Машинный код чипа (напр. annual_leave), регистронезависимый; API резолвит code → id. |
| name_en | text | Название по-английски. |
| name_id | text | Название по-индонезийски. |
| handler | hr.request_handler | Какой обработчик исполняет тип: leave / day_off / attendance_correction. |
| leave_type_id | uuid FK → hr.leave_type (restrict) | Для leave-чипов — тип отпуска, чья квота тратится; для остальных NULL. |
| approver_role_id | uuid FK → core.role | Single-hop аппрув: NULL = решает текущий супервайзер заявителя; роль = решают её члены. Мультишаговый роутинг отложен (ADR-16). |
| sort_order | smallint, default 100 | Порядок чипов в UI. |
| is_active | boolean, default true | Выключенный тип не принимает новые заявки (проверяет триггер на hr.request). |
| created_at | timestamptz | Служебное: создание строки. |
| updated_at | timestamptz | Служебное: последнее изменение (триггер). |
Правила:
id; code уникален.(id, handler) — мишень композитного «пиннящего» FK из hr.request (фикс review 1 CRITICAL: handler конверта нельзя рассогласовать с типом).handler = 'leave' тогда и только тогда, когда leave_type_id задан.request_type_touch — проставляет updated_at при UPDATE.Data-driven статус-машина: код ветвится по этой таблице, а не по захардкоженным правилам. Глобальная и закрытая; для роли приложения staffapp_app — read-only каталог (insert/update/delete отозваны). Сид: pending→approved (без причины), pending→rejected (причина обязательна), pending→cancelled (без причины), rejected→pending (причина обязательна), approved→cancelled (причина обязательна; переход, доказанно отсутствовавший в v1 — review 1 CRITICAL).
| Поле | Тип | Смысл |
|---|---|---|
| from_status | hr.request_status, PK-часть | Статус «откуда». |
| to_status | hr.request_status, PK-часть | Статус «куда». |
| requires_note | boolean, default false | Переход требует непустую причину (note) в ленте событий. |
| created_at | timestamptz | Служебное: создание строки. |
| updated_at | timestamptz | Служебное: последнее изменение (триггер). |
Правила:
(from_status, to_status) — каждый переход описан ровно один раз.staffapp_app не может менять таблицу (revoke insert/update/delete) — машина правится только миграцией/оператором.request_status_transition_touch — updated_at при UPDATE.Единая строка любой заявки (REQ-000123): владеет статусом, датами жизненного цикла и снапшотом отдела; типоспецифичных полей не несёт никогда. Пишется при подаче сотрудником, решается через hr.request_decide(); читается инбоксом /hr/requests (через view hr.request_inbox). Фактически неудаляема: append-only лента событий каскадом «отказывается» удаляться — by design.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор конверта. |
| request_no | bigint generated always as identity | Сквозной номер; сырьё для reference. |
| reference | text generated stored, unique | Считается из request_no: 'REQ-' || lpad(request_no, 6, '0') — человекочитаемый номер карточки. |
| request_type_id | uuid (композитный FK, см. правила) | Тип заявки (чип); неизменяем после вставки (триггер). |
| type_handler | hr.request_handler | Handler типа, «припинен» композитным FK и заполняется триггером из request_type; дискриминатор для typed-payload таблиц. |
| requester_employee_id | uuid FK → hr.employee (restrict) | Заявитель. |
| requester_department_id | uuid FK → core.department | Снапшот отдела на момент подачи; при NULL автозаполняется триггером из текущего employment. |
| status | hr.request_status, default 'pending' | Текущий статус; меняется только по белому списку переходов. Фасет «New» в инбоксе = pending. |
| body | text, default '' | Текст заявителя. |
| leave_request_id | uuid FK → hr.leave_request (restrict) | Для leave-конвертов — прицепленная заявка на отпуск; статус зеркалируется туда триггером. |
| submitted_at | timestamptz, default now() | Момент подачи; ось сортировки/keyset-пагинации инбокса. |
| decided_at | timestamptz | Момент решения; штампуется триггером при переходе в approved/rejected. |
| applied_at | timestamptz | Отметка «побочные эффекты применены ровно один раз»; request_decide() ставит при первом approve. |
| created_at | timestamptz | Служебное: создание строки. |
| updated_at | timestamptz | Служебное: последнее изменение (триггер). |
Правила:
id; reference уникален.(id, type_handler) — мишень пиннящих FK из typed-payload таблиц (payload не может прицепиться к конверту чужого handler'а).(request_type_id, type_handler) → hr.request_type (id, handler) on update restrict — handler конверта всегда совпадает с handler'ом типа (фикс review 1 CRITICAL: старый одноколоночный FK эту проверку терял).type_handler = 'leave' тогда и только тогда, когда leave_request_id задан.request_leave_request_key по leave_request_id where not null — у одной leave-заявки максимум один конверт.request_recency_idx (submitted_at desc, id desc) — лента/keyset; request_type_status_idx (request_type_id, status, submitted_at desc, id desc) — чип+фасет; request_requester_idx (requester_employee_id, submitted_at desc) — «мои заявки»; партиальный request_department_idx (requester_department_id) where not null — фильтр по отделу; request_reference_trgm_idx GIN gin_trgm_ops по reference — поиск по подстроке номера.request_before_write (before insert/update) — на INSERT: проверяет существование и активность типа, пинит type_handler, автозаполняет снапшот отдела, для leave сверяет что leave_request принадлежит заявителю и совпадает по типу отпуска; на смене статуса: валидирует переход по белому списку и штампует decided_at для approved/rejected.request_type_immutable (before update of request_type_id) — запрещает менять тип: «отмени и подай заново» (нет брошенных payload-строк).request_require_child (deferred, after insert/update of request_type_id) — к COMMIT у day_off/attendance_correction-конверта обязана существовать payload-строка.request_status_event_required (deferred, after update of status) — каждая смена статуса обязана оставить строку в request_status_event, с непустым note, если переход того требует.request_day_off_leave_clash (deferred, after update of status) — запрещает одновременно одобренные отпуск и day-off на одну дату (проверка в обе стороны).request_sync_leave (after update of status) — односторонне зеркалирует статус конверта в hr.leave_request.status.request_touch — updated_at при UPDATE.hr.request_decide(p_request_id, p_to, p_actor_employee_id, p_note): лочит строку (for update), проверяет requires_note на statement'е (v3-фикс: понятная ошибка сразу, а не из отложенного триггера на COMMIT), делает UPDATE статуса + вставку строки ленты одним вызовом, при первом approve ставит applied_at.Типизированная полезная нагрузка «один неоплачиваемый выходной» (Q-19): это handler, а НЕ тип отпуска — без квоты и без разворачивания в дни (ADR-06). Пишется вместе с конвертом при подаче; на неё ссылается hr.attendance_record.day_off_request_id (on delete set null) — так одобренный отгул попадает в табель.
| Поле | Тип | Смысл |
|---|---|---|
| request_id | uuid PK | 1:1 с конвертом hr.request. |
| type_handler | hr.request_handler, default 'day_off' | Дискриминатор; всегда day_off (CHECK). |
| day_off_date | date | Запрошенная дата отгула. |
| created_at | timestamptz | Служебное: создание строки. |
| updated_at | timestamptz | Служебное: последнее изменение (триггер). |
Правила:
request_id (одна payload-строка на конверт).type_handler = 'day_off'.(request_id, type_handler) → hr.request (id, type_handler) on delete cascade on update cascade — payload прицепляется только к конверту с handler'ом day_off; умирает вместе с конвертом.day_off_request_touch — updated_at при UPDATE.Типизированная полезная нагрузка «дозаполнить забытую отметку» (Q-23): сотрудник просит проставить check-in и/или check-out за конкретную дату. Пишется вместе с конвертом; по одобрению исполняется в домене Attendance.
| Поле | Тип | Смысл |
|---|---|---|
| request_id | uuid PK | 1:1 с конвертом hr.request. |
| type_handler | hr.request_handler, default 'attendance_correction' | Дискриминатор; всегда attendance_correction (CHECK). |
| correction_date | date | Дата, за которую корректируется отметка. |
| requested_check_in | time | Запрошенное время прихода (может быть NULL). |
| requested_check_out | time | Запрошенное время ухода (может быть NULL). |
| reason | text not null | Обоснование заявителя — обязательно. |
| created_at | timestamptz | Служебное: создание строки. |
| updated_at | timestamptz | Служебное: последнее изменение (триггер). |
Правила:
request_id.type_handler = 'attendance_correction'.requested_check_in / requested_check_out задано.(request_id, type_handler) → hr.request (id, type_handler) on delete cascade on update cascade — привязка только к конверту нужного handler'а.attendance_correction_request_touch — updated_at при UPDATE.Лента событий по заявке — блок History на карточке. Каждая смена статуса ОБЯЗАНА оставить здесь строку (страхует отложенный триггер на hr.request); канонически пишется из hr.request_decide(). Append-only «ремень и подтяжки»: и гранты (у staffapp_app отозваны update/delete), и триггер, гейтящийся на роль staffapp_retention.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор события. |
| request_id | uuid FK → hr.request (cascade) | Конверт, к которому относится событие. |
| from_status | hr.request_status | Статус «до» (nullable). |
| to_status | hr.request_status not null | Статус «после». |
| note | text | Причина/комментарий; непустой обязателен на переходах с requires_note (отказ, реоткрытие, отмена approved). |
| actor_employee_id | uuid FK → hr.employee | Кто решил; NULL = система. |
| created_at | timestamptz | Момент события; ось сортировки блока History. |
| updated_at | timestamptz | Служебное (мутации всё равно запрещены). |
Правила:
id.request_status_event_append_only (before update/delete) — core.forbid_mutation(): отклоняет UPDATE/DELETE, если текущий пользователь не член роли staffapp_retention (гейт на роль, а не на клиентский GUC — review 1).revoke update, delete от staffapp_app — второй слой append-only.request_status_event_feed_idx (request_id, created_at desc) — выборка ленты карточки.request_status_event_touch — updated_at (практически недостижим из-за запрета UPDATE).Файлы, приложенные к заявке с handler'ом day_off/attendance_correction; для leave-заявок вложения живут в hr.leave_request_attachment, а инбокс-view (hr.request_inbox) объединяет оба источника через UNION.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | Идентификатор вложения. |
| request_id | uuid FK → hr.request (cascade) | Конверт-владелец; вложения умирают вместе с ним. |
| file_id | uuid FK → core.file (restrict) | Сам файл в общем файловом хранилище; файл нельзя удалить, пока на него ссылается вложение. |
| position | int, default 0 | Порядок вложений на карточке. |
| created_at | timestamptz | Служебное: создание строки. |
| updated_at | timestamptz | Служебное: последнее изменение (триггер). |
Правила:
id.(request_id, file_id) — один файл не прикладывается к заявке дважды.request_attachment_request_idx (request_id) — выборка вложений карточки.request_attachment_touch — updated_at при UPDATE.Домен посещаемости (§12 DDL): одна строка на сотрудника на календарный день + append-only журнал правок отметок. Смежная таблица hr.attendance_correction_request (заявка сотрудника на корректировку) относится к домену Requests — здесь не описывается, но упоминается: её одобрение порождает строку в hr.attendance_correction через request_id.
Одна строка на сотрудника на КАЛЕНДАРНЫЙ день (экраны S03/S14/S19, мобильный check-in). Пишется дневным движком (снапшот из hr.resolve_working_day()) и мобильными/веб-отметками; читается экранами посещаемости и rollup-функциями. Отметки — абсолютные моменты (timestamptz), поэтому ночные смены безопасны; минуты — за вычетом неоплачиваемого перерыва; status — generated с приоритетом holiday > day_off > leave > absent > late > present.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | gen_random_uuid() |
| employee_id | uuid NOT NULL FK → hr.employee (restrict) | Чей день; сотрудники не удаляются — история защищена RESTRICT |
| work_date | date NOT NULL | Календарный день записи (часть уникального ключа с employee_id) |
| time_zone | text NOT NULL | Снапшот таймзоны на момент записи: аудит-строка никогда не переинтерпретирует себя (ADR-13) |
| department_id | uuid FK → core.department | Снапшот отдела на дату work_date; автозаполняется триггером из hr.employment (review 3) |
| expected_check_in | time | Ожидаемое время прихода (из рота/смены) |
| expected_check_out | time | Ожидаемое время ухода |
| break_minutes | integer NOT NULL default 0, CHECK >= 0 | Снапшот неоплачиваемого перерыва (reviews 1+3) — вычитается из expected/worked |
| expected_source | hr.expected_source NOT NULL | Откуда взят ожидаемый день: schedule / shift / schedule_day_off / day_off / public_holiday / leave |
| shift_id | uuid FK → hr.shift (set null) | Датированная смена, если день пришёл из смены |
| is_holiday | boolean NOT NULL default false | Праздничный день (жёстко связан с expected_source, см. CHECK) |
| is_day_off | boolean NOT NULL default false | Выходной (по роте или одобренному day-off) |
| leave_type_id | uuid FK → hr.leave_type | Тип отпуска, если день — отпуск |
| leave_category | hr.leave_category | Категория отчётности; копируется триггером из leave_type (ADR-10), не пишется руками |
| leave_request_id | uuid FK → hr.leave_request (set null) | Ссылка на заявку на отпуск-источник |
| day_off_request_id | uuid FK → hr.day_off_request(request_id) (set null) | Ссылка на одобренный day-off-источник |
| checked_in_at | timestamptz | Фактическая отметка прихода (момент, не local time) |
| checked_out_at | timestamptz | Фактическая отметка ухода |
| check_in_source | hr.check_source | Канал отметки прихода: mobile / web / manual |
| check_out_source | hr.check_source | Канал отметки ухода |
| check_in_location_id | uuid FK → hr.work_location (set null) | Геозона, где сделан check-in |
| check_out_location_id | uuid FK → hr.work_location (set null) | Геозона check-out |
| check_in_photo_file_id | uuid FK → core.file (set null) | Селфи при check-in |
| check_out_photo_file_id | uuid FK → core.file (set null) | Селфи при check-out |
| late_minutes | integer NOT NULL default 0, CHECK >= 0 | Опоздание в минутах; > 0 даёт status = late |
| expected_minutes | integer GENERATED stored | span_minutes(expected_check_in, expected_check_out) - break_minutes (не ниже 0); NULL если ожидаемых времён нет |
| worked_minutes | integer GENERATED stored | (checked_out_at - checked_in_at) в минутах минус break_minutes (не ниже 0); NULL пока нет обеих отметок |
| status | hr.attendance_status GENERATED stored | Из флагов и отметок: holiday > day_off > leave > absent (нет check-in) > late (late_minutes > 0) > present |
| created_at | timestamptz NOT NULL default now() | Создание строки |
| updated_at | timestamptz NOT NULL default now() | Обновляется touch-триггером |
Правила:
attendance_record_source_flags_chk (review 1 CRITICAL): флаги не могут противоречить источнику — expected_source = 'public_holiday' ⇔ is_holiday; = 'leave' ⇔ leave_category заполнена; in ('day_off','schedule_day_off') ⇔ is_day_off. «Праздник подавляет прогул» — структурно, а не конвенцией.attendance_record_no_expected_when_off_chk: если день — праздник/выходной/отпуск, ожидаемые времена обязаны быть NULL.attendance_record_marks_near_day_chk (review 1): отметки держатся рядом со своим днём, интерпретируясь в таймзоне строки — check-in в окне [work_date − 6ч; work_date + 30ч), check-out в окне [work_date − 6ч; work_date + 36ч).attendance_record_date_emp_idx (work_date, employee_id) — sargable-диапазоны для rollup-функций (ADR-12 revised).attendance_record_date_dept_idx (work_date, department_id) — фильтр по отделу на дневных/месячных экранах.attendance_before_write (BEFORE INSERT/UPDATE): копирует leave_category из hr.leave_type (ADR-10, одна корзина — одна точка записи) и автозаполняет department_id из hr.employment, действующего на work_date.attendance_record_requires_correction (AFTER UPDATE of checked_in_at/checked_out_at, DEFERRED, review 1 IMPORTANT): переписать уже записанную отметку можно только при наличии строки hr.attendance_correction с точно совпадающими новыми значениями — лазейки через check_in_source = 'manual' нет.attendance_location_not_archived (BEFORE INSERT/UPDATE of location-колонок, review 1): архивная work_location новых отметок не принимает.attendance_record_touch: поддерживает updated_at.Append-only леджер поправок с обязательной причиной. Пишется админом/движком при любой правке записанных отметок (это НЕ опционально: триггер на attendance_record отклоняет правку без строки здесь); читается карточкой дня и аудитом. Источник правки — либо одобренная заявка сотрудника (request_id), либо ручная правка админа.
| Поле | Тип | Смысл |
|---|---|---|
| id | uuid PK | gen_random_uuid() |
| attendance_record_id | uuid NOT NULL FK → hr.attendance_record (restrict) | Какая дневная запись исправлена; запись нельзя удалить, пока есть поправки |
| request_id | uuid FK → hr.request | Заявка-источник (одобренная attendance_correction); NULL = ручная правка админа |
| corrected_by_user_account_id | uuid NOT NULL FK → core.user_account | Кто внёс правку |
| reason | text NOT NULL, CHECK btrim(reason) <> '' | Обязательная непустая причина |
| old_checked_in_at | timestamptz | Отметка прихода до правки |
| old_checked_out_at | timestamptz | Отметка ухода до правки |
| new_checked_in_at | timestamptz | Отметка прихода после правки (сверяется триггером attendance_record) |
| new_checked_out_at | timestamptz | Отметка ухода после правки |
| created_at | timestamptz NOT NULL default now() | Момент правки |
| updated_at | timestamptz NOT NULL default now() | Формально есть, но UPDATE запрещён (append-only) |
Правила:
attendance_correction_record_idx (attendance_record_id) — история правок одной записи.attendance_correction_append_only (BEFORE UPDATE/DELETE → core.forbid_mutation): мутации разрешены только членам роли staffapp_retention; плюс на уровне грантов у staffapp_app отозваны UPDATE/DELETE («belt and braces»).attendance_correction_touch: поддерживает updated_at (на практике не срабатывает — UPDATE отклоняется).Все 5 views объявлены with (security_invoker = true) — view никогда не эскалирует поверх грантов вызывающего (в т.ч. append-only-REVOKE'ов).
| View | Что делает / кто читает |
|---|---|
| core.v_employee_access | ADR-02: состояние доступа — ВЫЧИСЛЯЕМОЕ, не хранимое. LEFT JOIN employee → user_account: none (аккаунта нет — Lark-импортированные до активации), suspended, active; + has_password (NULL hash = приглашён, не активирован). Читают админ-экраны доступа и hr.employee_current. |
| hr.non_working_day | Единая join-поверхность трёх КОНЕЧНЫХ нерабочих источников на сотрудника: публичные праздники (через holiday_schedule_assignee), одобренные leave_day, одобренные day-off. Рота-выходные бесконечны по датам и сюда не входят — их разрешает hr.resolve_working_day(). Читает движок посещаемости. |
| hr.leave_balance | Годовой баланс на employee × leave_type × period_year: entitled/carried_over (с погашением просроченного carry-over на якорь периода), accrued_days по методу (upfront_annual — сразу всё; monthly_worked — по целым отработанным месяцам), taken/pending (счётчик дней из leave_day, только counts_against_balance), remaining и available. Агрегация ДО join'а — защита от fan-out (review 1 CRITICAL). Целые дни (v3). Годовая цифра, не as-of — месячные экраны берут hr.leave_balance_asof(). Читают /hr/leave и hr.request_inbox. |
| hr.employee_current | Read-поверхность S34: сотрудник + текущий-или-ПОСЛЕДНИЙ engagement (неактивные сохраняют последний отдел), user_type, рота, менеджер, состояние доступа, jsonb-массивы локаций CI/CO и праздничных календарей (архивные строки ВКЛЮЧЕНЫ и помечены — лейблы не исчезают, review 3). Facet-ФИЛЬТРЫ идут через EXISTS по hr.employment, не через эти колонки. |
| hr.request_inbox | Read-поверхность /hr/requests: конверт заявки + тип, заявитель (фото, должность, отдел-снапшот), leave-детали с балансом (параметризованный lateral на hr.leave_balance — леджер не материализуется целиком), day-off-дата, поля корректировки, комментарий, jsonb-вложения из ОБОИХ домов вложений (request_attachment ∪ leave_request_attachment, review 3 CRITICAL). Фасет «New» = status=pending; даты — сырые, формат и пагинация — контракт API. |
| Функция | Что делает / кто вызывает |
|---|---|
| hr.request_decide(p_request_id, p_to, p_actor_employee_id, p_note) | Канонический путь решения по заявке: FOR UPDATE, проверка requires_note ПРЯМО в statement (v3-фикс: понятная ошибка сразу, а не из deferred-триггера на COMMIT), UPDATE статуса (+ applied_at при approve, ровно один раз), вставка строки в hr.request_status_event. Вызывает API на approve/reject/cancel. |
| hr.resolve_working_day(p_employee_id, p_date) | Разрешает ожидаемый день сотрудника. Приоритет: публичный праздник > одобренный отпуск > одобренный day-off > датированная смена > недельная рота > рота-выходной. Возвращает ожидаемые времена, break_minutes, источник, флаги, ссылки. Вызывает дневной движок, снапшотя вывод (+ отдел из employment) в hr.attendance_record. |
| hr.leave_request_rebuild_days(p_leave_request_id) | Пересобирает hr.leave_day заявки: delete + insert по generate_series(date_from, date_to); праздник или рота-выходной внутри диапазона получают counts_against_balance = false (не списываются). Save-путь S42 (правка праздников) ОБЯЗАН перезапустить её для pending/approved заявок поверх изменённых дат. |
| hr.grant_entitlements(p_year) | Годовой грант: по каждой живой политике × её назначенцам (только active-сотрудники) вставляет hr.leave_entitlement на год; идемпотентна (ON CONFLICT DO NOTHING), возвращает число вставленных строк. Один вызов джобы в январе. |
| hr.leave_balance_asof(p_employee_id, p_leave_type_id, p_asof) | Балансовая цифра S14 на дату: entitlement as-of-года минус дни, списанные с начала года по дату (review 3). Вызывают месячные экраны. |
| hr.attendance_monthly_summary(p_month) | Rollup S14 на месяц: на сотрудника — рабочие/праздничные дни, отпуска по категориям (annual/sick/unpaid), day-off, прогулы, опоздания, суммы expected/worked_minutes; department_id — последний за месяц. Полуоткрытый диапазон по work_date — sargable по attendance_record_date_emp_idx (rollup'ы — ФУНКЦИИ, не views: ADR-12 revised). Строка MM/YYYY — display-only, форматируется в API. |
| hr.attendance_yearly_summary(p_year) | Rollup S19: та же форма, что месячный, поверх годового диапазона. |
Служебные (приложением напрямую не вызываются): hr.span_minutes(time, time) — минуты между времени с переходом через полночь; hr.whole_months_elapsed(date, date) — целые месяцы для monthly-accrual; core.touch_updated_at() и core.forbid_mutation() — триггерные хелперы; плюс триггерные функции доменов (перечислены при своих таблицах).
| Enum | Значения | Где используется |
|---|---|---|
| core.role_scope | company, department, location, subordinates | core.user_role.scope_type — область действия RBAC-гранта |
| core.audit_action | create, update, delete, archive, restore, login, logout, grant, revoke, sync | core.audit_log.action |
| core.notification_channel | in_app, lark, email | core.notification_type.default_channels (массив) |
| hr.employee_source | lark, manual | hr.employee.source — откуда заведён сотрудник |
| hr.employee_status | active, inactive | hr.employee.status — ось «активности» вместо archived_at |
| hr.location_direction | check_in, check_out | hr.employee_work_location.direction — разрешение отмечаться по направлению |
| hr.request_status | pending, approved, rejected, cancelled | hr.request.status, hr.leave_request.status (зеркало), hr.request_status_transition (машина состояний), hr.request_status_event |
| hr.request_handler | leave, day_off, attendance_correction | hr.request_type.handler, hr.request.type_handler, типизированные payload-таблицы |
| hr.leave_category | annual, sick, holiday, unpaid, other | hr.leave_type.category, hr.attendance_record.leave_category (копия по ADR-10) — корзина отчётности |
| hr.accrual_method | upfront_annual, monthly_worked | hr.leave_policy.accrual_method — как начисляется квота |
| hr.holiday_scope | national, regional, company | hr.holiday_schedule.scope |
| hr.attendance_status | present, late, absent, leave, day_off, holiday | hr.attendance_record.status (generated) |
| hr.expected_source | schedule, shift, schedule_day_off, day_off, public_holiday, leave | hr.attendance_record.expected_source, результат hr.resolve_working_day() |
| hr.check_source | mobile, web, manual | hr.attendance_record.check_in_source / check_out_source — канал отметки |
id uuid primary key default gen_random_uuid() (pgcrypto). Исключения: типизированные payload'ы заявок (PK = request_id) и core.role_permission / hr.request_status_transition (составные PK).timestamptz not null default now() на каждой таблице. §16 DDL динамическим циклом ставит триггер <table>_touch (BEFORE UPDATE → core.touch_updated_at()) на ВСЕ таблицы core/hr с колонкой updated_at; Phase-0 CI обязан проверять покрытие.timestamptz; локальные календарные значения — date / time; hr.attendance_record дополнительно снапшотит собственную time_zone (ADR-13). Таймзона компании — core.company.time_zone (закрывает attendance-день).comment on table/column — на английском; DDL-комментарии — основной источник смысла схемы.bigint generated always as identity + stored generated text (REQ-, LV-, LOC-, WSC-, SHF-, HOL- + lpad), unique.archived_at timestamptz у справочников; hr.employee вместо этого использует status; история держится на FK RESTRICT (сотрудники не hard-delete'ятся никогда).staffapp_app (login) — рабочая роль приложения: USAGE на схемы core/hr, SELECT/INSERT/UPDATE/DELETE на все таблицы + ALTER DEFAULT PRIVILEGES на будущие. Но: на append-only леджерах (core.audit_log, hr.request_status_event, hr.attendance_correction) отозваны UPDATE/DELETE; на глобальных каталогах (core.permission, core.notification_type, core.setting_definition, hr.request_status_transition) — read-only (отозваны INSERT/UPDATE/DELETE).staffapp_retention (nologin) — единственная роль, которой core.forbid_mutation() разрешает purge append-only леджеров (проверка pg_has_role, а не клиентский GUC).security_invoker = true: view не эскалирует поверх грантов вызывающего.