Canteen — Архитектурный документ

Проект: canteen.ai2b.dev — система учёта питания для столовой
Репозиторий: /opt/stacks/apps/my-projects-VPS/Canteen
Контейнеры: canteen-postgres, canteen-backend, canteen-frontend
Дата: 2026-05-30

1. Назначение

Учёт заказов и фактических посещений столовой на крупном складском объекте. Основные роли:

РольЧем занимается
Сотрудник складаОбъект учёта. Приходит на КПП4 утром, потом в столовую.
Кухня (Antria Kitchen)Внешний поставщик питания. Получает xlsx-план заказа утром (Обед) и днём (Ужин).
Руководители сменыУправляют исключениями (нестандартные графики), отказами от питания, корректируют отметки.
АналитикиСмотрят расхождения, конверсию заказа, сводки по юр. лицам и по сотрудникам.
Бизнес-цель: минимизировать невостребованные порции (заказали → не пришли) и видеть «пришли без заказа» для корректировки плана на следующий день.

2. Стек

СлойТехнология
FrontendReact 18 + Vite, Axios, ExcelJS (генерация в браузере)
BackendNode.js + Express, pg, node-cron, exceljs
СУБДPostgreSQL 16
КонтейнеризацияDocker Compose
Reverse proxyTraefik (общий, traefik-net)
Внешние БДSupabase (REST API) — два контура: персоны и события
Оркестратор интеграцийn8n (self-hosted)
Источник СКУДHikCentral (физический сервер, n8n проксирует)

3. Топология

                                ┌───────────────────────┐
                                │     HikCentral        │
                                │     (СКУД-сервер)     │
                                └──────────┬────────────┘
                                           │ webhook
                                           ▼
                                ┌───────────────────────┐
                                │        n8n            │
                                │  (workflow runner)    │
                                └──────────┬────────────┘
                                           │
                              ┌────────────┼────────────────────┐
                              │            │                    │
              ┌──── POST /events    POST /schedule/build-meal-plan
              │              ▼            │              ▼
   ┌──────────┴────────────────────────────────────────────────────┐
   │                  canteen-backend (Express)                    │
   │  events.controller / schedule.controller / orders.controller  │
   │  analytics / discrepancies / export / persons / settings      │
   │  cron: 09:55,15:30 pacs-sync   20:55,03:00 pacs-order-push    │
   └────┬───────────────────────────────────────────────────┬─────┘
        │ pg                                          REST  │
        ▼                                                    ▼
   ┌─────────────────┐                          ┌─────────────────────┐
   │ Postgres 16     │                          │ Supabase            │
   │ (canteen)       │                          │ PACS - Database     │
   │ persons         │                          │ PACS - Webhook ALL  │
   │ meal_orders     │                          │ PACS - Failed Access│
   │ check_ins       │                          │ PACS - Order Lunch  │
   │ meal_exceptions │                          │ PACS - Order Dinner │
   │ meal_refusals   │                          └─────────────────────┘
   │ settings        │
   └─────────────────┘
        ▲
        │ pg
   ┌────┴────────────────────────────────────────────┐
   │       canteen-frontend (React + nginx)          │
   │  Главная (Заказы на сегодня), Аналитика,        │
   │  Календарь, AdminPage                           │
   └─────────────────────────────────────────────────┘

4. Модель данных

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

ПолеТипНазначение
idserial PKВнутренний ID (FK для meal_orders)
hik_person_idvarchar(50) UNIQUEID из HikCentral — ключ во всех интеграциях
person_codevarcharPINFL (паспортный код)
full_namevarchar(255)ФИО
companyvarchar(255)Юр. лицо (Merlion, Nwl, Prostaffing, …)
positionvarchar(255)Должность
pic_uritextID фото в HikCentral
phototextbase64-кэш фото
commenttextЗаметка по сотруднику (Аналитика > По сотрудникам)
updated_attimestampПоследняя правка
Заполняется из Supabase view PACS - Database update. Не хранит исторических версий ЮЛ — все отчёты показывают текущую компанию.

4.2 meal_orders — план питания на день

