Перейти к содержанию

Модель данных CM Ocean Loyalty

Версия продукта (CalVer): 26.3.0

CM Ocean Loyalty хранит данные в собственной PostgreSQL; доступ — через Spring Data JDBC (JdbcClient, явный SQL, без JPA). Схема ведётся миграциями Liquibase и применяется приложением на старте: ранние изменения — плоской нумерацией (0001…0066), новые — папками по функциональным областям (subaccounts/, currency/, manual-operations/, ledgertime/, program-config/, purchase/, client-profile/ и т.д.). Все суммы — в минорных единицах (*_minor bigint). Идентификаторы проводок, лотов, резервов, акций, офферов, покупок и событий согласования — снежинки (snowflake), назначаемые приложением; справочные записи получают IDENTITY от БД; короткие человекочитаемые коды (акции PR-00001, партнёрские офферы PO-0000001) — из SEQUENCE.

В тексте ниже колонки таблицы перечисляются через точку с запятой; тип указан, когда он важен для понимания.

Документ описывает таблицы по доменам. Обзор продукта — Погружение в продукт.

1. Обзорная карта

erDiagram
  customer ||--o{ account : "владелец"
  points_subaccount ||--o{ account : "субсчёт"
  currency ||--o{ points_subaccount : "валюта"
  chart_of_accounts ||--o{ account : "тип счёта"
  account ||--o{ account_lot : "лоты"
  account ||--o{ ledger_entry : "дебет/кредит"
  account ||--o{ redemption_hold : "резервы"
  redemption_scope |o--o{ account_lot : "область списания"
  mechanic ||--o{ promotion : "шаблон"
  promotion ||--o{ promotion_label_link : "метки"
  promotion ||--o{ promotion_audience_condition : "аудитория"
  promotion ||--o| promotion_participation_limit : "лимит участий"
  promotion ||..o{ approval_event : "согласование"
  program_config ||--o{ loyalty_tier : "уровни"
  program_config ||--o{ program_config_promotion : "акции программы"
  loyalty_tier ||--o{ customer_tier : "уровень клиента"
  loyalty_tier ||--o{ customer_tier_override : "ручное назначение"
  customer ||..o{ purchase : "покупки"
  purchase ||--o{ purchase_return : "возвраты"
  customer ||--o{ customer_tag : "теги"
  tag ||--o{ customer_tag : ""
  customer_segment ||--o{ customer_segment_member : "состав"
  client_profile_configuration ||--o{ client_profile_field : "поля карточки"
  customer ||--o{ referral_link : "реферер/реферал"
  achievement ||--o{ customer_achievement : "выданные бейджи"
  partner ||--o{ partner_offer : "офферы"
  partner |o--o{ merchant : "мерчанты"
  operation_batch ||--o{ bonus_operation : "движения пакета"
  audit_log }o--o{ audit_seal : "печати"

Группы таблиц: бонусное ядро (леджер, валюты, субсчета); акции и механики; конфигурация программы, уровни и правила списания; покупки и возвраты; клиенты, профиль и теги; сегменты клиентов; реферальная программа и достижения; партнёрские предложения; ручные операции; справочники и конфигурация; операционные журналы, уведомления и обмен данными; аудит и безопасность; служебные таблицы.

2. Бонусное ядро (леджер)

erDiagram
  currency ||--o{ currency_rate_history : "история курса"
  currency ||--o{ points_subaccount : "субсчета валюты"
  currency ||--o{ exchange_pair : "пары обмена"
  exchange_pair ||--o{ exchange_pair_limit : "направления"
  exchange_pair_limit ||--o{ exchange_pair_subaccount : "субсчета источника"
  currency ||--o{ account_opening_run : "открытие счетов"
  points_subaccount ||--o{ points_subaccount_restriction : "область списания"
  points_subaccount ||--o{ account : "счета"
  chart_of_accounts ||--o{ account : "статья"
  customer ||--o{ account : "владелец"
  account ||--o{ account_lot : "лоты"
  redemption_scope ||--o{ redemption_scope_item : "значения"
  redemption_scope |o--o{ account_lot : "область партии"
  account ||--o{ ledger_entry : "дебет/кредит"
  account_lot ||--o{ ledger_entry_lot : "движение лота"
  account ||--o{ redemption_hold : "резервы"
  redemption_hold ||--o{ redemption_hold_part : "разбивка по счетам"

chart_of_accounts — план счетов

coa_id PK (identity); code text UNIQUE; title; balance_type balance_nature (DEBIT/CREDIT/BOTH, дефолт CREDIT); redemption redemption_type (nullable). Задаёт природу сальдо и назначение счёта. Статьи, заводимые миграциями во всех контекстах: CORR «Корректировки» (контрсчёт ручного снятия баллов), PRTF «Финансирование партнёров», MRK (источник начислений), RDM «Списания баллов», BRK «Сгорание баллов», 2110 (счета клиентов в балльных валютах) и 2120 (счета клиентов в денежных валютах).

План счетов нового клиентского счёта берётся из параметра приложения customer.default.chart-of-accounts (по коду валюты: PTS → 2110, RUB → 2120); валюта без записи получает 2110, если она балльная, и 2120, если денежная.

currency — справочник валют (деньги и баллы)

currency_id PK; code text (UNIQUE uq_currency_code, CHECK — только верхний регистр); title; symbol (nullable — без символа в суммах показывается код); precision int (CHECK 0…6); description; kind (CHECK MONEY/POINTS); rate numeric(18,6); rate_currency_id → currency; enabled; is_base; sort_order; enabled_at / enabled_by / enabled_by_name; disabled_at / disabled_by / disabled_by_name; points_lifetime_days int (базовый срок жизни баллов, дни).

Деньги и баллы — одна запись с типом. У балльной валюты обязательны текущий курс и валюта курса (1 единица баллов = rate единиц rate_currency_id), у денежной их нет (CHECK currency_kind_rate_ck). Базовая денежная валюта — не более одной (частичный UNIQUE currency_single_base_uq), и она обязана быть MONEY. Базовый срок жизни баллов points_lifetime_days — только у балльной валюты, 1…3650 дней (CHECK currency_points_lifetime_days_ck); пусто — баллы, для которых более точный срок не задан, не сгорают. При переходе на это правило всем балльным валютам проставлено 365 дней (валюте баллов программы — единый срок активной конфигурации, если он был задан). При установке сидируются RUB «Российский рубль» (базовая) и валюта баллов PTS (курс 1 к базовой) с базовым субсчётом. Индекс по account.currency_id обслуживает проверку «по валюте есть операции».

Таблица point_currency удалена

Прежний отдельный справочник валют баллов (point_currency: курс, цвет, порядок) слит в currency: курс, признак включения и порядок перенесены, по каждой балльной валюте заведена первая запись истории курса, таблица удалена.

currency_rate_history — история курса балльной валюты

id PK (identity); currency_id → currency (ON DELETE CASCADE); rate numeric(18,6) (CHECK ≥ 0); rate_currency_id → currency; changed_at; changed_by; changed_by_name. Курс действует с момента сохранения (changed_at); текущее значение денормализовано в currency.rate, расчёты историю не читают. Индекс (currency_id, changed_at DESC, id DESC).

exchange_rate — курсы обмена

rate_id PK; source_currency / target_currency → currency (CHECK different_currencies); price numeric (CHECK > 0); operation_type operation_type (PURCHASE/SELL). Курсы, по которым выполняется операция обмена POST /v1/exchange.

exchange_pair — пары обмена между балльными валютами

pair_id PK (identity); from_currency_id / to_currency_id → currency (CHECK — разные валюты; UNIQUE exchange_pair_direction_uq — одна пара на направление); rate_mode (CHECK BASE — курс считается из курсов валют, UNIT — «сколько единиц получателя за 1 единицу источника», PACK — пачка целых единиц); rate numeric(18,6) (только у UNIT, > 0); pack_from / pack_to (только у PACK: размер пачки источника и сколько целых единиц получателя за неё зачисляется); rounding (DOWN/HALF_UP/UP) и keep_remainder (обязательны у BASE и UNIT, отсутствуют у PACK — пачки обмениваются целиком); bidirectional («в обе стороны»); enabled; created_at / updated_at.

Курс хранится так, как его вводит администратор; обратный курс не хранится. Согласованность значений с режимом держат CHECK exchange_pair_mode_values_ck и exchange_pair_rounding_mode_ck; то, что пара «в обе стороны» не пересекается с парой обратного направления, проверяет сервис. Пары ведутся в администрировании (/v1/admin/exchange-pairs); операция обмена POST /v1/exchange по-прежнему считает по exchange_rate.

exchange_pair_limit — ограничения направления пары

PK (pair_id, direction); pair_id → exchange_pair (ON DELETE CASCADE); direction (CHECK FORWARD/BACKWARD); min_amount_minor (минимум за операцию); limit_amount_minor (лимит за период); limit_period (CHECK DAY/WEEK/MONTH/YEAR, дефолт MONTH). Суммы — в минорных единицах валюты-источника направления. Прямое направление есть у любой пары, обратное — только у пары «в обе стороны».

exchange_pair_subaccount — разрешённые субсчета источника

PK (pair_id, direction, subaccount_id); (pair_id, direction) → exchange_pair_limit (ON DELETE CASCADE); subaccount_id → points_subaccount. С каких субсчетов источника можно обменивать баллы в этом направлении; нет строк — с любых.

points_subaccount — субсчёт баллов

subaccount_id PK (identity); code; name; description; currency_id → currency; state subaccount_state (DRAFT/ACTIVE/CLOSED, дефолт DRAFT); is_base; expiry_mode subaccount_expiry_mode (PERIOD/FIXED_DATE/NONE); expiry_period interval; expiry_at timestamptz; redeem_priority int; created_at / updated_at.

Целевой счёт внутри карты клиента со своим правилом сгорания и местом в очереди списания. Ограничения: UNIQUE (currency_id, code); ровно один базовый субсчёт на валюту (частичный UNIQUE points_subaccount_base_uq по currency_id WHERE is_base); UNIQUE (currency_id, redeem_priority) DEFERRABLE — очередь списания однозначна (меньший приоритет списывается раньше); CHECK согласованности режима сгорания: PERIOD — только период, FIXED_DATE — только момент expiry_at, NONE — ни того ни другого.

  • Фиксированный срок — момент с часовым поясом («действуют до 31.01.2027, 23:59:59»), а не календарная дата. Прежняя колонка expiry_date date перенесена в expiry_at как 00:00:00 этого дня в UTC и удалена.
  • Базовый субсчёт (is_base, прежнее имя колонки — is_default) закреплён за валютой и не переназначается: сюда попадают начисления без явного указания субсчёта. Своего срока сгорания и товарных ограничений у него нет — при переходе они сброшены, дальше их не даёт сервер. Базовый субсчёт BASE «Основной» (без сгорания, приоритет 100) заводится у каждой валюты при её создании; включённым денежным валютам, заведённым раньше, его досоздала миграция, выключенной — он заводится при включении.
  • «Акционные баллы» (PROMO) заводятся у каждой балльной валюты (без сгорания, следующее свободное место в очереди списания) — субсчёт начисления акций по умолчанию; для балльных валют, появившихся позже, он заводится при первом обращении.

points_subaccount_restriction — область списания субсчёта

restriction_id PK (identity); subaccount_id → points_subaccount; kind subaccount_restriction_kind (PRODUCT / CATEGORY — «на что», MERCHANT / STORE — «где»); value (код товара, категории, MID мерчанта или код торговой точки из справочников продукта). UNIQUE (subaccount_id, kind, value). Пустой набор — без ограничений.

Ограничения действуют при списании за покупку: субсчёт с ограничениями «на что» участвует в списании, только если в запросе переданы позиции чека (и в пределах подходящих позиций), с ограничениями «где» — только если переданное место списания (торговая точка или мерчант) входит в ограничения. У базового субсчёта ограничений нет.

redemption_scope / redemption_scope_item — снимки области списания

redemption_scope: scope_id PK (identity); fingerprint (UNIQUE — отпечаток нормализованного набора значений); created_at. redemption_scope_item: PK (scope_id, kind, value); scope_id → redemption_scope; kind subaccount_restriction_kind; value.

Область списания баллов, начисленных акцией (где и на что их можно тратить), хранится неизменяемым снимком: одинаковый набор значений — один снимок, на него ссылаются акция (promotion.points_redemption_scope_id) и партия баллов (account_lot.redemption_scope_id). Правка акции создаёт новый снимок, уже начисленные партии остаются на прежнем. Снимки, на которые ссылаются, не удаляются. Сейчас область фиксируется на партии при начислении (акция, отмена возврата, копия партии при переводе); при списании область партии пока не проверяется.

account — счёт

account_id PK; account_number; customer_id → customer (nullable — у системных счетов нет владельца); coa_id → chart_of_accounts; currency_id → currency; subaccount_id → points_subaccount (NOT NULL, валюта счёта совпадает с валютой субсчёта); total_debit bigint; total_credit bigint; state account_state (ACTIVE/CLOSED/SUSPENDED); created_at / updated_at.

Ключ счёта — «клиент + субсчёт»: у клиента ровно один счёт на субсчёт (частичный UNIQUE account_customer_subaccount_uq WHERE customer_id IS NOT NULL). Баланс — производная от проводок: total_credit − total_debit, отдельной колонкой он не хранится; total_* ведутся движком. updated_at проставляет триггер БД при любом изменении. Индексы: по account_number, по currency_id, (updated_at, account_id) — под ленту изменений.

  • Системные счета CORR, MRK, RDM и BRK (одноимённые статьи, валюта баллов программы, базовый субсчёт) заводятся миграциями всегда — вместе с валютой баллов и её субсчётом, если их ещё нет. Источник начислений, списание и сгорание по умолчанию — разные счета, поэтому ушедшее с клиентских счетов делится на «списано» и «сгорело» по номеру счёта-получателя.
  • Счета клиента во всех включённых валютах: при заведении клиента открываются счета на базовых субсчетах каждой включённой валюты; при создании включённой валюты и при включении выключенной счета активным клиентам открывает фоновая задача account_opening_run.

account_opening_run — задачи открытия счетов в валюте

run_id PK (identity); currency_id → currency (ON DELETE CASCADE); status (CHECK PENDING/DONE/CANCELLED, дефолт PENDING); reason (CHECK ENABLED — валюту включили или создали включённой, DEPLOY — разовый прогон при выкладке, CATCH_UP — при заведении клиента счёт в валюте открыть было не на что); cursor_customer_id (последний обработанный клиент); opened_count; requested_at; finished_at. Задачу разбирает шедулер пачками по customer_id; прогресс — курсор в строке, рестарт продолжает с места остановки. Незавершённая задача по валюте одна (частичный UNIQUE account_opening_run_pending_uq WHERE status = 'PENDING').

account_lot — лот баллов

lot_id PK (снежинка); account_id → account; original_balance / current_balance bigint (CHECK ≥ 0, positive_balance); redemption redemption_type; status lot_status (OPEN/CLOSED/CANCELLED/EXPIRED); expires_at timestamptz; created_at; updated_at (триггер БД); expiry_notified_at (когда по лоту последний раз поставлено в очередь предупреждение о скором сгорании; NULL — не ставилось); expiry_notified_step interval (последний поставленный шаг предупреждения — за сколько до срока); expiry_source (источник срока); expiry_tier_code / expiry_config_id (уровень и конфигурация — только у источника TIER); redemption_scope_id → redemption_scope (область списания партии; NULL — без ограничений).

Партия начисления со своим сроком; списание и сгорание — по лотам в порядке FIFO/LIFO/FEFO.

  • Срок партии — первый заданный по цепочке: срок начисления (акция — PROMOTION или запрос — REQUEST) → свой срок субсчёта (SUBACCOUNT) → срок уровня клиента в конфигурации, по которой он жил в момент начисления (TIER) → базовый срок валюты (CURRENCY) → бессрочно (NONE). Результат фиксируется в партии и дальше не пересчитывается. У партий, начисленных до правила, источника нет.
  • Согласованность источника и срока держит база: NONE — только без expires_at, любой другой источник — только с ним (CHECK account_lot_expiry_moment_ck); уровень и конфигурация заданы ровно у TIER и только вместе (CHECK account_lot_expiry_tier_ck). Внешнего ключа на конфигурацию нет — партия живёт дольше конфигурации.
  • Несколько шагов предупреждения: выборка шага берёт только лоты, по которым этот шаг ещё не уходил; уже предупреждённые до перехода лоты получили прежний единственный шаг — 7 суток.
  • Индексы: частичный по account_id WHERE status='OPEN' (расчёт доступного остатка), частичный (expires_at, lot_id) по открытым лотам со сроком (выборка предупреждений), (updated_at, lot_id), частичный по redemption_scope_id.

ledger_entry — проводка (помесячно партиционирована)

entry_id bigint (снежинка); debit_account_id / credit_account_id → account; amount bigint; operation ledger_operation (ACCRUAL/STORNO/ADJUSTMENT); posted_at timestamptz (дефолт — часы БД); business_date date (дефолт — открытый операционный день, при пустом справочнике — календарная дата в часовом поясе программы: system_config.program.timezone, иначе env-дефолт контура, иначе Europe/Moscow); publish_xid xid8 (идентификатор транзакции записи).

Составной PK (posted_at, entry_id); таблица PARTITION BY RANGE (posted_at) с помесячными партициями ledger_entry_YYYY_MM, нарезаемыми вперёд (по умолчанию на 12 месяцев) миграцией и обслуживающим заданием, плюс страховочная ledger_entry_default, которая должна оставаться пустой (заполнение под метрикой). business_date — источник истины для учётных итогов; есть у всех проводок, включая пополнения, переводы, обмены и плановое сгорание. Индексы (debit_account_id, posted_at) и (credit_account_id, posted_at). Append-only — основа двойной записи.

Переход на модель времени проводки

Уникальность entry_id схемой больше не держится (PK составной), внешний ключ ledger_entry_lot → ledger_entry снят. Оба инварианта удерживаются генератором идентификаторов и общей транзакцией записи и проверяются ночным заданием (дубли идентификаторов, лоты-сироты). На пустой базе леджер пересоздаётся миграцией; на контуре с данными перевод выполняется эксплуатационным скриптом deploy/sql/ledger-time-model-swap.sql в окно обслуживания до деплоя — иначе приложение не стартует с сообщением о непереведённой схеме.

ledger_entry_lot — разбивка проводки по лотам

PK (entry_id, lot_id); entry_id (ссылка на проводку без FK); lot_id → account_lot; amount bigint; metadata jsonb. Связывает движение с конкретными лотами.

redemption_hold — резерв под списание

hold_id PK (снежинка); account_id → account; amount bigint (CHECK > 0); status text; reference; ledger_id; created_at; expires_at. Двухфазное списание: hold уменьшает доступный баланс (balance − активные holds) без движения леджера; commit захватывает, cancel освобождает. Резерв без явного срока живёт system_config.redemption.hold-ttl (env-дефолт PT30M). Частичный индекс idx_redemption_hold_active по status='ACTIVE'.

redemption_hold_part — разбивка резерва по субсчетам

part_id PK (identity); hold_id → redemption_hold (ON DELETE CASCADE); account_id → account; amount_minor bigint (CHECK > 0). UNIQUE (hold_id, account_id). Раскладка резерва по счетам субсчетов фиксируется при создании; подтверждение идёт по ней, даже если приоритеты субсчетов с тех пор изменились.

idempotency_record — журнал идемпотентности денежных операций

idempotency_key PK; operation; response_status int; response_body; created_at. Повтор ключа в пределах окна возвращает сохранённый ответ; ключ, занятый другой операцией, отклоняется. Окно повтора конечно (LOYALTY_IDEMPOTENCY_RETENTION, дефолт P7D), просроченные записи убираются по расписанию LOYALTY_IDEMPOTENCY_CLEANUP_CRON (по умолчанию выключено); индекс по created_at — под уборку. После закрытия окна двойную проводку по-прежнему отсекает ledger_posting_reference.

ledger_posting_reference — дедуп проводок

reference PK; entry_id bigint; created_at. Защита внутри леджера: UNIQUE на бизнес-ссылке (вне партиционированной ledger_entry); дубль ссылки откатывает всю операцию. Строки пакетов ручных операций пишут ссылку <ссылка пакета>:<externalId>.

Порядок списания лотов — операционный параметр (system_config.redemption.consume-order): FIFO / LIFO / FEFO, редактируется без редеплоя; NULL → env-дефолт FEFO.

3. Акции и механики

erDiagram
  mechanic ||--o{ param_def : "параметры"
  mechanic ||--o{ promotion : "шаблон"
  promotion ||--|| promotion_stat : "статистика"
  promotion ||--o| promotion_participation_limit : "лимит участий клиента"
  promotion ||--o{ promo_participation : "регистр участий"
  promotion ||--o{ promotion_label_link : "метки версии"
  promotion_label ||--o{ promotion_label_link : ""
  promotion ||--o{ promotion_label_history : "история меток"
  promotion ||--o{ promotion_audience_condition : "условия аудитории"
  promotion_audience_condition ||--o{ promotion_audience_ref : "теги/метки/акции"
  redemption_scope |o--o{ promotion : "область списания баллов"
  promotion ||..o{ approval_event : "история согласования"
  criterion_def ||..o{ promotion : "условия (criteria_json)"

mechanic — каталог механик

code PK; title; type text; description; rt_flow_diagram text (имя диаграммы RT Flow); accrues_points bool (дефолт true). Сидированы: CASHBACK, MULTIPLIER, GIFT, REFERRAL, UNIT_BONUS, BUNDLE_1PLUS1. Признак «начисляет баллы» решает, доступны ли у акции срок жизни, субсчёт и область списания начисляемых баллов; он снят только у BUNDLE_1PLUS1 — механика отдаёт единицы товара, а не баллы.

param_def — параметры механик

id PK; mechanic_code → mechanic; param_key; label; type (CHECK: PERCENT/MONEY/ INT/DECIMAL/STRING/BOOLEAN/ENUM/MULTISELECT/CATEGORY/SKU/SEGMENT); required; default_value; options_json; group_name; sort_order; hint; unit; min_value / max_value; ref_dict. UNIQUE (mechanic_code, param_key). Описывает поля карточки акции.

Параметр механики — только то, что нужно расчёту этой механики и не задано ни условием, ни ограничением акции. У CASHBACK удалены «Макс. бонус», «Мин. сумма чека» и «Сегменты клиентов», у BUNDLE_1PLUS1 — «Категория товаров» (эти значения задаются полями и условиями акции). Добавлены параметры-награды: multiplier «Множитель» (MULTIPLIER), giftMinor «Баллов в подарок» (GIFT), pointsPerUnit «Баллов за единицу» (UNIT_BONUS) и обязательная unitPriceMinor «Стоимость дополнительной единицы» (BUNDLE_1PLUS1, по умолчанию 1 ₽); rewardQty у BUNDLE_1PLUS1 называется «Дополнительных единиц». У механики REFERRAL четыре параметра: referrerRewardValue «Вознаграждение реферера» и referrerRewardUnit «Единица вознаграждения реферера», так же refereeRewardValue / refereeRewardUnit для реферала (единица — POINTS «баллы» или PERCENT «%» от суммы активирующей покупки).

promotion — акция (версия акции)

promotion_id PK (снежинка); version bigint (оптимистичная блокировка); mechanic_code → mechanic; code (UNIQUE uq_promotion_code, из promotion_code_seq, формат PR-00001); name; description (необязательное описание цели акции, на начисления не влияет); group_name; status; archived bool; starts_at / ends_at; value numeric; params jsonb (значения параметров механики и условия по клиенту); criteria_json text (условия акции, дефолт []); event_type (дефолт PURCHASE); max_bonus_minor; budget_minor; total_limit int (CHECK > 0); priority int; stacking (дефолт STACKABLE); root_id / revision (линия версий); points_lifetime_days int; points_subaccount_code; points_redemption_scope_id → redemption_scope; approval_status (дефолт UNDERWAY); resume_requires_approval bool; created_at / updated_at; created_by / created_by_name; updated_by / updated_by_name.

  • Статус — DRAFT / SCHEDULED / ACTIVE / PAUSED / COMPLETED. Архив — отдельный обратимый флаг archived, а не статус: при переходе акции со статусом ARCHIVED получили archived = true и статус PAUSED (после разархивирования акция сама не начнёт применяться).
  • Условия акции criteria_json — список записей {code, mode, values | range, method}: code — условие из реестра criterion_def, mode — INCLUDE («применяется, если») или EXCLUDE («не применяется, если»), values — выбранные значения, range — диапазон, method — способ задать значение у добавленного условия. Прежние scope_json (категории, артикулы, сегменты) и колонки границ суммы чека min_check_amount_minor / max_check_amount_minor удалены: непустые списки и заданные границы перенесены в записи INCLUDE (граница чека — условие check-amount).
  • Условие «Уровень клиента» хранится в params.conditions.client.levels списком пар {configId, code}: код уровня уникален только внутри конфигурации программы.
  • Тип события event_type — код опубликованной записи справочника event-type (из коробки — только PURCHASE «Покупка»). Движок это поле не читает: применение акций запускает событие покупки.
  • Баллы акции: points_lifetime_days — срок жизни начисляемых баллов, 1…36500 дней (NULL — срок определяет цепочка: субсчёт, уровень, валюта); points_subaccount_code — субсчёт начисления (по умолчанию PROMO «Акционные баллы»; NULL у механики без начисления баллов); points_redemption_scope_id — область списания начисленных баллов (NULL — без ограничений).
  • Потолок бонуса за операцию — только поле max_bonus_minor: прежний ключ params.maxBonusMinor перенесён в колонку и удалён.
  • Согласование запуска — независимая от статуса ось approval_status: UNDERWAY (в работе), SUBMITTED (на согласовании), APPROVED (согласована), REJECTED (отклонена), CHECK ck_promotion_approval_status. resume_requires_approval — пауза, поправленная после запуска, возобновляется только после нового согласования.
  • Бюджет budget_minor — в деньгах (минорные единицы базовой валюты); расход считается по журналу bonus_operation на всю линию версий с пересчётом баллов по курсу операции. Заведённые ранее бюджеты (в баллах) пересчитаны по курсу PTS.
  • Общий лимит участий total_limit — сколько раз акция срабатывает на всю базу за всё время линии версий.
  • Индексы: idx_promotion_status, (updated_at, promotion_id) — под ленту изменений, частичный по points_redemption_scope_id.

criterion_def — реестр условий акции

code PK; origin (CHECK BOX — коробочное, CUSTOM — добавленное); label; hint_include / hint_exclude (подсказки для зон); source (CHECK PURCHASE — переменная покупки, CUSTOMER — поле профиля клиента); attribute_key; methods jsonb (способы задать значение); zones jsonb (зоны, где условие доступно, дефолт INCLUDE и EXCLUDE); visible; sort_order; deleted_at (мягкое удаление); created_at / updated_at.

  • Коробочные условия (сид): product-category «Категории товаров», sku «Артикулы (SKU)», geo-region «Регион», city «Город», store «Магазин», merchant «Мерчант», channel «Канал продаж», customer-segment «Сегменты клиентов», check-amount «Сумма чека». Сравнение у них зашито в продукт, способов нет.
  • Добавленные условия получают код purchase.<переменная> или customer.<код поля профиля>; у них обязательны source и attribute_key (CHECK criterion_def_custom_has_source). Одна переменная — одно условие: UNIQUE (source, lower(attribute_key)) среди неудалённых добавленных. Способы в methods: REF_LIST — из справочника продукта (поле сравнения, признак множественного выбора), MANUAL — ручной ввод (TEXT / NUMBER / DATE), RANGE — диапазон (NUMBER / DATE).
  • Мягкое удаление — строка остаётся, чтобы завершённые и архивные акции показывали название удалённого условия. Индекс (sort_order, code) по неудалённым.

criterion_registry_revision — ревизия реестра условий

Синглтон id = 1 (CHECK); revision bigint. Реестр сохраняется целиком одной операцией; ревизия передаётся в If-Match и защищает от перезаписи чужой правки.

promotion_stat — статистика акции

promotion_id PK → promotion (ON DELETE CASCADE); applications bigint; bonus_minor bigint; updated_at. Проекция, ведётся транзакционно циклом покупки.

promotion_participation_limit — лимит участий клиента в акции

promotion_id PK → promotion (ON DELETE CASCADE); max_count int (CHECK > 0); period (CHECK DAY/WEEK/MONTH/YEAR/TOTAL, дефолт TOTAL); period_amount int; period_mode (CALENDAR/SLIDING). Сколько раз один клиент может участвовать за окно. Пустые period_amount и period_mode читаются как 1 и CALENDAR; при period = TOTAL оба обязаны быть пустыми (CHECK promotion_participation_limit_window_chk).

promo_participation — регистр участий

id PK (identity); promotion_id; promotion_root_id (линия версий); external_id; occurred_at; period_key / used_count (устаревшие, заполнены только у исторических строк). Одна строка на участие с отметкой времени — так считаются и календарные, и скользящие окна; строки без occurred_at в подсчёт не попадают. История участий при переходе восстановлена из начислений по акциям в bonus_operation. Индексы (promotion_root_id, external_id, occurred_at) — лимиты; (external_id, occurred_at) — условие аудитории «Участие в акциях».

promotion_label — справочник меток акций

label_id PK (identity); value (1–60 символов, хранится нормализованным в нижнем регистре, UNIQUE uq_promotion_label_value — регистронезависимо); version bigint (для If-Match при переименовании/слиянии); created_at / updated_at. Метка объединяет акции в кампанию; клиент попадает в «группу метки», участвуя в помеченной акции. На клиенте метка не хранится.

PK (promotion_id, label_id); promotion_id → promotion (ON DELETE CASCADE); label_id → promotion_label (без каскада — связи снимает сервис с записью истории); created_at. Метка стоит на конкретной версии акции; новая версия получает метки исходной копированием. Индекс по label_id.

promotion_label_history — история меток акции

history_id PK (identity); promotion_id → promotion (ON DELETE CASCADE); action (CHECK ASSIGNED/REMOVED/MERGED/DELETED); label_value (значение на момент события); target_value (метка-цель, только и обязательно при MERGED); actor_id; actor_name; occurred_at. Пишется в транзакции самой операции (пакетная простановка, каскад удаления, слияние). Индекс (promotion_id, occurred_at DESC).

promotion_audience_condition — условие аудитории акции

condition_id PK (identity); promotion_id → promotion (ON DELETE CASCADE); zone (CHECK INCLUDE — «Акция применяется, если» / EXCLUDE — «Не применяется, если»); criterion (CHECK CUSTOMER_TAG / PROMOTION_PARTICIPATION); position int (номер набора в зоне: наборы участий соединяются «И», у тегов набор один — 0); tag_match (ANY/ALL — только и обязательно для тегов); includes_self bool (ссылка «Эта акция»); count_operator (GT/LT/GTE/LTE/EQ/BETWEEN); count_value (≥ 0); count_value_to; period (ALL_TIME/PROMOTION_PERIOD). Для участия в акциях оператор, значение и период обязательны. UNIQUE (promotion_id, zone, criterion, position).

promotion_audience_ref — значения условия

PK (condition_id, kind, ref_id); condition_id → promotion_audience_condition (ON DELETE CASCADE); kind (CHECK TAG/LABEL/PROMOTION); ref_id (id тега, метки или линии версий акции, намеренно без FK); value_snapshot (последнее известное значение — битая ссылка называется словами). Индекс (kind, ref_id) — обратные запросы «какие акции ссылаются на тег/метку/акцию» (запрет удаления, слияние, список акций метки).

approval_event — журнал согласования запуска

id PK (снежинка); entity_type (CHECK PROMOTION_ACTIVATION); entity_id (акция, без FK); action (CHECK SUBMIT — отправка, APPROVE — согласование, REJECT — отклонение, CANCEL — отзыв); comment (до 1000 символов); actor_id; actor_name; approver_ids / notify_user_ids (text[], согласующие и получатели оповещений — только у отправки, CHECK ck_approval_event_lists_only_on_submit); created_at. История согласования одной акции, новые сверху — индекс (entity_type, entity_id, created_at DESC, id DESC). Переходы статуса promotion.approval_status: отправка — из «в работе» или «отклонена» в «на согласовании»; согласование, отклонение и отзыв — только из «на согласовании».

unit_of_measure — единицы измерения

code PK; title; symbol; decimals int; dimension (VOLUME/MASS/COUNT/ENERGY/ DISTANCE). Для начисления за натуральный объём (механика UNIT_BONUS).

global_mechanic — глобальная механика

Синглтон id=1 (CHECK); enabled (дефолт false); mechanic_code (дефолт CASHBACK); value numeric; description. Базовое начисление на все покупки поверх акций.

4. Конфигурация программы, уровни и правила списания

erDiagram
  program_config ||--o{ program_config_activation : "история активаций"
  program_config ||--o{ program_config_promotion : "акции программы"
  program_config ||--o{ loyalty_tier : "уровни"
  program_config ||--o| program_config_redemption : "правила списания"
  program_config ||--o{ program_config_redemption_item : "товары и категории"
  loyalty_tier ||--o| program_config_redemption_tier : "максимум уровня"
  loyalty_tier ||--o{ customer_tier : "уровень клиента"
  loyalty_tier ||--o{ customer_tier_override : "ручное назначение"
  program_config ||--o{ customer_tier_transition : "переходы"

Правила программы (уровни, настройки накопления, акции программы и правила списания) собраны в конфигурацию программы. Конфигураций может быть несколько, действует ровно одна; остальные — черновики, которые готовятся, не влияя на клиентов, до явной активации. Руководство — Конфигурации программы.

program_config — конфигурация программы

id PK (identity); name (UNIQUE uq_program_config_name); status (CHECK draft/active, дефолт draft); notify_customers bool; version int (оптимистичная блокировка); created_at / updated_at; created_by / updated_by; настройки накопления уровня: tier_metric (CHECK amount — сумма покупок, count — количество, both — оба порога), tier_recalc_mode (CHECK rolling — скользящее окно, personal — персональный период), tier_window_days (окно, 1…365, дефолт 365), tier_review_every + tier_review_unit (периодичность пересмотра: единица day/week/month/year, число — от 1 до 10 лет в пересчёте), tier_period_days (персональный период, 1…3650), tier_carry_over bool (перенос излишка в персональном периоде).

Активна ровно одна — частичный UNIQUE uq_program_config_one_active WHERE status = 'active'. Параметры обоих режимов пересчёта хранятся всегда, поэтому переключение режима у черновика не теряет введённое. Миграция заводит активную конфигурацию «Основная» и привязывает к ней существующие уровни. Своего срока жизни баллов у конфигурации нет: прежние колонки default_points_expiry_days, points_expiry_mode и points_expiry_days удалены, базовый срок — у валюты (currency.points_lifetime_days), отличия — у уровней (loyalty_tier.points_expiry_days).

program_config_activation — история активаций

id PK (identity); config_id / previous_config_id → program_config (ON DELETE SET NULL — запись переживает удаление конфигурации); snapshot jsonb (настройки и уровни активированной конфигурации на момент активации — в том виде, в каком их отдаёт API конфигурации); notify_customers; activated_by; activated_at. Append-only журнал; индекс (activated_at DESC).

program_config_promotion — акции программы

PK (config_id, root_promotion_id); config_id → program_config (ON DELETE CASCADE); root_promotion_id → promotion (линия версий акции, без каскада); created_at. Акции, образующие базовое начисление конфигурации. Привязка — на линию версий, поэтому переживает «Создать новую версию»; привязанную акцию удалить нельзя. Индекс по root_promotion_id.

loyalty_tier — уровни конфигурации

PK (config_id, code); config_id → program_config (ON DELETE CASCADE — удаление черновика удаляет его уровни); code; name; ordinal int; threshold_minor bigint (порог по сумме покупок); entry_count bigint (порог по количеству покупок, CHECK ≥ 0); accrual_rate numeric; segments text (CSV-кодов сегментов); points_expiry_days int (срок жизни баллов уровня, 1…3650; NULL — не задан).

Код, название и порядковый номер уникальны в рамках конфигурации: UNIQUE (config_id, ordinal) и (config_id, name) — DEFERRABLE INITIALLY DEFERRED, чтобы перестановка уровней черновика проходила через промежуточные совпадения. Порог, не используемый метрикой конфигурации, хранится нулём. Колонка metric удалена — метрика принадлежит конфигурации. Срок жизни баллов уровня — звено цепочки срока партии (раздел 2, account_lot). В «Основной» сидированы BRONZE «Бронза» (базовый, ordinal 0, порог 0) / SILVER / GOLD / PLATINUM с порогами 0 / 2 000 000 / 6 000 000 / 15 000 000 (минорные единицы) и ставками 1.0 / 1.0 / 2.0 / 3.0.

tier_profile_field — схема дополнительных полей карточки уровня

field_id PK (identity); field_key (UNIQUE); label; field_type (CHECK STRING/NUMBER/DATE/BOOLEAN/LIST); input_source (SYSTEM/MANUAL); required; editable; hidden; show_in_list; default_value; options_json; ref_dict; system_field; sort_order; created_at / updated_at. Сидировано поле retention_amount «Сумма покупок для удержания уровня».

tier_attribute — значения полей уровня

PK (tier_code, field_key); value; updated_at.

customer_tier — уровень клиента

external_id PK; config_id → program_config (конфигурация, в которой клиент получил уровень: до своего пересмотра он живёт по её порогам); tier_code (вместе с config_id — ключ loyalty_tier, без каскада: удалить уровень или конфигурацию с клиентами нельзя); assigned_at; next_review_at (личная дата пересмотра, считается от перехода); period_start; carry_amount_minor / carry_count (перенос излишка в персональном периоде, CHECK ≥ 0). Уровень — хранимое состояние: меняют его покупка (повышение) и пересмотр в личную дату (любое направление). Накопление здесь не хранится — оно считается по журналу покупок purchase (сумма за вычетом возвратов) за окно конфигурации. Индексы: next_review_at — выборка фонового пересмотра, config_id — проверка «на конфигурации есть клиенты».

customer_tier_override — ручное назначение уровня

external_id PK; config_id + tier_code → loyalty_tier (ON DELETE CASCADE); reason (подпись причины из справочника «Причины операций» на момент назначения; у старых назначений — свободный текст); reason_code (код причины); comment (обязателен при причине OTHER); set_by; set_at. Назначение делается в конфигурации, в которой клиент сейчас. Назначение — «пол»: если клиент заработал уровень выше назначенного, действует заработанный.

customer_tier_transition — история переходов уровня

id PK; external_id; from_code (nullable — у первого перехода) / to_code; from_name / to_name (названия уровней на момент перехода — снимок; NULL у строк, для которых название не восстановлено); accumulated_minor (сумма покупок на момент перехода); accumulated_count (количество покупок; NULL — переход записан до появления колонки); source (CHECK purchase — повышение покупкой, review — пересмотр в личную дату, включая переход на новую конфигурацию, manual — назначение, массовый перевод или снятие назначения оператором, migration — перенос системой, например при удалении уровня); config_id → program_config; operator, reason_code, reason (у ручных переходов); occurred_at. Прежняя колонка kind (ORGANIC/MANUAL) заменена на source. Индекс (external_id, occurred_at DESC).

tier_event_outbox — исходящие события о смене уровня

id PK (bigserial); external_id (ключ сообщения — события клиента идут по порядку); event_type (tiers.customer.tier-changed); payload jsonb; created_at; published_at (NULL — ещё не отправлено); expired_at (событие устарело неотправленным и уже не уйдёт); attempts; last_error. Строка пишется той же транзакцией, что и строка истории переходов; отдельный отправитель публикует её в Kafka (топик по умолчанию loyalty.tiers.tier-changed) и помечает, доставка — at-least-once. Частичные индексы: очередь неотправленных, уборка отправленных и устаревших.

program_config_redemption — правила списания конфигурации

config_id PK → program_config (ON DELETE CASCADE); scope (CHECK any — любые покупки, product_list — только товары списка, category_list — только категории списка, min_amount — от минимальной суммы); min_purchase_minor; min_type / min_value, max_type / max_value (тип FIX — баллы в минорных единицах, PERCENT — % суммы чека, а у областей «товары» и «категории» — % суммы подходящих позиций; ноль — «без ограничения»); max_mode (CHECK uniform — один максимум, per_tier — свой на каждом уровне); min_balance_minor (списание доступно, только когда на счету не меньше); multiplicity_minor (списание только кратно значению). Все значения ≥ 0.

Правила берутся из активной конфигурации и действуют сразу. Строки у конфигурации может не быть — тогда списание идёт по лимитам разрезов и глобальной политике, как раньше.

program_config_redemption_item — товары и категории области списания

PK (config_id, kind, code); config_id → program_config (ON DELETE CASCADE); kind (CHECK product/category); code (код из справочника продукта, без FK — исчезнувший из выгрузки код не ломает сохранённую конфигурацию).

program_config_redemption_tier — максимум списания уровня

PK (config_id, tier_code) → loyalty_tier (ON DELETE CASCADE); max_value (≥ 0). Используется при max_mode = per_tier.

redemption_policy — глобальная политика списания

Синглтон id=1 (CHECK); enabled; min_type / max_type (CHECK FIX/PERCENT); min_value; max_value. Мин/макс суммы списания на транзакцию; дефолт permissive (min 0 FIX, max 100%).

redemption_limit — лимиты списания по разрезам

id PK; scope_type (CHECK: TIER/CURRENCY/SEGMENT); scope_key; enabled; min_type / min_value; max_type / max_value (типы границ — CHECK FIX/PERCENT). UNIQUE (scope_type, scope_key).

Порядок проверки списания

Проверка идёт от простого к тонкому: минимальный баланс → область списания → минимум и максимум → кратность. Эффективные минимум и максимум — самый частный из заданных разрезов: уровень (TIER) → сегмент (SEGMENT, при нескольких — самый строгий) → валюта (CURRENCY) → правило активной конфигурации → глобальная политика redemption_policy.

5. Покупки и возвраты

erDiagram
  purchase ||--o{ purchase_return : "возвраты"
  purchase_return ||--o{ purchase_return_line : "сторнированные начисления"
  purchase ||--o{ bonus_operation : "начисления покупки"

purchase — покупка

purchase_id PK (снежинка); external_id (клиент); reference (UNIQUE uq_purchase_reference — ссылка события: повторная доставка не создаёт вторую покупку); amount_minor (сумма, от которой считались начисления); currency (валюта суммы — деньги, не баллы); occurred_at; business_date (дефолт — открытый операционный день); returned_minor (сумма активных возвратов, CHECK 0…amount_minor); store_code (код торговой точки, nullable); attributes jsonb (переменные покупки).

Строка на покупку — основание начисления: по ней считаются выручка, средний чек и ROI акции, а также накопление уровня. Позиции и артикулы чека не хранятся. Переменные покупки — {"check": {"название": "значение"}, "lines": [{"название": "значение"}]}, NULL — переменных нет; с ними сравниваются добавленные условия акции purchase.<переменная>; повтор той же ссылки их не перезаписывает. Город и регион покупки не дублируются — выводятся из справочника по коду точки. Индексы (occurred_at), (external_id, occurred_at).

purchase_return — возврат по чеку

return_id PK (идентификатор возврата кассы); purchase_id → purchase; amount_minor (CHECK > 0); status (CHECK ACTIVE/CANCELLED); created_at; cancelled_at; balance_minor и purchase_fully_returned (ответ кассе на момент приёма — повтор того же returnId отдаёт его). Клиент и валюта возврата берутся у покупки. Отменённый возврат остаётся строкой — тот же return_id повторно не принимается. Отмена уменьшает purchase.returned_minor, и накопление уровня, посчитанное по журналу покупок, возвращается само. Индекс по purchase_id.

purchase_return_line — что сторнировано по возврату

PK (return_id, operation_id); return_id → purchase_return; operation_id (начисление покупки в bonus_operation); share_minor (сколько полагалось снять, > 0); reversed_minor (сколько реально снято, 0…share_minor). Сторнируется не больше доступного баланса: разница — баллы, которые клиент уже потратил. Ссылка проводки для отмены берётся из журнала по operation_id. Индекс по operation_id.

purchase_attribute_daily / purchase_attribute_value — статистика переменных покупки

purchase_attribute_daily: PK (business_date, attr_key); purchases; line_purchases; check_purchases (сколько покупок несли переменную — всего, в позициях и в чеке); unparsed_number / unparsed_date (сколько покупок пришли со значением, которое не разбирается как число и как дата). purchase_attribute_value: PK (attr_key, attr_value); business_date; last_seen_at — последние различные значения переменной (не больше пяти с покупки); индекс (attr_key, last_seen_at DESC).

Статистика за 7 дней помогает администратору заметить ошибку в названии переменной до запуска акции. Пополняется в транзакции регистрации новой покупки; хранится сверх окна (loyalty.purchase-attributes.stats-retention, дефолт P30D), уборка по расписанию loyalty.purchase-attributes.cleanup-cron по умолчанию выключена.

6. Клиенты, профиль и теги

erDiagram
  customer ||--o| identity_profile : "профиль (без внешнего MDM)"
  customer ||--o| customer_timezone : "часовой пояс"
  customer ||--o{ customer_status_history : "история статуса"
  client_profile_configuration ||--o{ client_profile_table_section : "разделы"
  client_profile_configuration ||--o{ client_profile_field_group : "карточки и таблицы"
  client_profile_table_section ||--o{ client_profile_field_group : "таблицы раздела"
  client_profile_field_group ||--o{ client_profile_table_column : "столбцы"
  client_profile_field_group ||--o{ client_profile_field : "поля"
  client_profile_field_key ||--o{ client_profile_field : "ключ"
  client_profile_field_key ||--o{ customer_attribute : "ключ"
  customer ||--o{ customer_attribute : "доп. поля"
  customer ||--o{ customer_tag : "теги"
  tag ||--o{ customer_tag : "носители"
  customer ||--o{ customer_tag_history : "история тегов"
  customer ||--o{ bonus_operation : "операции"

customer — участник

customer_id PK (identity); external_id text UNIQUE; status (CHECK ACTIVE/BLOCKED/MERGED/DELETED); merged_into (external_id выжившего при слиянии); archived bool (обратимое скрытие из каталога, независимо от статуса); test bool (тестовый клиент); referral_code (NOT NULL, UNIQUE); created_at (дата заведения в реестре программы); updated_at (триггер БД).

  • Мягкое удаление — статус DELETED: клиент скрывается из каталога, поиска и карточки, леджер и история операций сохраняются.
  • created_at хранится у себя, чтобы список клиентов сортировался на сервере независимо от внешнего MDM; у существующих клиентов заполнен датой регистрации из identity_profile.
  • referral_code — 8 символов из алфавита без неоднозначных символов (без 0/O, 1/I), генерируется один раз при создании клиента и не меняется.
  • updated_at сдвигается и при изменении профиля (identity_profile) или набора тегов — для ленты изменений это «клиент изменился».
  • Индексы: (created_at DESC), archived, (updated_at, customer_id).

customer_status_history — история смены статуса клиента

history_id PK (identity); external_id → customer (ON DELETE CASCADE — физического удаления клиента в продукте нет, каскад срабатывает только при очистке тестовых данных); status / previous_status (CHECK — набор статусов клиента, значения различаются); reason (у слияния — из запроса); actor_id / actor_name (автор на момент события; NULL — изменение вне HTTP-запроса); occurred_at (clock_timestamp() под блокировкой строки клиента). Пишется приложением вместе со сменой статуса — блокировка, разблокировка, удаление, слияние; история начинается с даты выкладки. Индекс (external_id, occurred_at DESC, history_id DESC).

identity_profile — встроенный профиль клиента

external_id PK; external_system; last_name / first_name / middle_name; phone; email; birth_date; city; loyalty_status; erased_fields (перечень обезличенных полей ПДн — отличает «не заполнялось» от «затёрто»); timezone (часовой пояс IANA, заявленный внешней системой; из интерфейса не правится); registered_at; created_at / updated_at. Источник личности для контуров без внешнего MDM; при подключённом MDM не используется. Колонка segments (CSV кодов сегментов) удалена — сегменты клиента берутся только из состава сегментов (раздел 7).

customer_timezone — вычисленный часовой пояс клиента

external_id PK; zone; source (CHECK PROFILE — пояс профиля, PROFILE_CITY — город профиля по справочнику городов, PURCHASE_CITY — самый частый город покупок, PROGRAM — пояс программы); resolved_at. Результат первого сработавшего источника в этом порядке; по нему считается момент отправки уведомлений. Отдельная таблица — значение вычисляет продукт, а профиль приходит из внешней системы.

client_profile_configuration — версии настройки карточки клиента

configuration_id PK (identity); name; status (CHECK ACTIVE/DRAFT); revision bigint (ревизия содержимого — передаётся в If-Match, каждая правка версии её увеличивает, переименование — нет); created_at / created_by / created_by_name; updated_at / updated_by / updated_by_name.

Единица версионирования — вся настройка: разделы, карточки и таблицы, столбцы, поля, их свойства, порядок и чувствительность. Активна ровно одна версия (частичный UNIQUE uq_client_profile_configuration_active); активную не правят — её меняют копией и активацией копии. Удаление версии каскадно удаляет всю её настройку. Миграция превратила существующую настройку в активную версию «Основная настройка». Руководство — Настройка карточки клиента.

client_profile_table_section — разделы таблиц

section_id PK (identity); configuration_id → client_profile_configuration (ON DELETE CASCADE); code (стабильный код, UNIQUE в версии, копируется как есть); name; sort_order; system_section; created_at / updated_at. Коробочные разделы: points_and_tier «Баллы и уровень», client «Клиент», activity «Активность и коммуникации».

client_profile_field_group — карточки и таблицы профиля

group_id PK (identity); configuration_id → client_profile_configuration (ON DELETE CASCADE); code (UNIQUE в версии); name; group_type (CHECK CARD — карточка полей, TABLE — таблица); source_schema / source_table (источник значений — оба заданы или оба пусты); section_id → client_profile_table_section (обязателен у таблицы, отсутствует у карточки); sort_order; system_group; show_in_card bool (дефолт true); created_at / updated_at.

  • Преднастроенные карточки: client «Клиент» и points_and_tier «Баллы и уровень».
  • Вкладки продукта — системные таблицы без источника, содержимое которых рисует продукт по коду: accounts «Счета и балансы», operations «Бонусные операции», points_expiry «Сроки сгорания», tier_history «История уровней» (раздел «Баллы и уровень»), status «Статус клиента», achievements «Достижения», referrals «Рефералы» (раздел «Клиент»), promotion_participation «Участие в акциях» (раздел «Активность и коммуникации»). Их переименовывают, переносят и переставляют, но не удаляют; вместо удаления вкладку скрывают признаком show_in_card (CHECK client_profile_field_group_show_in_card_check — скрыть можно только вкладку продукта).

client_profile_table_column — столбцы таблицы

PK (group_id, column_key); group_id → client_profile_field_group (ON DELETE CASCADE); visible; sort_order; label (своя подпись; пусто — подпись берётся из комментария колонки источника).

client_profile_field — поля карточки клиента

field_id PK (identity); configuration_id → client_profile_configuration (ON DELETE CASCADE); field_key → client_profile_field_key; group_id → client_profile_field_group (NOT NULL — место поля); label; field_type (CHECK STRING/NUMBER/DATE/DATETIME/BOOLEAN); input_source (CHECK MANUAL — ручной ввод, DROPDOWN — выпадающий список, SYSTEM — из системы); multiple (множественный выбор — только у DROPDOWN); ref_dict (справочник вариантов — обязателен у DROPDOWN); source_column (колонка источника группы; пусто — значение вводит оператор); required; editable; hidden; show_in_list; sensitive (чувствительное поле, дефолт false); archived (поле в архиве — значения клиентов сохраняются; системное поле не архивируется); default_value; options_json (выводится из употребления, не заполняется); system_field; sort_order; created_at / created_by / created_by_name; updated_at / updated_by / updated_by_name. UNIQUE (configuration_id, field_key).

Прежний тип LIST описывал способ ввода, а не природу значения: поля LIST со справочником стали STRING + DROPDOWN, без справочника — STRING + MANUAL; SYSTEM у кастомных полей заменён на MANUAL. Системные поля (ID клиента, Телефон, ФИО, Email, Дата рождения, Баллы, Уровень, Сегменты, Статус, Дата регистрации) структурно неизменны; дополнительные поля добавляются из интерфейса. Из коробки заведено дополнительное поле «Регион» — выпадающий список из справочника geo-region. Поле «Сегменты» — только для чтения, значение даёт состав сегментов.

client_profile_field_key — реестр ключей полей

field_key PK. Общий для всех версий настройки: на него ссылаются и поля (client_profile_field), и значения клиентов (customer_attribute) — ON UPDATE CASCADE, ON DELETE RESTRICT. Заполненное у клиентов поле нельзя удалить в обход проверки сервиса, а смена ключа переносит значения.

customer_attribute — значения дополнительных полей клиента

PK (external_id, field_key, value_index); field_key → client_profile_field_key; value; value_index int (номер значения при множественном выборе, CHECK ≥ 0, у одиночного значения — 0); updated_at. Значения удалённых из схемы полей при переходе убраны. Индекс по field_key — проверка «поле заполнено у клиентов».

tag — справочник тегов клиента

tag_id PK (identity); value (1–60 символов, нормализовано: нижний регистр, без краевых и двойных пробелов, без ;, , и управляющих символов, UNIQUE uq_tag_value — регистронезависимо); version bigint (для If-Match); created_at / updated_at. Переименование тега — правка одной записи, новое значение сразу видно у всех клиентов.

customer_tag — теги клиента

PK (external_id, tag_id); external_id → customer; tag_id → tag (без каскада: связи с удаляемым тегом снимает сервис, записывая историю); created_at. Прежний свободный текст tag переведён на ссылку на справочник: значения нормализованы, пустые отброшены, дубли у одного клиента («VIP» и «vip») схлопнуты. Индекс по tag_id.

customer_tag_history — история тегов клиента

history_id PK (identity); external_id → customer; action (CHECK ASSIGNED/REMOVED/MERGED/DELETED); tag_value (значение на момент события); target_value (тег-цель, только и обязательно при MERGED); actor_id; actor_name; occurred_at. Запись создаётся у каждого клиента пакета и у всех носителей при удалении или слиянии тега. Индекс (external_id, occurred_at DESC).

Представления

  • customer_directory_v — каталог клиентов: customer + identity_profile + массив значений тегов (по алфавиту) + last_activity_at (последняя операция в bonus_operation).
  • customer_operation_v — история бонусных операций клиента с полями сработавшей акции и субсчёта: bonus_operation (включая reason_code, comment, subaccount_id, ledger_id) LEFT JOIN promotion по promotion_id → promotion_name, promotion_mechanic_code; LEFT JOIN points_subaccount → subaccount_code, subaccount_name. Название берётся у сработавшей версии акции; счёт операции разрешается отдельным запросом по ledger_id.

7. Сегменты клиентов

erDiagram
  customer_segment ||--o{ customer_segment_member : "текущий состав"
  customer_segment ||..o{ customer_segment_member_history : "интервалы членства"
  customer_segment ||..o{ segment_fill : "заливки"
  segment_fill ||--o{ segment_fill_member : "участники заливки"

Сегменты рассчитывает внешняя система (Batch Flow) и заливает их составом; в продукте сегмент нельзя создать или переименовать — только архивировать и удалить. Прежние записи справочника reference_entry с ключом customer-segment удалены.

customer_segment — справочник сегментов

code PK (1–64 символа, в написании первой регистрации; UNIQUE uq_customer_segment_code_ci по lower(code) — «VIP» и «vip» один сегмент); label (1–255); description (до 1000); source (CHECK BATCH_FLOW); is_archived bool; members_count (число клиентов текущего состава, ≥ 0); members_as_of (момент расчёта последней завершённой заливки); created_at / updated_at. Кто и когда архивировал сегмент, пишется в dictionary_audit; колонки archived_at / archived_by удалены. Ссылки на сегмент (условия акций, партнёрские офферы, лимиты списания) держат код значением и сравнивают без учёта регистра.

customer_segment_member — текущий состав

PK (segment_code, external_id); segment_code → customer_segment (ON DELETE CASCADE); external_id (без FK — клиент, которого ещё нет в программе, принимается и получит сегмент при регистрации); entered_at (момент завершения заливки, которой клиент вошёл); fill_id. Индекс по external_id — сегменты клиента читаются при покупке, в карточке и из RT Flow.

customer_segment_member_history — история входов и выходов

id PK (identity); segment_code / segment_label (снимком, без FK — история переживает удаление сегмента); external_id; entered_at / exited_at; enter_fill_id / exit_fill_id; exit_reason (CHECK NOT_IN_FILL / SEGMENT_DELETED / ANONYMIZED; задан ровно у закрытых интервалов). Открытый интервал клиента в сегменте один (частичный UNIQUE по (lower(segment_code), external_id) WHERE exited_at IS NULL). Индексы (external_id, entered_at) и по exited_at закрытых — уборка по сроку хранения.

segment_fill — журнал заливок

id PK (снежинка — отдаётся Batch Flow как fillId); segment_code / segment_label (снимком); mode (CHECK CREATED / REPLACE); status (CHECK OPEN / COMPLETED / ABANDONED / REJECTED); source; external_load_id (идентификатор расчёта, 1–128 символов); as_of (момент расчёта); started_at; last_batch_at; completed_at; batches_count; счётчики members_received / members_accepted / members_duplicate / members_unknown_customer / members_rejected; entered / exited / members_count (итог завершения — повтор завершения отдаёт его); created_by. Незавершённая заливка на сегмент одна (частичный UNIQUE по lower(segment_code) WHERE status = 'OPEN'); расчёт старше уже применённого (customer_segment.members_as_of) отклоняется.

segment_fill_member — участники незавершённой заливки

PK (fill_id, external_id); fill_id → segment_fill (ON DELETE CASCADE). Действующий состав их не видит, пока заливку не завершат.

8. Реферальная программа и достижения

referral_link_id PK; referrer_external_id → customer; referee_external_id → customer (UNIQUE — у реферала ровно один реферер); status (CHECK pending/activated); promotion_id → promotion (акция, по которой начислено при активации; пусто, если акции не было); activation_operation_id (зарезервировано под сторно, не заполняется); referrer_accrued_minor / referee_accrued_minor (снимок начисленного при активации); created_at; activated_at; updated_at; version. Связь создаётся при регистрации реферала, активируется его первой покупкой с начислением обеим сторонам. Индексы по referrer_external_id и status.

achievement — каталог достижений

achievement_id PK; code (UNIQUE); name; description; icon; condition_type (PURCHASE_COUNT / PURCHASE_AMOUNT / ACCRUED_POINTS / REDEEMED_POINTS / TIER_REACHED); threshold bigint (для пороговых условий); condition_value (код уровня для TIER_REACHED); active; ordinal; created_at / updated_at. Правило «условие → бейдж»; бейдж нефинансовый — проводок не порождает.

customer_achievement — выданные бейджи

PK (external_id, achievement_id); achievement_id → achievement (ON DELETE CASCADE); awarded_at; awarded_value (значение метрики в момент выдачи). Повторная выдача невозможна схемой. Индекс (external_id, awarded_at DESC).

customer_achievement_metric — накопительные метрики клиента

external_id PK; purchase_count; purchase_amount_minor; accrued_minor; redeemed_minor (сгорание не входит); updated_at. Ведутся инкрементом на событии, по ним проверяются условия.

9. Партнёрские предложения

Совместные акции лояльности на продуктах партнёров (Loyalty × Promo): реестр партнёров, офферы с четырьмя ортогональными осями, акцепт клиентом и атрибуция транзакций под card-linked кэшбэк. Эксплуатация — Партнёрские предложения.

partner — реестр партнёров

partner_id PK (снежинка); version; code text UNIQUE; name; category; logo_url; status (дефолт ACTIVE); created_at / updated_at. Леджер-счёт партнёра (PRT-<code>) заводится лениво при первом partner-funded начислении.

Точки приёма партнёра — справочник мерчантов merchant (раздел 11): прежние CSV-колонки merchant_ids и mcc перенесены туда (MID, указанный у двух партнёров, достался первому по алфавиту кода, конфликт записан в журнал применения миграции) и удалены. Card-linked транзакция находит мерчанта прямым поиском по MID.

partner_offer — партнёрский оффер

offer_id PK (снежинка); version; partner_id → partner; code UNIQUE (из partner_offer_code_seq, формат PO-0000001; ранее выданные шестизначные коды остаются валидными); title; description; status (CHECK DRAFT/ACTIVE/PAUSED/ARCHIVED); starts_at / ends_at. Четыре оси: delivery (PROMOCODE/CALCULATED), acceptance (OPT_IN/AUTO), audience (ALL/SEGMENT) + segments (CSV), funding (PARTNER/PROGRAM/SHARED). Вознаграждение: value_kind (PERCENT/POINTS/FIXED); value numeric; max_reward_minor. Для PROMOCODE — promocode + promo_campaign_id; для CALCULATED — mechanic_code (диаграмма RT Flow). Ограничения: min_amount_minor; budget_minor (при исчерпании оффер переводится в PAUSED); per_customer_limit int; new_customer_only. Индексы (status, starts_at), partner_id.

partner_offer_acceptance — акцепт OPT_IN

acceptance_id PK (identity); offer_id → partner_offer (ON DELETE CASCADE); external_id; status (CHECK ACTIVE/REVOKED); accepted_at. UNIQUE (offer_id, external_id); отмена клиентом — статус REVOKED. Индекс (external_id, status).

partner_offer_redemption — атрибуция транзакции

redemption_id PK (identity); offer_id → partner_offer (ON DELETE CASCADE); external_id; transaction_ref; amount_minor (сумма чека); reward_minor (начислено); created_at. UNIQUE (offer_id, transaction_ref) — повтор той же транзакции по тому же офферу не начисляет дважды. Бюджет оффера — SUM(reward_minor).

План счетов и взаиморасчёты

Статья PRTF «Финансирование партнёров». Ось funding определяет счёт-источник начисления: PARTNER/SHARED дебетуют счёт партнёра PRT-<code>; PROGRAM — системный источник начислений (accrual.source-account). Для SHARED в леджере одна проводка (со счёта партнёра); доля программы (50 %) — расчётная строка отчёта взаиморасчётов. Фактически профинансировано — сумма дебет-проводок со счёта партнёра.

Три уровня идемпотентности card-linked начисления

Повтор транзакции у партнёра гасится на трёх рубежах: ключ запроса (Idempotency-Key = externalRef) → атрибуция partner_offer_redemption UNIQUE (offer_id, transaction_ref) → backstop леджера ledger_posting_reference. Начисление помечается источником PARTNER: в истории личного кабинета клиента запись начинается с «Кэшбэк партнёра» (дальше — партнёр и оффер), на вкладке «Бонусные операции» карточки клиента источник называется «Партнёрский оффер».

10. Ручные операции и пакеты

erDiagram
  operation_batch ||--o{ operation_batch_row : "строки"
  operation_batch ||--o{ operation_batch_chunk : "чанки"
  operation_batch ||--o{ bonus_operation : "движения"
  reference_entry ||..o{ operation_batch : "причина (operation-reason)"

operation_batch — пакет массовой операции

batch_id uuid PK; operation (ACCRUAL / CORRECTION / TIER_ASSIGN / TAG_ASSIGN / TAG_REMOVE); source (FILE — файл, LIST — ручной ввод списка на экране «Ручные операции», CATALOG — выбор в каталоге клиентов); initiated_by; reason_code / reason_text; comment; reference (ссылка пакета); file_name; subaccount_code; tier_code; points_minor (общая сумма для строк без своей); tags jsonb (теги пакета); status; scheduled_at; deduct_available bool; expire_at; total_count; succeeded_count; failed_count; rejected_count; unprocessed_count; chunk_size; chunk_count; chunks_done; progress_at; finished_at; created_at / updated_at.

  • Статусы: SCHEDULED (отложенный, ждёт scheduled_at) → QUEUED → RUNNING → COMPLETED / ABORTED.
  • Источник у JSON-пакетов (/v1/customers/bulk-accruals, /v1/customers/bulk-corrections, /v1/customers/tier-bulk) проставляет сервер — CATALOG; отдельного значения для внешних вызовов нет — аудит отличает их по субъекту токена.
  • Теги пакета «Добавить теги» / «Снять теги» (TAG_ASSIGN / TAG_REMOVE) — снимок [{id, value}] на момент приёма: выполнение идёт по id, а тег, удалённый из справочника после приёма, остаётся в пакете своей подписью. У прочих операций tags пусто.
  • Отложенное выполнение: пакет с моментом выполнения сохраняет все параметры запроса — «списать доступное» при нехватке остатка (deduct_available, корректировка) и срок жизни лота (expire_at, начисление; пусто — по цепочке срока). Интерфейс трактует дату в часовом поясе программы (system_config.program.timezone).
  • Одна активная загрузка на оператора — уникальный частичный индекс по initiated_by WHERE status IN ('QUEUED','RUNNING').
  • Подхват брошенных пакетов — по progress_at (последний признак жизни исполнителя).
  • Индексы: (created_at DESC), частичный по scheduled_at для SCHEDULED, частичный по progress_at для активных.

operation_batch_row — строки пакета

PK (batch_id, seq); batch_id → operation_batch; line; external_id; full_name; outcome (PENDING — строка отложенного пакета до исполнения, при исполнении заменяется итогом / SUCCEEDED / FAILED / REJECTED — отбракована проверкой / UNPROCESSED — чанк прерван); code; reason; points_minor; balance_minor; transaction_id. Источник отчёта CSV по пакету.

operation_batch_chunk — итог чанка

PK (batch_id, chunk); batch_id → operation_batch; status (DONE / ABORTED); succeeded; failed; rejected; unprocessed; problem_counts jsonb (код отказа → число строк); attempts; started_at / finished_at. Счётчики пакета собираются из итогов чанков.

Диапазон строк чанка не хранится — он выводится из нарезки пакета: чанк i (нумерация с нуля) накрывает seq от i × chunk_size + 1 до min((i + 1) × chunk_size, total_count). У синхронного JSON-пакета чанк один и накрывает все строки.

Связанные изменения: у bonus_operation появились batch_id, reason_code, comment; у customer_tier_override — reason_code, comment; системный счёт CORR и параметр correction.sink-account; справочник operation-reason пересобран (см. раздел 11).

11. Справочники / НСИ и конфигурация

Справочники продукта ведутся в собственной базе: простые — общей таблицей reference_entry, справочники с собственной структурой (города, торговые точки, мерчанты, категории товаров, артикулы, сегменты, теги, метки акций) — своими таблицами и своими экранами. Чтение справочников из внешнего dictionary-api предусмотрено реестром справочников, но сейчас таких справочников нет. Администрирование — Справочники.

reference_entry — операционные справочники

reference_entry_id PK (identity); dict_key (имя справочника); code; label; description; sort_order; status text (DRAFT/ACTIVE/ARCHIVED); created_at / updated_at. UNIQUE (dict_key, code). Сиды: promotion-group, event-type (только PURCHASE «Покупка» — прежние незадействованные типы событий удалены), channel, geo-region, point-type, operation-reason. Плоские справочники product-category и sku перенесены в product_category / product_item (категория — уровнем 1, черновики — в архив), customer-segment — в customer_segment; их записи удалены.

«Причины операций» (operation-reason) — единый справочник для всех трёх ручных операций. Действующие коды: DUPLICATE_ACCRUAL «Двойное начисление», OPERATOR_ERROR «Ошибка оператора», PROMOTION_ERROR «Ошибка настройки акции», REFUND_MANUAL «Возврат покупки», FRAUD «Злоупотребление», RECONCILIATION «Расхождение при сверке», CUSTOMER_REQUEST «По обращению клиента», COMPENSATION «Компенсация», MISSED_ACCRUAL «Не начислено автоматически», MARKETING «Маркетинговая акция», MANAGER_DECISION «Решение менеджера», OTHER «Прочее» (всегда последняя, комментарий обязателен). Прежние записи MANUAL_ACCRUAL, GOODWILL, CORRECTION, PROMO_COMPENSATION, MANUAL_REDEMPTION переведены в ARCHIVED.

city — города

city_id PK (identity); code (UNIQUE ux_city_code, хранится как пришёл из системы источника, с учётом регистра); name; description; region_code (код записи справочника geo-region, существование проверяет приложение); timezone (идентификатор IANA); status (CHECK ACTIVE/ARCHIVED); created_at / updated_at. Индексы по region_code и по нормализованному названию (обрезка, регистр, «ё» → «е») — сопоставление города из профиля клиента для часового пояса.

store — торговые точки

store_id PK (identity); code (UNIQUE ux_store_code, с учётом регистра); name; city_code → city(code); address; status (CHECK ACTIVE/CLOSED — закрытая точка не удаляется, на неё ссылаются условия отработавших акций); created_at / updated_at. Региона у точки нет — он выводится через город.

merchant — мерчанты

merchant_id PK (identity); code (MID из процессинга, UNIQUE ux_merchant_code, с учётом регистра); name; description; partner_code → partner(code) (необязательно); mcc; city_code → city(code); status (CHECK ACTIVE/CLOSED — закрытый не удаляется); created_at / updated_at. Точка приёма партнёрских платежей: сопоставляется с транзакцией по MID, питает условие акции «Мерчант» и область списания «где». Индексы по partner_code, city_code, mcc.

product_category — категории товаров

product_category_id PK (identity); code (UNIQUE ux_product_category_code по всему справочнику); name; level (CHECK 1…3); parent_code (родитель — логическая ссылка, без FK); ancestor_1_code / ancestor_2_code (денормализованная цепочка предков, пересчитывается при записи); status (CHECK ACTIVE/ARCHIVED); source (CHECK FORM — карточка, IMPORT — CSV-импорт); last_seen_at (последний импорт, в файле которого была запись); created_at / updated_at. Дерево глубиной до трёх уровней; проверку родителя и уровня делает приложение.

product_item — товары (артикулы)

product_item_id PK (identity); code (UNIQUE ux_product_item_code); name; description; category_1_code / category_2_code / category_3_code (категории всех трёх уровней — логические ссылки); unit_code; brand; extra_1…extra_5 (поля общего назначения, подпись задаётся под внедрение); status (CHECK ACTIVE/ARCHIVED); source (CHECK FORM/IMPORT); last_seen_at; created_at / updated_at. Индексы по категориям каждого уровня и статусу.

dictionary_audit — аудит справочников

audit_id PK; dict_key; code; action; actor; details; at. Индекс по (dict_key, at DESC).

system_config — операционная конфигурация

config_key PK; value (nullable → env-дефолт); title; sort_order. Ключи:

Ключ Назначение
accrual.source-account Счёт-источник начислений (env-дефолт MRK)
redemption.sink-account Счёт-приёмник списаний (env-дефолт RDM)
expiry.breakage-account Счёт сгорания баллов (env-дефолт BRK)
correction.sink-account Счёт корректировок (env-дефолт CORR)
points.currency Валюта баллов (env-дефолт PTS)
redemption.consume-order Порядок списания лотов (FIFO/LIFO/FEFO)
redemption.hold-ttl Срок жизни резерва баллов (ISO-8601, env-дефолт PT30M)
program.timezone Часовой пояс программы

12. Операционные журналы, уведомления и обмен данными

bonus_operation — журнал бонусных операций

operation_id PK; external_id; type (ACCRUAL / REDEMPTION / CORRECTION / REVERSAL); source (PURCHASE / PROMOTION / PROMOCODE / MANUAL / REDEMPTION / EXPIRY / MANUAL_EXPIRY / REFERRAL / RT_FLOW / PARTNER); amount_minor bigint; reason; reason_code (код причины, для ручных операций); comment; operator; promotion_id; promotion_root_id (линия версий акции); point_rate numeric(18,6) (курс баллов, применённый к операции); ledger_id; batch_id → operation_batch; subaccount_id → points_subaccount (ON DELETE SET NULL); purchase_id (покупка, из которой возникло начисление); created_at; business_date.

Контекст, которого нет в леджере, — подложка для мониторинга, аномалий, расхода бюджета акций, истории участий и выручки по акциям.

  • Тип REVERSAL — отмена начисления, а не трата клиента; источник MANUAL_EXPIRY — сгорание, запущенное оператором (плановое — EXPIRY).
  • subaccount_id — субсчёт записи: плановое сгорание пишется записью на каждый субсчёт. У прошлых записей и у писателей, которым субсчёт не нужен, пусто.
  • purchase_id связывает начисление с покупкой (раздел 5); у ручных, партнёрских операций и сгорания пусто.
  • business_date — NOT NULL, дефолт loyalty_current_business_date(), как у проводки; прежние пустые значения заполнены днём проводки, а без неё — календарной датой UTC.
  • Индексы: created_at, external_id, (created_at, operation_id), business_date, ledger_id, (promotion_root_id, created_at), частичные по batch_id и по purchase_id.

business_day — операционный день

business_date PK (date); status (CHECK OPEN/CLOSED); opened_at; closed_at. Открыт ровно один день — частичный уникальный индекс business_day_single_open_uq по status WHERE status='OPEN': второй открытый день отвергается базой. Открытый день подставляется в ledger_entry.business_date, bonus_operation.business_date и purchase.business_date.

command_inbox — очередь приёма команд

event_id PK (идентификатор события от отправителя — транспортный дубль не создаёт второй строки); command_type (ACCRUE / REDEEM / HOLD); external_id; payload jsonb; reference (бизнес-ключ операции → ledger_posting_reference); occurred_at (время события у источника); source; status command_inbox_status (PENDING / RUNNING / DONE / PARKED); attempts; next_attempt_at; locked_until (аренда обработчика); failure_reason; result jsonb (ответ для повторной подачи); created_at / updated_at. Приём (HTTP или брокер) — один INSERT, исполнение — отдельный обработчик. Частичные индексы: захват PENDING, зависшие RUNNING, разбор PARKED, очистка DONE.

outbound_notification — очередь исходящих уведомлений клиенту

notification_id PK (bigserial); external_id; type (ACCRUAL / REDEMPTION / POINTS_EXPIRING / POINTS_EXPIRED); message; dedup_key (UNIQUE — ключ повода: повторная постановка того же повода не создаёт второго сообщения); send_after (не отправлять раньше); valid_until (после — не отправлять вовсе); send_zone (пояс, в котором посчитан send_after); status (CHECK PENDING / SENT / OBSOLETE / PARKED / EXPIRED / DISCARDED); attempts; next_attempt_at; last_error; discard_reason; sent_at; created_at / updated_at.

Уведомление ставится в очередь той же транзакцией, что и факт; отправитель забирает созревшие сообщения отдельным тиком, повторяет при отказе и списывает устаревшие (EXPIRED — списано по сроку, отдельно от SENT). Момент отправки считается в часовом поясе клиента (customer_timezone). Индексы: частичный (send_after, notification_id) по PENDING, (status, updated_at) — разбор и уборка.

exchange_job — задания регламентного обмена

job_code PK; title; direction (EXPORT); cron (NULL — только ручной запуск); enabled (дефолт false); batch_size; lookback_seconds (отступ назад от водяного знака); catchup_seconds (максимальная ширина среза при догоне); watermark_at; next_run_at; last_run_id; last_status; updated_at. Сидированы выключенными: DWH_TRANSACTIONS «Выгрузка витрины проводок в DWH», DWH_BALANCES «Выгрузка витрины балансов в DWH», DWH_CUSTOMERS «Выгрузка витрины клиентов в DWH» (ежечасно).

exchange_run — прогоны обмена

run_id PK; job_code → exchange_job; trigger_kind; status (RUNNING / SUCCEEDED / FAILED); slice_from / slice_to; records; error_code; error_detail; actor; started_at / finished_at. Второй одновременный прогон задания невозможен (частичный UNIQUE по job_code WHERE status='RUNNING').

Лента изменений

Для инкрементальной выдачи у customer, account, account_lot появились updated_at (у account и customer_tag — также created_at), проставляемые триггерами БД по часам базы; изменение identity_profile и набора customer_tag сдвигает customer.updated_at. Ключ ленты — пара (updated_at/created_at, id) с индексами на customer, account, account_lot, bonus_operation, promotion.

reconciliation_run / reconciliation_diff — сверка итогов

reconciliation_run: run_id PK; scope; business_date; status (RUNNING / MATCHED / MISMATCHED / FAILED); started_at / finished_at; checked; diffs; actor. reconciliation_diff: diff_id PK; run_id → reconciliation_run; check_code; severity; subject; expected / actual bigint; detail. Расхождения хранятся для разбора с историей.

13. Аудит и безопасность

audit_log — сквозной журнал аудита

audit_id PK; at; actor; actor_roles (роли субъекта на момент действия, через запятую); source_ip (адрес источника; за ingress — первый хоп X-Forwarded-For); access_kind (CHECK CHANGE — изменение, READ — просмотр карточки, SEARCH — поиск, EXPORT — выгрузка); subject_external_id (чью карточку смотрели; для поиска и выгрузки пусто); item_count (сколько записей отдано); criteria (имена параметров отбора без значений); action (HTTP-метод); target (путь); status (код ответа, включая отклонённые 403/422); trace_id.

Append-only: триггер запрещает любую правку записи и удаление записи, покрытой печатью. Индексы: at DESC, actor, (source_ip, at DESC), (subject_external_id, at DESC), частичный по at DESC для обращений к ПДн (access_kind <> 'CHANGE').

audit_seal — печати целостности журнала

seal_id PK (identity); from_audit_id / to_audit_id (диапазон записей, CHECK to ≥ from, UNIQUE по to_audit_id); entries_count; digest (отпечаток диапазона); prev_digest (отпечаток предыдущей печати); created_at. Цепочка отпечатков делает подделку записи задним числом обнаружимой; удалить старый период можно, только осознанно сняв его печати (разрыв цепочки виден).

siem_cursor — курсор передачи аудита в SIEM

Одна строка (id boolean PK, CHECK id); sent_at / sent_audit_id (последняя отданная запись, курсор по паре «время, id»); delivered_at (последняя успешная отправка — по нему считается отставание); failure_reason; updated_at. Буфер потока — сам audit_log; курсор двигается только после подтверждения отправки.

app_user — реестр сотрудников

user_id PK (subject токена — тот же идентификатор, что в promotion.created_by и actor_id журналов); display_name; login (логин учётной записи из токена; NULL, если токен его не несёт или сотрудник не входил после появления колонки); email (для оповещений, наружу через API не отдаётся); roles text[] (роли LOYALTY_* на момент последнего входа); last_seen_at. Запись обновляется из токена при обращениях сотрудника; по ним выбираются согласующие и показываются ФИО и логин авторов конфигураций и активаций. Индексы по lower(display_name) и GIN по roles — отбор обладателей права.

14. Служебные таблицы

id_generator_slot — аренда instance-id генератора снежинок

instance_id PK; claimed_by; claimed_at; heartbeat_at. Каждая реплика приложения при старте арендует свой instance_id и подтверждает аренду heartbeat'ом; протухшие слоты переиспользуются, не удаляются. Гарантирует уникальность идентификаторов при горизонтальном масштабировании и в нескольких ЦОД.

Функции без собственных таблиц

Карта участника в мобильном кошельке (Apple Wallet / Google Кошелёк) и правила оповещений мониторинга данных в БД не хранят: выпуск карты строится из текущих данных клиента, оповещения — правила Prometheus поверх метрик приложения.

15. Enum-типы и доменные инварианты

PostgreSQL enum-типы:

Тип Значения
balance_nature DEBIT, CREDIT, BOTH
redemption_type DISCOUNT, PAYMENT, REIMBURSED
lot_status OPEN, CLOSED, CANCELLED, EXPIRED
ledger_operation ACCRUAL, STORNO, ADJUSTMENT
account_state ACTIVE, CLOSED, SUSPENDED
operation_type PURCHASE, SELL
subaccount_state DRAFT, ACTIVE, CLOSED
subaccount_expiry_mode PERIOD, FIXED_DATE, NONE
subaccount_restriction_kind CATEGORY, PRODUCT, MERCHANT, STORE
command_inbox_status PENDING, RUNNING, DONE, PARKED

Текстовые «enum-ы» с CHECK: customer.status и customer_status_history.status / previous_status (ACTIVE/BLOCKED/MERGED/DELETED), currency.kind (MONEY/POINTS), business_day.status (OPEN/CLOSED), redemption_limit.scope_type (TIER/CURRENCY/SEGMENT), типы границ списания (FIX/PERCENT), param_def.type (11 типов), promotion.approval_status, approval_event.entity_type / action, promotion_participation_limit.period / period_mode, promotion_audience_condition.zone / criterion / tag_match / count_operator / period, promotion_audience_ref.kind, criterion_def.origin / source, customer_tag_history.action и promotion_label_history.action, audit_log.access_kind, referral_link.status, оси и статус partner_offer, partner_offer_acceptance.status, program_config.status / tier_metric / tier_recalc_mode / tier_review_unit, program_config_redemption.scope / max_mode, program_config_redemption_item.kind, customer_tier_transition.source, account_lot.expiry_source, account_opening_run.status / reason, exchange_pair.rate_mode / rounding, exchange_pair_limit.direction / limit_period, purchase_return.status, client_profile_configuration.status, client_profile_field_group.group_type, client_profile_field.field_type / input_source, tier_profile_field (field_type, input_source — прежний набор с LIST), customer_segment.source, segment_fill.mode / status, customer_segment_member_history.exit_reason, customer_timezone.source, outbound_notification.status, статусы city, store, merchant, product_category, product_item и источник записей каталога (FORM/IMPORT). Статусы акции (promotion.status), операция и статус пакета (operation_batch.operation / status) и типы и источники bonus_operation ограничиваются приложением.

Ключевые инварианты:

  • Двойная запись — каждое движение баллов есть ledger_entry (дебет = кредит); баланс счёта производен от проводок, леджер append-only.
  • Неотрицательность лотов — account_lot.positive_balance (original/current ≥ 0).
  • Согласованный срок партии — источник срока и момент сгорания заданы вместе (account_lot_expiry_moment_ck, account_lot_expiry_tier_ck).
  • Ноль двойных начислений — idempotency_record (окно повтора) + ledger_posting_reference (PK на бизнес-ссылке, бессрочно) + command_inbox.event_id для принятых команд; для card-linked кэшбэка — partner_offer_redemption UNIQUE (offer_id, transaction_ref); одна покупка на ссылку события — purchase.reference UNIQUE, возвраты — не больше суммы покупки (ck_purchase_returned_range).
  • Один счёт клиента на субсчёт, один базовый субсчёт на валюту, однозначная очередь списания субсчетов.
  • Одна базовая валюта, и она денежная; у балльной валюты всегда есть курс.
  • Открыт ровно один операционный день.
  • Одна активная конфигурация программы и одна активная версия настройки карточки клиента; код, название и порядковый номер уровня уникальны в своей конфигурации.
  • Одна активная загрузка пакета на оператора; один активный прогон задания обмена; одна незавершённая задача открытия счетов на валюту; одна незавершённая заливка на сегмент и один открытый интервал клиента в сегменте.
  • Синглтоны — redemption_policy / global_mechanic / criterion_registry_revision CHECK (id = 1), siem_cursor CHECK (id).
  • Уникальность кодов и значений — currency.code, promotion.code, partner.code, partner_offer.code, customer.referral_code, tag.value, promotion_label.value, achievement.code, program_config.name, customer_segment.code (без учёта регистра), city.code, store.code, merchant.code, product_category.code, product_item.code, outbound_notification.dedup_key, redemption_scope.fingerprint, (reference_entry.dict_key, code), (redemption_limit.scope_type, scope_key), (param_def.mechanic_code, param_key), (points_subaccount.currency_id, code), (exchange_pair.from_currency_id, to_currency_id), (client_profile_field.configuration_id, field_key), referral_link.referee_external_id.
  • Неизменяемость журнала аудита — запрет правки и удаления запечатанных записей, цепочка печатей.
  • Партиционирование леджера — ledger_entry RANGE по posted_at помесячно, PK (posted_at, entry_id); UNIQUE на ссылке вынесен в отдельную непартиционированную таблицу, уникальность entry_id и отсутствие лотов-сирот контролируются ночной проверкой.

См. также Погружение в продукт и Архитектуру.