Учёт заказов и фактических посещений столовой на крупном складском объекте. Основные роли:
| Роль | Чем занимается |
|---|---|
| Сотрудник склада | Объект учёта. Приходит на КПП4 утром, потом в столовую. |
| Кухня (Antria Kitchen) | Внешний поставщик питания. Получает xlsx-план заказа утром (Обед) и днём (Ужин). |
| Руководители смены | Управляют исключениями (нестандартные графики), отказами от питания, корректируют отметки. |
| Аналитики | Смотрят расхождения, конверсию заказа, сводки по юр. лицам и по сотрудникам. |
| Слой | Технология |
|---|---|
| Frontend | React 18 + Vite, Axios, ExcelJS (генерация в браузере) |
| Backend | Node.js + Express, pg, node-cron, exceljs |
| СУБД | PostgreSQL 16 |
| Контейнеризация | Docker Compose |
| Reverse proxy | Traefik (общий, traefik-net) |
| Внешние БД | Supabase (REST API) — два контура: персоны и события |
| Оркестратор интеграций | n8n (self-hosted) |
| Источник СКУД | HikCentral (физический сервер, n8n проксирует) |
┌───────────────────────┐
│ 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 │
└─────────────────────────────────────────────────┘
| Поле | Тип | Назначение |
|---|---|---|
id | serial PK | Внутренний ID (FK для meal_orders) |
hik_person_id | varchar(50) UNIQUE | ID из HikCentral — ключ во всех интеграциях |
person_code | varchar | PINFL (паспортный код) |
full_name | varchar(255) | ФИО |
company | varchar(255) | Юр. лицо (Merlion, Nwl, Prostaffing, …) |
position | varchar(255) | Должность |
pic_uri | text | ID фото в HikCentral |
photo | text | base64-кэш фото |
comment | text | Заметка по сотруднику (Аналитика > По сотрудникам) |
updated_at | timestamp | Последняя правка |
PACS - Database update. Не хранит исторических версий ЮЛ — все отчёты показывают текущую компанию.| Поле | Тип | Назначение |
|---|---|---|
id | serial PK | |
person_id | int FK | persons.id |
order_date | date | Дата приёма |
meal_type | varchar | 'Обед' или 'Ужин' |
row_number | int | Номер строки в xlsx |
checked_in | bool | Фактический приход (СКУД отметил) |
at_other_site | bool | Ручная отметка «на другом объекте» — в статистике как пришёл |
check_in_time | timestamp | Время прихода в столовую |
comment | text | Комментарий — причина невыхода и т.п. |
| Поле | Тип | Назначение |
|---|---|---|
id | serial PK | |
hik_person_id | varchar | persons.hik_person_id (без FK) |
person_code | varchar | |
full_name | varchar | snapshot на момент события |
event_id | varchar | ID события HikCentral / supabase-{id} для бэкфилла |
src_name | varchar | 'Столовая', 'КПП4 Вход' |
check_in_time | timestamp | Время события (Asia/Tashkent) |
created_at | timestamp | Когда мы получили событие |
meal_type | varchar? | 'Обед'/'Ужин' или NULL (вне окна) |
session_date | date | Операционная дата: для ужина после полуночи = вчера |
has_order | bool | Был ли в плане заказа |
photo | text | base64 |
comment | text | Причина |
session_date — ключевое поле для аналитики ужина: ужин 21:00–02:00 пересекает полночь, поэтому событие в 00:30 16 мая принадлежит ужину 15 мая.Сотрудники, которые не проходят через КПП4 в стандартное окно 06:00–10:00 (ночная смена, удалённая работа). Заказываются вручную через календарь.
| Поле | Тип | Назначение |
|---|---|---|
hik_person_id | varchar | |
full_name, company, position | varchar | Снапшот для отображения |
schedule | varchar(50) | Текстовое описание графика («13:00–01:00») |
shifts | jsonb | Массив дат-смен: "2026-05-30" или "2026-05-30|Обед" |
password | varchar(50) | Личный пароль руководителя — может править только своих |
responsible | varchar | Ответственный за заполнение |
comment | text | |
dismissed_at | timestamp? | Отметка «уволен» — soft-delete |
dismissed_at IS NOT NULL) не участвуют ни в одном из путейСотрудник написал «я не буду питаться у вас с такой-то даты». Запись (full_name, refusal_date, meal_type). Действует «от даты и далее» (refusal_date <= sync_date).
| Ключ | Назначение |
|---|---|
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» |
{
"date": "2026-05-30",
"meal_type": "Обед",
"events": [{ "personId": "1234", "deviceTime": "..." }, ...],
"format": "xlsx"
}
| # | Шаг |
|---|---|
| 1 | Дедупликация events по personId — оставляем самое раннее deviceTime |
| 2 | syncCompaniesFromSupabase() — подтянуть свежие компании (без 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 |
| 8 | final_order_count = round(kpp4OrderCount × (1 − delete_pct/100)) |
| 9 | Добавляем inclusions (тех из exceptions с shift, кого нет в events) |
| 10 | Сортируем по компании, ФИО |
| 11 | UPSERT в meal_orders (защита checked_in=TRUE от удаления) |
| 12 | Push в Supabase PACS - Order food Lunch/Dinner |
| 13 | Snapshot в settings — final_order_count_{date}_{meal_type} |
| 14 | Генерируем XLSX + отдаём с заголовками (X-Summary и др.) |
Webhook от n8n, тело — массив событий HikCentral. Шаги:
| # | Шаг |
|---|---|
| 1 | Идемпотентность по event_id (если уже есть — пропускаем) |
| 2 | Lookup 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 |
| 6 | INSERT в check_ins |
Отдаёт orders, unmatched, anomalies, stats. На UI:
| Карточка | Что показывает |
|---|---|
| Пришло на работу (через КПП4) | Всего записей в meal_orders на сегодня |
| Заказано в Antria Kitchen | final_order_count из snapshot или расчёт (− excluded_pct%) |
| Пришли (+ N без заказа) | checked_in + at_other_site + unmatched |
| Не пришли / Ожидаются | pending — в зависимости mealEnded |
| Пришли без заказа | unmatched (check_ins без meal_order) |
Дополнительно: Сверка для Antria Kitchen (две inline-редактируемые: «Выдано порций», «Остаток порций»), таблица заказов с inline-меняемым статусом (Ожидается/Не пришёл → «На другом объекте» = в статистике как Покушал).
| 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: Обзор Расхождения — с фильтром Обед/Ужин. По сотрудникам Административные Логи СКУД По юр. лицам — без фильтра, агрегируют оба приёма.
| Endpoint | Назначение |
|---|---|
GET /schedule/exceptions?dismissed=1 | Активные или уволенные |
POST /schedule/exceptions | Добавить (с паролем) |
PATCH /schedule/exceptions/:id | Изменить |
DELETE /schedule/exceptions/:id | Soft-delete (UPDATE dismissed_at), удаляет будущие meal_orders без отметки |
POST /schedule/exceptions/:id/restore | Вернуть из уволенных |
UI: матрица «сотрудник × дни месяца», ячейки разделены на полу-приёмы (верх = обед, низ = ужин). Двойной клик на день — добавить/убрать shift. Двойной клик на ФИО — редактирование. Свёрнутая секция «Уволенные» внизу.
Прокси к Supabase PACS - Failed Access Report (отказы доступа).
/stats — лёгкая выборка для счётчиков (не зависят от пагинации списка)Когда n8n-webhook не дошёл (сервер падал) — поднимаем события из Supabase PACS - Webhook ALL.
Endpoint: POST /api/events/backfill-supabase body { from, to?, src_index? }. Кнопка в UI: AdminPage → Подтянуть отметки за день.
Никогда не дёргаем напрямую — всё через n8n. Источник истины для: событий СКУД, персон.
| 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 |
| Таблица | Назначение | Кто пишет | Кто читает |
|---|---|---|---|
| PACS - Database update | Справочник персон (id, Full name, Company, Job title, PINFL, PicUri) | n8n (sync HikCentral) | runPacsSync, syncCompaniesFromSupabase |
| PACS - Webhook ALL | Все события HikCentral | n8n (на каждое событие) | backfillFromSupabase |
| PACS - Failed Access Report | События отказов доступа | n8n | getSkudLogs, getSkudStats |
| PACS - Order food Lunch | Текущий заказ на обед | pushOrderToSupabase, cron 20:55 | внешние потребители (кухня) |
| PACS - Order food Dinner | То же для ужина | cron 03:00 |
Внешний поставщик питания. Получает xlsx из ответа build-meal-plan (через n8n email/Telegram). Отдаёт обратно «выдано N порций / остаток M» — вбивается вручную в Сверка для Antria Kitchen на главной.
В 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-апдейтом.
Если КПП4 присылает personId, которого нет в persons — мы создаём пустую запись (full_name='', company='', position=''). Потом runPacsSync() (cron или lazy из webhook) заливает реальные данные. Это объясняет «загадочно появляющиеся ФИО» через несколько минут после первого построения плана.
В upsert persons используется COALESCE(NULLIF(persons.X, ''), EXCLUDED.X) — если в локальной БД уже есть осмысленное значение, оно не перезаписывается.
Исключение — company имеет ОТДЕЛЬНУЮ принудительную синхронизацию syncCompaniesFromSupabase(), которая всегда перезаписывает (введено 30.05.2026 для актуализации после перевода сотрудника между ЮЛ). Вызывается в конце runPacsSync() и в начале каждого build-meal-plan.
При построении ужина: люди, которые были в обеденном meal_orders того же дня, исключаются из ужина. Кроме:
position ILIKE '%охран%')Логика — экономия порций, охрана может оставаться на ночь и быть на оба приёма.
При первом построении плана build-meal-plan сохраняет:
final_order_count_{date}_{meal_type} — финальное число, не дрейфует если потом меняют ручные правкиmeal_times_{date} — окна выдачи на датуГлавная страница использует эти snapshot-ы при просмотре архива через HistoryPanel.
HTTP RFC требует ASCII в названиях. Все «человекочитаемые» заголовки (например, X-Summary) URL-encode-нуты на стороне сервера. Клиент (n8n, фронт) делает decodeURIComponent для отображения.
# Бэкенд (с пересборкой)
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 идемпотентны.
| Среда | URL / Параметр |
|---|---|
| Production | canteen.ai2b.dev (Traefik → canteen-frontend / canteen-backend) |
| Backend API | https://canteen.ai2b.dev/api/* |
| База данных | Контейнер canteen-postgres, том ./data/postgres |