ПолеТипНазначение
idserial PK
person_idint FKpersons.id
order_datedateДата приёма
meal_typevarchar'Обед' или 'Ужин'
row_numberintНомер строки в xlsx
checked_inboolФактический приход (СКУД отметил)
at_other_siteboolРучная отметка «на другом объекте» — в статистике как пришёл
check_in_timetimestampВремя прихода в столовую
commenttextКомментарий — причина невыхода и т.п.

4.3 check_ins — фактические события прохода

ПолеТипНазначение
idserial PK
hik_person_idvarcharpersons.hik_person_id (без FK)
person_codevarchar
full_namevarcharsnapshot на момент события
event_idvarcharID события HikCentral / supabase-{id} для бэкфилла
src_namevarchar'Столовая', 'КПП4 Вход'
check_in_timetimestampВремя события (Asia/Tashkent)
created_attimestampКогда мы получили событие
meal_typevarchar?'Обед'/'Ужин' или NULL (вне окна)
session_datedateОперационная дата: для ужина после полуночи = вчера
has_orderboolБыл ли в плане заказа
phototextbase64
commenttextПричина
session_date — ключевое поле для аналитики ужина: ужин 21:00–02:00 пересекает полночь, поэтому событие в 00:30 16 мая принадлежит ужину 15 мая.

4.4 meal_exceptions — исключения (нестандартные графики)

Сотрудники, которые не проходят через КПП4 в стандартное окно 06:00–10:00 (ночная смена, удалённая работа). Заказываются вручную через календарь.

ПолеТипНазначение
hik_person_idvarchar
full_name, company, positionvarcharСнапшот для отображения
schedulevarchar(50)Текстовое описание графика («13:00–01:00»)
shiftsjsonbМассив дат-смен: "2026-05-30" или "2026-05-30|Обед"
passwordvarchar(50)Личный пароль руководителя — может править только своих
responsiblevarcharОтветственный за заполнение
commenttext
dismissed_attimestamp?Отметка «уволен» — soft-delete
Используется в build-meal-plan:

4.5 meal_refusals — отказы

Сотрудник написал «я не буду питаться у вас с такой-то даты». Запись (full_name, refusal_date, meal_type). Действует «от даты и далее» (refusal_date <= sync_date).

4.6 settings — KV-настройки

КлючНазначение
lunch_start, lunch_end, dinner_start, dinner_endОкна выдачи
excluded_pct_lunch, excluded_pct_dinner% «исключается из заказа» (отдельно для обеда и ужина)
password_settings, password_delete, password_exceptionПароли для разных режимов
final_order_count_{date}_{meal_type}Snapshot финального заказа после вычета %
antria_issued_{date}_{meal}, antria_remaining_{date}_{meal}Сверка кухни: выдано/остаток порций
meal_times_{date}Snapshot окон на дату (для архивного просмотра)
sync_{meal}Время последней успешной синхронизации
supabase_orders_backfill_v2_doneФлаг «исторические заказы залиты в Supabase»

5. Ключевые потоки

5.1 Сборка плана — POST /api/schedule/build-meal-plan

Самый важный endpoint. Вызывается из n8n утром (обед) и днём (ужин). Принимает события КПП4 и строит итоговый файл заказа.
{
  "date": "2026-05-30",
  "meal_type": "Обед",
  "events": [{ "personId": "1234", "deviceTime": "..." }, ...],
  "format": "xlsx"
}
#Шаг
1Дедупликация events по personId — оставляем самое раннее deviceTime
2syncCompaniesFromSupabase() — подтянуть свежие компании (без n8n, ~1–2 сек)
3Загружаем persons локально
4Загружаем meal_exceptions (только активные). Делим на inclusions (есть shift) и exclusions
5Загружаем meal_refusals с refusal_date <= date
6Для ужина — lunchHikIds из meal_orders того же дня (правило «обед→ужин»)
7Цикл по events: применяем exclusions → refusals → правило обед→ужин → собираем result
8final_order_count = round(kpp4OrderCount × (1 − delete_pct/100))
9Добавляем inclusions (тех из exceptions с shift, кого нет в events)
10Сортируем по компании, ФИО
11UPSERT в meal_orders (защита checked_in=TRUE от удаления)
12Push в Supabase PACS - Order food Lunch/Dinner
13Snapshot в settingsfinal_order_count_{date}_{meal_type}
14Генерируем XLSX + отдаём с заголовками (X-Summary и др.)

