Dev Specs

BetterPlace Staff App — Specification · 12 Database Reference

Справочник базы данных (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 + 31 hr), 5 view (security_invoker), 2 rollup-функции, 14 enum-типов, 69 триггеров. Пять доменов: D1 Platform · D2 People & Scheduling · D3 Leave & Holidays · D4 Requests & Approvals · D5 Attendance.

Домен D1 · Platform (core)

Платформенный фундамент 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()), поэтому ниже он указывается одной строкой.

core.company — профиль компании

Однострочный профиль компании (структурный синглтон — вторую строку вставить невозможно). Читается всеми экранами для локали/валюты; time_zone закрывает «день» в учёте посещаемости. Сидируется строкой BetterPlace / Asia/Makassar.

ПолеТипСмысл
iduuid PKИдентификатор, gen_random_uuid()
nametext not nullНазвание компании
time_zonetext not null, default 'Asia/Makassar'Часовой пояс компании; по нему закрывается день посещаемости
default_localechar(2) not null, default 'en'Локаль по умолчанию; CHECK: только 'en' или 'id'
default_currencychar(3) not null, default 'IDR'Валюта по умолчанию
created_attimestamptz not null, default now()Момент создания строки
updated_attimestamptz not null, default now()Момент последнего изменения (триггер)
Правила:
  • Уникальный индекс company_singleton по константе (true) — структурный синглтон: в таблице не может появиться вторая строка.
  • CHECK на default_locale: значение только 'en' или 'id'.
  • Триггер company_touch — before update проставляет updated_at = now().

core.department — департамент

Канонический справочник департаментов: скоуп для RBAC-грантов и настроек, фасет на экранах S03/S14/S19/S34. Пишет админ, читают почти все списки. Мягкое архивирование через archived_at.

ПолеТипСмысл
iduuid PKИдентификатор
codecitext not null uniqueУникальный код департамента (регистронезависимый)
name_entext not nullНазвание на английском
name_idtext not nullНазвание на индонезийском
archived_attimestamptzМомент архивирования; NULL = активен
created_attimestamptz not nullСоздание
updated_attimestamptz not nullПоследнее изменение (триггер)
Правила:
  • Unique по code.
  • Триггер department_touch — before update проставляет updated_at.

core.user_account — учётная запись

Логин-идентичность (ключ — login, не e-mail: у emp-007/emp-008 почты нет, ADR-04). Состояние доступа НЕ хранится — оно производное (core.v_employee_access). Пишет админ через управление доступом; читает auth-слой.

ПолеТипСмысл
iduuid PKИдентификатор
employee_iduuid unique, FK → hr.employee (on delete set null)Привязка аккаунта к сотруднику (кросс-доменный FK из §11); один аккаунт на сотрудника
logincitext not null uniqueЛогин (регистронезависимый), первичный ключ входа
emailcitextПочта; опциональна (ADR-04)
password_hashtextХэш пароля; NULL = приглашён, но не активирован
is_suspendedboolean not null, default falseФлаг блокировки; suspend отзывает все сессии
last_login_attimestamptzПоследний вход
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Unique по login; unique по employee_id (не более одного аккаунта на сотрудника).
  • FK user_account_employee_fk → hr.employee, on delete set null (аккаунт переживает удаление карточки).
  • Триггер user_account_touch — before update проставляет updated_at.

core.session — сессия (refresh-токен)

Серверный refresh-токен, одна строка на устройство; цепочка ротации через parent_session_id. Пишет auth-слой при входе/ротации; suspend аккаунта отзывает все сессии.

ПолеТипСмысл
iduuid PKИдентификатор
user_account_iduuid not null, FK → core.user_account (cascade)Чей токен; удаление аккаунта уносит сессии
token_hashbytea not null uniqueХэш токена (сам токен в БД не хранится)
parent_session_iduuid, FK → core.session (set null)Предыдущая сессия в цепочке ротации
user_agenttextUser-Agent устройства
ipinetIP входа
expires_attimestamptz not nullСрок жизни токена
revoked_attimestamptzМомент отзыва; NULL = живая
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Unique по token_hash.
  • Партиальный индекс session_live_idx по user_account_id только для строк с revoked_at is null — быстрый поиск живых сессий аккаунта (и массовый отзыв).
  • Триггер session_touch — before update проставляет updated_at.

core.credential_reset — одноразовый код доступа

Одноразовый код приглашения/сброса пароля, хранится только хэш. Пишет флоу «invite/reset», читает auth-слой при активации.

ПолеТипСмысл
iduuid PKИдентификатор
user_account_iduuid not null, FK → core.user_account (cascade)Чей код
code_hashbytea not nullХэш одноразового кода
purposetext not null, default 'reset'Назначение; CHECK: 'invite' или 'reset'
expires_attimestamptz not nullСрок действия кода
used_attimestamptzМомент использования; NULL = код ещё жив
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Партиальный unique credential_reset_one_live_key по user_account_id при used_at is null — максимум один живой код на аккаунт.
  • CHECK на purpose: только 'invite' или 'reset'.
  • Триггер credential_reset_touch — before update проставляет updated_at.

core.role — роль (RBAC-бандл)

Набор прав (permission bundle). НЕ должность (это hr.position) и НЕ ячейка user-type экрана S34 (это hr.user_type) — ADR-05. Сидируется четырёхуровневой иерархией: super_admin(100), admin(80), supervisor(50), staff(10).

ПолеТипСмысл
iduuid PKИдентификатор
codecitext not null uniqueКод роли (super_admin/admin/supervisor/staff)
name_entext not nullНазвание EN
name_idtext not nullНазвание ID
ranksmallint not nullУровень в иерархии для структурной анти-эскалации: выдать роль с rank >= собственного запрещено на сервисном слое
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Unique по code.
  • Триггер role_touch — before update проставляет updated_at.

core.permission — право (capability)

Глобальный каталог атомарных прав; каждый гейт кнопки в UI завязан на код отсюда (например hr.request.decide). Для приложения каталог read-only (insert/update/delete отозваны у staffapp_app — правится только миграциями).

ПолеТипСмысл
iduuid PKИдентификатор
codecitext not null uniqueКод права, например hr.request.decide
descriptiontext not null, default ''Описание права
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Unique по code.
  • Грант: у staffapp_app отозваны insert/update/delete — каталог для приложения только на чтение.
  • Триггер permission_touch — before update проставляет updated_at.

core.role_permission — роль → право

Маппинг роли на права; редактируется супер-админом через настройки «admin powers» (specs/11-security-operations.md §3) — «grants-as-data». Сид: super_admin и admin получают полный операционный набор, supervisor/staff — усечённые.

ПолеТипСмысл
role_iduuid not null, FK → core.role (cascade)Роль
permission_iduuid not null, FK → core.permission (cascade)Право
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Составной PK (role_id, permission_id) — одно право одной роли выдаётся один раз.
  • Триггер role_permission_touch — before update проставляет updated_at.

core.user_role — грант роли в скоупе

Выдача роли пользователю ВНУТРИ скоупа — строка «supervisor видит свою команду». Фильтрацию по скоупу авторитетно резолвит app-слой. Скоуп-типы (enum core.role_scope): company, department, location, subordinates.

