Store Service — Data Model

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

erDiagram
    stores {
        uuid id PK
        uuid franchise_id "NOT NULL"
        uuid legal_entity_id "NOT NULL"
        varchar name "NOT NULL"
        text address "NOT NULL"
        decimal latitude "NOT NULL, (10,7)"
        decimal longitude "NOT NULL, (10,7)"
        varchar city "NULL, (100)"
        varchar phone "NULL, (20)"
        varchar email "NULL, (255)"
        varchar status "NOT NULL, default active"
        boolean is_published "NOT NULL, default false"
        uuid price_list_id "NULL"
        varchar timezone "NOT NULL, default Europe/Moscow, (50)"
        integer standby_idle_minutes "NOT NULL, default 5 — BR 6.1"
        integer standby_transition_seconds "NOT NULL, default 9 — BR 6.1"
        timestamp created_at "NOT NULL"
        timestamp updated_at "NOT NULL"
        timestamp deleted_at "NULL"
    }

    marketing_slides {
        uuid id PK
        uuid store_id FK "NOT NULL"
        uuid franchise_id "NOT NULL"
        text image_url "NOT NULL — public URL"
        varchar image_mime "NOT NULL, (50)"
        integer image_size_bytes "NOT NULL"
        varchar title "NOT NULL, (200)"
        integer order_index "NOT NULL"
        boolean active "NOT NULL, default true"
        varchar source "NOT NULL, default manual"
        jsonb source_ref "NULL"
        timestamp created_at "NOT NULL"
        timestamp updated_at "NOT NULL"
        timestamp deleted_at "NULL"
    }

    store_schedules {
        uuid id PK
        uuid store_id FK "NOT NULL"
        integer day_of_week "NOT NULL, 0-6"
        time open_time "NULL"
        time close_time "NULL"
        boolean is_closed "NOT NULL, default false"
    }

    pos_terminals {
        uuid id PK
        uuid store_id FK "NOT NULL"
        uuid franchise_id "NOT NULL"
        varchar label "NOT NULL, (100)"
        varchar fs_number "NOT NULL, UNIQUE, (30)"
        varchar rn_kkt "NULL, (30)"
        varchar status "NOT NULL, default active"
        timestamp last_seen_at "NULL"
        timestamp deleted_at "NULL"
        timestamp created_at "NOT NULL"
        timestamp updated_at "NOT NULL"
    }

    zal_tables {
        uuid id PK
        uuid store_id FK "NOT NULL"
        uuid franchise_id "NOT NULL"
        integer number "NOT NULL"
        varchar label "NULL, (50)"
        integer capacity "NOT NULL, default 4"
        integer position_x "NOT NULL, default 0"
        integer position_y "NOT NULL, default 0"
        varchar status "NOT NULL, default free"
        uuid current_order_id "NULL"
        uuid current_waiter_id "NULL — BR 3.2, для привязки чаевых"
        varchar reserved_note "NULL, (200)"
        timestamp reserved_until "NULL"
        timestamp deleted_at "NULL"
        timestamp created_at "NOT NULL"
        timestamp updated_at "NOT NULL"
    }

    stores ||--o{ store_schedules : "has schedule"
    stores ||--o{ pos_terminals : "has terminals"
    stores ||--o{ zal_tables : "has tables"
    stores ||--o{ marketing_slides : "has slides"

Таблицы

stores

Торговые точки сети.

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()PK
franchise_iduuidNOT NULLID франшизы (из JWT)
legal_entity_iduuidNOT NULLFK → User Service legal_entities (логическая связь)
namevarchar(255)NOT NULLНазвание ТТ
addresstextNOT NULLПолный адрес
latitudedecimal(10,7)NOT NULLШирота
longitudedecimal(10,7)NOT NULLДолгота
cityvarchar(100)NULLГород
phonevarchar(20)NULLТелефон
emailvarchar(255)NULLEmail
statusvarchar(20)NOT NULL'active'active / suspended
is_publishedbooleanNOT NULLfalseОпубликована для клиентов
price_list_iduuidNULLNULLID прейскуранта (Catalog Service). NULL = используется дефолтный. (BR 1.10)
timezonevarchar(50)NOT NULL'Europe/Moscow'IANA timezone (e.g. Europe/Moscow)
standby_idle_minutesintegerNOT NULL5Минут неактивности кассира до запуска standby-режима. (BR 6.1)
standby_transition_secondsintegerNOT NULL9Секунд между слайдами в standby-карусели. (BR 6.1)
created_attimestampNOT NULLnow()Дата создания
updated_attimestampNOT NULLnow()Дата обновления
deleted_attimestampNULLSoft delete

Индексы:

  • idx_stores_franchise_idfranchise_id
  • idx_stores_legal_entity_idlegal_entity_id
  • idx_stores_status_publishedstatus, is_published (для фильтрации по виртуальным статусам)
  • uq_stores_le_name — UNIQUE (legal_entity_id, name) WHERE deleted_at IS NULL (название ТТ уникально в рамках ЮЛ)

legal_entity_id — логическая FK

Ссылается на таблицу legal_entities в User Service (database-per-service). Валидация при создании через internal API.

store_schedules

Расписание работы ТТ по дням недели.

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()PK
store_iduuidNOT NULLFK → stores.id
day_of_weekintegerNOT NULL0=Понедельник..6=Воскресенье
open_timetimeNULLВремя открытия (HH:MM)
close_timetimeNULLВремя закрытия (HH:MM)
is_closedbooleanNOT NULLfalseВыходной день

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

  • UNIQUE (store_id, day_of_week) — один день = одна запись
  • FK store_id REFERENCES stores(id) ON DELETE CASCADE

Правила валидации

  • Если is_closed = true, то open_time и close_time должны быть NULL
  • Если is_closed = false, то open_time и close_time обязательны
  • close_time может быть меньше open_time (работа через полночь)

pos_terminals

POS-терминалы (физические устройства), привязанные к ТТ по заводскому номеру ФН.

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()PK
store_iduuidNOT NULLFK → stores.id
franchise_iduuidNOT NULLФраншиза (из JWT)
labelvarchar(100)NOT NULLНазвание терминала (например «Касса 1»)
fs_numbervarchar(30)NOT NULLЗаводской номер ФН, UNIQUE глобально
rn_kktvarchar(30)NULLРегистрационный номер ККТ
statusvarchar(20)NOT NULL'active'active / inactive
last_seen_attimestampNULLПоследнее обращение с терминала
deleted_attimestampNULLSoft delete
created_attimestampNOT NULLnow()
updated_attimestampNOT NULLnow()

Индексы:

  • idx_pos_terminals_storestore_id
  • idx_pos_terminals_franchisefranchise_id
  • uq_pos_terminals_fs_number — UNIQUE fs_number

zal_tables

Столы в зале для dine-in: визуальная карта с позициями и статусами.

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()PK
store_iduuidNOT NULLFK → stores.id
franchise_iduuidNOT NULLФраншиза (из JWT)
numberintegerNOT NULLНомер стола
labelvarchar(50)NULLДоп. метка (например «у окна»)
capacityintegerNOT NULL4Кол-во посадочных мест
position_xintegerNOT NULL0Координата X на canvas-карте
position_yintegerNOT NULL0Координата Y на canvas-карте
statusvarchar(20)NOT NULL'free'free / occupied / reserved (CHECK-constraint)
current_order_iduuidNULLID текущего активного заказа
current_waiter_iduuidNULLТекущий назначенный официант (BR 3.2, для привязки чаевых из Нетмонета, миграция 009)
reserved_notevarchar(200)NULLКомментарий к брони
reserved_untiltimestampNULLВремя до которого стол забронирован
deleted_attimestampNULLSoft delete
created_attimestampNOT NULLnow()
updated_attimestampNOT NULLnow()

Индексы:

  • idx_zal_tables_storestore_id
  • idx_zal_tables_franchisefranchise_id
  • idx_zal_tables_ordercurrent_order_id
  • idx_zal_tables_waitercurrent_waiter_id
  • uq_zal_tables_store_number_live — UNIQUE (store_id, number) WHERE deleted_at IS NULL

CHECK-constraint: status IN ('free','occupied','reserved')

marketing_slides

(BR 6.1 — Маркетинговая информация / standby-карусель POS Desktop)

Слайды для standby-карусели на POS Desktop. Per-store, статичные картинки (jpg/png/webp), управляются маркетологом из админки.

КолонкаТипNullableDefaultОписание
iduuidNOT NULLgen_random_uuid()PK
store_iduuidNOT NULLFK → stores.id (ON DELETE CASCADE)
franchise_iduuidNOT NULLДенормализация из stores, для быстрых scope-фильтров (event-публикация по franchise_id)
image_urltextNOT NULLПубличный URL картинки в S3-бакете erp-marketing-slides
image_mimevarchar(50)NOT NULLimage/jpeg / image/png / image/webp (CHECK-constraint)
image_size_bytesintegerNOT NULLРазмер файла в байтах (для аудита + валидации лимита 10 МБ при апдейтах)
titlevarchar(200)NOT NULLЗаголовок для админки. На POS не отображается
order_indexintegerNOT NULLПорядок в карусели. order — зарезервированное слово в SQL, поэтому колонка order_index. В API наружу отдаётся как order
activebooleanNOT NULLtrueАктивность слайда. Только активные крутятся в standby
sourcevarchar(20)NOT NULL'manual'manual / ai_photo_studio (CHECK-constraint). См. BR 6.2
source_refjsonbNULLДля source=ai_photo_studio: { "job_id": "uuid", "preset_id": "string" } для трассировки
created_attimestampNOT NULLnow()
updated_attimestampNOT NULLnow()
deleted_attimestampNULLSoft delete

Индексы:

  • idx_marketing_slides_storestore_id
  • idx_marketing_slides_store_active_order(store_id, active, order_index) WHERE deleted_at IS NULL (для быстрого чтения активных в правильном порядке)
  • uq_marketing_slides_store_order — UNIQUE (store_id, order_index) WHERE deleted_at IS NULL (защита от дублей order при reorder; reorder происходит в транзакции с временным сдвигом если упирается)

CHECK-constraints:

  • image_mime IN ('image/jpeg', 'image/png', 'image/webp')
  • source IN ('manual', 'ai_photo_studio')
  • order_index >= 0
  • image_size_bytes > 0 AND image_size_bytes <= 10485760 (10 МБ)

Бизнес-лимит активных слайдов на ТТ

Лимит 20 активных слайдов на ТТ форсится на уровне сервиса (проверка COUNT WHERE active = true AND deleted_at IS NULL перед INSERT/UPDATE). На уровне БД не закреплён — было бы дорогой constraint.

S3-хранилище

image_url хранит публичный URL вида https://s3.erp/erp-marketing-slides/{store_id}/{slide_id}.{ext}. Бакет erp-marketing-slides — отдельный от gensvc-outputs AI Photo Studio. При создании слайда с source=ai_photo_studio файл копируется из gensvc-бакета в erp-marketing-slides (не ссылается напрямую, т.к. presigned TTL gensvc — 1 час, сломал бы карусель).

Отложенная очистка S3

При soft delete файл в S3 не удаляется. Cron раз в сутки чистит файлы, у которых нет ссылки в активной строке marketing_slides (включая deleted_at IS NULL для свежих soft-deletes — даём окно отката).

Маппинг виртуальных статусов

Фронтенд оперирует виртуальными статусами, бэкенд хранит два отдельных поля:

Виртуальный статусstatusis_published
draftactivefalse
publishedactivetrue
suspendedsuspendedfalse

Приостановленная ТТ не может быть опубликована

suspended + is_published = true — невалидная комбинация. При приостановке ЮЛ вызывается POST /internal/stores/unpublish-by-legal-entity.