5.2 Приход через СКУД — POST /api/events

Webhook от n8n, тело — массив событий HikCentral. Шаги:

#Шаг
1Идемпотентность по event_id (если уже есть — пропускаем)
2Lookup persons по hik_person_id. Если нет ФИО → triggerLazyPacsSync() (cooldown 5 мин)
3Определяем meal_type по времени и окнам выдачи
4Определяем session_date через localDateOffsetStr(d, -1) если hh < 6 и meal=Ужин/NULL. НЕ использовать toISOString().slice(0,10) — это UTC-баг (см. §7.1)
5Если есть meal_order с checked_in=FALSE → UPDATE на TRUE
6INSERT в check_ins

5.3 Главная страница — GET /api/orders/today

Отдаёт orders, unmatched, anomalies, stats. На UI:

КарточкаЧто показывает
Пришло на работу (через КПП4)Всего записей в meal_orders на сегодня
Заказано в Antria Kitchenfinal_order_count из snapshot или расчёт (− excluded_pct%)
Пришли (+ N без заказа)checked_in + at_other_site + unmatched
Не пришли / Ожидаютсяpending — в зависимости mealEnded
Пришли без заказаunmatched (check_ins без meal_order)

Дополнительно: Сверка для Antria Kitchen (две inline-редактируемые: «Выдано порций», «Остаток порций»), таблица заказов с inline-меняемым статусом (Ожидается/Не пришёл → «На другом объекте» = в статистике как Покушал).

5.4 Аналитика

EndpointНазначение
/analytics?from&to&mealСводная по периоду + daily breakdown
/analytics/discrepanciesПричины не прихода, тепловая карта
/analytics/by-employeesРейтинг по числу ошибок (legacy)
/analytics/employee-casesКейсы по конкретному сотруднику
/analytics/administrativeБез комментария / без должности — для исправления
/analytics/by-companiesСводная по юр.лицам (Обед+Ужин если meal='')
/analytics/by-companies/employees-detailСотрудник × lunch/dinner ordered/came — для Excel

Вкладки в UI: Обзор Расхождения — с фильтром Обед/Ужин. По сотрудникам Административные Логи СКУД По юр. лицам — без фильтра, агрегируют оба приёма.

5.5 Календарь исключений

EndpointНазначение
GET /schedule/exceptions?dismissed=1Активные или уволенные
POST /schedule/exceptionsДобавить (с паролем)
PATCH /schedule/exceptions/:idИзменить
DELETE /schedule/exceptions/:idSoft-delete (UPDATE dismissed_at), удаляет будущие meal_orders без отметки
POST /schedule/exceptions/:id/restoreВернуть из уволенных

UI: матрица «сотрудник × дни месяца», ячейки разделены на полу-приёмы (верх = обед, низ = ужин). Двойной клик на день — добавить/убрать shift. Двойной клик на ФИО — редактирование. Свёрнутая секция «Уволенные» внизу.

5.6 Логи СКУД

Прокси к Supabase PACS - Failed Access Report (отказы доступа).

5.7 Бэкфилл check-ins из Supabase

Когда n8n-webhook не дошёл (сервер падал) — поднимаем события из Supabase PACS - Webhook ALL.

Endpoint: POST /api/events/backfill-supabase body { from, to?, src_index? }. Кнопка в UI: AdminPage → Подтянуть отметки за день.

6. Интеграции

6.1 HikCentral

Никогда не дёргаем напрямую — всё через n8n. Источник истины для: событий СКУД, персон.

6.2 n8n workflows

WorkflowЧто делает
Build meal plan Lunch/DinnerУтром/днём забирает события КПП4 из HikCentral, вызывает POST /api/schedule/build-meal-plan, отправляет xlsx на кухню
Push event to backendНа каждое событие HikCentral → POST /api/events. Также пишет в Supabase PACS - Webhook ALL для бэкфилла
Refresh persons from AntriaНаш backend → n8n webhook (N8N_REFRESH_PERSONS_URL), n8n синкает HikCentral → Supabase
Photo by picUriНаш backend → N8N_PHOTO_WEBHOOK_URL, n8n возвращает base64 → callback POST /api/persons/:id/photo

