Customer Service — Data Model

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

(Создана в BR 3.1)

Отдельная БД, не разделяется с user_db (User Service) и order_db (Order Service). Cross-service ссылки (registered_by_employee_id, customer_addresses.delivery_zone_id, orders.customer_id) — логические, без FK.

erDiagram
    customers {
        uuid id PK
        uuid franchise_id "NOT NULL, индекс"
        varchar phone "NOT NULL, (20), E.164 формат"
        varchar email "NULL, (255), lower-case"
        varchar first_name "NOT NULL, (100)"
        varchar last_name "NULL, (100)"
        date birthday "NULL"
        varchar gender "(10), default unknown"
        text notes "NULL"
        varchar registration_source "NOT NULL, (20), default admin"
        uuid registered_by_employee_id "NULL (-> User Service)"
        timestamp registered_at "NOT NULL"
        timestamp consent_signed_at "NULL"
        timestamp deleted_at "NULL, soft delete + anonymize"
        timestamp created_at "NOT NULL"
        timestamp updated_at "NOT NULL"
    }

    customer_addresses {
        uuid id PK
        uuid customer_id FK "NOT NULL -> customers.id CASCADE"
        varchar city "NOT NULL, (100)"
        varchar street "NOT NULL, (255)"
        varchar apartment "NULL, (50)"
        varchar entrance "NULL, (20)"
        varchar floor "NULL, (20)"
        varchar intercom "NULL, (50)"
        uuid delivery_zone_id "NULL (-> Store Service)"
        text notes "NULL"
        boolean is_default "NOT NULL, default false"
        timestamp created_at "NOT NULL"
        timestamp updated_at "NOT NULL"
    }

    customer_groups {
        uuid id PK
        uuid franchise_id "NOT NULL, индекс"
        varchar name "NOT NULL, (100)"
        text description "NULL"
        varchar type "NOT NULL, (10), static | dynamic"
        jsonb rules_json "NULL для static, NOT NULL для dynamic"
        uuid created_by "NULL (-> User Service, employee_id)"
        timestamp last_recomputed_at "NULL, только для dynamic"
        timestamp deleted_at "NULL, soft delete"
        timestamp created_at "NOT NULL"
        timestamp updated_at "NOT NULL"
    }

    customer_group_members {
        uuid group_id PK "NOT NULL -> customer_groups.id CASCADE"
        uuid customer_id PK "NOT NULL -> customers.id CASCADE"
        timestamp added_at "NOT NULL"
    }

    customers ||--o{ customer_addresses : "has"
    customers ||--o{ customer_group_members : "member of"
    customer_groups ||--o{ customer_group_members : "contains"

Таблицы

customers

Карточка клиента. Soft delete через deleted_at с анонимизацией PII — физическое удаление запрещено из-за FK из orders.customer_id (Order Service) и требований 54-ФЗ.

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()Primary key
franchise_iduuidNOT NULLTenant. Логическая ссылка на franchises.id в User Service
phonevarchar(20)NOT NULLНормализованный формат E.164 (+7XXXXXXXXXX)
emailvarchar(255)NULLLower-case. Валидация по RFC 5322
first_namevarchar(100)NOT NULL
last_namevarchar(100)NULL
birthdaydateNULLДля промо «именинники ±N дней»
gendervarchar(10)NOT NULL’unknown’male / female / other / unknown
notestextNULLЗаметки кассира или менеджера
registration_sourcevarchar(20)NOT NULL’admin’pos / web / mobile / admin / import
registered_by_employee_iduuidNULLСотрудник, завёдший клиента. Логическая ссылка на employees.id в User Service
registered_attimestampNOT NULLnow()Дата регистрации (дубликат created_at для удобства фильтров)
consent_signed_attimestampNULLОтметка о согласии на обработку ПД (ФЗ-152). Процесс сбора — out of scope BR 3.1
deleted_attimestampNULLSoft delete. При IS NOT NULL — PII обезличиваются
created_attimestampNOT NULLnow()
updated_attimestampNOT NULLnow()

Индексы:

  • uq_customers_franchise_phone — unique на (franchise_id, phone) WHERE deleted_at IS NULL (уникальность телефона в рамках франшизы среди живых)
  • idx_customers_franchise на (franchise_id)
  • idx_customers_franchise_email на (franchise_id, email) WHERE deleted_at IS NULL AND email IS NOT NULL (быстрый поиск по email, без unique)
  • idx_customers_registered_at на (registered_at DESC) — сортировка списка по умолчанию
  • idx_customers_deleted_at на (deleted_at) — для фильтра active/deleted

Анонимизация при soft delete (выполняется сервисом атомарно):

  • first_name = 'Удалён'
  • last_name = NULL
  • phone = '+7000000' || substring(id::text, 1, 7) — обезличенный с сохранением уникальности
  • email = NULL
  • notes = NULL
  • birthday = NULL
  • gender = 'unknown'
  • Адреса — удаляются записями из customer_addresses (ON DELETE CASCADE)
  • deleted_at = now()
  • Связь с orders.customer_id (Order Service) сохраняется — история обезличена, но видна

customer_addresses

Адреса доставки клиента. У одного клиента — N адресов.

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()Primary key
customer_iduuidNOT NULLFK → customers.id ON DELETE CASCADE
cityvarchar(100)NOT NULL
streetvarchar(255)NOT NULL
apartmentvarchar(50)NULL
entrancevarchar(20)NULLПодъезд
floorvarchar(20)NULLЭтаж
intercomvarchar(50)NULLКод домофона
delivery_zone_iduuidNULLЛогическая ссылка на delivery_zones.id в Store Service (для фильтров групп «по зоне доставки»)
notestextNULL«Оставить у консьержа» и пр.
is_defaultbooleanNOT NULLfalseРовно один default на клиента
created_attimestampNOT NULLnow()
updated_attimestampNOT NULLnow()

Индексы:

  • idx_customer_addresses_customer на (customer_id) — для lookup адресов клиента
  • uq_customer_addresses_default — unique на (customer_id) WHERE is_default = true — гарантия одного default

Триггер / логика (в сервисе, не в БД): при PATCH адреса с is_default = true — предыдущий default снимается в одной транзакции.


customer_groups

Группы клиентов двух типов: static и dynamic.

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()Primary key
franchise_iduuidNOT NULLTenant
namevarchar(100)NOT NULLУникально per franchise
descriptiontextNULL
typevarchar(10)NOT NULLstatic — ручное членство; dynamic — по правилам
rules_jsonjsonbNULLОбязательно для type=dynamic, игнорируется для type=static. Массив до 5 правил с AND-логикой
created_byuuidNULLEmployee_id создателя (из JWT)
last_recomputed_attimestampNULLТолько для type=dynamic. Обновляется при каждом пересчёте
deleted_attimestampNULLSoft delete
created_attimestampNOT NULLnow()
updated_attimestampNOT NULLnow()

Индексы:

  • uq_customer_groups_name_per_franchise — unique на (franchise_id, LOWER(name)) WHERE deleted_at IS NULL
  • idx_customer_groups_franchise_type на (franchise_id, type) WHERE deleted_at IS NULL

Check constraint: CHECK ((type = 'static' AND rules_json IS NULL) OR (type = 'dynamic' AND rules_json IS NOT NULL))


customer_group_members

Membership клиента в группе (для обоих типов — static и dynamic). Для dynamic записи обновляются движком пересчёта.

КолонкаТипNullableDefaultОписание
group_iduuidNOT NULLFK → customer_groups.id ON DELETE CASCADE
customer_iduuidNOT NULLFK → customers.id ON DELETE CASCADE
added_attimestampNOT NULLnow()Для static — когда админ добавил, для dynamic — когда пересчёт добавил

Primary key: (group_id, customer_id)

Индексы:

  • idx_cgm_customer на (customer_id) — для lookup «в каких группах клиент»

Формат customer_groups.rules_json

Массив до 5 правил с логикой AND между ними. Каждое правило — объект:

{
  "version": 1,
  "rules": [
    { "type": "total_spent_all_time", "operator": "gte", "amount": 50000 },
    { "type": "days_inactive", "operator": "gt", "days": 60 }
  ]
}

Каталог типов правил (MVP):

typeПараметрыСемантика
total_spent_all_timeoperator (gt/gte/lt/lte/between), amount / amount_from+amount_toLTV клиента за всё время
total_spent_periodoperator, amount / amount_from+amount_to, daysСумма покупок за последние N дней
total_spent_rangeoperator, amount / amount_from+amount_to, start_date, end_dateСумма покупок в диапазон дат
days_inactiveoperator (gt/gte), daysДней с последнего заказа
birthday_windowdays_before, days_afterДР попадает в окно ±N дней от сегодня
citycities: [string]Адреса клиента включают указанные города
delivery_zonezone_ids: [uuid]Адреса клиента включают указанные зоны доставки
gendervalues: [male | female | other | unknown]Пол клиента
registration_sourcevalues: [pos | web | mobile | admin | import]Источник регистрации
include_groupsgroup_ids: [uuid]Клиент должен быть в указанных группах
exclude_groupsgroup_ids: [uuid]Клиент НЕ должен быть в указанных группах

Отложено (не в MVP):

  • points_balance — требует Loyalty Service (BR 3.3)
  • order_rating — требует системы оценок
  • Логика OR между правилами (сейчас только AND)

Источник истории покупок для dynamic правил: Customer Service запрашивает у Order Service через internal API GET /internal/orders/customer-summary?customer_id={id} (возвращает total_spent_all_time, last_order_at, first_order_at, orders_by_month за последний год). Этот internal-endpoint в Order Service добавляется в рамках шага 2 — см. 03-Services/Order Service/API.md.


Миграции

(BR 3.1)

Создаются с нуля (нет существующей БД customer_db):

  1. 001-create-customers — таблица + индексы (uq_customers_franchise_phone, остальные)
  2. 002-create-customer-addresses — таблица + индексы
  3. 003-create-customer-groups — таблица + индексы + check constraint по type/rules_json
  4. 004-create-customer-group-members — таблица + индексы

Связи с другими сервисами (логические)

  • User Service:
    • customers.registered_by_employee_idemployees.id (cross-service, логически)
    • customer_groups.created_byemployees.id
  • Order Service:
    • orders.customer_idcustomers.id (cross-service, добавлено в BR 3.1)
    • Customer Service вызывает internal API Order Service GET /internal/orders/customer-summary при пересчёте dynamic правил
  • Store Service:
    • customer_addresses.delivery_zone_iddelivery_zones.id (cross-service, без валидации в MVP)

Ссылки