ПолеТипСмысл
iduuid PKИдентификатор
user_account_iduuid not null, FK → core.user_account (cascade)Кому выдана роль
role_iduuid not null, FK → core.role (cascade)Какая роль
scope_typecore.role_scope not null, default 'company'Тип скоупа: company / department / location / subordinates
scope_department_iduuid, FK → core.department (cascade)Департамент-скоуп; обязателен ровно при scope_type='department'
scope_location_iduuid, FK → hr.work_location (restrict, §11)Локация-скоуп; обязательна ровно при scope_type='location'; локацию с грантами удалить нельзя
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • CHECK: scope_type = 'department' тогда и только тогда, когда scope_department_id заполнен.
  • CHECK: scope_type = 'location' тогда и только тогда, когда scope_location_id заполнен.
  • UNIQUE NULLS NOT DISTINCT по (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.

core.file — реестр файлов

Единый реестр блобов: фото сотрудников, вложения заявок, фото-доказательства чек-инов. Уникальность checksum = идемпотентная загрузка; пара owner_kind/owner_id — единая поверхность для будущего сборщика сирот (review 2).

ПолеТипСмысл
iduuid PKИдентификатор
storage_keytext not null uniqueКлюч в объектном хранилище
original_nametext not nullИсходное имя файла
mime_typetext not nullMIME-тип
byte_sizebigint not nullРазмер в байтах; CHECK: >= 0
checksum_sha256bytea not nullSHA-256 содержимого
uploaded_by_user_account_iduuid, FK → core.user_account (set null)Кто загрузил; переживает удаление аккаунта
owner_kindtextТип сущности-владельца (для sweep'а сирот)
owner_iduuidИдентификатор владельца (для sweep'а сирот)
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Unique по storage_key.
  • Уникальный индекс file_dedup_key по (checksum_sha256, byte_size) — дедупликация: повторная загрузка того же содержимого идемпотентна.
  • CHECK: byte_size >= 0.
  • Индекс file_owner_idx по (owner_kind, owner_id) — поверхность для сборщика сирот.
  • Триггер file_touch — before update проставляет updated_at.

core.audit_log — журнал аудита

Append-only «кто/что/когда» по всем доменам. Пишет app-слой на каждое значимое действие; читается по паре (сущность, время) — JSONB GIN сознательно НЕ создан (review 2). Чистка возможна только под ролью staffapp_retention.

ПолеТипСмысл
iduuid PKИдентификатор
actor_user_account_iduuidАктор; намеренно БЕЗ FK — лог переживает удаление субъекта
actioncore.audit_action not nullДействие: create/update/delete/archive/restore/login/logout/grant/revoke/sync
entity_typetext not nullТип затронутой сущности
entity_iduuidИдентификатор сущности
beforejsonbСнимок до изменения
afterjsonbСнимок после изменения
redacted_fieldstext[] not null, default '{}'Список полей, вымаранных из снимков
correlation_iduuidСквозной идентификатор операции
created_attimestamptz not nullМомент записи
updated_attimestamptz not nullСлужебный (update всё равно запрещён)
Правила:
  • Триггер audit_log_append_only — before update/delete вызывает core.forbid_mutation(): мутация разрешена только членам роли staffapp_retention, иначе ошибка 42501.
  • Грант: update/delete отозваны у staffapp_app — «ремень и подтяжки» вместе с триггером.
  • BRIN-индекс 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).

core.notification_type — тип уведомления

Каталог нотифицируемых событий и их каналов по умолчанию. Read-only для приложения (грант отозван). Сид: request.submitted, request.decided, request.needs_action, attendance.corrected.

ПолеТипСмысл
iduuid PKИдентификатор
codecitext not null uniqueКод события (на него ссылается core.notification.type_code)
name_entext not nullНазвание EN
name_idtext not nullНазвание ID
default_channelscore.notification_channel[] not null, default '{in_app}'Каналы по умолчанию: in_app / lark / email
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Unique по code.
  • Грант: у staffapp_app отозваны insert/update/delete — каталог правится только миграциями.
  • Триггер notification_type_touch — before update проставляет updated_at.

core.notification — уведомление

Один факт на получателя; текст НЕ хранится — app рендерит из payload (аргументы для next-intl) в локали читателя. Пофасетная доставка по каналам отложена до дизайна мессенджера S49.

ПолеТипСмысл
iduuid PKИдентификатор
type_codecitext not null, FK → core.notification_type (code)Тип события
recipient_employee_iduuid, FK → hr.employee (restrict, §11)Получатель-сотрудник; сотрудника с уведомлениями удалить нельзя
recipient_user_account_iduuid, FK → core.user_account (cascade)Получатель-аккаунт; удаление аккаунта уносит его уведомления
payloadjsonb not null, default '{}'Аргументы рендера для next-intl, не готовое предложение
read_attimestamptzМомент прочтения; NULL = непрочитано
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • CHECK: хотя бы один из 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.

core.setting_definition — каталог ключей настроек

Справочник конфигурационных ключей с типом и дефолтом (Q-17 «Departments Settings»). Read-only для приложения. Сид: attendance.late_grace_minutes, leave.min_notice_days, attendance.require_photo, attendance.geofence_enforced.

ПолеТипСмысл
iduuid PKИдентификатор
keycitext not null uniqueКлюч настройки (на него ссылается core.setting.key)
value_typetext not nullТип значения; CHECK: boolean / integer / numeric / text / json
default_valuejsonbЗначение по умолчанию
descriptiontext not null, default ''Описание ключа
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Unique по key.
  • CHECK на value_type: только boolean / integer / numeric / text / json.
  • Грант: у staffapp_app отозваны insert/update/delete — каталог правится только миграциями.
  • Триггер setting_definition_touch — before update проставляет updated_at.

core.setting — значение настройки

Действующее значение конфигурации: строка с department_id = NULL — общекомпанийский дефолт, строка с департаментом — переопределение для него. Пишет админ на экране настроек, читают движки (посещаемость, отпуска).

ПолеТипСмысл
iduuid PKИдентификатор
keycitext not null, FK → core.setting_definition (key)Какой ключ настроен
department_iduuid, FK → core.department (cascade)Скоуп: NULL = вся компания, иначе override департамента
valuejsonb not nullЗначение
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • UNIQUE NULLS NOT DISTINCT по (key, department_id) — один ключ имеет максимум одно значение на скоуп, включая единственный общекомпанийский дефолт.
  • Триггер setting_touch — before update проставляет updated_at.

core.lark_sync_run — журнал Lark-синка

Журнал здоровья синхронизации: одна строка на запуск employee-синка из Lark (v3: заменил собой «универсальную интеграционную платформу»). Пишет sync-джоб, читает админский экран здоровья синка.

ПолеТипСмысл
iduuid PKИдентификатор
started_attimestamptz not null, default now()Старт запуска
finished_attimestamptzФиниш; NULL = ещё идёт
statustext not null, default 'running'Статус; CHECK: running / success / error
created_countint not null, default 0Сколько сотрудников создано
updated_countint not null, default 0Сколько обновлено
flagged_countint not null, default 0Сколько помечено на разбор
errortextТекст ошибки при status='error'
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • CHECK на status: только running / success / error.
  • Триггер lark_sync_run_touch — before update проставляет updated_at.

core.legacy_id_map — карта легаси-идентификаторов

Соответствие «emp-001 → uuid» на время поэкранного переезда со старых данных на API. Таблица временная: будет удалена, когда последний экран перейдёт на API.

ПолеТипСмысл
iduuid PKИдентификатор
entity_typetext not nullТип сущности (пространство легаси-идентификаторов)
legacy_idtext not nullСтарый строковый идентификатор (например emp-001)
entity_iduuid not nullНовый uuid сущности
created_attimestamptz not nullСоздание
updated_attimestamptz not nullИзменение (триггер)
Правила:
  • Unique по (entity_type, legacy_id) — один легаси-идентификатор маппится ровно в одну сущность.
  • Триггер legacy_id_map_touch — before update проставляет updated_at.

Домен D2 · People & Scheduling (hr)

Таблицы домена (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'.


hr.position — должность

Справочник должностей — колонка «Role» на экране S34. Это НЕ авторизация: права доступа живут в core.role (ADR-05). Пишет админ из таблицы персонала, читают экраны персонала и hr.employment.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
codecitext NOT NULL UNIQUEМашинный код должности, регистронезависимый, уникальный
name_entext NOT NULLНазвание на английском
name_idtext NOT NULLНазвание на индонезийском
archived_attimestamptz NULLМягкое удаление: NULL = активна, иначе момент архивации
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, default now(), обновляется триггером

Правила:

  • PK по id; UNIQUE по code.
  • Триггер position_touch — before update, проставляет updated_at.

hr.user_type — тип пользователя

Справочник Staff/Manager/Admin — ячейка «User Type» на S34, расширяется прямо из редактора ячейки. Читается hr.employee.user_type_id.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
codecitext NOT NULL UNIQUEМашинный код типа, регистронезависимый, уникальный
name_entext NOT NULLНазвание на английском
name_idtext NOT NULLНазвание на индонезийском
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id; UNIQUE по code. Поля archived_at нет.
  • Триггер user_type_touch — before update, проставляет updated_at.

hr.employment_type — тип занятости

Справочник типов трудовых отношений: PKWTT / PKWT / probation / contractor. Читается hr.employment.employment_type_id.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
codecitext NOT NULL UNIQUEМашинный код, регистронезависимый, уникальный
name_entext NOT NULLНазвание на английском
name_idtext NOT NULLНазвание на индонезийском
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id; UNIQUE по code. Поля archived_at нет.
  • Триггер employment_type_touch — before update, проставляет updated_at.

hr.work_location — рабочая локация (геозона)

Вручную редактируемая геоточка для check-in/check-out (геофенс). Мягкое удаление через archived_at; лейблы продолжают отображать заархивированные строки (review 3). Читается табелем (валидация геозоны), hr.employee_work_location, hr.employment, hr.shift.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
location_nobigint identityСквозной номер, generated always as identity
referencetext generated, UNIQUEЧеловекочитаемый код LOC-0001: 'LOC-' || lpad(location_no, 4, '0'), stored
nametext NOT NULL default ''Название; пустое по умолчанию — паттерн «сначала пустая строка, потом правка», поэтому CHECK на btrim намеренно нет (review 3)
latitudenumeric(9,6) NULLШирота центра геозоны
longitudenumeric(9,6) NULLДолгота центра геозоны
radius_minteger NOT NULL default 100Радиус геозоны в метрах, CHECK > 0
unit_iduuid NULLЗадел: станет FK на core.unit, когда появится домен недвижимости (E01); сейчас без FK
archived_attimestamptz NULLМягкое удаление: NULL = активна
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id; UNIQUE по generated-полю reference.
  • CHECK: radius_m > 0.
  • Триггер work_location_touch — before update, проставляет updated_at.

hr.work_schedule — недельное расписание (рота)

Именованная недельная рота (экран S32). Новое расписание и его СЕМЬ строк hr.work_schedule_day обязаны вставляться в ОДНОЙ транзакции: триггер полноты — DEFERRED и срабатывает на COMMIT. Привязывается к сотруднику через hr.employee.work_schedule_id.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
schedule_nobigint identityСквозной номер, generated always as identity
referencetext generated, UNIQUEКод WSC-0001: 'WSC-' || lpad(schedule_no, 4, '0'), stored
nametext NOT NULL default ''Название; пустое допустимо (blank-row-then-edit)
role_labeltext NULLПодсказка для массового назначения (bulk-assign hint), больше ни на что не влияет
archived_attimestamptz NULLМягкое удаление: NULL = активно
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id; UNIQUE по reference.
  • Партиальный уникальный индекс work_schedule_name_key по name при условии btrim(name) <> '' — непустые имена уникальны, пустые черновики могут дублироваться.
  • Constraint-триггер work_schedule_complete — after insert, DEFERRABLE INITIALLY DEFERRED: на COMMIT проверяет через hr.work_schedule_check_complete(), что у расписания ровно 7 строк дней, иначе исключение.
  • Триггер work_schedule_touch — before update, проставляет updated_at.

hr.work_schedule_day — день недельного расписания

Один день недели роты; ровно 7 строк на расписание (гарантируется отложенным триггером). break_minutes — неоплачиваемый перерыв, который снапшотится в табель (attendance).

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
work_schedule_iduuid NOT NULL FKhr.work_schedule(id), ON DELETE CASCADE — дни умирают вместе с расписанием
iso_weekdaysmallint NOT NULLДень недели ISO, CHECK between 1 and 7 (1=Пн … 7=Вс)
is_day_offboolean NOT NULL default falseВыходной ли этот день недели
start_timetime NULLНачало рабочего дня; NULL только у выходного
end_timetime NULLКонец рабочего дня; NULL только у выходного
break_minutesinteger NOT NULL default 0Неоплачиваемый перерыв в минутах, CHECK >= 0; снапшотится на attendance
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id; UNIQUE (work_schedule_id, iso_weekday) — один ряд на день недели.
  • CHECK is_day_off = (start_time is null and end_time is null) — выходной ⇔ оба времени NULL, рабочий день ⇔ оба заданы.
  • Constraint-триггер 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.

hr.shift_template — шаблон смены

Именованный пресет смены (пикер на S38). Ночная смена 19:00→04:00 легальна: crosses_midnight — generated-флаг, paid_minutes — длительность за вычетом перерыва.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
nametext NOT NULL default ''Название пресета; пустое допустимо
start_timetime NOT NULLНачало смены
end_timetime NOT NULLКонец смены (может быть «раньше» начала — переход через полночь)
break_minutesinteger NOT NULL default 0Неоплачиваемый перерыв, CHECK >= 0
crosses_midnightboolean generated storedend_time <= start_time — смена пересекает полночь
paid_minutesinteger generated storedhr.span_minutes(start_time, end_time) - break_minutes — оплачиваемые минуты нетто (span_minutes добавляет 1440 при переходе через полночь)
archived_attimestamptz NULLМягкое удаление: NULL = активен
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id.
  • Партиальный уникальный индекс shift_template_name_key по name при btrim(name) <> '' — непустые имена уникальны.
  • Триггер shift_template_touch — before update, проставляет updated_at.

hr.employee — сотрудник

Человек: идентичность, фото, рота. Поля archived_at нет — ось жизненного цикла это status. Сотрудники никогда не удаляются физически: исторические FK стоят как RESTRICT. У search_text НАМЕРЕННО нет trigram-индекса (≈650 строк, review 2). Пишут админ-экран S34 и Lark-синхронизация; читает почти всё приложение.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
employee_notext NOT NULL UNIQUEТабельный номер emp-001 — колонка ID на S34
sourcehr.employee_source NOT NULL default 'manual'Откуда заведён: lark (синхронизация) или manual
full_nametext NOT NULL default ''Полное имя; пустое допустимо (blank-row-then-edit)
emailcitext NULLE-mail, регистронезависимый
phonetext NULLТелефон
photo_file_iduuid NULL FKcore.file(id), ON DELETE SET NULL — фото сотрудника
statushr.employee_status NOT NULL default 'active'active / inactive; согласован с hr.employment триггером (см. ниже)
user_type_iduuid NULL FKhr.user_type(id) — тип пользователя (Staff/Manager/Admin)
work_schedule_iduuid NULL FKhr.work_schedule(id), ON DELETE SET NULL — каноническая привязка к недельной роте (решение Q-20)
lark_idtext NULLИдентификатор в Lark
lark_id_origintext NOT NULL default 'manual'CHECK in ('sync','manual'). Инвариант review 3, хранимый как колонка: Lark-sync НИКОГДА не должен перезаписывать lark_id, чей origin = manual (правка ячейки Lark ID на S34 пишет origin=manual)
search_texttext generated storedlower(full_name || ' ' || employee_no || ' ' || coalesce(email, '')) — строка для поиска
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id; UNIQUE по employee_no.
  • Партиальный уникальный индекс employee_lark_id_key по lark_id при lark_id is not null — непустые Lark ID уникальны, NULL можно у многих.
  • CHECK: lark_id_origin in ('sync','manual').
  • Обычные индексы: employee_status_idx по status; employee_user_type_idx по user_type_id.
  • Constraint-триггер 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.

hr.employment — трудовое назначение (temporal contract)

Темпоральный контракт (ADR-03): период работы сотрудника с департаментом, должностью, типом занятости, руководителем и локацией. ЕДИНСТВЕННАЯ легальная запись для inline-правки Department/Role/Supervisor на S34: UPDATE текущей строки SET end_date = current_date - 1, затем INSERT новой записи — обе в ОДНОЙ транзакции (review 3). Свободный текст из UI резолвится через справочники core.department / hr.position.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
employee_iduuid NOT NULL FKhr.employee(id), ON DELETE RESTRICT — история не даёт удалить сотрудника
start_datedate NOT NULLНачало периода назначения
end_datedate NULLКонец периода; NULL = текущее (открытое) назначение
department_iduuid NULL FKcore.department(id) — департамент в этот период
position_iduuid NULL FKhr.position(id) — должность в этот период
employment_type_iduuid NULL FKhr.employment_type(id) — тип занятости (PKWTT/PKWT/…)
manager_employee_iduuid NULL FKhr.employee(id) — руководитель (колонка Supervisor на S34)
work_location_iduuid NULL FKhr.work_location(id) — основная рабочая локация периода
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id.
  • CHECK: end_date is null or end_date >= start_date.
  • EXCLUDE-констрейнт 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 (…) — обратные выборки по департаменту/должности/руководителю.
  • Constraint-триггер employment_status_consistent — after insert or update of end_date, DEFERRABLE INITIALLY DEFERRED: та же проверка согласованности со статусом сотрудника (открытое назначение у inactive запрещено).
  • Триггер employment_touch — before update, проставляет updated_at.

hr.employee_work_location — разрешение отмечаться на локации

Право «можно отмечаться здесь», раздельно по направлению check-in / check-out (ячейки Check In / Check Out на S34). Читается валидацией табеля.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
employee_iduuid NOT NULL FKhr.employee(id), ON DELETE RESTRICT
work_location_iduuid NOT NULL FKhr.work_location(id), ON DELETE CASCADE — разрешения умирают вместе с локацией
directionhr.location_direction NOT NULLНаправление: check_in или check_out
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id; UNIQUE (employee_id, work_location_id, direction) — одно разрешение на сотрудника-локацию-направление.
  • Триггер employee_work_location_touch — before update, проставляет updated_at.

hr.shift — датированная смена

Датированное переопределение недельной роты (экран S38). Модель кардинальности ЗАФИКСИРОВАНА: смена — исключение (~1k строк/год), а не норма — см. ADR-14. Может создаваться из шаблона; параметры шаблона штампуются в строку.

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
shift_nobigint identityСквозной номер, generated always as identity
referencetext generated, UNIQUEКод SHF-00001: 'SHF-' || lpad(shift_no, 5, '0'), stored
shift_template_iduuid NULL FKhr.shift_template(id), ON DELETE SET NULL — из какого пресета создана
nametext NOT NULL default ''Название смены; пустое допустимо
work_datedate NOT NULLДата смены
start_timetime NOT NULLНачало смены
end_timetime NOT NULLКонец смены
break_minutesinteger NOT NULL default 0Неоплачиваемый перерыв, CHECK >= 0; review 3 — штампуется из шаблона и не теряется при удалении шаблона
crosses_midnightboolean generated storedend_time <= start_time — переход через полночь
work_location_iduuid NULL FKhr.work_location(id), ON DELETE SET NULL — переопределение геозоны для этой смены
notetext NULLПроизвольная заметка
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id; UNIQUE по reference.
  • UNIQUE (id, work_date) — не бизнес-ограничение, а мишень для композитного «date-pinning» FK из hr.shift_assignment.
  • Индекс shift_date_idx (work_date, work_location_id) — выборка смен по дате/локации.
  • Триггер shift_touch — before update, проставляет updated_at.

hr.shift_assignment — назначение на смену

Кто выходит в конкретную датированную смену. 0 строк = открытая (никому не назначенная) смена. UNIQUE (employee_id, work_date) делает структурно недостижимым состояние «две пересекающиеся смены молча потерялись».

ПолеТипСмысл
iduuid PKПервичный ключ, default gen_random_uuid()
shift_iduuid NOT NULLСмена; часть композитного FK ниже (отдельного одиночного FK нет)
work_datedate NOT NULLДата смены — денормализована и «пришпилена» композитным FK: не может разойтись с hr.shift.work_date
employee_iduuid NOT NULL FKhr.employee(id), ON DELETE RESTRICT
created_attimestamptz NOT NULLМомент создания, default now()
updated_attimestamptz NOT NULLМомент последнего изменения, триггер

Правила:

  • PK по id; UNIQUE (shift_id, employee_id) — сотрудник назначается на смену не более одного раза.
  • UNIQUE (employee_id, work_date) — структурно одна смена на сотрудника в день (reviews 1+3); сплит-смены потребуют hr.attendance_segment и сначала продуктового решения.
  • Композитный FK (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.

Домен D3 · Leave & Holidays (hr)

Домен покрывает каталог видов отпуска, квотные схемы (политики), годовые начисления, заявления на отпуск с посуточной «росписью», вложения-доказательства и именованные календари праздников. Статусами заявлений владеет конверт hr.request (домен Requests, ADR-07); здесь статус только зеркалируется. На каждой таблице домена стоит служебный триггер <table>_touch (before update: updated_at := now(), ставится bootstrap-блоком §16) — ниже отдельно не повторяется.

hr.leave_type — вид отпуска (каталог)

Каталог видов отпуска и порядок «чипов» на экране /hr/leave. Пишет админ в настройках HR; читают заявления (hr.leave_request), чипы инбокса (hr.request_type.leave_type_id) и посещаемость (hr.attendance_record копирует category). Сидируется 4 строками: annual, holiday, sick, other.

ПолеТипСмысл
iduuid PKКлюч, gen_random_uuid().
codecitext NOT NULL UNIQUEМашинный код вида (case-insensitive), напр. annual, sick.
name_entext NOT NULLНазвание по-английски.
name_idtext NOT NULLНазвание по-индонезийски.
categoryhr.leave_category NOT NULLРепортинговая корзина (annual/sick/holiday/unpaid/other); копируется на attendance-строку триггером (ADR-10).
tracks_balanceboolean NOT NULL DEFAULT falseВедётся ли баланс; false → в колонках баланса выводится длинное тире.
requires_attachmentboolean NOT NULL DEFAULT falseТребуется ли вложение к заявлению (напр. справка для sick).
sort_orderint NOT NULL DEFAULT 100Порядок чипа на /hr/leave.
archived_attimestamptz NULLМетка архивации; архивный вид не принимает новых заявлений (триггер на hr.leave_request).
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено (триггер _touch).

Правила:

  • PK по id; UNIQUE по code.
  • Триггеров, кроме leave_type_touch, на самой таблице нет — запрет заявлений на архивный вид реализован триггером leave_request_type_not_archived на hr.leave_request.

hr.leave_policy — квотная схема (политика отпуска)

Квота «как данные»: сколько дней в год даёт вид отпуска и по какому методу начисляется. Пишет админ; читают hr.grant_entitlements() (годовое начисление) и вью hr.leave_balance. Версионирование — архивировать старую строку и вставить новую (две живые политики на один вид размножали бы баланс и строки инбокса — review 1 CRITICAL). Сид: «Annual Leave 12 (UU 13/2003)» и «Holiday Leave 15».

ПолеТипСмысл
iduuid PKКлюч.
leave_type_iduuid NOT NULL FK → hr.leave_type (RESTRICT)Вид отпуска, чью квоту описывает схема.
nametext NOT NULL UNIQUEЧеловекочитаемое имя схемы.
days_per_yearnumeric(4,1) NOT NULL CHECK >= 0Дней в год; 12 = норма UU 13/2003.
accrual_methodhr.accrual_method NOT NULL DEFAULT 'upfront_annual'Метод начисления: upfront_annual (вся норма сразу) или monthly_worked (по отработанным месяцам — см. accrued_days в hr.leave_balance).
min_service_monthsint NOT NULL DEFAULT 0Минимальный стаж (месяцев) для права на схему.
carry_over_max_daysnumeric(4,1) NOT NULL DEFAULT 0Максимум дней переноса на следующий год.
carry_over_expiry_monthsint NULLЧерез сколько месяцев сгорает перенос (NULL — не сгорает).
effective_fromdate NOT NULL DEFAULT current_dateС какой даты схема действует.
archived_attimestamptz NULLАрхивация = «закрыть версию»; архивные схемы не участвуют в начислении.
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено.

Правила:

  • PK по id; UNIQUE по name.
  • Партиальный уникальный индекс leave_policy_one_live_per_type_key на (leave_type_id) WHERE archived_at IS NULL — не больше ОДНОЙ живой политики на вид отпуска.
  • CHECK: days_per_year >= 0.
  • Триггер: только leave_policy_touch.

hr.leave_policy_assignee — участники схемы

M:N «кто на какой схеме». Пишет админ; читает hr.grant_entitlements() при годовом начислении.

ПолеТипСмысл
iduuid PKКлюч.
leave_policy_iduuid NOT NULL FK → hr.leave_policy (CASCADE)Схема; удаление схемы уносит назначения.
employee_iduuid NOT NULL FK → hr.employee (RESTRICT)Сотрудник на схеме.
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено.

Правила:

  • UNIQUE (leave_policy_id, employee_id) — сотрудник в схеме не дублируется.
  • Триггер: только leave_policy_assignee_touch.

hr.leave_entitlement — годовое начисление (грант)

Грант на сотрудника × схему × год — СНИМОК, а не указатель: правка политики задним числом не переписывает уже выданный год. Пишется функцией 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().

ПолеТипСмысл
iduuid PKКлюч.
employee_iduuid NOT NULL FK → hr.employee (RESTRICT)Кому начислено.
leave_policy_iduuid NOT NULL FK → hr.leave_policy (RESTRICT)По какой схеме (ссылка для трассировки; числа — снимок).
period_yearint NOT NULL CHECK 2000..2100Год периода.
entitled_daysnumeric(4,1) NOT NULLНачислено дней на год (снимок days_per_year на момент гранта).
carried_over_daysnumeric(4,1) NOT NULL DEFAULT 0Перенесено с прошлого года.
carry_over_expires_ondate NULLДата сгорания переноса; после неё hr.leave_balance считает перенос нулём.
accrual_start_datedate NOT NULLСтарт начисления (для monthly_worked — от неё считаются целые месяцы).
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено.

Правила:

  • UNIQUE (employee_id, leave_policy_id, period_year) — один грант на сотрудника×схему×год (на него же опирается идемпотентность grant_entitlements).
  • CHECK: period_year BETWEEN 2000 AND 2100.
  • Триггер: только leave_entitlement_touch.

hr.leave_request — заявление на отпуск

Заявление (регистр S22), только целые дни (v3: механика полудней удалена). Создаёт сотрудник; решение принимается на конверте hr.request (ADR-07), а статус сюда зеркалируется триггером — прямой UPDATE статуса отвергается. Со стороны конверта: hr.request.leave_request_id FK RESTRICT + партиальный уникальный индекс request_leave_request_key — ровно один конверт на заявление.

ПолеТипСмысл
iduuid PKКлюч.
leave_nobigint GENERATED ALWAYS AS IDENTITYСквозной номер-счётчик.
referencetext GENERATED ('LV-' || lpad(leave_no,6,'0')) STORED UNIQUEЧеловекочитаемый номер вида LV-000123.
employee_iduuid NOT NULL FK → hr.employee (RESTRICT)Заявитель.
leave_type_iduuid NOT NULL FK → hr.leave_type (RESTRICT)Вид отпуска.
statushr.request_status NOT NULL DEFAULT 'pending'pending/approved/rejected/cancelled; ЗЕРКАЛО статуса конверта hr.request, напрямую не пишется.
date_fromdate NOT NULLНачало диапазона.
date_todate NOT NULLКонец диапазона.
total_daysinteger NOT NULL DEFAULT 0Считается триггером: число списываемых дней (counts_against_balance) в hr.leave_day.
commenttext NOT NULL DEFAULT ''Комментарий заявителя (в инбоксе — fallback для requester_comment).
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено.

Правила:

  • CHECK: date_to >= date_from.
  • UNIQUE (id, employee_id) — мишень составного FK из hr.leave_day: день всегда привязан к сотруднику своего заявления (review 1).
  • EXCLUDE (gist) leave_request_no_overlap: employee_id = и daterange(date_from, date_to, '[]') && при status IN ('pending','approved') — живые заявления одного сотрудника не пересекаются по датам.
  • UNIQUE по reference (генерируемый столбец).
  • Индекс leave_request_employee_idx на (employee_id, status).
  • Триггер leave_request_status_is_mirrored (before update of status): отвергает смену статуса при pg_trigger_depth() = 1 — писать статус может только триггерный каскад с конверта.
  • Constraint-триггер 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_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 обязан перегонять её для живых заявлений поверх изменённых дат.

ПолеТипСмысл
iduuid PKКлюч.
leave_request_iduuid NOT NULLРодительское заявление (часть составного FK ниже).
employee_iduuid NOT NULL FK → hr.employee (RESTRICT)Сотрудник; через составной FK гарантированно совпадает с сотрудником заявления.
leave_datedate NOT NULLКалендарная дата дня отпуска.
counts_against_balanceboolean NOT NULL DEFAULT trueСписывается ли день с баланса; false для праздника/ротного выходного внутри диапазона.
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено.

Правила:

  • UNIQUE (leave_request_id, leave_date) — дата в заявлении один раз.
  • Составной FK (leave_request_id, employee_id)hr.leave_request (id, employee_id) ON DELETE CASCADE — день принадлежит именно сотруднику заявления (review 1); удаление заявления уносит дни.
  • FK employee_idhr.employee RESTRICT.
  • Партиальный индекс leave_day_balance_idx на (employee_id, leave_date) WHERE counts_against_balance — балансовые выборки; индекс leave_day_request_idx на (leave_request_id).
  • Constraint-триггер 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.

hr.leave_request_attachment — вложения заявления на отпуск

Файлы-доказательства заявления (surat dokter и т.п.). Пишет заявитель при подаче; на карточке запроса всплывают через hr.request_inbox (union с hr.request_attachment неотпускных запросов).

ПолеТипСмысл
iduuid PKКлюч.
leave_request_iduuid NOT NULL FK → hr.leave_request (CASCADE)Чьё заявление; удаление заявления уносит связки.
file_iduuid NOT NULL FK → core.file (RESTRICT)Сам файл в файловом реестре; файл нельзя удалить, пока привязан.
positionint NOT NULL DEFAULT 0Порядок вложений на карточке.
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено.

Правила:

  • UNIQUE (leave_request_id, file_id) — файл к заявлению один раз.
  • Триггер: только leave_request_attachment_touch.

hr.holiday_schedule — календарь праздников (годовой набор)

Именованный годовой набор праздников (экран S42). Пишет админ; читают резолвер hr.resolve_working_day(), rebuild дней отпуска и вью. Правка года у заполненного календаря: SET CONSTRAINTS public_holiday_schedule_fk DEFERRED, обновить год родителя И сдвинуть даты детей в ОДНОЙ транзакции. Удаление при существующих датах блокируется (FK без каскада) — вместо этого архивировать: списанная история должна оставаться объяснимой.

ПолеТипСмысл
iduuid PKКлюч.
holiday_nobigint GENERATED ALWAYS AS IDENTITYСквозной счётчик.
referencetext GENERATED ('HOL-' || lpad(holiday_no,4,'0')) STORED UNIQUEНомер вида HOL-0001.
nametext NOT NULL DEFAULT ''Имя набора (может быть пустым).
calendar_yearint NOT NULL CHECK 2000..2100Календарный год набора.
scopehr.holiday_scope NOT NULL DEFAULT 'company'Охват: national/regional/company.
archived_attimestamptz NULLАрхивация вместо удаления.
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено.

Правила:

  • UNIQUE (id, calendar_year) — мишень составного FK из hr.public_holiday (жёстко связывает даты с годом родителя).
  • Партиальный уникальный индекс holiday_schedule_name_key на (name, calendar_year) WHERE btrim(name) <> '' — непустые имена уникальны в пределах года.
  • CHECK: calendar_year BETWEEN 2000 AND 2100; UNIQUE по reference.
  • Триггер: только holiday_schedule_touch.

hr.public_holiday — праздничная дата

Одна датированная строка праздника внутри календаря. Читается резолвером рабочего дня (высший приоритет источника) и rebuild'ом дней отпуска (праздник внутри диапазона не списывается).

ПолеТипСмысл
iduuid PKКлюч.
holiday_schedule_iduuid NOT NULLРодительский календарь (часть составного FK).
calendar_yearint NOT NULLДублированный год — вторая нога составного FK, синхронизирует дату с годом родителя.
holiday_datedate NOT NULLДата праздника.
name_entext NOT NULL DEFAULT ''Название по-английски.
name_idtext NOT NULL DEFAULT ''Название по-индонезийски.
is_cuti_bersamaboolean NOT NULL DEFAULT falseМетка cuti bersama — «совместный отпуск», который клиент может списывать с годовой квоты (открытый вопрос Q4).
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено.

Правила:

  • UNIQUE (holiday_schedule_id, holiday_date) — дата в календаре один раз.
  • CHECK public_holiday_year_chk: extract(year from holiday_date) = calendar_year — дата не выпадает из года календаря.
  • Составной FK 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.

hr.holiday_schedule_assignee — назначение календаря сотрудникам

M:N «календарь → люди» — именно поэтому у hr.employee нет колонки holiday_schedule_id. Пишет админ; читают резолвер, rebuild дней и вью hr.non_working_day / hr.employee_current.

ПолеТипСмысл
iduuid PKКлюч.
holiday_schedule_iduuid NOT NULL FK → hr.holiday_schedule (RESTRICT)Какой календарь назначен.
employee_iduuid NOT NULL FK → hr.employee (RESTRICT)Кому назначен.
created_attimestamptz NOT NULL DEFAULT now()Создано.
updated_attimestamptz NOT NULL DEFAULT now()Обновлено.

Правила:

  • UNIQUE (holiday_schedule_id, employee_id) — назначение не дублируется.
  • Триггер: только holiday_schedule_assignee_touch.

Производные read-поверхности домена (справочно)

  • Вью 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_workedleast(норма, целых месяцев от 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 (см. там же).

Домен D4 · Requests & Approvals (hr)

Механика конверта. Любая заявка сотрудника — это строка-«конверт» 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.request_type — тип заявки (чип инбокса)

Справочник «одна строка = один чип» на экране /hr/requests; leave-чипы указывают на тип отпуска, чью квоту тратят — таксономия отпусков не дублируется. Читается всеми экранами заявок, редактируется админкой настроек. Сид: 4 leave-чипа (annual/holiday/sick/other, approver_role_id NULL → супервайзер) + day_off и attendance_correction (аппрувер — роль admin).

ПолеТипСмысл
iduuid PKИдентификатор типа.
codecitext, uniqueМашинный код чипа (напр. annual_leave), регистронезависимый; API резолвит code → id.
name_entextНазвание по-английски.
name_idtextНазвание по-индонезийски.
handlerhr.request_handlerКакой обработчик исполняет тип: leave / day_off / attendance_correction.
leave_type_iduuid FK → hr.leave_type (restrict)Для leave-чипов — тип отпуска, чья квота тратится; для остальных NULL.
approver_role_iduuid FK → core.roleSingle-hop аппрув: NULL = решает текущий супервайзер заявителя; роль = решают её члены. Мультишаговый роутинг отложен (ADR-16).
sort_ordersmallint, default 100Порядок чипов в UI.
is_activeboolean, default trueВыключенный тип не принимает новые заявки (проверяет триггер на hr.request).
created_attimestamptzСлужебное: создание строки.
updated_attimestamptzСлужебное: последнее изменение (триггер).

Правила:

  • PK — id; code уникален.
  • Unique (id, handler) — мишень композитного «пиннящего» FK из hr.request (фикс review 1 CRITICAL: handler конверта нельзя рассогласовать с типом).
  • CHECK: handler = 'leave' тогда и только тогда, когда leave_type_id задан.
  • Триггер request_type_touch — проставляет updated_at при UPDATE.

hr.request_status_transition — белый список переходов статусов

Data-driven статус-машина: код ветвится по этой таблице, а не по захардкоженным правилам. Глобальная и закрытая; для роли приложения staffapp_app — read-only каталог (insert/update/delete отозваны). Сид: pending→approved (без причины), pending→rejected (причина обязательна), pending→cancelled (без причины), rejected→pending (причина обязательна), approved→cancelled (причина обязательна; переход, доказанно отсутствовавший в v1 — review 1 CRITICAL).

ПолеТипСмысл
from_statushr.request_status, PK-частьСтатус «откуда».
to_statushr.request_status, PK-частьСтатус «куда».
requires_noteboolean, default falseПереход требует непустую причину (note) в ленте событий.
created_attimestamptzСлужебное: создание строки.
updated_attimestamptzСлужебное: последнее изменение (триггер).

Правила:

  • Составной PK (from_status, to_status) — каждый переход описан ровно один раз.
  • Гранты: staffapp_app не может менять таблицу (revoke insert/update/delete) — машина правится только миграцией/оператором.
  • Триггер request_status_transition_touchupdated_at при UPDATE.

hr.request — конверт заявки (хребет инбокса)

Единая строка любой заявки (REQ-000123): владеет статусом, датами жизненного цикла и снапшотом отдела; типоспецифичных полей не несёт никогда. Пишется при подаче сотрудником, решается через hr.request_decide(); читается инбоксом /hr/requests (через view hr.request_inbox). Фактически неудаляема: append-only лента событий каскадом «отказывается» удаляться — by design.

ПолеТипСмысл
iduuid PKИдентификатор конверта.
request_nobigint generated always as identityСквозной номер; сырьё для reference.
referencetext generated stored, uniqueСчитается из request_no: 'REQ-' || lpad(request_no, 6, '0') — человекочитаемый номер карточки.
request_type_iduuid (композитный FK, см. правила)Тип заявки (чип); неизменяем после вставки (триггер).
type_handlerhr.request_handlerHandler типа, «припинен» композитным FK и заполняется триггером из request_type; дискриминатор для typed-payload таблиц.
requester_employee_iduuid FK → hr.employee (restrict)Заявитель.
requester_department_iduuid FK → core.departmentСнапшот отдела на момент подачи; при NULL автозаполняется триггером из текущего employment.
statushr.request_status, default 'pending'Текущий статус; меняется только по белому списку переходов. Фасет «New» в инбоксе = pending.
bodytext, default ''Текст заявителя.
leave_request_iduuid FK → hr.leave_request (restrict)Для leave-конвертов — прицепленная заявка на отпуск; статус зеркалируется туда триггером.
submitted_attimestamptz, default now()Момент подачи; ось сортировки/keyset-пагинации инбокса.
decided_attimestamptzМомент решения; штампуется триггером при переходе в approved/rejected.
applied_attimestamptzОтметка «побочные эффекты применены ровно один раз»; request_decide() ставит при первом approve.
created_attimestamptzСлужебное: создание строки.
updated_attimestamptzСлужебное: последнее изменение (триггер).

Правила:

  • PK — id; reference уникален.
  • Unique (id, type_handler) — мишень пиннящих FK из typed-payload таблиц (payload не может прицепиться к конверту чужого handler'а).
  • Композитный FK (request_type_id, type_handler)hr.request_type (id, handler) on update restrict — handler конверта всегда совпадает с handler'ом типа (фикс review 1 CRITICAL: старый одноколоночный FK эту проверку терял).
  • CHECK: type_handler = 'leave' тогда и только тогда, когда leave_request_id задан.
  • Партиальный unique-индекс 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-строк).
  • Constraint-триггер request_require_child (deferred, after insert/update of request_type_id) — к COMMIT у day_off/attendance_correction-конверта обязана существовать payload-строка.
  • Constraint-триггер request_status_event_required (deferred, after update of status) — каждая смена статуса обязана оставить строку в request_status_event, с непустым note, если переход того требует.
  • Constraint-триггер request_day_off_leave_clash (deferred, after update of status) — запрещает одновременно одобренные отпуск и day-off на одну дату (проверка в обе стороны).
  • Триггер request_sync_leave (after update of status) — односторонне зеркалирует статус конверта в hr.leave_request.status.
  • Триггер request_touchupdated_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.

hr.day_off_request — payload: отгул (day-off)

Типизированная полезная нагрузка «один неоплачиваемый выходной» (Q-19): это handler, а НЕ тип отпуска — без квоты и без разворачивания в дни (ADR-06). Пишется вместе с конвертом при подаче; на неё ссылается hr.attendance_record.day_off_request_id (on delete set null) — так одобренный отгул попадает в табель.

ПолеТипСмысл
request_iduuid PK1:1 с конвертом hr.request.
type_handlerhr.request_handler, default 'day_off'Дискриминатор; всегда day_off (CHECK).
day_off_datedateЗапрошенная дата отгула.
created_attimestamptzСлужебное: создание строки.
updated_attimestamptzСлужебное: последнее изменение (триггер).

Правила:

  • PK — request_id (одна payload-строка на конверт).
  • CHECK: type_handler = 'day_off'.
  • Композитный FK (request_id, type_handler)hr.request (id, type_handler) on delete cascade on update cascade — payload прицепляется только к конверту с handler'ом day_off; умирает вместе с конвертом.
  • Триггер day_off_request_touchupdated_at при UPDATE.

hr.attendance_correction_request — payload: коррекция отметки посещаемости

Типизированная полезная нагрузка «дозаполнить забытую отметку» (Q-23): сотрудник просит проставить check-in и/или check-out за конкретную дату. Пишется вместе с конвертом; по одобрению исполняется в домене Attendance.

ПолеТипСмысл
request_iduuid PK1:1 с конвертом hr.request.
type_handlerhr.request_handler, default 'attendance_correction'Дискриминатор; всегда attendance_correction (CHECK).
correction_datedateДата, за которую корректируется отметка.
requested_check_intimeЗапрошенное время прихода (может быть NULL).
requested_check_outtimeЗапрошенное время ухода (может быть NULL).
reasontext not nullОбоснование заявителя — обязательно.
created_attimestamptzСлужебное: создание строки.
updated_attimestamptzСлужебное: последнее изменение (триггер).

Правила:

  • PK — request_id.
  • CHECK: type_handler = 'attendance_correction'.
  • CHECK: хотя бы одно из requested_check_in / requested_check_out задано.
  • Композитный FK (request_id, type_handler)hr.request (id, type_handler) on delete cascade on update cascade — привязка только к конверту нужного handler'а.
  • Триггер attendance_correction_request_touchupdated_at при UPDATE.

hr.request_status_event — append-only лента решений

Лента событий по заявке — блок History на карточке. Каждая смена статуса ОБЯЗАНА оставить здесь строку (страхует отложенный триггер на hr.request); канонически пишется из hr.request_decide(). Append-only «ремень и подтяжки»: и гранты (у staffapp_app отозваны update/delete), и триггер, гейтящийся на роль staffapp_retention.

ПолеТипСмысл
iduuid PKИдентификатор события.
request_iduuid FK → hr.request (cascade)Конверт, к которому относится событие.
from_statushr.request_statusСтатус «до» (nullable).
to_statushr.request_status not nullСтатус «после».
notetextПричина/комментарий; непустой обязателен на переходах с requires_note (отказ, реоткрытие, отмена approved).
actor_employee_iduuid FK → hr.employeeКто решил; NULL = система.
created_attimestamptzМомент события; ось сортировки блока History.
updated_attimestamptzСлужебное (мутации всё равно запрещены).

Правила:

  • PK — 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_touchupdated_at (практически недостижим из-за запрета UPDATE).

hr.request_attachment — вложения не-leave заявок

Файлы, приложенные к заявке с handler'ом day_off/attendance_correction; для leave-заявок вложения живут в hr.leave_request_attachment, а инбокс-view (hr.request_inbox) объединяет оба источника через UNION.

ПолеТипСмысл
iduuid PKИдентификатор вложения.
request_iduuid FK → hr.request (cascade)Конверт-владелец; вложения умирают вместе с ним.
file_iduuid FK → core.file (restrict)Сам файл в общем файловом хранилище; файл нельзя удалить, пока на него ссылается вложение.
positionint, default 0Порядок вложений на карточке.
created_attimestamptzСлужебное: создание строки.
updated_attimestamptzСлужебное: последнее изменение (триггер).

Правила:

  • PK — id.
  • Unique (request_id, file_id) — один файл не прикладывается к заявке дважды.
  • Индекс request_attachment_request_idx (request_id) — выборка вложений карточки.
  • Триггер request_attachment_touchupdated_at при UPDATE.

Домен D5 · Attendance (hr)

Домен посещаемости (§12 DDL): одна строка на сотрудника на календарный день + append-only журнал правок отметок. Смежная таблица hr.attendance_correction_request (заявка сотрудника на корректировку) относится к домену Requests — здесь не описывается, но упоминается: её одобрение порождает строку в hr.attendance_correction через request_id.

hr.attendance_record — дневная запись посещаемости

Одна строка на сотрудника на КАЛЕНДАРНЫЙ день (экраны S03/S14/S19, мобильный check-in). Пишется дневным движком (снапшот из hr.resolve_working_day()) и мобильными/веб-отметками; читается экранами посещаемости и rollup-функциями. Отметки — абсолютные моменты (timestamptz), поэтому ночные смены безопасны; минуты — за вычетом неоплачиваемого перерыва; status — generated с приоритетом holiday > day_off > leave > absent > late > present.

ПолеТипСмысл
iduuid PKgen_random_uuid()
employee_iduuid NOT NULL FK → hr.employee (restrict)Чей день; сотрудники не удаляются — история защищена RESTRICT
work_datedate NOT NULLКалендарный день записи (часть уникального ключа с employee_id)
time_zonetext NOT NULLСнапшот таймзоны на момент записи: аудит-строка никогда не переинтерпретирует себя (ADR-13)
department_iduuid FK → core.departmentСнапшот отдела на дату work_date; автозаполняется триггером из hr.employment (review 3)
expected_check_intimeОжидаемое время прихода (из рота/смены)
expected_check_outtimeОжидаемое время ухода
break_minutesinteger NOT NULL default 0, CHECK >= 0Снапшот неоплачиваемого перерыва (reviews 1+3) — вычитается из expected/worked
expected_sourcehr.expected_source NOT NULLОткуда взят ожидаемый день: schedule / shift / schedule_day_off / day_off / public_holiday / leave
shift_iduuid FK → hr.shift (set null)Датированная смена, если день пришёл из смены
is_holidayboolean NOT NULL default falseПраздничный день (жёстко связан с expected_source, см. CHECK)
is_day_offboolean NOT NULL default falseВыходной (по роте или одобренному day-off)
leave_type_iduuid FK → hr.leave_typeТип отпуска, если день — отпуск
leave_categoryhr.leave_categoryКатегория отчётности; копируется триггером из leave_type (ADR-10), не пишется руками
leave_request_iduuid FK → hr.leave_request (set null)Ссылка на заявку на отпуск-источник
day_off_request_iduuid FK → hr.day_off_request(request_id) (set null)Ссылка на одобренный day-off-источник
checked_in_attimestamptzФактическая отметка прихода (момент, не local time)
checked_out_attimestamptzФактическая отметка ухода
check_in_sourcehr.check_sourceКанал отметки прихода: mobile / web / manual
check_out_sourcehr.check_sourceКанал отметки ухода
check_in_location_iduuid FK → hr.work_location (set null)Геозона, где сделан check-in
check_out_location_iduuid FK → hr.work_location (set null)Геозона check-out
check_in_photo_file_iduuid FK → core.file (set null)Селфи при check-in
check_out_photo_file_iduuid FK → core.file (set null)Селфи при check-out
late_minutesinteger NOT NULL default 0, CHECK >= 0Опоздание в минутах; > 0 даёт status = late
expected_minutesinteger GENERATED storedspan_minutes(expected_check_in, expected_check_out) - break_minutes (не ниже 0); NULL если ожидаемых времён нет
worked_minutesinteger GENERATED stored(checked_out_at - checked_in_at) в минутах минус break_minutes (не ниже 0); NULL пока нет обеих отметок
statushr.attendance_status GENERATED storedИз флагов и отметок: holiday > day_off > leave > absent (нет check-in) > late (late_minutes > 0) > present
created_attimestamptz NOT NULL default now()Создание строки
updated_attimestamptz NOT NULL default now()Обновляется touch-триггером

Правила:

  • UNIQUE (employee_id, work_date) — ровно одна строка на сотрудника на день.
  • CHECK: checked_out_at не может существовать без checked_in_at.
  • CHECK: checked_out_at >= checked_in_at.
  • CHECK: (leave_category IS NULL) = (leave_type_id IS NULL) — категория есть тогда и только тогда, когда есть тип отпуска.
  • CHECK 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. «Праздник подавляет прогул» — структурно, а не конвенцией.
  • CHECK attendance_record_no_expected_when_off_chk: если день — праздник/выходной/отпуск, ожидаемые времена обязаны быть NULL.
  • CHECK 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.
  • Constraint-триггер 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.

hr.attendance_correction — журнал правок отметок

Append-only леджер поправок с обязательной причиной. Пишется админом/движком при любой правке записанных отметок (это НЕ опционально: триггер на attendance_record отклоняет правку без строки здесь); читается карточкой дня и аудитом. Источник правки — либо одобренная заявка сотрудника (request_id), либо ручная правка админа.

ПолеТипСмысл
iduuid PKgen_random_uuid()
attendance_record_iduuid NOT NULL FK → hr.attendance_record (restrict)Какая дневная запись исправлена; запись нельзя удалить, пока есть поправки
request_iduuid FK → hr.requestЗаявка-источник (одобренная attendance_correction); NULL = ручная правка админа
corrected_by_user_account_iduuid NOT NULL FK → core.user_accountКто внёс правку
reasontext NOT NULL, CHECK btrim(reason) <> ''Обязательная непустая причина
old_checked_in_attimestamptzОтметка прихода до правки
old_checked_out_attimestamptzОтметка ухода до правки
new_checked_in_attimestamptzОтметка прихода после правки (сверяется триггером attendance_record)
new_checked_out_attimestamptzОтметка ухода после правки
created_attimestamptz NOT NULL default now()Момент правки
updated_attimestamptz 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 отклоняется).

Views, функции, enum-типы

Все 5 views объявлены with (security_invoker = true) — view никогда не эскалирует поверх грантов вызывающего (в т.ч. append-only-REVOKE'ов).

Views (5)

ViewЧто делает / кто читает
core.v_employee_accessADR-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_currentRead-поверхность S34: сотрудник + текущий-или-ПОСЛЕДНИЙ engagement (неактивные сохраняют последний отдел), user_type, рота, менеджер, состояние доступа, jsonb-массивы локаций CI/CO и праздничных календарей (архивные строки ВКЛЮЧЕНЫ и помечены — лейблы не исчезают, review 3). Facet-ФИЛЬТРЫ идут через EXISTS по hr.employment, не через эти колонки.
hr.request_inboxRead-поверхность /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-типы (14)

EnumЗначенияГде используется
core.role_scopecompany, department, location, subordinatescore.user_role.scope_type — область действия RBAC-гранта
core.audit_actioncreate, update, delete, archive, restore, login, logout, grant, revoke, synccore.audit_log.action
core.notification_channelin_app, lark, emailcore.notification_type.default_channels (массив)
hr.employee_sourcelark, manualhr.employee.source — откуда заведён сотрудник
hr.employee_statusactive, inactivehr.employee.status — ось «активности» вместо archived_at
hr.location_directioncheck_in, check_outhr.employee_work_location.direction — разрешение отмечаться по направлению
hr.request_statuspending, approved, rejected, cancelledhr.request.status, hr.leave_request.status (зеркало), hr.request_status_transition (машина состояний), hr.request_status_event
hr.request_handlerleave, day_off, attendance_correctionhr.request_type.handler, hr.request.type_handler, типизированные payload-таблицы
hr.leave_categoryannual, sick, holiday, unpaid, otherhr.leave_type.category, hr.attendance_record.leave_category (копия по ADR-10) — корзина отчётности
hr.accrual_methodupfront_annual, monthly_workedhr.leave_policy.accrual_method — как начисляется квота
hr.holiday_scopenational, regional, companyhr.holiday_schedule.scope
hr.attendance_statuspresent, late, absent, leave, day_off, holidayhr.attendance_record.status (generated)
hr.expected_sourceschedule, shift, schedule_day_off, day_off, public_holiday, leavehr.attendance_record.expected_source, результат hr.resolve_working_day()
hr.check_sourcemobile, web, manualhr.attendance_record.check_in_source / check_out_source — канал отметки

Сквозные конвенции

  • PK: каждая таблица — id uuid primary key default gen_random_uuid() (pgcrypto). Исключения: типизированные payload'ы заявок (PK = request_id) и core.role_permission / hr.request_status_transition (составные PK).
  • created_at / updated_at: 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'ятся никогда).
  • citext для кодов и логинов (регистронезависимая уникальность); btree_gist для EXCLUDE-констрейнтов с uuid; pg_trgm для substring-поиска по hr.request.reference.
  • GRANT-модель (§17, v3: RLS удалён вместе с tenant-осью):
    • 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).
    • Append-only = «belt and braces»: REVOKE на грантах + role-gated триггер.
    • Доступ = GRANT/REVOKE в БД + permission-коды (core.permission) в app-слое; scope-фильтрация user_role — авторитетно в приложении.
  • Views — все security_invoker = true: view не эскалирует поверх грантов вызывающего.