6.3 Supabase

ТаблицаНазначениеКто пишетКто читает
PACS - Database updateСправочник персон (id, Full name, Company, Job title, PINFL, PicUri)n8n (sync HikCentral)runPacsSync, syncCompaniesFromSupabase
PACS - Webhook ALLВсе события HikCentraln8n (на каждое событие)backfillFromSupabase
PACS - Failed Access ReportСобытия отказов доступаn8ngetSkudLogs, getSkudStats
PACS - Order food LunchТекущий заказ на обедpushOrderToSupabase, cron 20:55внешние потребители (кухня)
PACS - Order food DinnerТо же для ужинаcron 03:00

6.4 Antria Kitchen

Внешний поставщик питания. Получает xlsx из ответа build-meal-plan (через n8n email/Telegram). Отдаёт обратно «выдано N порций / остаток M» — вбивается вручную в Сверка для Antria Kitchen на главной.

7. Известные особенности и приёмы

7.1 Таймзонный баг (исправлен 30.05.2026) FIXED

В events.controller.js для вычисления session_date использовалось .toISOString().slice(0,10), что всегда даёт UTC. Для событий 00:00–05:00 местного времени (Asia/Tashkent +05:00) после −86400000 мс получалось позавчера — на день раньше нужного. Затронуло 19 записей.

Исправлено: введён helper localDateOffsetStr(d, dayOffset), который использует getFullYear/getMonth/getDate (они уважают TZ=Asia/Tashkent env-var). Исторические записи поправлены SQL-апдейтом.

7.2 Auto-create persons в build-meal-plan

Если КПП4 присылает personId, которого нет в persons — мы создаём пустую запись (full_name='', company='', position=''). Потом runPacsSync() (cron или lazy из webhook) заливает реальные данные. Это объясняет «загадочно появляющиеся ФИО» через несколько минут после первого построения плана.

7.3 Защита от затирания при runPacsSync

В upsert persons используется COALESCE(NULLIF(persons.X, ''), EXCLUDED.X) — если в локальной БД уже есть осмысленное значение, оно не перезаписывается.

Исключениеcompany имеет ОТДЕЛЬНУЮ принудительную синхронизацию syncCompaniesFromSupabase(), которая всегда перезаписывает (введено 30.05.2026 для актуализации после перевода сотрудника между ЮЛ). Вызывается в конце runPacsSync() и в начале каждого build-meal-plan.

7.4 Правило «Обед → Ужин»

При построении ужина: люди, которые были в обеденном meal_orders того же дня, исключаются из ужина. Кроме:

Логика — экономия порций, охрана может оставаться на ночь и быть на оба приёма.

7.5 Snapshot-ы для архивного просмотра

При первом построении плана build-meal-plan сохраняет:

Главная страница использует эти snapshot-ы при просмотре архива через HistoryPanel.

7.6 Кириллица в HTTP-заголовках

HTTP RFC требует ASCII в названиях. Все «человекочитаемые» заголовки (например, X-Summary) URL-encode-нуты на стороне сервера. Клиент (n8n, фронт) делает decodeURIComponent для отображения.

8. Деплой

# Бэкенд (с пересборкой)
cd /opt/stacks/apps/my-projects-VPS/Canteen
docker compose up -d --build backend

# Фронтенд
docker build --pull=false -t canteen-frontend ./frontend && docker compose up -d frontend

# Оба сразу
docker build --pull=false -t canteen-frontend ./frontend && docker compose up -d --build frontend backend

Миграции применяются автоматически при старте backend (runMigrations() в app.js) — все ALTER TABLE ... ADD COLUMN IF NOT EXISTS идемпотентны.

9. Среды

СредаURL / Параметр
Productioncanteen.ai2b.dev (Traefik → canteen-frontend / canteen-backend)
Backend APIhttps://canteen.ai2b.dev/api/*
База данныхКонтейнер canteen-postgres, том ./data/postgres
Документ описывает состояние на 2026-05-30. Обновлять при значимых изменениях архитектуры.