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-ФЗ.
| Колонка | Тип | Nullable | Default | Описание |
|---|---|---|---|---|
| id | uuid | NOT NULL | gen_random_uuid() | Primary key |
| franchise_id | uuid | NOT NULL | — | Tenant. Логическая ссылка на franchises.id в User Service |
| phone | varchar(20) | NOT NULL | — | Нормализованный формат E.164 (+7XXXXXXXXXX) |
| varchar(255) | NULL | — | Lower-case. Валидация по RFC 5322 | |
| first_name | varchar(100) | NOT NULL | — | |
| last_name | varchar(100) | NULL | — | |
| birthday | date | NULL | — | Для промо «именинники ±N дней» |
| gender | varchar(10) | NOT NULL | ’unknown’ | male / female / other / unknown |
| notes | text | NULL | — | Заметки кассира или менеджера |
| registration_source | varchar(20) | NOT NULL | ’admin’ | pos / web / mobile / admin / import |
| registered_by_employee_id | uuid | NULL | — | Сотрудник, завёдший клиента. Логическая ссылка на employees.id в User Service |
| registered_at | timestamp | NOT NULL | now() | Дата регистрации (дубликат created_at для удобства фильтров) |
| consent_signed_at | timestamp | NULL | — | Отметка о согласии на обработку ПД (ФЗ-152). Процесс сбора — out of scope BR 3.1 |
| deleted_at | timestamp | NULL | — | Soft delete. При IS NOT NULL — PII обезличиваются |
| created_at | timestamp | NOT NULL | now() | |
| updated_at | timestamp | NOT NULL | now() |
Индексы:
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=NULLphone='+7000000' || substring(id::text, 1, 7)— обезличенный с сохранением уникальностиemail=NULLnotes=NULLbirthday=NULLgender='unknown'- Адреса — удаляются записями из
customer_addresses(ON DELETE CASCADE) deleted_at=now()- Связь с
orders.customer_id(Order Service) сохраняется — история обезличена, но видна
customer_addresses
Адреса доставки клиента. У одного клиента — N адресов.
| Колонка | Тип | Nullable | Default | Описание |
|---|---|---|---|---|
| id | uuid | NOT NULL | gen_random_uuid() | Primary key |
| customer_id | uuid | NOT NULL | — | FK → customers.id ON DELETE CASCADE |
| city | varchar(100) | NOT NULL | — | |
| street | varchar(255) | NOT NULL | — | |
| apartment | varchar(50) | NULL | — | |
| entrance | varchar(20) | NULL | — | Подъезд |
| floor | varchar(20) | NULL | — | Этаж |
| intercom | varchar(50) | NULL | — | Код домофона |
| delivery_zone_id | uuid | NULL | — | Логическая ссылка на delivery_zones.id в Store Service (для фильтров групп «по зоне доставки») |
| notes | text | NULL | — | «Оставить у консьержа» и пр. |
| is_default | boolean | NOT NULL | false | Ровно один default на клиента |
| created_at | timestamp | NOT NULL | now() | |
| updated_at | timestamp | NOT NULL | now() |
Индексы:
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.
| Колонка | Тип | Nullable | Default | Описание |
|---|---|---|---|---|
| id | uuid | NOT NULL | gen_random_uuid() | Primary key |
| franchise_id | uuid | NOT NULL | — | Tenant |
| name | varchar(100) | NOT NULL | — | Уникально per franchise |
| description | text | NULL | — | |
| type | varchar(10) | NOT NULL | — | static — ручное членство; dynamic — по правилам |
| rules_json | jsonb | NULL | — | Обязательно для type=dynamic, игнорируется для type=static. Массив до 5 правил с AND-логикой |
| created_by | uuid | NULL | — | Employee_id создателя (из JWT) |
| last_recomputed_at | timestamp | NULL | — | Только для type=dynamic. Обновляется при каждом пересчёте |
| deleted_at | timestamp | NULL | — | Soft delete |
| created_at | timestamp | NOT NULL | now() | |
| updated_at | timestamp | NOT NULL | now() |
Индексы:
uq_customer_groups_name_per_franchise— unique на(franchise_id, LOWER(name)) WHERE deleted_at IS NULLidx_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 записи обновляются движком пересчёта.
| Колонка | Тип | Nullable | Default | Описание |
|---|---|---|---|---|
| group_id | uuid | NOT NULL | — | FK → customer_groups.id ON DELETE CASCADE |
| customer_id | uuid | NOT NULL | — | FK → customers.id ON DELETE CASCADE |
| added_at | timestamp | NOT NULL | now() | Для 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_time | operator (gt/gte/lt/lte/between), amount / amount_from+amount_to | LTV клиента за всё время |
total_spent_period | operator, amount / amount_from+amount_to, days | Сумма покупок за последние N дней |
total_spent_range | operator, amount / amount_from+amount_to, start_date, end_date | Сумма покупок в диапазон дат |
days_inactive | operator (gt/gte), days | Дней с последнего заказа |
birthday_window | days_before, days_after | ДР попадает в окно ±N дней от сегодня |
city | cities: [string] | Адреса клиента включают указанные города |
delivery_zone | zone_ids: [uuid] | Адреса клиента включают указанные зоны доставки |
gender | values: [male | female | other | unknown] | Пол клиента |
registration_source | values: [pos | web | mobile | admin | import] | Источник регистрации |
include_groups | group_ids: [uuid] | Клиент должен быть в указанных группах |
exclude_groups | group_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):
001-create-customers— таблица + индексы (uq_customers_franchise_phone, остальные)002-create-customer-addresses— таблица + индексы003-create-customer-groups— таблица + индексы + check constraint поtype/rules_json004-create-customer-group-members— таблица + индексы
Связи с другими сервисами (логические)
- User Service:
customers.registered_by_employee_id→employees.id(cross-service, логически)customer_groups.created_by→employees.id
- Order Service:
orders.customer_id→customers.id(cross-service, добавлено в BR 3.1)- Customer Service вызывает internal API Order Service
GET /internal/orders/customer-summaryпри пересчёте dynamic правил
- Store Service:
customer_addresses.delivery_zone_id→delivery_zones.id(cross-service, без валидации в MVP)