User Service — Data Model

База данных: user_db

erDiagram
    franchises {
        uuid id PK
        string name "varchar(255)"
        string type "corporate | individual, default corporate"
        timestamp created_at
        timestamp updated_at
    }

    legal_entities {
        uuid id PK
        uuid franchise_id
        string type "franchise | franchisee"
        string name
        string inn
        string kpp
        string ogrn
        string legal_address
        string bank_name
        string bank_bik
        string bank_account
        string bank_corr_account
        string contact_phone
        string contact_email
        boolean is_primary "default false"
        string status "active | suspended"
        string contract_number
        date contract_date
        uuid owner_user_id FK "nullable"
        timestamp deleted_at "nullable, soft delete"
        timestamp created_at
        timestamp updated_at
    }

    employees {
        uuid id PK
        uuid franchise_id
        string first_name "varchar(100)"
        string last_name "varchar(100)"
        string email "varchar(255)"
        string password_hash "varchar(72), bcrypt"
        string phone "varchar(20), nullable"
        string pin_hash "varchar(72), nullable, bcrypt"
        string status "active | inactive, default active"
        boolean is_courier "default false"
        string netmonet_profile_id "varchar(100), nullable — BR 3.2"
        boolean netmonet_enabled "default false — BR 3.2"
        timestamp created_at
        timestamp updated_at
    }

    employee_stores {
        uuid id PK
        uuid employee_id FK
        uuid store_id
    }

    roles {
        uuid id PK
        uuid franchise_id
        uuid owner_legal_entity_id FK "nullable — скрытая роль владельца партнёра (BR 1.4.4)"
        string name "varchar(100), unique per franchise+deleted_at IS NULL"
        text description "nullable"
        boolean is_system "default false"
        timestamp deleted_at "nullable, soft delete"
        timestamp created_at
        timestamp updated_at
    }

    role_permissions {
        uuid role_id PK_FK "ON DELETE CASCADE"
        string permission_key PK "varchar(100), e.g. menu.edit, pos.cash.withdraw"
        boolean granted "default true"
    }

    employee_roles {
        uuid employee_id PK_FK "ON DELETE CASCADE"
        uuid role_id PK_FK "ON DELETE RESTRICT"
        timestamp created_at
    }

    employee_role_stores {
        uuid employee_id PK
        uuid role_id PK
        uuid store_id PK
    }

    franchises ||--o{ legal_entities : "owns (multi-tenant)"
    franchises ||--o{ employees : "owns (multi-tenant)"
    franchises ||--o{ roles : "owns (multi-tenant)"
    legal_entities ||--o{ roles : "hidden owner role (BR 1.4.4)"
    employees ||--o{ employee_stores : "works at"
    employees ||--o| employee_legal_details : "has"
    employees ||--o{ shift_schedules : "planned"
    employees ||--o{ shift_records : "worked"
    employees ||--o{ salary_formulas : "individual"
    employees ||--o{ payroll_records : "paid"
    employees ||--o{ employee_roles : "has roles (BR 1.4.3)"
    roles ||--o{ role_permissions : "granted"
    roles ||--o{ employee_roles : "assigned to"
    employee_roles ||--o{ employee_role_stores : "scoped to stores"
    roles ||--o{ salary_formulas : "role formula (BR 1.4.3)"
    shift_records ||--o{ shift_corrections : "corrected"
    shift_templates ||--o{ shift_schedules : "from template"

    employee_legal_details {
        uuid id PK
        uuid employee_id FK "UNIQUE"
        uuid franchise_id
        string inn "nullable"
        string passport_series "nullable"
        string passport_number "nullable"
        string driver_license_number "nullable"
        date driver_license_expiry "nullable"
        string snils "nullable"
        timestamp created_at
        timestamp updated_at
    }

    shift_templates {
        uuid id PK
        uuid franchise_id
        uuid store_id
        string name "varchar(100)"
        time start_time
        integer duration_minutes
        timestamp created_at
        timestamp updated_at
    }

    shift_schedules {
        uuid id PK
        uuid franchise_id
        uuid store_id
        uuid employee_id FK
        date date
        uuid template_id FK "nullable"
        time start_time
        time end_time
        timestamp created_at
        timestamp updated_at
    }

    shift_records {
        uuid id PK
        uuid franchise_id
        uuid store_id
        uuid employee_id FK
        date date "business day"
        timestamp clock_in
        timestamp clock_out "nullable"
        timestamp break_start "nullable"
        timestamp break_end "nullable"
        integer break_duration_minutes "default 0"
        string source "pos | manual"
        boolean auto_closed "default false"
        string status "on_schedule | off_schedule | missed | unplanned"
        timestamp created_at
        timestamp updated_at
    }

    shift_corrections {
        uuid id PK
        uuid shift_record_id FK
        string type "increase | decrease"
        integer minutes
        text comment "required"
        uuid created_by FK
        timestamp created_at
    }

    salary_formulas {
        uuid id PK
        uuid franchise_id
        uuid store_id "nullable (deferred in BR 1.4.3 — always NULL in MVP)"
        uuid role_id FK "nullable, role-based formula (BR 1.4.3)"
        uuid employee_id FK "nullable, individual formula"
        string formula_type "hourly | fixed | mixed"
        decimal hourly_rate "nullable"
        decimal monthly_salary "nullable"
        decimal overtime_rate "nullable"
        integer norm_hours "default 160"
        timestamp created_at
        timestamp updated_at
    }

    payroll_records {
        uuid id PK
        uuid franchise_id
        uuid store_id
        uuid employee_id FK
        date period_start
        date period_end
        decimal planned_hours
        decimal actual_hours
        decimal break_hours
        decimal net_hours
        jsonb formula_snapshot
        decimal amount
        string status "calculated | confirmed | paid"
        uuid confirmed_by FK "nullable"
        timestamp confirmed_at "nullable"
        timestamp created_at
        timestamp updated_at
    }

    import_previews {
        uuid id PK
        uuid franchise_id
        uuid user_id
        string entity_type "legal_entity"
        jsonb valid_rows
        jsonb errors
        integer total_rows
        integer valid_count
        integer error_count
        timestamp expires_at
        timestamp created_at
    }

Таблицы

franchises

(Введено в BR 1.4.4 §3)

Tenant-сущность. Каждая запись — отдельная франшиза (бренд) со своим изолированным набором ЮЛ, ТТ, сотрудников и каталога. На все остальные таблицы ссылается через franchise_id.

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
namevarchar(255)noБренд / название сети
typevarchar(20)nocorporatecorporate / individual — см. Франшизы
created_attimestampnonow()
updated_attimestampnonow()

Constraints:

  • CHECK (type IN ('corporate','individual'))

Индексы (franchises)

ИмяКолонкиТип
pk_franchises(id)PRIMARY KEY

Ключевые правила (franchises)

  • type = 'corporate' (default) — полноценная франшиза с иерархией: одно главное ЮЛ + произвольное число партнёров. Раздел «Юр. лица» виден в UI; через POST /legal-entities можно создавать ЮЛ type=franchisee
  • type = 'individual' — ИП-режим: одно главное ЮЛ, партнёров нет. Раздел «Юр. лица» скрыт в UI. На бэке POST /legal-entities с type=franchisee403 FRANCHISE_TYPE_INDIVIDUAL
  • Изменение type после старта — вручную SQL (UI-операции в MVP нет)
  • При миграции существующей единственной франшизы → corporate

Юридические лица. Два типа: franchise (головное ЮЛ бренда) и franchisee (ЮЛ партнёра).

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
franchise_iduuidnoТенант
typevarchar(20)nofranchise / franchisee
namevarchar(255)noПолное наименование
innvarchar(12)noИНН (10 или 12 цифр)
kppvarchar(9)yesКПП (у ИП нет)
ogrnvarchar(15)noОГРН (13) или ОГРНИП (15)
legal_addresstextnoЮридический адрес
bank_namevarchar(255)yesНазвание банка
bank_bikvarchar(9)yesБИК
bank_accountvarchar(20)yesРасчётный счёт
bank_corr_accountvarchar(20)yesКорр. счёт
contact_phonevarchar(20)yesТелефон (+7XXXXXXXXXX)
contact_emailvarchar(255)yesEmail
is_primarybooleannofalseГлавное ЮЛ (только type=franchise)
statusvarchar(20)noactiveactive / suspended
contract_numbervarchar(50)yesНомер договора (только type=franchisee)
contract_datedateyesДата договора (только type=franchisee)
owner_user_iduuidyesВладелец-франчайзи (FK → employees.id, только type=franchisee)
deleted_attimestampyesSoft delete (NULL = активно)
created_attimestampnonow()
updated_attimestampnonow()

Индексы

ИмяКолонкиТипУсловие
uq_legal_entities_inn(franchise_id, inn)UNIQUEWHERE deleted_at IS NULL
idx_legal_entities_franchise(franchise_id)BTREE
idx_legal_entities_type(franchise_id, type)BTREEWHERE deleted_at IS NULL
idx_legal_entities_owner(owner_user_id)BTREEWHERE deleted_at IS NULL
idx_legal_entities_status(franchise_id, status)BTREEWHERE deleted_at IS NULL

employees

Сотрудники франшизы.

(Обновлено в BR 1.4.4)

Колонка role (enum admin_franchise / admin_franchisee / manager / cashier) удалена (ALTER TABLE employees DROP COLUMN role). Вместе с ней удалены CHECK-constraint и индекс idx_employees_role. Scope сотрудника определяется через владение ЮЛ + employee_role_stores — см. Ролевая модель.

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
franchise_iduuidnoТенант
first_namevarchar(100)noИмя
last_namevarchar(100)noФамилия
emailvarchar(255)noEmail (уникален в рамках franchise_id)
password_hashvarchar(72)noХэш пароля (bcrypt, cost 12)
phonevarchar(20)yesТелефон (+7XXXXXXXXXX)
pin_hashvarchar(72)yesХэш PIN-кода (bcrypt, 4 цифры)
rolevarchar(20)(Удалено в BR 1.4.4 — единая permission-based модель)
statusvarchar(20)noactiveactive / inactive
is_courierbooleannofalseМожет доставлять заказы
netmonet_profile_idvarchar(100)yes(BR 3.2) ID сотрудника в системе Нетмонет для сопоставления webhook чаевых (миграция 025)
netmonet_enabledbooleannofalse(BR 3.2) Согласие сотрудника на получение чаевых через Нетмонет
created_attimestampnonow()
updated_attimestampnonow()

Индексы (employees)

ИмяКолонкиТипУсловие
uq_employees_email(franchise_id, email)UNIQUE
idx_employees_role(franchise_id, role)(Удалён в BR 1.4.4 вместе с колонкой role)
idx_employees_status(franchise_id, status)BTREE

employee_stores

Привязка сотрудников к торговым точкам (many-to-many).

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
employee_iduuidnoFK → employees.id
store_iduuidnoID торговой точки

Индексы (employee_stores)

ИмяКолонкиТипУсловие
uq_employee_stores(employee_id, store_id)UNIQUE
idx_employee_stores_store(store_id)BTREEДля PIN lookup по ТТ

import_previews

Временное хранилище preview импорта (вместо Redis на этапе MVP).

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
franchise_iduuidnoТенант
user_iduuidnoКто загрузил
entity_typevarchar(50)noТип сущности (legal_entity)
valid_rowsjsonbnoВалидные строки (готовые к вставке)
errorsjsonbnoОшибки: [{row, field, message}]
total_rowsintegernoВсего строк
valid_countintegernoВалидных
error_countintegernoС ошибками
expires_attimestampnoСрок жизни (created_at + 30 min)
created_attimestampnonow()

Очистка expired preview — scheduled task или проверка при обращении (lazy cleanup).

Юридические детали сотрудника. Связь 1:1 с employees.

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
employee_iduuidnoFK → employees.id, UNIQUE
franchise_iduuidnoТенант
innvarchar(12)yesИНН физлица (12 цифр)
passport_seriesvarchar(4)yesСерия паспорта
passport_numbervarchar(6)yesНомер паспорта
driver_license_numbervarchar(20)yesНомер водительского удостоверения
driver_license_expirydateyesСрок действия ВУ
snilsvarchar(14)yesСНИЛС (формат XXX-XXX-XXX XX)
created_attimestampnonow()
updated_attimestampnonow()
ИмяКолонкиТип
uq_employee_legal_details_employee(employee_id)UNIQUE
idx_employee_legal_details_franchise(franchise_id)BTREE

shift_templates (BR 1.4.1)

Шаблоны смен для быстрого создания расписания. Макс. 4 на ТТ.

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
franchise_iduuidnoТенант
store_iduuidnoFK → Store Service
namevarchar(100)noНазвание (“Утренняя”, “Вечерняя”)
start_timetimenoВремя начала
duration_minutesintegernoПродолжительность (минуты)
created_attimestampnonow()
updated_attimestampnonow()

Индексы (shift_templates)

ИмяКолонкиТипУсловие
idx_shift_templates_store(franchise_id, store_id)BTREE

Ограничение макс. 4 шаблона на ТТ — проверяется на уровне приложения.

shift_schedules (BR 1.4.1)

Плановые смены — расписание работы сотрудников.

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
franchise_iduuidnoТенант
store_iduuidnoFK → Store Service
employee_iduuidnoFK → employees.id
datedatenoБизнес-день плановой смены
template_iduuidyesFK → shift_templates.id (если из шаблона)
start_timetimenoВремя начала
end_timetimenoВремя окончания
created_attimestampnonow()
updated_attimestampnonow()

Индексы (shift_schedules)

ИмяКолонкиТипУсловие
uq_shift_schedules_employee_date(employee_id, store_id, date)UNIQUE
idx_shift_schedules_store_date(franchise_id, store_id, date)BTREE

shift_records (BR 1.4.1)

Фактически отработанные смены.

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
franchise_iduuidnoТенант
store_iduuidnoFK → Store Service
employee_iduuidnoFK → employees.id
datedatenoБизнес-день (дата clock_in)
clock_intimestampnoВремя прихода
clock_outtimestampyesВремя ухода (null = смена открыта)
break_starttimestampyesНачало перерыва
break_endtimestampyesКонец перерыва
break_duration_minutesintegerno0Вычисляется: break_end − break_start
sourcevarchar(10)nopos / manual
auto_closedbooleannofalseАвтозакрытие (превышение 24ч)
statusvarchar(20)noon_schedule / off_schedule / missed / unplanned
created_attimestampnonow()
updated_attimestampnonow()

Индексы (shift_records)

ИмяКолонкиТипУсловие
idx_shift_records_employee_date(employee_id, date)BTREE
idx_shift_records_store_date(franchise_id, store_id, date)BTREE
idx_shift_records_status(franchise_id, store_id, status)BTREE

shift_corrections (BR 1.4.1)

Корректировки отработанных часов для прошедших смен.

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
shift_record_iduuidnoFK → shift_records.id
typevarchar(10)noincrease / decrease
minutesintegernoВеличина корректировки (минуты)
commenttextnoПричина (обязательно)
created_byuuidnoFK → employees.id (кто корректировал)
created_attimestampnonow()

Индексы (shift_corrections)

ИмяКолонкиТип
idx_shift_corrections_record(shift_record_id)BTREE

salary_formulas (BR 1.4.1 → обновлено в BR 1.4.3)

Формулы начисления зарплаты. Могут быть привязаны к роли-объекту или к конкретному сотруднику.

(Изменено в BR 1.4.3: поле role (varchar enum) заменено на role_id (UUID FK → roles.id). Per-ТТ ставки (store_id) помечены как deferred — всегда NULL в MVP.)

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
franchise_iduuidnoТенант
store_iduuidyesNULLDeferred BR 1.4.3 — в MVP всегда NULL. В будущем возможна per-ТТ ставка
role_iduuidyesFK → roles.id. NULL = индивидуальная формула
employee_iduuidyesFK → employees.id. NULL = формула по роли
formula_typevarchar(10)nohourly / fixed / mixed
hourly_ratedecimal(10,2)yesСтавка ₽/час (для hourly, mixed)
monthly_salarydecimal(12,2)yesОклад ₽/мес (для fixed, mixed)
overtime_ratedecimal(10,2)yesСтавка за переработку ₽/час (для mixed)
norm_hoursintegeryes160Норма часов/мес (для mixed)
created_attimestampnonow()
updated_attimestampnonow()

Иерархия формул

employee_id != NULL → индивидуальная. Иначе role_id → по роли. Индивидуальная перекрывает ролевую.

Индексы (salary_formulas)

ИмяКолонкиТипУсловие
uq_salary_formulas_role(role_id)UNIQUEWHERE employee_id IS NULL (одна формула на роль — обновлено в BR 1.4.3)
uq_salary_formulas_employee(employee_id)UNIQUEWHERE employee_id IS NOT NULL

payroll_records (BR 1.4.1)

Ведомости начислений за период.

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
franchise_iduuidnoТенант
store_iduuidnoFK → Store Service
employee_iduuidnoFK → employees.id
period_startdatenoНачало периода (1-е число месяца)
period_enddatenoКонец периода (последний день месяца)
planned_hoursdecimal(6,2)no0Плановые часы
actual_hoursdecimal(6,2)no0Фактические часы
break_hoursdecimal(6,2)no0Часы перерывов
net_hoursdecimal(6,2)no0Чистое время (actual − break ± corrections)
formula_snapshotjsonbnoСнимок формулы на момент расчёта
amountdecimal(12,2)no0Рассчитанная сумма
statusvarchar(20)nocalculatedcalculated / confirmed / paid
confirmed_byuuidyesFK → employees.id
confirmed_attimestampyes
created_attimestampnonow()
updated_attimestampnonow()

Индексы (payroll_records)

ИмяКолонкиТип
uq_payroll_records_employee_store_period(employee_id, store_id, period_start)UNIQUE
idx_payroll_store_period(franchise_id, store_id, period_start)BTREE
idx_payroll_status(franchise_id, status)BTREE

roles (BR 1.4.3, обновлено BR 1.4.4)

Объект-роль с настраиваемыми permissions. После BR 1.4.4 — единственный слой ролевой модели (enum employees.role удалён).

ПолеТипNullableDefaultОписание
iduuidnogen_random_uuid()PK
franchise_iduuidnoТенант
owner_legal_entity_iduuidyesNULLFK → legal_entities.id ON DELETE CASCADE. Если задано — роль скрытая (персональная для владельца партнёра), не показывается в списках /admin/roles, редактируется только через карточку ЮЛ. При удалении ЮЛ партнёра скрытая роль каскадно удаляется (Введено в BR 1.4.4 §5.2)
namevarchar(100)noНазвание роли (для скрытых — техническое, владельцу не показывается)
descriptiontextyesНеобязательное описание
is_systembooleannofalsetrue для системной роли «Администратор» — нельзя удалить/изменить права
deleted_attimestampyesSoft delete. NULL = активна
created_attimestampnonow()
updated_attimestampnonow()

Индексы (roles)

ИмяКолонкиТипУсловие
uq_roles_name_per_franchise(franchise_id, LOWER(name))UNIQUEWHERE deleted_at IS NULL AND owner_legal_entity_id IS NULL
idx_roles_franchise(franchise_id)BTREE
idx_roles_system(franchise_id, is_system)BTREE
idx_roles_visible(franchise_id)BTREE (partial)WHERE owner_legal_entity_id IS NULL AND deleted_at IS NULL — для быстрого списка обычных ролей (BR 1.4.4)
idx_roles_hidden(owner_legal_entity_id)BTREE (partial)WHERE owner_legal_entity_id IS NOT NULL — для lookup скрытой роли по ЮЛ (BR 1.4.4)

Уникальность имени и скрытые роли

Партиал-уникальный индекс uq_roles_name_per_franchise исключает скрытые роли — их технические имена не участвуют в проверке коллизий с пользовательскими ролями.


role_permissions (BR 1.4.3)

Набор permission-ключей для каждой роли. Ключи перечислены в API.md в разделе «Permission catalog».

ПолеТипNullableDefaultОписание
role_iduuidnoFK → roles.id, ON DELETE CASCADE
permission_keyvarchar(100)nomenu.read, pos.cash.withdraw и т.п.
grantedbooleannotruetrue = право выдано. false позволяет явно «выключить» унаследованное (резерв на будущее)

PRIMARY KEY: (role_id, permission_key).

Системные роли

Для is_system = true ролей permissions фиксированы (все возможные ключи granted=true). Редактирование этой таблицы для системных ролей запрещено на уровне приложения.


employee_roles (BR 1.4.3)

Связь «сотрудник — роль» (N:M).

ПолеТипNullableDefaultОписание
employee_iduuidnoFK → employees.id, ON DELETE CASCADE
role_iduuidnoFK → roles.id, ON DELETE RESTRICT (нельзя удалить роль, назначенную сотруднику)
created_attimestampnonow()

PRIMARY KEY: (employee_id, role_id).

Индексы

ИмяКолонкиТип
idx_employee_roles_role(role_id)BTREE
idx_employee_roles_employee(employee_id)BTREE

employee_role_stores (BR 1.4.3)

Набор магазинов, в которых действует данная связка (сотрудник, роль). Если сотруднику назначена роль «Курьер» только в ТТ-3, а роль «Бариста» в ТТ-1 и ТТ-2 — это две разные записи employee_roles с разными наборами строк employee_role_stores.

ПолеТипNullableDefaultОписание
employee_iduuidno
role_iduuidno
store_iduuidnoFK → Store Service

PRIMARY KEY: (employee_id, role_id, store_id). FOREIGN KEY (employee_id, role_id)employee_roles(employee_id, role_id) ON DELETE CASCADE.

Пустой набор магазинов

Если у связки employee_roles вообще нет строк в employee_role_stores — роль действует без ограничения по магазинам (например, для системной роли «Администратор»).

Индексы

ИмяКолонкиТип
idx_employee_role_stores_store(store_id)BTREE

kds_devices (BR 5.1)

(Добавлено в BR 5.1)

KDS-устройства (Android-планшеты), зарегистрированные в системе. Регистрируются явно через POST /admin/kds/devices/register, привязаны к одной ТТ. Soft-delete (revoked_at) — force-logout админом.

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()Суррогатный PK
device_iduuidNOT NULLUUID, генерится на устройстве при первом запуске и хранится в SQLite. Передаётся в каждом запросе как X-Device-Id.
franchise_iduuidNOT NULLМультитенантность
store_iduuidNOT NULLТТ устройства (cross-service ref → Store Service)
namevarchar(100)NULLПонятное имя (default = 'KDS-{first 6 hex of device_id}')
last_user_iduuidNULLПоследний логинившийся (для аудита)
current_user_iduuidNULLАктивная сессия (NULL если разлогинен или ещё не логинился)
app_versionvarchar(20)NULLУстановленная версия APK (передаётся при heartbeat)
last_seen_attimestampNULLHeartbeat. Используется для расчёта is_online (< 2 мин ago)
revoked_attimestampNULLSoft-удалено админом через DELETE /admin/kds/devices/{id}
created_attimestampNOT NULLnow()
updated_attimestampNOT NULLnow()

Ограничения:

  • uq_kds_devices_franchise_device — UNIQUE (franchise_id, device_id) WHERE revoked_at IS NULL — одно активное устройство с этим device_id в франшизе. После revoke — можно ре-зарегистрировать с тем же device_id.
  • Кросс-сервисные ссылки: store_id → Store Service, last_user_id / current_user_idusers.id (внутри User Service)

Индексы:

ИмяКолонкиТип
idx_kds_devices_franchise_store(franchise_id, store_id) WHERE revoked_at IS NULLBTREE
idx_kds_devices_last_seen(last_seen_at DESC) WHERE revoked_at IS NULLBTREE

Бизнес-правила:

  • revoked_at != NULL → 401 DEVICE_REVOKED при любом use устройства; KDS должен пройти регистрацию заново
  • Повторная регистрация (после revoke) — это новая запись с тем же device_id. Старая остаётся в БД для аудита
  • current_user_id сбрасывается в NULL при logout / auto-logout / revoke
  • last_user_id сохраняет последнего успешного юзера (для аудита, не сбрасывается)

Миграция (Liquibase changeset ZZ-add-kds-devices.xml):

  • Создаёт таблицу kds_devices
  • Добавляет permissions kds.access и kds.settings.edit в каталог permissions (см. ниже)

pos_desktop_devices (POS Desktop onboarding)

(Добавлено как часть POS Desktop onboarding, миграция 027-pos-desktop-devices.xml)

POS-устройства (Windows-кассы), зарегистрированные в системе. Структура и поведение зеркальные kds_devices — выделено в отдельную таблицу для будущих расхождений (например, разные heartbeat-интервалы).

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()Суррогатный PK
device_iduuidNOT NULLUUID, генерится на ПК при первом запуске и хранится в localStorage. Передаётся в каждом запросе как X-Device-Id.
franchise_iduuidNOT NULLМультитенантность
store_iduuidNOT NULLТТ кассы (cross-service ref → Store Service)
namevarchar(100)NULLПонятное имя (default = 'POS-{first 6 hex of device_id}')
last_user_iduuidNULLПоследний логинившийся кассир (для аудита)
current_user_iduuidNULLАктивная сессия (NULL если разлогинен)
app_versionvarchar(20)NULLВерсия инсталлера (передаётся в X-App-Version)
last_seen_attimestampNULLHeartbeat. Используется для расчёта is_online (< 2 мин ago)
revoked_attimestampNULLSoft-удалено админом через DELETE /admin/pos/devices/{id}
created_attimestampNOT NULLnow()
updated_attimestampNOT NULLnow()

Ограничения:

  • uq_pos_desktop_devices_franchise_device — UNIQUE (franchise_id, device_id) WHERE revoked_at IS NULL — одно активное устройство на франшизу.

Индексы:

ИмяКолонкиТип
idx_pos_desktop_devices_franchise_store(franchise_id, store_id) WHERE revoked_at IS NULLBTREE
idx_pos_desktop_devices_last_seen(last_seen_at DESC) WHERE revoked_at IS NULLBTREE

Бизнес-правила:

  • Идентичны kds_devices. Permission pos.settings.edit (расширенный смысл — покрывает и настройки кассы, и управление устройствами).
  • Force-logout через 401 DEVICE_REVOKED на следующем heartbeat’e в pos-bff middleware (см. API · /internal/pos-devices/{deviceId}/heartbeat).

Permissions catalog (KDS-расширение)

(Добавлено в BR 5.1)

В каталог permissions (см. таблицу roles.permissions и permission-catalog) добавляются:

PermissionКатегорияОписание
kds.accessKDSБазовый доступ к KDS-приложению (PIN-логин). Без этого — 403 KDS_ACCESS_DENIED
kds.settings.editKDSРегистрация/правка/удаление KDS-устройств; редактирование kds_franchise_settings (звуки/громкость/auto_logout); правка kitchen_stations пороги
marketing.read (BR 6.1)МаркетингПросмотр списка слайдов своей ТТ. См. Маркетинговая информация
marketing.write (BR 6.1)МаркетингСоздание, редактирование, удаление, изменение порядка слайдов. Edit implies Read

Эти permissions выдаются ролям через стандартный механизм role_permissions (см. таблицу выше).

Миграция BR 6.1 (NNN-marketing-permissions.xml):

  • INSERT в permission_catalog ключей marketing.read, marketing.write с категорией «Маркетинг»
  • INSERT в role_permissions для системной роли «Администратор» каждой франшизы — оба ключа

Ключевые правила

Юридические лица

  • inn — уникален в рамках franchise_id (partial unique index, WHERE deleted_at IS NULL)
  • inn — неизменяем после создания
  • is_primary = true — максимум одно ЮЛ на franchise_id, только для type = franchise
  • status = suspended — возможен только для type = franchisee; type = franchise всегда active
  • owner_user_id — nullable, привязка к франчайзи может быть выполнена позже
  • deleted_at — soft delete, запись остаётся в БД
  • contract_number, contract_date — только для type = franchisee, nullable

Сотрудники

(Переработано в BR 1.4.4 — enum employees.role удалён, единая permission-модель)

  • password_hash — bcrypt, cost factor 12
  • pin_hash — bcrypt, 4 цифры, уникален в рамках store_id (проверка на уровне приложения, не DB-constraint, т.к. значение захэшировано)
  • email — уникален в рамках franchise_id
  • Нет поля role: scope сотрудника определяется отдельным internal endpoint GET /internal/users/{id}/scope (см. API) по правилам:
    1. user = legal_entities.owner_user_id и ЮЛ type=franchise → вся франшиза
    2. user = legal_entities.owner_user_id и ЮЛ type=franchisee → свои ЮЛ + их ТТ
    3. иначе → ТТ из employee_role_stores (объединение всех permissions-ролей)
  • Привязка к магазинам — только через employee_role_stores (per-role); employee_stores остаётся как legacy для прямой связи (используется PIN-логином)
  • Правила создания:
    • Владелец франшизы — только при bootstrap (SQL), не через API
    • Владелец партнёра — только в одной транзакции с POST /legal-entities (type=franchisee)
    • Обычные сотрудники — через POST /employees (поля role нет)
  • Доступ к юридическим деталям — только владельцы (по scope) для своих сотрудников
  • Миграция BR 1.4.4: ALTER TABLE employees DROP COLUMN role + удаление CHECK constraint и индекса idx_employees_role

Роли и permissions (BR 1.4.3, обновлено BR 1.4.4)

  • roles.name — уникально в рамках franchise_id (case-insensitive) среди неудалённых обычных ролей (owner_legal_entity_id IS NULL); скрытые роли владельцев партнёров в проверке не участвуют
  • is_system = true — можно переименовать/изменить описание, но нельзя удалить, изменить permissions или снять флаг системной
  • При удалении роли (soft delete) проверяется: нет ли записей в employee_roles с этой role_id у активных сотрудников → 409 ROLE_IN_USE
  • employee_roles — сотрудник может иметь несколько ролей одновременно
  • employee_role_stores — набор магазинов для каждой связки (employee, role) независим; пустой набор = без ограничения
  • store_id в employee_role_stores должен быть подмножеством ТТ, доступных сотруднику по scope (см. правила scope в разделе «Сотрудники») — проверка на уровне приложения
  • Edit implies Read: при granted=true для <section>.edit автоматически должна быть запись <section>.read с granted=true — проверка на уровне приложения при сохранении
  • Скрытые роли владельцев партнёров (BR 1.4.4 §5.2):
    • owner_legal_entity_id IS NOT NULL → роль скрыта из всех публичных списков (GET /roles, GET /roles/{id} возвращает 404)
    • Создаётся автоматически при POST /legal-entities с режимом «Настроенные права» или через PUT /legal-entities/{id}/owner-permissions при переключении full→custom
    • Удаляется при переключении custom→full или при физическом удалении ЮЛ (ON DELETE SET NULL → но в коде роль удаляется вместе с ЮЛ)
    • Минимум permissions (форсится сервером, нельзя снять): pos.access, stores.read, employees.read

Юридические детали (BR 1.4.1)

  • 1:1 с employees (уникальный employee_id)
  • Все поля (кроме employee_id и franchise_id) — nullable
  • Доступ: только владельцы (по scope — см. правила scope в разделе «Сотрудники»), для своих сотрудников

Шаблоны смен (BR 1.4.1)

  • Максимум 4 шаблона на store_id (проверка на уровне приложения)
  • При удалении шаблона привязанные shift_schedules не удаляются (template_id остаётся для истории)

Расписание смен (BR 1.4.1)

  • Уникальность: один сотрудник — одна плановая смена на (employee_id, store_id, date)
  • Плановые смены можно создавать только на будущие дни (date > CURRENT_DATE)
  • Удалять и редактировать — только будущие

Фактические смены (BR 1.4.1)

  • date (бизнес-день) = дата clock_in — для ночных смен бизнес-день = день начала
  • status — вычисляется автоматически при создании/обновлении записи сопоставлением с shift_schedules:
    • on_schedule — факт в пределах ±30 мин от плана
    • off_schedule — факт есть, но отклонение >30 мин
    • missed — план есть, факта нет (ставится ретроспективно)
    • unplanned — факт есть, плана нет
  • auto_closed = true — смена автоматически закрыта через 24ч (scheduled job)
  • break_duration_minutes = break_end - break_start (в минутах, 0 если нет перерыва)

Корректировки смен (BR 1.4.1)

  • Можно корректировать смены в любом статусе кроме missed
  • comment — обязательное поле
  • Корректировку можно удалить (сброс)

Формулы зарплаты (BR 1.4.1)

  • employee_id IS NOT NULL → индивидуальная формула, перекрывает ролевую
  • employee_id IS NULL + role + store_id → формула по роли для ТТ
  • Иерархия: индивидуальная → по роли+ТТ → нет формулы
  • formula_type = hourly: обязательно hourly_rate
  • formula_type = fixed: обязательно monthly_salary
  • formula_type = mixed: обязательно monthly_salary, overtime_rate, norm_hours

Ведомости (BR 1.4.1)

  • Уникальность: (employee_id, store_id, period_start)
  • formula_snapshot — JSON-снимок формулы на момент расчёта (для историчности)
  • Статус-машина: calculatedconfirmedpaid (только вперёд)
  • confirmed — пересчёт невозможен; calculated — можно пересчитать