Files
neon-bar86.ru/crm/storage.py
T
2026-10-11 22:24:54 +05:00

3165 lines
158 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
Хранилище НЕОН CRM.
Два бэкенда с одинаковым API (выбор через переменную окружения CRM_DB):
- postgres (по умолчанию) — продакшн под Docker, пул psycopg;
- sqlite — локальный запуск на машине без Docker/PostgreSQL
(`CRM_DB=sqlite python server.py`), реализация в storage_sqlite.py,
файл базы: crm/data/crm.db.
Общая логика (статусы, хэши паролей, seed-каталог, демо-заказы) живёт здесь,
специфичные для БД функции — в своей ветке.
"""
import hashlib
import hmac
import json
import os
import re
import secrets
import shutil
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
SEED_DIR = Path(__file__).resolve().parent / "seed"
DATA_DIR = Path(__file__).resolve().parent / "data"
MEDIA_DIR = Path(__file__).resolve().parent / "media" # фото позиций, отдаются как /media/<файл>
GAMES_MEDIA_DIR = MEDIA_DIR / "games" # обложки игр PS5: /media/games/<id>.<ext>
STATUSES = ("new", "active", "done", "cancelled")
# NF-03: переходы статусов, доступные сменным ролям (официант/кальянщик).
# «Сразу выдать» — это new→done, тоже легитимный путь. Из терминальных
# (done, cancelled) и назад — только владелец/админ: эти переходы меняют
# выручку отчётов задним числом.
ORDER_TRANSITIONS_STAFF = frozenset({
("new", "active"), ("new", "done"), ("new", "cancelled"),
("active", "done"), ("active", "cancelled"),
})
SESSION_TTL = timedelta(hours=12)
NOVELTY_DAYS = 7 # сколько дней позиция, добавленная владельцем, считается «новинкой»
BACKEND = (os.environ.get("CRM_DB", "postgres").strip().lower() or "postgres")
class PostgresOnlyFeature(RuntimeError):
"""Функция не реализована в sqlite-режиме (F-11): игры, доски/заметки,
брони столов, подсветка позиций, сброс нумерации — только postgres
(боевой режим). Сервер переводит её в 501 с понятным текстом."""
# Столы и зоны зала — канонический список для отметок занятости (GET /api/halls).
# Имена должны совпадать с массивом halls в crm/js/app.js и TABLE_OPTIONS сайта.
HALLS = [
{"name": "Стол 1", "sub": "основной зал"},
{"name": "Стол 2", "sub": "основной зал"},
{"name": "Стол 3", "sub": "основной зал"},
{"name": "Стол 4", "sub": "основной зал"},
{"name": "Стол 5", "sub": "основной зал"},
{"name": "VIP-зал", "sub": "до 8 гостей"},
{"name": "Кинозал", "sub": "до 15 гостей"},
]
def order_week_start(now: datetime | None = None) -> date:
"""Дата старта «недели нумерации» заказов — вторник 00:00 по времени бара.
Понедельник у бара выходной, ночная смена вс→пн должна остаться в одной
нумерации, поэтому неделя нумерации начинается во вторник: номера
обнуляются каждую неделю в закрытые часы.
"""
day = (now or datetime.now().astimezone()).astimezone().date()
return day - timedelta(days=(day.weekday() - 1) % 7)
# Алфавит кодов отслеживания заказов — без похожих символов (0/O, 1/I/L)
TRACK_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
def generate_track_code() -> str:
return "".join(secrets.choice(TRACK_ALPHABET) for _ in range(6))
# Гигиена свободного текста (и гостя, и персонала — NF-14): убираем
# управляющие и скрытые символы (нуль-байты, bidi-переопределения,
# zero-width), которые не видны глазу, но ломают рендер/БД или прячут
# часть строки от персонала. Живёт в storage, потому что нужен обоим
# слоям — server.py (публичные заказы, ручные заказы) и notes_api.py
# (заголовки досок и заметок), а импортировать server из notes_api нельзя.
_HIDDEN_CHARS_RE = re.compile(
r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f\u00ad\u061c\u180e\u200b-\u200f"
r"\u2028\u2029\u202a-\u202e\u2060-\u206f\ufeff]"
)
def clean_guest_text(value: str, limit: int) -> str:
return _HIDDEN_CHARS_RE.sub("", value)[:limit]
def photo_for_item(item_id: str) -> str | None:
"""Путь к загруженному фото позиции (/media/<файл>) или None."""
if not MEDIA_DIR.is_dir():
return None
for p in MEDIA_DIR.iterdir():
if p.stem == item_id and p.is_file():
return f"/media/{p.name}"
return None
def purge_item_photos(item_id: str) -> None:
"""Удалить фото позиции с диска (все версии с разными расширениями).
Общий для обоих бэкендов — purge_menu_item вызывает при полном удалении."""
if not MEDIA_DIR.is_dir():
return
for p in MEDIA_DIR.iterdir():
if p.stem == item_id and p.is_file():
p.unlink(missing_ok=True)
def game_cover(game_id: int) -> str | None:
"""Путь к обложке игры (/media/games/<id>.<ext>) или None."""
if not GAMES_MEDIA_DIR.is_dir():
return None
for p in GAMES_MEDIA_DIR.iterdir():
if p.stem == str(game_id) and p.is_file():
return f"/media/games/{p.name}"
return None
def _copy_seed_cover(covers_dir: Path, game: dict, game_id: int) -> None:
"""Обложка сид-игры: seed/covers/<файл> → media/games/<id>.<ext>.
Перезаписывает и убирает версии с другим расширением (как фото меню)."""
cover = str(game.get("cover") or "")
if not cover:
return
src = covers_dir / cover
if not src.is_file():
return
GAMES_MEDIA_DIR.mkdir(parents=True, exist_ok=True)
for old in GAMES_MEDIA_DIR.glob(f"{game_id}.*"):
old.unlink()
shutil.copyfile(src, GAMES_MEDIA_DIR / f"{game_id}{src.suffix.lower()}")
# ---------------------------------------------------------------- расписание смен
# Часы работы бара: длительность смены по дню недели (Пн — выходной).
# Python weekday(): Пн=0 … Вс=6. Начало всегда в 19:00.
SHIFT_START_HOUR = 19
SHIFT_DURATION_HOURS = {0: None, 1: 6, 2: 6, 3: 6, 4: 8, 5: 8, 6: 6}
SHIFT_PREPARE_MINUTES = 20 # за сколько минут до начала официант может выйти на смену
# Единое барное время (F-21): окна смен, гейтинг входа и авто-закрытие
# считают по фиксированному UTC+5 — тому же поясу, что онлайн-заказы в
# server.py (BAR_TZ). Раньше смены жили по host-поясу: без TZ на сервере
# окно смены уезжало относительно окна заказов.
BAR_TZ = timezone(timedelta(hours=5))
def bar_now() -> datetime:
"""Текущее барное время — единый барный-clock для всего хранилища."""
return datetime.now(BAR_TZ)
def shift_window(day_offset: int) -> tuple[datetime, datetime] | None:
"""Окно смены (начало, конец) для дня со смещением от сегодня; None — выходной."""
from datetime import date, time as dtime
tz = BAR_TZ
day = bar_now().date() + timedelta(days=day_offset)
hours = SHIFT_DURATION_HOURS.get(day.weekday())
if not hours:
return None
start = datetime.combine(day, dtime(SHIFT_START_HOUR, 0), tzinfo=tz)
return start, start + timedelta(hours=hours)
def clock_in_check() -> tuple[bool, str]:
"""Можно ли выйти на смену сейчас: за 20 минут до начала, во время смены
или в хвосте вчерашней. Иначе — причина с временем ближайшей смены."""
now = bar_now()
prepare = timedelta(minutes=SHIFT_PREPARE_MINUTES)
for offset in (-1, 0):
window = shift_window(offset)
if window and window[0] - prepare <= now <= window[1]:
return True, ""
for offset in range(0, 8):
window = shift_window(offset)
if window and now < window[0]:
return False, (f"Смена начнётся {window[0].strftime('%d.%m в %H:%M')} — "
f"кнопка появится за {SHIFT_PREPARE_MINUTES} минут до начала")
return False, "Сегодня выходной"
def auto_close_cutoff(started: datetime, now: datetime) -> datetime | None:
"""Момент, когда открытая смена, начавшаяся в started, должна закрыться
автоматически; None — пока рано. Смена, начавшаяся в окно расписания
(в т.ч. за 20 минут), закрывается в конце этого окна. Записанная вне
расписания — через 10 часов после начала."""
local = started.astimezone(BAR_TZ)
prepare = timedelta(minutes=SHIFT_PREPARE_MINUTES)
for offset in range(-2, 8):
window = shift_window(offset)
if window and window[0] - prepare <= local <= window[1]:
return window[1] if now > window[1] else None
cap = local + timedelta(hours=10)
return cap if now > cap else None
def _assemble_report(statuses: list[dict], by_day: list[dict],
top_items: list[dict], avg_duration: float | None) -> dict:
"""Общая сборка отчёта для обоих бэкендов."""
totals = {
"orders": 0, "new": 0, "active": 0, "done": 0, "cancelled": 0,
"revenue": 0, "cancelled_amount": 0,
}
for r in statuses:
status, cnt, total = r["status"], r["cnt"], r["total"]
totals["orders"] += cnt
totals[status] = cnt
if status == "done":
totals["revenue"] = total
if status == "cancelled":
totals["cancelled_amount"] = total
totals["avg_check"] = round(totals["revenue"] / totals["done"]) if totals["done"] else 0
totals["avg_duration_seconds"] = round(avg_duration) if avg_duration is not None else None
return {
"totals": totals,
"by_day": [dict(d) for d in by_day],
"top_items": [dict(t) for t in top_items],
}
def _report_extras(orders: list[dict]) -> dict:
"""Доп. разрезы отчёта, общие для обоих бэкендов. На вход — строки
заказов периода: {status, source, price, table_name, created_at}.
by_source: заказы и выручка (выданные) с сайта и из зала.
by_hour: 24 корзины по локальному часу создания — когда идёт поток.
top_tables: самые «горячие» столы (без отменённых)."""
by_source = {
"site": {"orders": 0, "revenue": 0},
"hall": {"orders": 0, "revenue": 0},
}
by_hour = [{"hour": h, "orders": 0, "revenue": 0} for h in range(24)]
tables: dict[str, dict] = {}
for o in orders:
status, source = o["status"], o.get("source") or "site"
if status != "cancelled":
src = by_source.setdefault(source, {"orders": 0, "revenue": 0})
src["orders"] += 1
bucket = by_hour[o["created_at"].astimezone(BAR_TZ).hour]
bucket["orders"] += 1
t = tables.setdefault(o.get("table_name") or "—",
{"table": o.get("table_name") or "—", "orders": 0, "revenue": 0})
t["orders"] += 1
if status == "done":
src = by_source.setdefault(source, {"orders": 0, "revenue": 0})
src["revenue"] += o["price"]
by_hour[o["created_at"].astimezone(BAR_TZ).hour]["revenue"] += o["price"]
if o.get("table_name") in tables:
tables[o["table_name"]]["revenue"] += o["price"]
top_tables = sorted(tables.values(), key=lambda x: (-x["orders"], -x["revenue"]))[:5]
return {
"by_source": by_source,
"by_hour": by_hour,
"top_tables": top_tables,
}
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def bar_tz_name() -> str | timedelta:
"""Зона бара для SQL (AT TIME ZONE).
Отдаём IANA-имя ('Asia/Yekaterinburg') из переменной TZ — Postgres
понимает его напрямую. Без IANA-имени возвращаем timedelta: psycopg
свяжет его как interval, а не как текст — текст '+05:00' Postgres
трактует по POSIX-правилу с ОБРАТНЫМ знаком и уводит день не туда."""
env_tz = os.environ.get("TZ", "").strip()
if env_tz and "/" in env_tz:
try:
import zoneinfo
zoneinfo.ZoneInfo(env_tz)
return env_tz
except Exception:
pass
# без IANA-имени в TZ — фиксированное барное смещение (как BAR_TZ),
# а не пояс сервера: SQL-окна отчётов обязаны совпадать с python-часами
return timedelta(hours=5)
# ---------------------------------------------------------------- пароли
def hash_password(password: str) -> str:
salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), 200_000)
return f"pbkdf2_sha256$200000${salt}${digest.hex()}"
def verify_password(password: str, stored: str) -> bool:
try:
_, iterations, salt, expected = stored.split("$")
digest = hashlib.pbkdf2_hmac(
"sha256", password.encode(), bytes.fromhex(salt), int(iterations)
)
return hmac.compare_digest(digest.hex(), expected)
except (ValueError, TypeError):
return False
# фиктивный хэш: логин несуществующей учётки тратит то же время на pbkdf2,
# что и существующей, — время ответа не выдаёт, какие логины есть в базе
_DUMMY_HASH = hash_password("no-such-account")
# ---------------------------------------------------------------- seed-каталог и демо-заказы
def _read_seed(name: str, strict: bool = False) -> list[dict]:
"""Строки seed-JSON из crm/seed/.
strict=True — для старта сервера: отсутствующий или повреждённый файл
поднимает ошибку, а не возвращает пустой список. Иначе битый menu.json
при рестарте синхронно удалил бы весь каталог (F-6): sync-DELETE прошёл
бы по списку из двух хуков. В рантайме (site_mixes) остаётся мягкое
поведение — панель показывает пустой блок вместо 500."""
path = SEED_DIR / name
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
if strict:
raise RuntimeError(f"seed-файл {name} отсутствует или повреждён: {e}") from e
return []
if not isinstance(data, list):
if strict:
raise RuntimeError(f"seed-файл {name}: ожидался JSON-массив")
return []
return data
def validate_seeds() -> None:
"""Проверка всех seed-файлов при старте — fail-closed (F-6)."""
for name in ("menu.json", "tobacco.json", "games.json", "mixes.json"):
_read_seed(name, strict=True)
def site_mixes() -> list[dict]:
"""Авторские миксы сайта (crm/seed/mixes.json) — read-only во вкладке
«Миксы». В mix_recipes их нет: сайт рендерит их сам, дубли не нужны.
Только файл, без БД — общий для обоих бэкендов."""
return _read_seed("mixes.json")
# Кальяны в системе — ровно два типа. Живут в каталоге CRM как обычные
# позиции (категория «КАЛЬЯНЫ»), на сайт не попадают: seed-экспорт их
# не содержит, а секции сайта рендерят только свои категории.
HOOKAH_ITEMS = [
{"id": "hookah-1400", "name": "Кальян 1400 ₽", "category": "КАЛЬЯНЫ", "price": 1400, "portion": "лёгкий/средний"},
{"id": "hookah-1800", "name": "Кальян 1800 ₽", "category": "КАЛЬЯНЫ", "price": 1800, "portion": "крепкий"},
]
def _demo_rows() -> list[dict]:
"""Несколько заказов на сегодня, чтобы демо-стенд не выглядел пустым."""
from datetime import date, time
day = date.today()
# часы заданы в локальной зоне: в контейнере она совпадает с UTC,
# на локальной машине демо-заказы не «переезжают» на завтра
tz = datetime.now().astimezone().tzinfo or timezone.utc
def t(h, m):
return datetime.combine(day, time(h, m), tzinfo=tz)
def row(number, table, waiter, status, created, accept_after, deliver_after, items):
created_at = t(*created)
accepted = created_at + accept_after if accept_after else None
delivered = accepted + deliver_after if accepted and deliver_after else None
duration = int((delivered - accepted).total_seconds()) if delivered and accepted else None
return {
"number": number, "table": table, "waiter": waiter, "status": status,
"price": sum(i["price"] * i["qty"] for i in items),
"items": json.dumps(items, ensure_ascii=False),
"created": created_at, "accepted": accepted, "delivered": delivered,
"duration": duration,
"week": order_week_start(created_at),
}
return [
row(143, "Стол 2", "Марк", "done", (20, 2), timedelta(minutes=3), timedelta(minutes=17), [
{"name": "Кальян 1400 ₽", "qty": 1, "price": 1400},
{"name": "Орехи ассорти", "qty": 1, "price": 250},
]),
row(144, "Стол 5", "Марк", "done", (20, 31), timedelta(minutes=3), timedelta(minutes=21), [
{"name": "Кальян 1800 ₽", "qty": 2, "price": 1800},
{"name": "Сырные шарики", "qty": 1, "price": 420},
]),
row(145, "Стол 1", "Марк", "active", (20, 55), timedelta(minutes=3), None, [
{"name": "Кальян 1400 ₽", "qty": 1, "price": 1400},
{"name": "Морс клюквенный", "qty": 1, "price": 200},
]),
row(146, "Кинозал", "Марк", "new", (21, 12), None, None, [
{"name": "Кальян 1800 ₽", "qty": 1, "price": 1800},
{"name": "Чай облепиховый", "qty": 2, "price": 390},
]),
]
# ---------------------------------------------------------------- выбор бэкенда
if BACKEND == "sqlite":
from storage_sqlite import ( # noqa: F401
init_pool, close_pool, db, init_schema,
login_user, session_user, logout_user,
list_users, create_user, delete_user,
list_menu, set_menu_stopped, set_menu_deleted, menu_categories,
create_custom_menu_item, update_menu_item, create_custom_tobacco,
list_tobacco, set_tobacco_stopped, set_tobacco_deleted,
list_mixes, create_custom_mix, update_mix, set_mix_deleted,
list_site_mix_edits, site_mixes_edited, update_site_mix, reset_site_mix,
purge_tobacco_item, purge_mix_item,
stoplist, list_orders, menu_prices, create_order, update_order,
cancel_order_as_guest, change_password, is_known_staff_name,
orders_report, custom_catalog, find_order_by_code,
get_open_shift, clock_in, clock_out, on_shift, shift_counts,
admin_clock_in, staff_shift_roles,
hall_status, set_hall_busy, halls_list, halls_active, hall_names,
hall_create, hall_delete, staff_report, purge_menu_item,
set_menu_stock, first_order_dt,
weak_default_logins,
# postgres-only разделы: в sqlite-режиме это стабы с честным 501 (F-11)
board_activity, board_create, board_delete, board_guard, board_rename,
boards_list, game_create, game_delete, game_get, game_set_deleted,
game_update, games_list, hall_reserve, hall_reserve_cancel,
note_assignee_ok, note_create, note_delete, note_get, note_update,
notes_list, reset_order_numbering, set_menu_accent,
)
else:
import psycopg
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
_pool: ConnectionPool | None = None
def _conninfo() -> str:
"""Строка подключения: DATABASE_URL или сборка из PG_* переменных.
keepalives/connect_timeout — чтобы запрос при «зависшей» БД отваливался
по таймауту, а не висел вечно и не копил потоки ThreadingHTTPServer."""
libpq = (
"keepalives=1 keepalives_idle=30 keepalives_interval=5"
" keepalives_count=3 connect_timeout=5"
)
url = os.environ.get("DATABASE_URL")
if url:
return url + ("&" if "?" in url else "?") + libpq.replace(" ", "&")
return (
f"host={os.environ.get('PG_HOST', 'db')}"
f" port={os.environ.get('PG_PORT', '5432')}"
f" dbname={os.environ.get('PG_DB', 'neon_crm')}"
f" user={os.environ.get('PG_USER', 'neon')}"
f" password={os.environ.get('PG_PASSWORD', '')}"
f" {libpq}"
)
def init_pool() -> None:
global _pool
_pool = ConnectionPool(
conninfo=_conninfo(),
min_size=1,
max_size=10,
open=True,
# dict_row: обращения к полям по имени (r["number"] и т.д.);
# statement_timeout — от «зависших» запросов к БД
kwargs={"row_factory": dict_row, "options": "-c statement_timeout=10000"},
)
def close_pool() -> None:
if _pool is not None:
_pool.close()
_pool = None
def db():
if _pool is None:
raise RuntimeError("pool is not initialised")
return _pool.connection()
# ---------------------------------------------------------------- схема
SCHEMA = """
CREATE TABLE IF NOT EXISTS orders (
id BIGSERIAL PRIMARY KEY,
number INTEGER NOT NULL,
week_start DATE NOT NULL, -- «неделя нумерации» (вторник, см. order_week_start)
table_name TEXT NOT NULL,
guest_name TEXT,
guest_phone TEXT,
source TEXT NOT NULL DEFAULT 'site',
waiter TEXT,
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'active', 'done', 'cancelled')),
price INTEGER NOT NULL DEFAULT 0,
items JSONB NOT NULL DEFAULT '[]'::jsonb,
comment TEXT,
track_code TEXT,
stock_applied BOOLEAN NOT NULL DEFAULT FALSE, -- штучные позиции уже списаны (статус done)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
accepted_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
duration_seconds INTEGER
);
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (status, number DESC);
CREATE INDEX IF NOT EXISTS orders_created_idx ON orders (created_at DESC);
-- уникальность номера внутри недели создаёт _migrate_order_week():
-- на старой базе колонки week_start ещё нет, когда выполняется SCHEMA
CREATE TABLE IF NOT EXISTS menu_items (
id TEXT PRIMARY KEY, -- id позиции на сайте (напр. 'bud')
name TEXT NOT NULL,
category TEXT NOT NULL,
price INTEGER NOT NULL DEFAULT 0,
portion TEXT,
stopped BOOLEAN NOT NULL DEFAULT FALSE,
custom BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: добавлена владельцем через CRM
deleted BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: удалена владельцем (мягко)
accent TEXT, -- подсветка карточки на сайте (#hex, CRM)
-- поштучный учёт (пиво/энергетики банками): NULL — учёт выключен,
-- число — остаток в штуках; 0 → позиция уходит в стоп-лист сама
stock INTEGER,
auto_stopped BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: в стоп ушло само (stock = 0),
-- пополнение вернёт в продажу
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS menu_stopped_idx ON menu_items (stopped);
CREATE TABLE IF NOT EXISTS tobacco_flavors (
id TEXT PRIMARY KEY, -- id вкуса на сайте (напр. 'bb-cola')
brand TEXT NOT NULL,
flavor TEXT NOT NULL,
note TEXT,
strength INTEGER NOT NULL DEFAULT 2,
cooling BOOLEAN NOT NULL DEFAULT FALSE,
stopped BOOLEAN NOT NULL DEFAULT FALSE,
custom BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: добавлен владельцем через CRM
deleted BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE: удалён владельцем (мягко)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS tobacco_stopped_idx ON tobacco_flavors (stopped);
-- настройки CRM (ключ-значение): сброс нумерации заказов и т.п.
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Миксы владельца: состав из вкусов табаков без долей — пропорции
-- подбирает кальянщик. Отдаются на сайт через /api/catalog.
-- strength/cooling/price — расширенная информация микса (все NULL —
-- не заданы): сайт показывает их в карточке «Миксы мастера».
-- tags — ярлыки для AI-генератора сайта («ягоды», «цитрус», «лёд»…):
-- по пересечению тегов миксы мастера подмешиваются в выдачу ИИ.
CREATE TABLE IF NOT EXISTS mix_recipes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
flavors JSONB NOT NULL DEFAULT '[]'::jsonb, -- [{id, brand, flavor}]
note TEXT,
strength INTEGER, -- крепость 1–5
cooling INTEGER, -- холодок 0–3
price INTEGER, -- цена, ₽
tags JSONB NOT NULL DEFAULT '[]'::jsonb, -- ["ягоды", ...]
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
-- Правки витринных миксов сайта («Авторские миксы сайта», seed/mixes.json):
-- строка на микс появляется, только когда владелец его отредактировал.
-- NULL — поле не менялось, сайт берёт исходное значение из своего каталога.
-- flavors NULL — состав не менялся; иначе [{id, brand, flavor}] из справочника.
CREATE TABLE IF NOT EXISTS site_mix_edits (
id TEXT PRIMARY KEY, -- id витринного микса ('showcase-N')
name TEXT,
note TEXT,
strength INTEGER,
cooling INTEGER,
price INTEGER,
tags JSONB,
flavors JSONB,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS crm_users (
login TEXT PRIMARY KEY,
password_hash TEXT NOT NULL, -- pbkdf2_sha256$iter$salt$hex
name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'waiter', 'hookah')),
-- NF-11: мягкое удаление (увольнение). Смены и заказы бывшего
-- сотрудника остаются в базе — физический DELETE каскадом стирал
-- зарплатную историю. Строку скрывают list_users/login_user/
-- staff_report/staff_shift_roles и др.; восстановление — вручную SQL.
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS shifts (
id BIGSERIAL PRIMARY KEY,
login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS shifts_login_idx ON shifts (login);
-- Занятость столов и зон: busy_until NULL — занят без таймера,
-- до ручного освобождения. Срок таймера истёк — освобождается сам.
CREATE TABLE IF NOT EXISTS hall_status (
name TEXT PRIMARY KEY,
busy BOOLEAN NOT NULL DEFAULT FALSE,
busy_by TEXT,
busy_until TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Справочник столов и зон: владелец добавляет и удаляет столы из CRM.
-- HALLS ниже — только стартовый сид (ON CONFLICT DO NOTHING: удалённый
-- владельцем стол при рестарте не воскресает).
CREATE TABLE IF NOT EXISTS halls_def (
name TEXT PRIMARY KEY,
sub TEXT,
sort INTEGER NOT NULL DEFAULT 0,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Брони столов и зон: окно «со скольки до скольки». До начала брони стол
-- можно занять как обычно (пометка занятости отдельная от брони).
-- Когда время брони наступает, hall_status() сам занимает стол до конца
-- окна (occupied_at — метка «автозанятие применено», чтобы один раз).
-- Пересечение броней на одном столе не допускается (проверяет hall_reserve);
-- отменённые остаются в истории (cancelled = TRUE).
CREATE TABLE IF NOT EXISTS hall_reservations (
id BIGSERIAL PRIMARY KEY,
hall_name TEXT NOT NULL,
guest TEXT,
dt_from TIMESTAMPTZ NOT NULL,
dt_until TIMESTAMPTZ NOT NULL,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cancelled BOOLEAN NOT NULL DEFAULT FALSE,
occupied_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_hall_res_active
ON hall_reservations (hall_name, dt_from) WHERE NOT cancelled;
-- Библиотека игр бара: PS5 и настолки. Видна всем ролям (официант
-- подбирает игру гостю), добавление/удаление — админ и владелец.
-- Обложки — файлы в GAMES_MEDIA_DIR (/media/games/<id>.<ext>).
-- seed_id: игры сайта из crm/seed/games.json (сид не воскрешает
-- удалённые: строка с seed_id остаётся даже после «удаления»);
-- deleted: мягкое удаление — корзина с восстановлением.
-- Настолки: difficulty 1–3 и «логотип» = icon (ключ иконки сайта)
-- + accent (неоновый цвет сайта), выбираются в CRM.
CREATE TABLE IF NOT EXISTS games (
id BIGSERIAL PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('ps5', 'board')),
title TEXT NOT NULL,
note TEXT,
created_by TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
seed_id TEXT,
deleted BOOLEAN NOT NULL DEFAULT FALSE,
difficulty INTEGER,
icon TEXT,
accent TEXT
);
-- уникальный индекс по seed_id создаёт _migrate_games_columns():
-- на живой базе колонки seed_id ещё нет, когда выполняется SCHEMA
-- Доски заметок/задач: одна общая (видят все роли) и приватные
-- «владелец ↔ админ» (ровно два участника в board_members).
-- Канонический DDL с инструкциями: crm/migrate/notes_boards.sql
CREATE TABLE IF NOT EXISTS boards (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('shared', 'private')),
title TEXT NOT NULL,
created_by_login TEXT NOT NULL REFERENCES crm_users(login),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- участники приватных досок; общая доска доступна всем без проверки
CREATE TABLE IF NOT EXISTS board_members (
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
user_login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE,
PRIMARY KEY (board_id, user_login)
);
-- заметка = задача; к сменам не привязана — доступна в любое время
-- по факту логина (владелец ставит задачи из дома, персонал проверяет
-- до выхода). updated_at ведётся через clock_timestamp() — см. notes_list
-- Логины автора/исполнителя без FK (как orders.waiter): удаление
-- сотрудника не должно ломать его задачи — имя подтягивается через
-- LEFT JOIN, у удалённого остаётся логин.
CREATE TABLE IF NOT EXISTS notes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
author_login TEXT NOT NULL,
assignee_login TEXT,
title TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'done')),
important BOOLEAN NOT NULL DEFAULT FALSE,
due_date DATE,
done_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_notes_board ON notes (board_id, status);
CREATE INDEX IF NOT EXISTS idx_notes_assignee ON notes (assignee_login) WHERE status <> 'done';
-- журнал действий (created/status/assigned/edited/deleted/board_created).
-- note_id ON DELETE SET NULL: запись переживает заметку, id удалённой —
-- в details (по нему клиенты узнают об удалениях при опросе с ?since=).
-- user_login без FK — журнал переживает удаление сотрудника
CREATE TABLE IF NOT EXISTS note_activity (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
note_id BIGINT REFERENCES notes(id) ON DELETE SET NULL,
user_login TEXT NOT NULL,
action TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
-- clock_timestamp, а не now(): now() замирает на старте транзакции,
-- и удаление из «длинной» транзакции навсегда выпадало из ?since-курсора
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
-- Append-only журнал операций с заказами (NF-03): строка на каждую смену
-- статуса и атрибуции через PATCH /api/orders/<id>. Финансовая целостность:
-- кто, когда и что перевёл. user_login без FK — журнал переживает
-- удаление сотрудника (как note_activity); order_id без FK — журнал
-- переживает и возможную вычистку старых заказов. Записей изменения
-- в коде нет — таблица заполняется только update_order'ом.
CREATE TABLE IF NOT EXISTS orders_audit (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
user_login TEXT NOT NULL,
action TEXT NOT NULL, -- 'status' | 'waiter'
old_value TEXT,
new_value TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp()
);
CREATE INDEX IF NOT EXISTS orders_audit_order_idx ON orders_audit (order_id, id);
"""
def init_schema() -> None:
with db() as conn:
conn.execute(SCHEMA)
# стоп-лист переехал с брендов на вкусы: старая таблица больше не нужна
conn.execute("DROP TABLE IF EXISTS tobacco_brands")
_migrate_order_status_check()
_migrate_role_check()
_migrate_catalog_columns()
_migrate_order_columns()
_migrate_order_week()
_migrate_games_columns()
_migrate_games_fields()
_migrate_menu_accent()
_migrate_mix_columns()
_migrate_app_settings()
_migrate_hall_res_columns()
_migrate_shift_unique()
_migrate_crm_users_deleted()
_migrate_note_activity_clock()
_seed_users()
_seed_catalog()
_seed_games()
_seed_halls()
_maybe_seed_demo_orders()
boards_seed_shared()
def _migrate_shift_unique() -> None:
"""F-20: одна открытая смена на логин. Двойной clock-in в гонке двух
устройств больше не создаёт вторую открытую смену: partial unique
индекс + перехват unique violation в clock_in. Старые дубли (созданные
до индекса) закрываем, оставляя самую раннюю."""
with db() as conn:
conn.execute("""
UPDATE shifts SET ended_at = now()
WHERE ended_at IS NULL
AND id NOT IN (
SELECT MIN(id) FROM shifts WHERE ended_at IS NULL GROUP BY login
)
""")
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS shifts_open_unique_idx"
" ON shifts (login) WHERE ended_at IS NULL"
)
def _migrate_crm_users_deleted() -> None:
"""NF-11: колонка deleted в crm_users на живых базах (мягкое удаление
сотрудников вместо каскадного стирания смен). Идемпотентно."""
with db() as conn:
conn.execute(
"ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE"
)
def _migrate_note_activity_clock() -> None:
"""Гонка ?since-курсора заметок: created_at с DEFAULT now() замирал на
старте транзакции, и удаление из «длинной» транзакции навсегда выпадало
из реплея. На живой базе переопределяем дефолт на clock_timestamp()
(в SCHEMA он уже такой для новых баз). Идемпотентно."""
with db() as conn:
conn.execute(
"ALTER TABLE note_activity"
" ALTER COLUMN created_at SET DEFAULT clock_timestamp()"
)
def _seed_halls() -> None:
"""Стартовый справочник столов в halls_def: HALLS — канонический
список при первом старте; на живой базе сид не перетирает правки
владельца (удалённый стол остаётся удалённым)."""
with db() as conn:
for i, h in enumerate(HALLS):
conn.execute(
"INSERT INTO halls_def (name, sub, sort) VALUES (%s, %s, %s)"
" ON CONFLICT (name) DO NOTHING",
(h["name"], h["sub"], i),
)
def _migrate_order_week() -> None:
"""Еженедельная нумерация заказов.
На старых базах number был глобально уникален (constraint
orders_number_key) и колонки week_start не было: снимаем старую
уникальность, дозаполняем неделю по created_at и строим уникальный
индекс (week_start, number). Все шаги идемпотентны.
"""
with db() as conn:
conn.execute("ALTER TABLE orders ADD COLUMN IF NOT EXISTS week_start DATE")
legacy = conn.execute(
"""
SELECT con.conname AS name
FROM pg_constraint con
JOIN pg_attribute att
ON att.attrelid = con.conrelid AND att.attnum = ANY (con.conkey)
WHERE con.conrelid = 'orders'::regclass AND con.contype = 'u'
AND att.attname = 'number'
"""
).fetchall()
for r in legacy:
conn.execute(f"ALTER TABLE orders DROP CONSTRAINT {r['name']}")
nulls = conn.execute(
"SELECT id, created_at FROM orders WHERE week_start IS NULL"
).fetchall()
for r in nulls:
conn.execute(
"UPDATE orders SET week_start = %s WHERE id = %s",
(order_week_start(r["created_at"]), r["id"]),
)
conn.execute("ALTER TABLE orders ALTER COLUMN week_start SET NOT NULL")
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS orders_week_number_idx"
" ON orders (week_start, number)"
)
def _migrate_order_columns() -> None:
"""Колонки comment/track_code/stock_applied добавлены позже — дополняем живую таблицу.
Индекс уникальности кода создаём здесь (а не в SCHEMA): на старой базе
колонки ещё нет, когда выполняется SCHEMA. Для новой базы — тоже
сработает, IF NOT EXISTS идемпотентен. NULL-коды старых заказов
уникальности не нарушают (частичный индекс).
"""
with db() as conn:
conn.execute("ALTER TABLE orders ADD COLUMN IF NOT EXISTS comment TEXT")
conn.execute("ALTER TABLE orders ADD COLUMN IF NOT EXISTS track_code TEXT")
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS orders_track_code_idx"
" ON orders (track_code) WHERE track_code IS NOT NULL"
)
# поштучный учёт: флаг «штучные позиции этого заказа уже списаны»
conn.execute(
"ALTER TABLE orders ADD COLUMN IF NOT EXISTS stock_applied BOOLEAN NOT NULL DEFAULT FALSE"
)
def _migrate_role_check() -> None:
"""Базы до появления новых ролей имеют CHECK без них — заменяем
на полный список: владелец, администратор, официант, кальянщик."""
want = "('owner', 'admin', 'waiter', 'hookah')"
with db() as conn:
rows = conn.execute(
"SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint"
" WHERE conrelid = 'crm_users'::regclass AND contype = 'c'"
).fetchall()
if any("hookah" in (r["def"] or "") for r in rows):
return
with db() as conn:
conn.execute("ALTER TABLE crm_users DROP CONSTRAINT IF EXISTS crm_users_role_check")
conn.execute(
"ALTER TABLE crm_users ADD CONSTRAINT crm_users_role_check"
f" CHECK (role IN {want})"
)
def _migrate_catalog_columns() -> None:
"""Колонки custom/created_at/deleted/stock добавлены позже — дополняем живые таблицы."""
with db() as conn:
conn.execute(
"ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS custom BOOLEAN NOT NULL DEFAULT FALSE"
)
conn.execute(
"ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()"
)
conn.execute(
"ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE"
)
conn.execute(
"ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS accent TEXT"
)
# поштучный учёт: NULL — учёт выключен, число — остаток в штуках
conn.execute("ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS stock INTEGER")
conn.execute(
"ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS auto_stopped BOOLEAN NOT NULL DEFAULT FALSE"
)
conn.execute(
"ALTER TABLE tobacco_flavors ADD COLUMN IF NOT EXISTS custom BOOLEAN NOT NULL DEFAULT FALSE"
)
conn.execute(
"ALTER TABLE tobacco_flavors ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()"
)
conn.execute(
"ALTER TABLE tobacco_flavors ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE"
)
def _migrate_mix_columns() -> None:
"""mix_recipes до появления расширенной информации микса: strength/cooling/price/tags."""
with db() as conn:
conn.execute("ALTER TABLE mix_recipes ADD COLUMN IF NOT EXISTS strength INTEGER")
conn.execute("ALTER TABLE mix_recipes ADD COLUMN IF NOT EXISTS cooling INTEGER")
conn.execute("ALTER TABLE mix_recipes ADD COLUMN IF NOT EXISTS price INTEGER")
conn.execute(
"ALTER TABLE mix_recipes ADD COLUMN IF NOT EXISTS tags JSONB NOT NULL DEFAULT '[]'::jsonb"
)
def _migrate_games_columns() -> None:
"""games до появления сид-игр сайта: добавляем seed_id/deleted.
Уникальный индекс по seed_id — частичный (NULL у игр владельца)."""
with db() as conn:
conn.execute("ALTER TABLE games ADD COLUMN IF NOT EXISTS seed_id TEXT")
conn.execute(
"ALTER TABLE games ADD COLUMN IF NOT EXISTS deleted BOOLEAN NOT NULL DEFAULT FALSE"
)
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS games_seed_id_idx"
" ON games (seed_id) WHERE seed_id IS NOT NULL"
)
def _migrate_hall_res_columns() -> None:
"""Брони до появления автозанятия: добавляем occupied_at
(метка однократного самозанятия стола в hall_status)."""
with db() as conn:
conn.execute(
"ALTER TABLE hall_reservations ADD COLUMN IF NOT EXISTS occupied_at TIMESTAMPTZ"
)
def _migrate_games_fields() -> None:
"""games до появления настольных полей: difficulty/icon/accent."""
with db() as conn:
conn.execute("ALTER TABLE games ADD COLUMN IF NOT EXISTS difficulty INTEGER")
conn.execute("ALTER TABLE games ADD COLUMN IF NOT EXISTS icon TEXT")
conn.execute("ALTER TABLE games ADD COLUMN IF NOT EXISTS accent TEXT")
def _migrate_menu_accent() -> None:
"""menu_items до появления подсветки: добавляем accent (#hex)."""
with db() as conn:
conn.execute("ALTER TABLE menu_items ADD COLUMN IF NOT EXISTS accent TEXT")
def _migrate_app_settings() -> None:
with db() as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS app_settings ("
" key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)
def _migrate_order_status_check() -> None:
"""Базы, созданные до появления отмены, имеют CHECK без 'cancelled'.
Если constraint ещё не знает про отмену — заменяем его. Для новой
базы constraint уже правильный, и миграция ничего не делает.
"""
with db() as conn:
rows = conn.execute(
"SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint"
" WHERE conrelid = 'orders'::regclass AND contype = 'c'"
).fetchall()
if any("cancelled" in (r["def"] or "") for r in rows):
return
with db() as conn:
conn.execute("ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_status_check")
conn.execute(
"ALTER TABLE orders ADD CONSTRAINT orders_status_check"
" CHECK (status IN ('new', 'active', 'done', 'cancelled'))"
)
# ---------------------------------------------------------------- пользователи/сессии
def _seed_users() -> None:
"""Учётки берутся из окружения; при их отсутствии — значения по умолчанию."""
users = [
(
os.environ.get("CRM_OWNER_LOGIN", "owner"),
os.environ.get("CRM_OWNER_PASSWORD", "neon2026"),
os.environ.get("CRM_OWNER_NAME", "Нурлан"),
"owner",
),
(
os.environ.get("CRM_ADMIN_LOGIN", "admin"),
os.environ.get("CRM_ADMIN_PASSWORD", "neon2026"),
os.environ.get("CRM_ADMIN_NAME", "Ирина"),
"admin",
),
(
os.environ.get("CRM_WAITER_LOGIN", "waiter.mark"),
os.environ.get("CRM_WAITER_PASSWORD", "neon2026"),
os.environ.get("CRM_WAITER_NAME", "Марк"),
"waiter",
),
]
with db() as conn:
for login, password, name, role in users:
conn.execute(
"""
INSERT INTO crm_users (login, password_hash, name, role)
VALUES (%s, %s, %s, %s)
ON CONFLICT (login) DO NOTHING
""",
(login, hash_password(password), name, role),
)
def list_users() -> list[dict]:
with db() as conn:
rows = conn.execute(
"SELECT login, name, role FROM crm_users WHERE NOT deleted ORDER BY role, login"
).fetchall()
return [dict(r) for r in rows]
def create_user(login: str, password: str, name: str, role: str) -> dict | None | str:
"""Новый сотрудник. None — логин уже занят; 'name_taken' — имя уже
есть (атрибуция заказов по имени требует уникальности, F-15)."""
with db() as conn:
exists = conn.execute(
"SELECT 1 FROM crm_users WHERE login = %s", (login,)
).fetchone()
if exists:
return None
name_taken = conn.execute(
"SELECT 1 FROM crm_users WHERE name = %s", (name,)
).fetchone()
if name_taken:
return "name_taken"
row = conn.execute(
"""
INSERT INTO crm_users (login, password_hash, name, role)
VALUES (%s, %s, %s, %s)
RETURNING login, name, role
""",
(login, hash_password(password), name, role),
).fetchone()
return dict(row)
def delete_user(login: str) -> bool:
"""Мягкое удаление официанта/кальянщика/администратора (NF-11):
помечаем deleted и гасим сессии в одной транзакции. Владельца удалить
нельзя. Смены и заказы бывшего сотрудника остаются в базе — раньше
физический DELETE каскадом стирал смены (зарплатную историю)."""
with db() as conn:
row = conn.execute(
"UPDATE crm_users SET deleted = TRUE"
" WHERE login = %s AND role IN ('admin', 'waiter', 'hookah') AND NOT deleted"
" RETURNING login",
(login,),
).fetchone()
if row is None:
return False
conn.execute("DELETE FROM sessions WHERE login = %s", (login,))
return True
def login_user(login: str, password: str) -> dict | None:
with db() as conn:
row = conn.execute(
# NF-11: удалённого (deleted) сотрудника не пускаем — для
# перебора он неотличим от несуществующего (dummy-хэш ниже)
"SELECT login, password_hash, name, role FROM crm_users"
" WHERE login = %s AND NOT deleted",
(login,),
).fetchone()
if row is None:
verify_password(password, _DUMMY_HASH)
return None
if not verify_password(password, row["password_hash"]):
return None
token = secrets.token_urlsafe(32)
with db() as conn:
conn.execute("DELETE FROM sessions WHERE expires_at < now()")
conn.execute(
"INSERT INTO sessions (token, login, role, expires_at)"
" VALUES (%s, %s, %s, now() + %s)",
(token, row["login"], row["role"], SESSION_TTL),
)
return {"token": token, "login": row["login"], "name": row["name"], "role": row["role"]}
def session_user(token: str) -> dict | None:
if not token:
return None
with db() as conn:
row = conn.execute(
"""
SELECT u.login, u.name, u.role, s.expires_at
FROM sessions s JOIN crm_users u ON u.login = s.login
WHERE s.token = %s AND s.expires_at > now()
-- ревью NF-11: сессия, влезшая в окно гонки
-- login_user→delete_user, удалённого не аутентифицирует
AND NOT u.deleted
""",
(token,),
).fetchone()
# Скольжение сессии только для сменных ролей: планшеты бара и
# кухни опрашивают API каждые 10 секунд — пока устройство живо,
# сессия не истечёт посреди смены и никому не надо перелогиниваться.
# Владелец/админ входят как раньше — раз в 12 часов.
if row and row["role"] in ("waiter", "hookah"):
remaining = row["expires_at"] - datetime.now(timezone.utc)
if remaining < timedelta(hours=2):
conn.execute(
"UPDATE sessions SET expires_at = now() + %s WHERE token = %s",
(SESSION_TTL, token),
)
if row is None:
return None
return {"login": row["login"], "name": row["name"], "role": row["role"]}
def logout_user(token: str) -> None:
with db() as conn:
conn.execute("DELETE FROM sessions WHERE token = %s", (token,))
def weak_default_logins(default: str = "neon2026") -> list[str]:
"""Логины, чей пароль в БД фактически совпадает с известным
ПУБЛИЧНЫМ дефолтом (F-13): литералы из истории проекта — 'neon2026'
и опечатка 'noen2026' из старого .env. Значения из окружения сюда
не входят: пароль из .env — и есть заданный секрет, а не дефолт.
Сид создаёт учётки один раз (ON CONFLICT DO NOTHING), поэтому смена
пароля в .env на живой базе ничего не меняет — проверяем факт."""
candidates = {default, "neon2026", "noen2026"}
with db() as conn:
rows = conn.execute(
"SELECT login, password_hash FROM crm_users WHERE NOT deleted"
).fetchall()
hits = {
r["login"] for r in rows
if any(verify_password(c, r["password_hash"]) for c in candidates)
}
return sorted(hits)
def change_password(login: str, current_password: str, new_password: str,
keep_token: str | None = None) -> bool:
"""Смена пароля своей учётки (F-16): текущий пароль обязателен.
Прочие сессии этого логина гасятся — украденный токен перестаёт
работать сразу после смены. False — логина нет или текущий пароль неверен."""
with db() as conn:
row = conn.execute(
"SELECT password_hash FROM crm_users WHERE login = %s", (login,)
).fetchone()
if row is None or not verify_password(current_password, row["password_hash"]):
return False
conn.execute(
"UPDATE crm_users SET password_hash = %s WHERE login = %s",
(hash_password(new_password), login),
)
conn.execute(
"DELETE FROM sessions WHERE login = %s AND token IS DISTINCT FROM %s",
(login, keep_token),
)
return True
def reset_password(login: str, new_password: str) -> bool:
"""Сброс пароля чужой учётки владельцем (без текущего пароля).
Все сессии сброшенной учётки гасятся — сотрудник на всех устройствах
улетает на экран входа. False — логина нет (или учётка удалена)."""
with db() as conn:
row = conn.execute(
"SELECT 1 FROM crm_users WHERE login = %s AND NOT deleted", (login,)
).fetchone()
if row is None:
return False
conn.execute(
"UPDATE crm_users SET password_hash = %s WHERE login = %s",
(hash_password(new_password), login),
)
conn.execute("DELETE FROM sessions WHERE login = %s", (login,))
return True
def is_known_staff_name(name: str) -> bool:
"""Существует ли действующий сотрудник с таким именем или логином —
валидация атрибуции заказов (F-15): свободный текст позволял приписать
выручку чужому имени. Удалённые (NF-11) сотрудникам не считаются."""
with db() as conn:
row = conn.execute(
"SELECT 1 FROM crm_users WHERE NOT deleted AND (name = %s OR login = %s)",
(name, name),
).fetchone()
return row is not None
# ---------------------------------------------------------------- каталог и стоп-лист
def _seed_catalog() -> None:
"""Наполняет меню и табаки из seed-файлов, экспортированных с сайта.
Существующие позиции не трогаем — стоп-лист живёт в БД и переживает
перезапуск и обновление каталога. Позиции, которых больше нет на сайте,
удаляем, иначе они навсегда зависнут в CRM. Кальяны (два типа) добавляем
всегда — они нужны для ручных заказов в CRM. Seed читается строго:
битый файл роняет старт ДО всякой записи в БД (F-6) — иначе sync-DELETE
по списку из двух хуков вычистил бы весь каталог.
"""
menu = _read_seed("menu.json", strict=True) + [dict(h) for h in HOOKAH_ITEMS]
tobacco = _read_seed("tobacco.json", strict=True)
if not menu and not tobacco:
return
with db() as conn:
if menu:
upsert = """
INSERT INTO menu_items (id, name, category, price, portion, created_at)
VALUES (%(id)s, %(name)s, %(category)s, %(price)s, %(portion)s,
now() - interval '30 days')
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name, category = EXCLUDED.category,
price = EXCLUDED.price, portion = EXCLUDED.portion
"""
for item in menu:
conn.execute(upsert, item)
# custom-позиции владельца не трогаем и не удаляем — их на сайте нет;
# сеянные позиции — не «новинки», их created_at всегда в прошлом
conn.execute(
"DELETE FROM menu_items WHERE custom = FALSE AND NOT (id = ANY(%s))",
([m["id"] for m in menu],),
)
conn.execute(
"UPDATE menu_items SET created_at = now() - interval '30 days'"
" WHERE custom = FALSE"
)
if tobacco:
upsert = """
INSERT INTO tobacco_flavors (id, brand, flavor, note, strength, cooling, created_at)
VALUES (%(id)s, %(brand)s, %(flavor)s, %(note)s, %(strength)s, %(cooling)s,
now() - interval '30 days')
ON CONFLICT (id) DO UPDATE
SET brand = EXCLUDED.brand, flavor = EXCLUDED.flavor,
note = EXCLUDED.note, strength = EXCLUDED.strength,
cooling = EXCLUDED.cooling
"""
for item in tobacco:
conn.execute(upsert, item)
# custom-вкусы владельца не трогаем и не удаляем — их на сайте нет
conn.execute(
"DELETE FROM tobacco_flavors WHERE custom = FALSE AND NOT (id = ANY(%s))",
([t["id"] for t in tobacco],),
)
conn.execute(
"UPDATE tobacco_flavors SET created_at = now() - interval '30 days'"
" WHERE custom = FALSE"
)
def _seed_games() -> None:
"""Игры сайта (crm/seed/games.json) → библиотека CRM.
Строка с seed_id уже существует (в т.ч. удалённая) — не трогаем:
удалённая владельцем игра не воскресает при перезапуске. У живых
сид-игр обновляем название/пометку и дозаливаем обложку, если её
нет. Обложки лежат в seed/covers/, копируются в GAMES_MEDIA_DIR
под числовым id игры.
"""
seed = _read_seed("games.json", strict=True)
if not seed:
return
covers = SEED_DIR / "covers"
with db() as conn:
for g in seed:
seed_id = str(g.get("seed_id") or "").strip()
title = str(g.get("title") or "").strip()
if not seed_id or not title:
continue
row = conn.execute(
"SELECT id, deleted FROM games WHERE seed_id = %s", (seed_id,)
).fetchone()
if row is not None:
if not row["deleted"]:
# сайт — источник названия/пометки/сложности; логотип
# (icon/accent) дозаливаем только если владелец ещё
# не выбирал свой (NULL) — его выбор не перетираем
conn.execute(
"UPDATE games SET title = %s, note = %s, difficulty = %s,"
" icon = COALESCE(icon, %s), accent = COALESCE(accent, %s)"
" WHERE id = %s",
(title, g.get("note"), g.get("difficulty"),
g.get("icon"), g.get("accent"), row["id"]),
)
_copy_seed_cover(covers, g, row["id"])
continue
new_id = conn.execute(
"""
INSERT INTO games (kind, title, note, created_by, seed_id,
difficulty, icon, accent)
VALUES (%s, %s, %s, 'NEON сайт', %s, %s, %s, %s)
RETURNING id
""",
(g.get("kind") or "board", title, g.get("note"), seed_id,
g.get("difficulty"), g.get("icon"), g.get("accent")),
).fetchone()["id"]
_copy_seed_cover(covers, g, new_id)
def list_menu() -> list[dict]:
with db() as conn:
rows = conn.execute(
"SELECT id, name, category, price, portion, stopped, custom, deleted, accent,"
" stock, created_at FROM menu_items ORDER BY category, name"
).fetchall()
out = []
for r in rows:
d = dict(r)
d["created_at"] = d["created_at"].astimezone().isoformat()
d["has_photo"] = photo_for_item(d["id"]) is not None
out.append(d)
return out
def set_menu_accent(item_id: str, accent: str | None, conn=None) -> dict | None:
"""Подсветка карточки позиции на сайте (#hex или NULL — как у всех).
NF-07: conn — внешняя транзакция мульти-полевого PATCH каталога."""
if conn is None:
with db() as conn:
return set_menu_accent(item_id, accent, conn)
row = conn.execute(
"UPDATE menu_items SET accent = %s, updated_at = now()"
" WHERE id = %s AND deleted = FALSE"
" RETURNING id, name, category, price, accent",
(accent, item_id),
).fetchone()
return dict(row) if row else None
def set_menu_stopped(item_id: str, stopped: bool, conn=None) -> dict | None:
if conn is None:
with db() as conn:
return set_menu_stopped(item_id, stopped, conn)
row = conn.execute(
"""
UPDATE menu_items SET stopped = %s, auto_stopped = FALSE, updated_at = now()
WHERE id = %s AND deleted = FALSE
RETURNING id, name, category, price, portion, stopped, stock
""",
(stopped, item_id),
).fetchone()
return dict(row) if row else None
def set_menu_deleted(item_id: str, deleted: bool, conn=None) -> dict | None:
"""Мягкое удаление/восстановление позиции (только владелец)."""
if conn is None:
with db() as conn:
return set_menu_deleted(item_id, deleted, conn)
row = conn.execute(
"""
UPDATE menu_items SET deleted = %s, updated_at = now()
WHERE id = %s
RETURNING id, name, category, price, portion, stopped, custom, deleted
""",
(deleted, item_id),
).fetchone()
return dict(row) if row else None
def purge_menu_item(item_id: str):
"""Безвозвратное удаление позиции меню (только владелец).
Разрешено только кастомным позициям (custom = TRUE): стартовые
позиции сайта пересоздаёт сид при рестарте — их «полное» удаление
невозможно в принципе. Фото стирается с диска; история заказов не
страдает (состав хранится снапшотом).
True — удалена; False — позиция не найдена; 'seed' — не кастомная.
"""
with db() as conn:
row = conn.execute(
"SELECT custom FROM menu_items WHERE id = %s", (item_id,)
).fetchone()
if row is None:
return False
if not row["custom"]:
return "seed"
conn.execute("DELETE FROM menu_items WHERE id = %s", (item_id,))
purge_item_photos(item_id)
return True
def menu_categories() -> list[str]:
with db() as conn:
rows = conn.execute(
"SELECT DISTINCT category FROM menu_items ORDER BY category"
).fetchall()
return [r["category"] for r in rows]
def set_menu_stock(item_id: str, new_stock: int | None = None, add: int | None = None,
conn=None) -> dict | None:
"""Поштучный учёт позиции (владелец).
new_stock — задать остаток абсолютно: число включит учёт (и снимет
авто-стоп, если остаток > 0), None — выключит учёт (авто-стоп снимаем:
позиция перестала быть штучной — она снова доступна; ручной стоп
остаётся). add — приход: к текущему остатку прибавить N штук
(учёт уже должен быть включён); остаток > 0 снимает авто-стоп.
NF-07: conn — внешняя транзакция мульти-полевого PATCH каталога."""
if conn is None:
with db() as conn:
return set_menu_stock(item_id, new_stock, add, conn)
if add is not None:
row = conn.execute(
"""
UPDATE menu_items SET
stock = COALESCE(stock, 0) + %s,
stopped = CASE WHEN COALESCE(stock, 0) + %s > 0 AND auto_stopped
THEN FALSE ELSE stopped END,
auto_stopped = CASE WHEN COALESCE(stock, 0) + %s > 0
THEN FALSE ELSE auto_stopped END,
updated_at = now()
WHERE id = %s AND deleted = FALSE AND stock IS NOT NULL
RETURNING id, name, category, price, portion, stopped, stock
""",
(add, add, add, item_id),
).fetchone()
elif new_stock is None:
# выключение учёта: авто-стоп снимаем (позиция перестала быть
# штучной — снова доступна), ручной стоп остаётся
row = conn.execute(
"""
UPDATE menu_items SET
stock = NULL,
stopped = CASE WHEN auto_stopped THEN FALSE ELSE stopped END,
auto_stopped = FALSE,
updated_at = now()
WHERE id = %s AND deleted = FALSE
RETURNING id, name, category, price, portion, stopped, stock
""",
(item_id,),
).fetchone()
else:
row = conn.execute(
"""
UPDATE menu_items SET
stock = %s,
stopped = CASE
WHEN %s > 0 THEN CASE WHEN auto_stopped THEN FALSE ELSE stopped END
ELSE TRUE END,
auto_stopped = (%s = 0),
updated_at = now()
WHERE id = %s AND deleted = FALSE
RETURNING id, name, category, price, portion, stopped, stock
""",
(new_stock, new_stock, new_stock, item_id),
).fetchone()
return dict(row) if row else None
def create_custom_menu_item(
name: str, category: str, price: int, portion: str | None, stock: int | None = None
) -> dict:
"""Новая позиция меню от владельца. Видна на сайте как «новинка» неделю.
stock (шт) — сразу включить поштучный учёт (пиво/энергетики банками)."""
item_id = "custom-" + secrets.token_hex(4)
with db() as conn:
row = conn.execute(
"""
INSERT INTO menu_items (id, name, category, price, portion, stopped, custom,
stock, created_at)
VALUES (%s, %s, %s, %s, %s, FALSE, TRUE, %s, now())
RETURNING id, name, category, price, portion, stock, created_at
""",
(item_id, name, category, price, portion, stock),
).fetchone()
d = dict(row)
d["created_at"] = d["created_at"].astimezone().isoformat()
return d
def update_menu_item(item_id: str, fields: dict, conn=None):
"""Правка позиции владельцем: имя/категория/цена/порция — только
переданные поля. 'seed' — стартовая позиция сайта (правится в коде
сайта, CRM меняет лишь стоп/подсветку/остаток); None — не найдена.
NF-07: conn — внешняя транзакция мульти-полевого PATCH каталога."""
sets, vals = [], []
for col in ("name", "category", "price", "portion"):
if col in fields:
sets.append(f"{col} = %s")
vals.append(fields[col])
if not sets:
return None
if conn is None:
with db() as conn:
return update_menu_item(item_id, fields, conn)
check = conn.execute(
"SELECT custom FROM menu_items WHERE id = %s", (item_id,)
).fetchone()
if check is None:
return None
if not check["custom"]:
return "seed"
row = conn.execute(
f"UPDATE menu_items SET {', '.join(sets)}, updated_at = now()"
f" WHERE id = %s"
f" RETURNING id, name, category, price, portion, stock, created_at",
(*vals, item_id),
).fetchone()
d = dict(row)
d["created_at"] = d["created_at"].astimezone().isoformat()
return d
def create_custom_tobacco(
brand: str, flavor: str, note: str | None, strength: int, cooling: bool
) -> dict:
"""Новый вкус табака от владельца. Виден на сайте как «новинка» неделю."""
flavor_id = "custom-" + secrets.token_hex(4)
with db() as conn:
row = conn.execute(
"""
INSERT INTO tobacco_flavors (id, brand, flavor, note, strength, cooling, stopped, custom, created_at)
VALUES (%s, %s, %s, %s, %s, %s, FALSE, TRUE, now())
RETURNING id, brand, flavor, note, strength, cooling, created_at
""",
(flavor_id, brand, flavor, note, strength, cooling),
).fetchone()
d = dict(row)
d["created_at"] = d["created_at"].astimezone().isoformat()
return d
def list_tobacco() -> list[dict]:
with db() as conn:
rows = conn.execute(
"SELECT id, brand, flavor, note, strength, cooling, stopped, custom, deleted, created_at"
" FROM tobacco_flavors ORDER BY brand, flavor"
).fetchall()
out = []
for r in rows:
d = dict(r)
d["created_at"] = d["created_at"].astimezone().isoformat()
out.append(d)
return out
def set_tobacco_deleted(flavor_id: str, deleted: bool, conn=None) -> dict | None:
"""Мягкое удаление/восстановление вкуса (только владелец)."""
if conn is None:
with db() as conn:
return set_tobacco_deleted(flavor_id, deleted, conn)
row = conn.execute(
"""
UPDATE tobacco_flavors SET deleted = %s, updated_at = now()
WHERE id = %s
RETURNING id, brand, flavor, note, strength, cooling, stopped, custom, deleted
""",
(deleted, flavor_id),
).fetchone()
return dict(row) if row else None
def set_tobacco_stopped(flavor_id: str, stopped: bool, conn=None) -> dict | None:
if conn is None:
with db() as conn:
return set_tobacco_stopped(flavor_id, stopped, conn)
row = conn.execute(
"""
UPDATE tobacco_flavors SET stopped = %s, updated_at = now()
WHERE id = %s AND deleted = FALSE
RETURNING id, brand, flavor, note, strength, cooling, stopped
""",
(stopped, flavor_id),
).fetchone()
return dict(row) if row else None
# ---------------------------------------------------------------- миксы владельца
def _mix_row(r) -> dict:
d = dict(r)
d["created_at"] = d["created_at"].astimezone().isoformat()
return d
def list_mixes() -> list[dict]:
"""Миксы владельца (не удалённые) для управления в CRM."""
with db() as conn:
rows = conn.execute(
"SELECT id, name, flavors, note, strength, cooling, price, tags, created_at"
" FROM mix_recipes WHERE deleted = FALSE ORDER BY created_at DESC"
).fetchall()
return [_mix_row(r) for r in rows]
def create_custom_mix(
name: str, flavor_ids: list[str], note: str | None,
strength: int | None = None, cooling: int | None = None, price: int | None = None,
tags: list[str] | None = None,
) -> dict | None:
"""Микс от владельца: название + вкусы из справочника табаков.
None — вкусы не выбраны или в справочнике их меньше двух.
Доли не хранятся: пропорции подбирает кальянщик. strength/cooling/price —
расширенная информация для сайта (крепость 1–5, холодок 0–3, цена ₽),
tags — ярлыки для подмешивания микса в AI-генерацию на сайте."""
with db() as conn:
rows = conn.execute(
"SELECT id, brand, flavor FROM tobacco_flavors"
" WHERE id = ANY(%s) AND deleted = FALSE",
(flavor_ids,),
).fetchall()
if len(rows) < 2:
return None
by_id = {r["id"]: dict(r) for r in rows}
flavors = [by_id[i] for i in flavor_ids if i in by_id]
row = conn.execute(
"""
INSERT INTO mix_recipes (id, name, flavors, note, strength, cooling, price,
tags, created_at)
VALUES (%s, %s, %s::jsonb, %s, %s, %s, %s, %s::jsonb, now())
RETURNING id, name, flavors, note, strength, cooling, price, tags, created_at
""",
("mix-" + secrets.token_hex(4), name,
json.dumps(flavors, ensure_ascii=False), note, strength, cooling, price,
json.dumps(tags or [], ensure_ascii=False)),
).fetchone()
return _mix_row(row)
def update_mix(
mix_id: str, name: str, flavor_ids: list[str], note: str | None,
strength: int | None = None, cooling: int | None = None, price: int | None = None,
tags: list[str] | None = None,
):
"""Правка микса: название, состав и расширенные поля — тот же набор,
что при создании (см. create_custom_mix). 'not_found' — микса нет
или он удалён; 'bad_flavors' — в справочнике меньше двух живых
вкусов; иначе обновлённый микс."""
with db() as conn:
row = conn.execute(
"SELECT id FROM mix_recipes WHERE id = %s AND deleted = FALSE",
(mix_id,),
).fetchone()
if row is None:
return "not_found"
rows = conn.execute(
"SELECT id, brand, flavor FROM tobacco_flavors"
" WHERE id = ANY(%s) AND deleted = FALSE",
(flavor_ids,),
).fetchall()
if len(rows) < 2:
return "bad_flavors"
by_id = {r["id"]: dict(r) for r in rows}
flavors = [by_id[i] for i in flavor_ids if i in by_id]
row = conn.execute(
"""
UPDATE mix_recipes SET name = %s, flavors = %s::jsonb, note = %s,
strength = %s, cooling = %s, price = %s, tags = %s::jsonb
WHERE id = %s AND deleted = FALSE
RETURNING id, name, flavors, note, strength, cooling, price, tags, created_at
""",
(name, json.dumps(flavors, ensure_ascii=False), note, strength, cooling,
price, json.dumps(tags or [], ensure_ascii=False), mix_id),
).fetchone()
return _mix_row(row)
# ------------------------------------------------------- витрина сайта
def list_site_mix_edits() -> list[dict]:
"""Правки витринных миксов сайта: {id, name?, note?, strength?,
cooling?, price?, tags?, flavors?} — NULL значит «не менялось».
Отдаются публично через /api/catalog (сайт применяет к своей витрине)."""
with db() as conn:
rows = conn.execute(
"SELECT id, name, note, strength, cooling, price, tags, flavors FROM site_mix_edits"
).fetchall()
return [dict(r) for r in rows]
def site_mixes_edited() -> list[dict]:
"""Витринные миксы сайта, слитые с правками CRM: edited=True, поверх
исходных значений наложены переопределённые поля (и состав, если правился)."""
edits = {e["id"]: e for e in list_site_mix_edits()}
out = []
for m in site_mixes():
row = dict(m)
e = edits.get(row["id"])
if not e:
row["edited"] = False
out.append(row)
continue
row["edited"] = True
if e.get("name"):
row["name"] = e["name"]
if e.get("note") is not None:
row["note"] = e["note"]
if e.get("strength") is not None:
row["strength"] = e["strength"]
if e.get("cooling") is not None:
row["cooling"] = e["cooling"]
if e.get("price") is not None:
row["price"] = e["price"]
if e.get("tags"):
row["flavor_tags"] = e["tags"]
if e.get("flavors"):
row["flavors"] = e["flavors"]
out.append(row)
return out
def update_site_mix(mix_id: str, fields: dict):
"""Сохранить правку витринного микса (владелец): flavors None = состав
не менялся. Возвращает id сохранённой правки."""
with db() as conn:
conn.execute(
"""
INSERT INTO site_mix_edits (id, name, note, strength, cooling, price, tags, flavors)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name, note = EXCLUDED.note, strength = EXCLUDED.strength,
cooling = EXCLUDED.cooling, price = EXCLUDED.price, tags = EXCLUDED.tags,
flavors = EXCLUDED.flavors, updated_at = now()
""",
(mix_id, fields.get("name"), fields.get("note"), fields.get("strength"),
fields.get("cooling"), fields.get("price"),
json.dumps(fields.get("tags"), ensure_ascii=False) if fields.get("tags") is not None else None,
json.dumps(fields.get("flavors"), ensure_ascii=False) if fields.get("flavors") is not None else None),
)
return mix_id
def reset_site_mix(mix_id: str) -> bool:
"""Вернуть витринный микс к исходному виду: правка удаляется.
True — была правка и она снята; False — правки не было."""
with db() as conn:
cur = conn.execute("DELETE FROM site_mix_edits WHERE id = %s", (mix_id,))
return cur.rowcount > 0
def purge_tobacco_item(flavor_id: str):
"""Безвозвратное удаление вкуса (см. purge_menu_item): только кастомные —
стартовые вкусы сайта сид пересоздаёт при рестарте. Фото у вкусов нет;
история заказов не страдает (состав хранится снапшотом).
True — удалён; False — вкус не найден; 'seed' — не кастомный.
"""
with db() as conn:
row = conn.execute(
"SELECT custom FROM tobacco_flavors WHERE id = %s", (flavor_id,)
).fetchone()
if row is None:
return False
if not row["custom"]:
return "seed"
conn.execute("DELETE FROM tobacco_flavors WHERE id = %s", (flavor_id,))
return True
def purge_mix_item(mix_id: str):
"""Безвозвратное удаление микса. В mix_recipes только миксы владельца —
авторские миксы сайта читаются из seed/mixes.json без БД (site_mixes),
сид здесь ничего не восстанавливает.
True — удалён; False — микс не найден.
"""
with db() as conn:
row = conn.execute(
"SELECT id FROM mix_recipes WHERE id = %s", (mix_id,)
).fetchone()
if row is None:
return False
conn.execute("DELETE FROM mix_recipes WHERE id = %s", (mix_id,))
return True
def set_mix_deleted(mix_id: str, deleted: bool) -> dict | None:
"""Мягкое удаление/восстановление микса (только владелец)."""
with db() as conn:
row = conn.execute(
"""
UPDATE mix_recipes SET deleted = %s
WHERE id = %s AND deleted <> %s
RETURNING id, name, flavors, note, strength, cooling, price, tags, created_at
""",
(deleted, mix_id, deleted),
).fetchone()
return _mix_row(row) if row else None
def stoplist() -> dict:
"""Публичный стоп-лист для сайта: id позиций меню и id вкусов табака."""
with db() as conn:
menu = [r["id"] for r in conn.execute(
"SELECT id FROM menu_items WHERE stopped"
).fetchall()]
tobacco = [r["id"] for r in conn.execute(
"SELECT id FROM tobacco_flavors WHERE stopped"
).fetchall()]
return {"stopped_menu": menu, "stopped_tobacco": tobacco}
# ---------------------------------------------------------------- заказы
def order_to_json(r) -> dict:
return {
"id": r["id"],
# недельный номер для показа (обнуляется каждую неделю);
# стабильный идентификатор API — внутренний id выше
"number": r["number"],
"table": r["table_name"],
"guest_name": r["guest_name"],
"guest_phone": r["guest_phone"],
"source": r["source"],
"waiter": r["waiter"],
"status": r["status"],
"price": r["price"],
"items": r["items"],
"comment": r["comment"],
"code": r["track_code"],
"created_at": r["created_at"].astimezone().isoformat()
if r["created_at"] else None,
"accepted_at": r["accepted_at"].astimezone().isoformat()
if r["accepted_at"] else None,
"delivered_at": r["delivered_at"].astimezone().isoformat()
if r["delivered_at"] else None,
"duration_seconds": r["duration_seconds"],
}
def list_orders() -> list[dict]:
# сортируем по внутреннему id: при недельной нумерации number
# сбрасывается и не отражает порядок создания между неделями
with db() as conn:
rows = conn.execute(
"SELECT * FROM orders ORDER BY id DESC LIMIT 500"
).fetchall()
return [order_to_json(r) for r in rows]
def menu_prices() -> dict[str, int]:
with db() as conn:
rows = conn.execute("SELECT id, price FROM menu_items").fetchall()
return {r["id"]: r["price"] for r in rows}
def create_order(
table_name: str,
items: list[dict],
source: str = "site",
guest_name: str | None = None,
guest_phone: str | None = None,
comment: str | None = None,
) -> dict:
"""Создаёт заказ; цену считаем на сервере.
Для позиций с известным id меню цену берём из БД, а не от клиента —
сумму заказа нельзя подделать с фронтенда. Номер — внутри «недели
нумерации» (MAX+1 по week_start, сбрасывается каждую неделю), код —
для отслеживания гостем. Коллизии (гонка двух заказов или код) —
повторная попытка: каждая попытка в своей транзакции.
"""
prices = menu_prices()
prepared = []
for i in items:
# цену берём ТОЛЬКО из каталога: у заказа с сайта id всегда из
# меню, а цена клиента игнорируется даже для неизвестного id —
# иначе сумму заказа можно подделать прямым запросом к API
price = prices.get(i.get("id") or "", 0)
prepared.append({
"id": i.get("id"),
"name": str(i.get("name", ""))[:120],
"qty": int(i.get("qty", 1)),
"price": price,
})
total = sum(i["price"] * i["qty"] for i in prepared)
week = effective_order_week(datetime.now().astimezone())
for _ in range(5):
code = generate_track_code()
try:
with db() as conn:
number = conn.execute(
"SELECT COALESCE(MAX(number), 0) + 1 AS n FROM orders"
" WHERE week_start = %s",
(week,),
).fetchone()["n"]
row = conn.execute(
"""
INSERT INTO orders (number, week_start, table_name, guest_name,
guest_phone, source, status, price, items,
comment, track_code, created_at)
VALUES (%s, %s, %s, %s, %s, %s, 'new', %s, %s::jsonb, %s, %s, now())
RETURNING *
""",
(number, week, table_name, guest_name, guest_phone, source, total,
json.dumps(prepared, ensure_ascii=False), comment, code),
).fetchone()
return order_to_json(row)
except psycopg.errors.UniqueViolation:
continue
raise RuntimeError("не удалось сгенерировать номер/код заказа")
def find_order_by_code(code: str) -> dict | None:
"""Заказ по коду отслеживания (публичная страница статуса на сайте)."""
with db() as conn:
row = conn.execute(
"SELECT * FROM orders WHERE track_code = %s", (code,)
).fetchone()
return order_to_json(row) if row else None
def _stock_touch(conn, items, sign: int) -> list[str]:
"""Списать (+1) или вернуть (-1... sign=-1 — возврат) штучные позиции
заказа. items — распарсенный jsonb заказа (список {id, qty, ...}).
Возвращает id затронутых позиций: после списания среди них снимем/поставим
авто-стоп. Учёт ведётся только по позициям с включённым учётом (stock NOT NULL).
Списание без клампа (F-17): остаток может уйти в минус — это честный
дефицит; асимметрия «списали с полом, вернули целиком» создавала
фантомные банки за каждый цикл done↔undone."""
touched: list[str] = []
for i in items or []:
if not isinstance(i, dict):
continue
item_id = str(i.get("id") or "")[:64]
try:
qty = min(50, max(1, int(i.get("qty", 1))))
except (TypeError, ValueError):
continue
if not item_id:
continue
if sign > 0: # выдача: списываем
row = conn.execute(
"UPDATE menu_items SET stock = stock - %s, updated_at = now()"
" WHERE id = %s AND stock IS NOT NULL AND deleted = FALSE"
" RETURNING id, stock",
(qty, item_id),
).fetchone()
else: # возврат (заказ ушёл из «Выдан»): возвращаем на полку
row = conn.execute(
"UPDATE menu_items SET stock = stock + %s, updated_at = now()"
" WHERE id = %s AND stock IS NOT NULL AND deleted = FALSE"
" RETURNING id, stock",
(qty, item_id),
).fetchone()
if row is not None:
touched.append(row["id"])
return touched
def update_order(order_id: int, status: str, waiter: str | None,
actor: dict | None = None) -> dict | str | None:
"""Смена статуса по внутреннему id; метки времени и длительность считает сервер.
NF-03: машина переходов и append-only журнал.
- Сменные роли (официант/кальянщик) ведут заказ только вперёд по
ORDER_TRANSITIONS_STAFF; из терминальных (done/cancelled) и назад —
только владелец/админ: 'bad_transition' (хендлер отвечает 403).
- Повтор того же статуса без смены официанта — no-op (повторный done
больше не перезаписывает delivered_at/длительность).
- Каждая фактическая смена статуса и атрибуции пишется в orders_audit
(кто — actor['login'], старое/новое значение, clock_timestamp).
Штучный учёт: переход в «Выдан» списывает позиции с включённым учётом
(однократно, флаг stock_applied), вышедшие из «Выдан» — возвращает.
Позиции с остатком 0 уходят в стоп-лист автоматически."""
with db() as conn:
row = conn.execute(
"SELECT * FROM orders WHERE id = %s", (order_id,)
).fetchone()
if row is None:
return None
old = row["status"]
old_waiter = row["waiter"]
actor_login = (actor or {}).get("login") or "system"
actor_role = (actor or {}).get("role")
if status == old and (waiter is None or waiter == old_waiter):
return order_to_json(row) # повторный PATCH без изменений — no-op
# ревью NF-03: сменная роль не может переатрибутить заказ «на
# месте» (без смены статуса) — в т.ч. переписать чужой выданный/
# отменённый заказ на себя; атрибуцию вне перехода правит только
# владелец/админ
if status == old and waiter is not None \
and actor_role not in ("owner", "admin"):
return "bad_transition"
if status != old and actor_role not in ("owner", "admin") \
and (old, status) not in ORDER_TRANSITIONS_STAFF:
return "bad_transition"
if status != old:
ts = utcnow()
accepted, delivered, duration = row["accepted_at"], row["delivered_at"], row["duration_seconds"]
if status == "new":
accepted = delivered = duration = None
elif status == "active":
accepted = accepted or ts
delivered, duration = None, None
elif status == "cancelled":
# отмена: историю принятия оставляем, выдачи и длительности нет
delivered, duration = None, None
else: # done
delivered = ts
# длительность — только реальный отрезок «принят → выдан»
duration = int((ts - accepted).total_seconds()) if accepted else None
else:
accepted = row["accepted_at"]
delivered = row["delivered_at"]
duration = row["duration_seconds"]
if waiter is None:
waiter = old_waiter
was_done = old == "done"
now_done = status == "done"
applied = row["stock_applied"]
updated = conn.execute(
"""
UPDATE orders SET status = %s, waiter = %s, accepted_at = %s,
delivered_at = %s, duration_seconds = %s
WHERE id = %s RETURNING *
""",
(status, waiter, accepted, delivered, duration, order_id),
).fetchone()
# журнал (NF-03): по строке на каждую фактическую смену
if status != old:
conn.execute(
"INSERT INTO orders_audit (order_id, user_login, action, old_value, new_value)"
" VALUES (%s, %s, 'status', %s, %s)",
(order_id, actor_login, old, status),
)
if waiter != old_waiter:
conn.execute(
"INSERT INTO orders_audit (order_id, user_login, action, old_value, new_value)"
" VALUES (%s, %s, 'waiter', %s, %s)",
(order_id, actor_login, old_waiter, waiter),
)
# списание/возврат под атомарным предикатом на флаге (F-кл.: RMW-гонка):
# конкурентный дублирующий PATCH увидит 0 затронутых строк и не
# спишет/не вернёт товар второй раз
if now_done and not was_done and not applied:
claimed = conn.execute(
"UPDATE orders SET stock_applied = TRUE"
" WHERE id = %s AND stock_applied = FALSE",
(order_id,),
)
if claimed.rowcount == 1:
touched = _stock_touch(conn, row["items"], sign=1)
# остаток кончился (в т.ч. ушёл в минус) → позиция в стоп-лист
if touched:
conn.execute(
"UPDATE menu_items SET stopped = TRUE, auto_stopped = TRUE,"
" updated_at = now()"
" WHERE id = ANY(%s) AND stock <= 0 AND stopped = FALSE",
(touched,),
)
elif was_done and not now_done and applied:
# заказ вернули из «Выдан» — товар вернулся на полку
released = conn.execute(
"UPDATE orders SET stock_applied = FALSE"
" WHERE id = %s AND stock_applied = TRUE",
(order_id,),
)
if released.rowcount == 1:
touched = _stock_touch(conn, row["items"], sign=-1)
if touched:
conn.execute(
"UPDATE menu_items SET stopped = FALSE, auto_stopped = FALSE,"
" updated_at = now()"
" WHERE id = ANY(%s) AND auto_stopped AND stock > 0",
(touched,),
)
return order_to_json(updated)
def cancel_order_as_guest(order_id: int) -> dict | None:
"""Гостевая отмена по коду: атомарно ТОЛЬКО из статуса «Новый».
Предикат в UPDATE закрывает гонку с PATCH персонала (needs_validation
fp.track-cancel): если заказ ушёл из «Новый» между проверкой и отменой,
UPDATE не тронет ни строку — гость получит честный отказ. None — уже поздно."""
with db() as conn:
row = conn.execute(
"UPDATE orders SET status = 'cancelled', delivered_at = NULL,"
" duration_seconds = NULL"
" WHERE id = %s AND status = 'new' RETURNING *",
(order_id,),
).fetchone()
return order_to_json(row) if row else None
# ---------------------------------------------------------------- отчёты и каталог владельца
def orders_report(dt_from, dt_to) -> dict:
"""Отчёт по заказам за период [dt_from, dt_to) — timezone-aware UTC."""
with db() as conn:
statuses = [dict(r) for r in conn.execute(
"SELECT status, COUNT(*) AS cnt, COALESCE(SUM(price), 0) AS total"
" FROM orders WHERE created_at >= %s AND created_at < %s"
" GROUP BY status",
(dt_from, dt_to),
).fetchall()]
by_day = [dict(r) for r in conn.execute(
"""
SELECT to_char(created_at AT TIME ZONE %s, 'YYYY-MM-DD') AS day,
COUNT(*) AS cnt,
COALESCE(SUM(price) FILTER (WHERE status = 'done'), 0) AS revenue,
COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled
FROM orders
WHERE created_at >= %s AND created_at < %s
GROUP BY day ORDER BY day
""",
(bar_tz_name(), dt_from, dt_to),
).fetchall()]
top = [dict(r) for r in conn.execute(
"""
SELECT i->>'name' AS name,
SUM((i->>'qty')::int) AS qty,
SUM((i->>'qty')::int * (i->>'price')::int) AS revenue
FROM orders o, jsonb_array_elements(o.items) i
WHERE o.created_at >= %s AND o.created_at < %s AND o.status <> 'cancelled'
GROUP BY name ORDER BY revenue DESC, qty DESC
LIMIT 10
""",
(dt_from, dt_to),
).fetchall()]
avg = conn.execute(
"SELECT AVG(duration_seconds) AS d FROM orders"
" WHERE created_at >= %s AND created_at < %s AND duration_seconds IS NOT NULL",
(dt_from, dt_to),
).fetchone()
raw = conn.execute(
"SELECT status, source, price, table_name, created_at FROM orders"
" WHERE created_at >= %s AND created_at < %s",
(dt_from, dt_to),
).fetchall()
by_day = [{"day": d["day"], "orders": d["cnt"], "revenue": d["revenue"],
"cancelled": d["cancelled"]} for d in by_day]
report = _assemble_report(statuses, by_day, top, avg["d"])
report.update(_report_extras([dict(r) for r in raw]))
return report
def first_order_dt() -> datetime | None:
"""Самый ранний заказ — для отчёта «за всё время». None, если заказов нет."""
with db() as conn:
row = conn.execute("SELECT MIN(created_at) AS mn FROM orders").fetchone()
return row["mn"] if row and row["mn"] else None
def staff_report(dt_from, dt_to) -> list[dict]:
"""Статистика по каждому сотруднику за период [dt_from, dt_to).
Заказы атрибутируются по orders.waiter — там хранится имя принявшего
(валидация на PATCH: только имена/логины из crm_users). Смены —
по shifts.login; открытая смена считается до текущего момента.
Забытая смена сначала автозакрывается (F-19): иначе фантомные часы
раздували отчёт до первого случайного триггера auto_close."""
auto_close_shifts()
with db() as conn:
order_rows = [dict(r) for r in conn.execute(
"""
SELECT u.login, u.name, u.role,
COUNT(o.id) AS orders_total,
COUNT(o.id) FILTER (WHERE o.status = 'done') AS orders_done,
COUNT(o.id) FILTER (WHERE o.status = 'cancelled') AS orders_cancelled,
COALESCE(SUM(o.price) FILTER (WHERE o.status = 'done'), 0) AS revenue,
COUNT(o.id) FILTER (WHERE o.source = 'site') AS site_orders,
COUNT(o.id) FILTER (WHERE o.source = 'hall') AS hall_orders,
AVG(o.price) FILTER (WHERE o.status = 'done') AS avg_check,
AVG(o.duration_seconds) AS avg_duration
FROM crm_users u
LEFT JOIN orders o
ON o.waiter = u.name
AND o.created_at >= %s AND o.created_at < %s
WHERE NOT u.deleted
GROUP BY u.login, u.name, u.role
""",
(dt_from, dt_to),
).fetchall()]
shift_rows = [dict(r) for r in conn.execute(
"""
SELECT s.login,
COUNT(*) AS shifts,
COALESCE(SUM(EXTRACT(EPOCH FROM
(COALESCE(s.ended_at, now()) - s.started_at))), 0) AS seconds
FROM shifts s
WHERE s.started_at >= %s AND s.started_at < %s
GROUP BY s.login
""",
(dt_from, dt_to),
).fetchall()]
shifts_by_login = {r["login"]: r for r in shift_rows}
out = []
for r in order_rows:
sh = shifts_by_login.get(r["login"])
out.append({
"login": r["login"],
"name": r["name"],
"role": r["role"],
"orders_total": r["orders_total"],
"orders_done": r["orders_done"],
"orders_cancelled": r["orders_cancelled"],
"revenue": r["revenue"],
"avg_check": round(r["avg_check"]) if r["avg_check"] is not None else None,
"avg_duration_seconds": round(r["avg_duration"]) if r["avg_duration"] is not None else None,
"site_orders": r["site_orders"],
"hall_orders": r["hall_orders"],
"shifts": sh["shifts"] if sh else 0,
"hours_seconds": round(sh["seconds"]) if sh else 0,
})
out.sort(key=lambda r: (-r["orders_done"], -r["revenue"], r["name"]))
return out
def custom_catalog() -> dict:
"""Публичный список позиций, добавленных владельцем через CRM, —
сайт подмешивает их в меню и табаки и помечает «новинку» на неделю.
Удалённые позиции сюда не попадают: их id сайт фильтрует из каталога.
accents: подсветка (#hex) любых неудалённых позиций с выставленным
акцентом — сайт накладывает её поверх своей (правка 2: новые позиции
перестают быть «все одного цвета»)."""
with db() as conn:
menu = conn.execute(
"SELECT id, name, category, price, portion, accent, created_at FROM menu_items"
" WHERE custom = TRUE AND deleted = FALSE ORDER BY created_at DESC"
).fetchall()
tobacco = conn.execute(
"SELECT id, brand, flavor, note, strength, cooling, created_at"
" FROM tobacco_flavors WHERE custom = TRUE AND deleted = FALSE"
" ORDER BY created_at DESC"
).fetchall()
mixes = conn.execute(
"SELECT id, name, flavors, note, strength, cooling, price, tags, created_at"
" FROM mix_recipes WHERE deleted = FALSE ORDER BY created_at DESC"
).fetchall()
deleted_menu = conn.execute(
"SELECT id FROM menu_items WHERE deleted = TRUE"
).fetchall()
deleted_tobacco = conn.execute(
"SELECT id FROM tobacco_flavors WHERE deleted = TRUE"
).fetchall()
accents = conn.execute(
"SELECT id, accent FROM menu_items"
" WHERE deleted = FALSE AND accent IS NOT NULL"
).fetchall()
def with_iso(rows):
out = []
for r in rows:
d = dict(r)
d["created_at"] = d["created_at"].astimezone().isoformat()
out.append(d)
return out
menu_rows = with_iso(menu)
for d in menu_rows:
d["image"] = photo_for_item(d["id"])
return {
"menu": menu_rows,
"tobacco": with_iso(tobacco),
"mixes": with_iso(mixes),
"site_mix_edits": list_site_mix_edits(),
"deleted_menu": [r["id"] for r in deleted_menu],
"deleted_tobacco": [r["id"] for r in deleted_tobacco],
"accents": {r["id"]: r["accent"] for r in accents},
}
# ---------------------------------------------------------------- настройки и нумерация
def get_setting(key: str) -> str | None:
with db() as conn:
row = conn.execute(
"SELECT value FROM app_settings WHERE key = %s", (key,)
).fetchone()
return row["value"] if row else None
def set_setting(key: str, value: str) -> None:
with db() as conn:
conn.execute(
"INSERT INTO app_settings (key, value) VALUES (%s, %s)"
" ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value",
(key, value),
)
def effective_order_week(now: datetime) -> date:
"""Неделя нумерации с учётом сброса владельцем.
Сброс хранит две настройки: order_number_reset_at (момент сброса) и
order_number_week (свободное пространство ключей). Переопределение
действует, пока сброс сделан в ТЕКУЩЕЙ естественной неделе: с
наступлением новой недели (вторник) условие само отпускает, и
нумерация начинается естественно с №1.
"""
week = order_week_start(now)
reset_at = get_setting("order_number_reset_at")
if reset_at:
try:
if order_week_start(datetime.fromisoformat(reset_at)) != week:
return week
except ValueError:
return week
override = get_setting("order_number_week")
if override:
try:
return date.fromisoformat(override)
except ValueError:
return week
return week
def reset_order_numbering() -> dict:
"""Сброс нумерации: следующий заказ получит №1.
Один сброс на естественную неделю: если прошлый сброс случился в эту
же неделю (order_number_reset_at) — повторный запрещён, иначе номера
1, 2, … выделялись бы заново поверх уже выданных. Пространство
(order_number_week) выбираем свободное: дата текущей недели, а если
в ней уже есть заказы — предыдущий день (он не может быть week_start
старых заказов: недели начинаются по вторникам)."""
natural = order_week_start()
prev = get_setting("order_number_reset_at")
if prev:
try:
if order_week_start(datetime.fromisoformat(prev)) == natural:
return {}
except ValueError:
pass
candidate = natural
with db() as conn:
for _ in range(2):
taken = conn.execute(
"SELECT 1 FROM orders WHERE week_start = %s LIMIT 1", (candidate,)
).fetchone()
if taken is None:
break
candidate = candidate - timedelta(days=1)
else:
return {}
set_setting("order_number_week", candidate.isoformat())
set_setting("order_number_reset_at", datetime.now(timezone.utc).isoformat())
return {"week": candidate.isoformat(), "next_number": 1}
# ---------------------------------------------------------------- смены официантов
def _shift_row(r) -> dict:
d = dict(r)
for key in ("started_at", "ended_at"):
if d.get(key) is not None:
d[key] = d[key].isoformat()
return d
def auto_close_shifts() -> int:
"""Автоматически закрывает смены, чьё окно уже завершилось
(забыл нажать «Закончить смену» — смена закроется сама)."""
now = utcnow()
closed = 0
with db() as conn:
rows = conn.execute(
"SELECT id, started_at FROM shifts WHERE ended_at IS NULL"
).fetchall()
for r in rows:
end = auto_close_cutoff(r["started_at"], now)
if end is not None:
conn.execute(
"UPDATE shifts SET ended_at = %s WHERE id = %s AND ended_at IS NULL",
(end, r["id"]),
)
closed += 1
return closed
def get_open_shift(login: str) -> dict | None:
auto_close_shifts()
with db() as conn:
row = conn.execute(
"SELECT id, login, started_at, ended_at FROM shifts"
" WHERE login = %s AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1",
(login,),
).fetchone()
return _shift_row(row) if row else None
def clock_in(login: str) -> tuple[dict | None, str]:
ok, reason = clock_in_check()
if not ok:
return None, reason
if get_open_shift(login):
return None, "Вы уже на смене"
try:
with db() as conn:
row = conn.execute(
"INSERT INTO shifts (login) VALUES (%s) RETURNING id, login, started_at, ended_at",
(login,),
).fetchone()
except Exception:
# partial unique index (см. миграцию): двойной clock-in в миллисекундной
# гонке двух устройств не создаёт вторую открытую смену (F-20)
return None, "Вы уже на смене"
return _shift_row(row), ""
def clock_out(login: str) -> dict | None:
with db() as conn:
row = conn.execute(
"UPDATE shifts SET ended_at = now()"
" WHERE login = %s AND ended_at IS NULL RETURNING id, login, started_at, ended_at",
(login,),
).fetchone()
return _shift_row(row) if row else None
def admin_clock_in(login: str) -> dict | None:
"""Ручная запись на смену владельцем/админом — без проверки окна."""
with db() as conn:
row = conn.execute(
"INSERT INTO shifts (login) VALUES (%s) RETURNING id, login, started_at, ended_at",
(login,),
).fetchone()
return _shift_row(row)
def staff_shift_roles() -> list[dict]:
"""Все действующие официанты и кальянщики (для управления сменами
владельцем/админом); удалённые (NF-11) скрыты."""
with db() as conn:
rows = conn.execute(
"SELECT login, name FROM crm_users"
" WHERE role IN ('waiter', 'hookah') AND NOT deleted ORDER BY name"
).fetchall()
return [dict(r) for r in rows]
def on_shift() -> list[dict]:
"""Официанты, находящиеся на смене прямо сейчас (без удалённых)."""
auto_close_shifts()
with db() as conn:
rows = conn.execute(
"SELECT s.login, u.name, s.started_at FROM shifts s"
" JOIN crm_users u ON u.login = s.login AND NOT u.deleted"
" WHERE s.ended_at IS NULL ORDER BY s.started_at"
).fetchall()
return [_shift_row(r) for r in rows]
def shift_counts() -> list[dict]:
"""Количество смен у действующих официантов и кальянщиков (для владельца)."""
with db() as conn:
rows = conn.execute(
"SELECT u.login, u.name, COUNT(s.id) AS shifts FROM crm_users u"
" LEFT JOIN shifts s ON s.login = u.login"
" WHERE u.role IN ('waiter', 'hookah') AND NOT u.deleted"
" GROUP BY u.login, u.name"
" ORDER BY shifts DESC, u.name"
).fetchall()
return [dict(r) for r in rows]
# ---------------------------------------------------------------- занятость столов и зон
def halls_list(include_deleted: bool = False) -> list[dict]:
"""Справочник столов/залов: активные по порядку сортировки;
include_deleted — с удалёнными в конце (для владельца)."""
with db() as conn:
rows = conn.execute(
"SELECT name, sub, sort, deleted FROM halls_def"
+ ("" if include_deleted else " WHERE NOT deleted")
+ " ORDER BY deleted, sort, name"
).fetchall()
return [dict(r) for r in rows]
def halls_active() -> list[dict]:
"""Активные столы/зоны — {name, sub} в порядке сортировки."""
with db() as conn:
rows = conn.execute(
"SELECT name, sub FROM halls_def WHERE NOT deleted ORDER BY sort, name"
).fetchall()
return [dict(r) for r in rows]
def hall_names() -> list[str]:
return [h["name"] for h in halls_active()]
def hall_create(name: str, sub: str | None) -> dict | None:
"""Новый стол/зал. None — активный стол с таким именем уже есть;
удалённый с тем же именем восстанавливается с новой подписью.
Восстановленный стол освобождается: старое «занят» могло остаться
с тех времён, когда стол удалили не освобождая."""
with db() as conn:
row = conn.execute(
"SELECT name, deleted FROM halls_def WHERE name = %s", (name,)
).fetchone()
if row is not None:
if row["deleted"]:
updated = conn.execute(
"UPDATE halls_def SET deleted = FALSE, sub = %s WHERE name = %s"
" RETURNING name, sub, sort, deleted",
(sub, name),
).fetchone()
created = dict(updated)
else:
return None
else:
nxt = conn.execute("SELECT COALESCE(MAX(sort), 0) + 1 AS n FROM halls_def").fetchone()
created = dict(conn.execute(
"INSERT INTO halls_def (name, sub, sort) VALUES (%s, %s, %s)"
" RETURNING name, sub, sort, deleted",
(name, sub, nxt["n"]),
).fetchone())
# сброс зависшей занятости (для нового стола — просто свободная строка)
conn.execute(
"INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at)"
" VALUES (%s, FALSE, NULL, NULL, now())"
" ON CONFLICT (name) DO UPDATE SET"
" busy = FALSE, busy_by = NULL, busy_until = NULL, updated_at = now()",
(name,),
)
return created
def hall_delete(name: str) -> bool:
"""Мягкое удаление стола: исчезает из списков и валидации,
история броней остаётся. Вернуть можно повторным созданием."""
with db() as conn:
row = conn.execute(
"UPDATE halls_def SET deleted = TRUE WHERE name = %s AND NOT deleted",
(name,),
)
return (row.rowcount or 0) > 0
def _hall_row(r) -> dict:
d = dict(r)
for key in ("busy_until", "updated_at"):
if d.get(key) is not None:
d[key] = d[key].astimezone().isoformat()
return d
def _reservation_row(r) -> dict:
return {
"id": r["id"],
"hall": r["hall_name"],
"guest": r["guest"],
"from": r["dt_from"].astimezone().isoformat(),
"until": r["dt_until"].astimezone().isoformat(),
"by": r["created_by"],
}
def hall_status() -> list[dict]:
"""Состояние всех столов и зон; таймеры с истёкшим сроком освобождаются
сами, наступившие брони сами занимают стол до конца своего окна
(occupied_at — метка однократного автозанятия). К каждому столу
прикладываются живые брони (будущие и идущие, до 4 ближайших)."""
with db() as conn:
active = halls_active()
conn.execute(
"UPDATE hall_status SET busy = FALSE, busy_by = NULL, busy_until = NULL,"
" updated_at = now()"
" WHERE busy AND busy_until IS NOT NULL AND busy_until <= now()"
)
# пришло время брони — стол занимается сам, таймер до конца окна
for r in conn.execute(
"""
SELECT id, hall_name, dt_until, COALESCE(NULLIF(guest, ''), 'гость') AS who
FROM hall_reservations
WHERE NOT cancelled AND occupied_at IS NULL
AND dt_from <= now() AND dt_until > now()
AND hall_name = ANY(%s)
""",
([h["name"] for h in active],),
).fetchall():
conn.execute(
"""
INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at)
VALUES (%s, TRUE, %s, %s, now())
ON CONFLICT (name) DO UPDATE SET
busy = TRUE, busy_by = EXCLUDED.busy_by,
busy_until = EXCLUDED.busy_until, updated_at = now()
""",
(r["hall_name"], "Бронь · " + r["who"], r["dt_until"]),
)
conn.execute(
"UPDATE hall_reservations SET occupied_at = now() WHERE id = %s",
(r["id"],),
)
rows = conn.execute(
"SELECT name, busy, busy_by, busy_until, updated_at FROM hall_status"
).fetchall()
res_rows = conn.execute(
"""
SELECT id, hall_name, guest, dt_from, dt_until, created_by
FROM hall_reservations
WHERE NOT cancelled AND dt_until > now()
ORDER BY dt_from
"""
).fetchall()
by_name = {r["name"]: _hall_row(r) for r in rows}
res_by_name: dict[str, list[dict]] = {}
for r in res_rows:
res_by_name.setdefault(r["hall_name"], []).append(_reservation_row(r))
return [
{"name": h["name"], "sub": h["sub"],
"reservations": res_by_name.get(h["name"], [])[:4],
**by_name.get(h["name"], {
"busy": False, "busy_by": None, "busy_until": None, "updated_at": None,
})}
for h in active
]
def hall_reserve(name: str, dt_from, dt_until, guest: str | None, by: str) -> dict | None:
"""Новая бронь стола/зоны. None — на этот стол в пересекающееся окно
уже есть бронь. Занятость стола (walk-in) пересечению не помеха:
до начала брони стол остаётся свободным для гостей без брони."""
with db() as conn:
# сериализуем брони одного стола: без лока две параллельные
# транзакции в READ COMMITTED не видят вставок друг друга и
# обе проходят проверку пересечения (двойная бронь)
conn.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (name,))
overlap = conn.execute(
"""
SELECT 1 FROM hall_reservations
WHERE hall_name = %s AND NOT cancelled
AND dt_from < %s AND dt_until > %s
""",
(name, dt_until, dt_from),
).fetchone()
if overlap:
return None
row = conn.execute(
"""
INSERT INTO hall_reservations (hall_name, guest, dt_from, dt_until, created_by)
VALUES (%s, %s, %s, %s, %s)
RETURNING id, hall_name, guest, dt_from, dt_until, created_by
""",
(name, guest, dt_from, dt_until, by),
).fetchone()
return _reservation_row(row)
def hall_reserve_cancel(res_id: int) -> bool:
"""Мягкая отмена: строка остаётся в истории помеченной cancelled.
Если броня уже успела сама занять стол (occupied_at) и стол до сих
пор помечен именно этой бронью — освобождаем сразу: гости не придут,
и «занят до конца окна» вводил бы персонал в заблуждение."""
with db() as conn:
row = conn.execute(
"""
SELECT hall_name, dt_until, occupied_at,
'Бронь · ' || COALESCE(NULLIF(guest, ''), 'гость') AS marker
FROM hall_reservations
WHERE id = %s AND NOT cancelled
""",
(res_id,),
).fetchone()
if row is None:
return False
if row["occupied_at"] is not None:
conn.execute(
"""
UPDATE hall_status SET busy = FALSE, busy_by = NULL, busy_until = NULL,
updated_at = now()
WHERE name = %s AND busy_by = %s AND busy_until = %s
""",
(row["hall_name"], row["marker"], row["dt_until"]),
)
conn.execute(
"UPDATE hall_reservations SET cancelled = TRUE WHERE id = %s",
(res_id,),
)
return True
def set_hall_busy(name: str, busy: bool, minutes: int | None, by: str) -> dict | None:
"""Занять стол/зону (опционально на minutes минут) или освободить.
Продление: если стол уже занят с активным таймером, минуты добавляются
к текущему дедлайну; занят без таймера — отсчёт идёт от сейчас.
"""
with db() as conn:
if not busy:
row = conn.execute(
"""
INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at)
VALUES (%s, FALSE, NULL, NULL, now())
ON CONFLICT (name) DO UPDATE SET
busy = FALSE, busy_by = NULL, busy_until = NULL, updated_at = now()
RETURNING name, busy, busy_by, busy_until, updated_at
""",
(name,),
).fetchone()
return _hall_row(row)
until = None
if minutes is not None:
base = utcnow()
current = conn.execute(
"SELECT busy_until FROM hall_status WHERE name = %s", (name,)
).fetchone()
if current and current["busy_until"] and current["busy_until"] > base:
base = current["busy_until"]
until = base + timedelta(minutes=minutes)
row = conn.execute(
"""
INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at)
VALUES (%s, TRUE, %s, %s, now())
ON CONFLICT (name) DO UPDATE SET
busy = TRUE, busy_by = EXCLUDED.busy_by,
busy_until = EXCLUDED.busy_until, updated_at = now()
RETURNING name, busy, busy_by, busy_until, updated_at
""",
(name, by, until),
).fetchone()
return _hall_row(row)
# ---------------------------------------------------------------- игры (PS5 и настолки)
def games_list(include_deleted: bool = False) -> list[dict]:
"""Библиотека игр, свежие сверху; include_deleted — для корзины
(админ/владелец): удалённые идут в конце с флагом deleted."""
with db() as conn:
rows = conn.execute(
"SELECT id, kind, title, note, created_by, created_at, seed_id, deleted,"
" difficulty, icon, accent"
" FROM games" + ("" if include_deleted else " WHERE NOT deleted")
+ " ORDER BY deleted, id DESC"
).fetchall()
out = []
for r in rows:
d = dict(r)
d["created_at"] = d["created_at"].astimezone().isoformat()
d["cover"] = None if d["deleted"] else game_cover(d["id"])
out.append(d)
return out
def game_create(kind: str, title: str, note: str | None, by: str,
difficulty: int | None = None, icon: str | None = None,
accent: str | None = None) -> dict:
with db() as conn:
row = conn.execute(
"""
INSERT INTO games (kind, title, note, created_by, difficulty, icon, accent)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id, kind, title, note, created_by, created_at, seed_id, deleted,
difficulty, icon, accent
""",
(kind, title, note, by, difficulty, icon, accent),
).fetchone()
d = dict(row)
d["created_at"] = d["created_at"].astimezone().isoformat()
d["cover"] = None
return d
def game_update(game_id: int, fields: dict) -> dict | None:
"""Правка игры: note/difficulty/icon/accent (название/вид — только
через пересоздание, чтобы сид сайта оставался источником названий)."""
sets, args = [], []
for key in ("note", "icon", "accent"):
if key in fields:
sets.append(f"{key} = %s")
args.append(fields[key])
if "difficulty" in fields:
sets.append("difficulty = %s")
args.append(fields["difficulty"])
if not sets:
return None
with db() as conn:
args.append(game_id)
row = conn.execute(
f"UPDATE games SET {', '.join(sets)} WHERE id = %s"
" RETURNING id, kind, title, note, created_by, created_at, seed_id,"
" deleted, difficulty, icon, accent",
args,
).fetchone()
if row is None:
return None
d = dict(row)
d["created_at"] = d["created_at"].astimezone().isoformat()
d["cover"] = None if d["deleted"] else game_cover(d["id"])
return d
def game_get(game_id: int) -> dict | None:
"""Одна игра (для проверки прав на обложку)."""
with db() as conn:
row = conn.execute(
"SELECT id, kind, title, note, created_by, created_at, seed_id, deleted,"
" difficulty, icon, accent FROM games WHERE id = %s",
(game_id,),
).fetchone()
if row is None:
return None
d = dict(row)
d["created_at"] = d["created_at"].astimezone().isoformat()
d["cover"] = game_cover(d["id"])
return d
def game_delete(game_id: int) -> bool:
"""Мягкое удаление → корзина: сид сайта не пересоздаёт игру, пока
строка существует; восстановление — game_set_deleted(id, False)."""
with db() as conn:
row = conn.execute(
"UPDATE games SET deleted = TRUE"
" WHERE id = %s AND NOT deleted RETURNING id",
(game_id,),
).fetchone()
return row is not None
def game_set_deleted(game_id: int, deleted: bool) -> dict | None:
"""Корзина ↔ восстановление игры (обложка при удалении сохраняется)."""
with db() as conn:
row = conn.execute(
"UPDATE games SET deleted = %s WHERE id = %s"
" RETURNING id, kind, title, note, created_by, created_at, seed_id, deleted",
(deleted, game_id),
).fetchone()
if row is None:
return None
d = dict(row)
d["created_at"] = d["created_at"].astimezone().isoformat()
d["cover"] = game_cover(d["id"])
return d
# ---------------------------------------------------------------- доски заметок/задач
# Общая доска видна всем ролям, приватные — только участникам (и владельцу).
# Чужая приватная доска неотличима от несуществующей (гвард ниже) — наружу 404.
_NOTES_SELECT = """
SELECT n.*, COALESCE(a.name, n.author_login) AS author_name,
COALESCE(s.name, n.assignee_login) AS assignee_name
FROM notes n
LEFT JOIN crm_users a ON a.login = n.author_login
LEFT JOIN crm_users s ON s.login = n.assignee_login
"""
def _note_row(r) -> dict:
return {
"id": r["id"],
"board_id": r["board_id"],
"author": {"id": r["author_login"], "name": r["author_name"]},
"assignee": ({"id": r["assignee_login"], "name": r["assignee_name"]}
if r["assignee_login"] else None),
"title": r["title"],
"body": r["body"],
"status": r["status"],
"important": r["important"],
"due_date": r["due_date"].isoformat() if r["due_date"] else None,
"done_at": r["done_at"].astimezone().isoformat() if r["done_at"] else None,
"created_at": r["created_at"].astimezone().isoformat(),
"updated_at": r["updated_at"].astimezone().isoformat(),
}
def boards_seed_shared() -> None:
"""Одна общая доска на базу; вызывается из init_schema после seed-учёток."""
with db() as conn:
if conn.execute("SELECT 1 FROM boards WHERE kind = 'shared'").fetchone():
return
owner = conn.execute(
"SELECT login FROM crm_users WHERE role = 'owner' ORDER BY login LIMIT 1"
).fetchone()
if owner is None:
return
conn.execute(
"INSERT INTO boards (kind, title, created_by_login)"
" VALUES ('shared', 'Общая', %s)",
(owner["login"],),
)
def boards_list(user: dict) -> list[dict]:
"""Доступные доски: общая для всех; приватные — свои, у владельца все.
Официант и кальянщик получают только общую — это норма.
members: участники (для общей — все сотрудники): селектор исполнителя
и карточки «владелец ↔ админ» на фронте."""
with db() as conn:
rows = conn.execute(
"""
SELECT b.id, b.kind, b.title, b.created_at
FROM boards b
WHERE b.kind = 'shared'
OR %s = 'owner'
OR EXISTS (SELECT 1 FROM board_members m
WHERE m.board_id = b.id AND m.user_login = %s)
ORDER BY b.kind, b.created_at
""",
(user["role"], user["login"]),
).fetchall()
# NF-11: удалённые сотрудники не участвуют в досках
users = conn.execute(
"SELECT login, name, role FROM crm_users WHERE NOT deleted ORDER BY name"
).fetchall()
member_rows = conn.execute(
"""
SELECT m.board_id, m.user_login AS login, u.name, u.role
FROM board_members m JOIN crm_users u ON u.login = m.user_login
WHERE m.board_id = ANY(%s) AND NOT u.deleted
""",
([b["id"] for b in rows],),
).fetchall() if rows else []
members: dict[int, list[dict]] = {}
for m in member_rows:
members.setdefault(m["board_id"], []).append(
{"login": m["login"], "name": m["name"], "role": m["role"]})
out = []
for r in rows:
d = dict(r)
d["created_at"] = d["created_at"].astimezone().isoformat()
# у общей доски неявный участник — весь персонал
d["members"] = members.get(d["id"]) if d["kind"] == "private" else [
{"login": u["login"], "name": u["name"], "role": u["role"]}
for u in users
]
out.append(d)
return out
def board_guard(board_id: int, user: dict) -> dict | None:
"""Доступ к доске. None — и для несуществующей, и для чужой приватной:
оба случая отдаются клиенту как 404, существование чужих досок не
раскрываем."""
with db() as conn:
row = conn.execute(
"""
SELECT id, kind, title, created_at FROM boards
WHERE id = %s AND (
kind = 'shared' OR %s = 'owner'
OR EXISTS (SELECT 1 FROM board_members m
WHERE m.board_id = boards.id AND m.user_login = %s))
""",
(board_id, user["role"], user["login"]),
).fetchone()
return dict(row) if row else None
def note_assignee_ok(board: dict, assignee_login: str) -> bool:
"""Исполнителем может быть только действующий участник доски (NF-11:
удалённые сотрудники исполнителями быть не могут): на общей — любой
сотрудник, на приватной — только её пара (владелец и админ)."""
if board["kind"] == "shared":
with db() as conn:
return conn.execute(
"SELECT 1 FROM crm_users WHERE login = %s AND NOT deleted",
(assignee_login,),
).fetchone() is not None
with db() as conn:
return conn.execute(
"""
SELECT 1 FROM board_members m
JOIN crm_users u ON u.login = m.user_login AND NOT u.deleted
WHERE m.board_id = %s AND m.user_login = %s
""",
(board["id"], assignee_login),
).fetchone() is not None
def board_create(title: str, admin_login: str, owner_login: str) -> dict:
"""Приватная доска «владелец ↔ админ»: ровно два участника, состав
фиксируется сразу (ON CONFLICT — повторный вызов не падает)."""
with db() as conn:
row = conn.execute(
"""
INSERT INTO boards (kind, title, created_by_login)
VALUES ('private', %s, %s)
RETURNING id, kind, title, created_at
""",
(title, owner_login),
).fetchone()
conn.execute(
"""
INSERT INTO board_members (board_id, user_login)
VALUES (%s, %s), (%s, %s) ON CONFLICT DO NOTHING
""",
(row["id"], owner_login, row["id"], admin_login),
)
conn.execute(
"INSERT INTO note_activity (board_id, user_login, action, details)"
" VALUES (%s, %s, 'board_created', %s)",
(row["id"], owner_login, admin_login),
)
d = dict(row)
d["created_at"] = d["created_at"].astimezone().isoformat()
return d
def board_rename(board_id: int, title: str) -> dict | None:
with db() as conn:
row = conn.execute(
"UPDATE boards SET title = %s WHERE id = %s"
" RETURNING id, kind, title, created_at",
(title, board_id),
).fetchone()
if row is None:
return None
d = dict(row)
d["created_at"] = d["created_at"].astimezone().isoformat()
return d
def board_delete(board_id: int) -> bool:
"""Каскадно уносит заметки, участников и журнал (FK ON DELETE CASCADE)."""
with db() as conn:
row = conn.execute(
"DELETE FROM boards WHERE id = %s RETURNING id", (board_id,)
).fetchone()
return row is not None
def notes_list(board_id: int, since: datetime | None = None) -> dict:
"""Заметки доски. since (из server_time прошлого ответа) — только
изменённые с того момента + id удалённых (из журнала).
Метки времени мутаций — clock_timestamp(), не now(): now() замирает
на старте транзакции, и изменение, закоммиченное между двумя опросами
из «длинной» транзакции, могло бы навсегда выпасть из курсора.
"""
with db() as conn:
ts = conn.execute("SELECT clock_timestamp() AS ts").fetchone()["ts"]
if since is None:
rows = conn.execute(
_NOTES_SELECT + " WHERE n.board_id = %s ORDER BY n.updated_at DESC",
(board_id,),
).fetchall()
deleted: list[int] = []
else:
rows = conn.execute(
_NOTES_SELECT
+ " WHERE n.board_id = %s AND n.updated_at > %s"
" ORDER BY n.updated_at DESC",
(board_id, since),
).fetchall()
deleted = [
r["nid"] for r in conn.execute(
"""
SELECT DISTINCT details::BIGINT AS nid FROM note_activity
WHERE board_id = %s AND action = 'deleted'
AND created_at > %s AND details ~ '^[0-9]+$'
""",
(board_id, since),
).fetchall()
]
return {
"notes": [_note_row(r) for r in rows],
"deleted_ids": deleted,
"server_time": ts.astimezone().isoformat(),
}
def note_get(note_id: int) -> dict | None:
"""Одна заметка в API-формате; доступ к её доске проверяет хендлер."""
with db() as conn:
row = conn.execute(
_NOTES_SELECT + " WHERE n.id = %s", (note_id,)
).fetchone()
return _note_row(row) if row else None
def note_create(board: dict, author: dict, title: str, body: str,
assignee_login: str | None, important: bool, due_date) -> dict:
"""Новая заметка; журнал и метки времени ведёт сервер."""
with db() as conn:
row = conn.execute(
"""
INSERT INTO notes (board_id, author_login, assignee_login, title,
body, important, due_date, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, clock_timestamp())
RETURNING id
""",
(board["id"], author["login"], assignee_login, title, body,
important, due_date),
).fetchone()
conn.execute(
"INSERT INTO note_activity (board_id, note_id, user_login, action, details)"
" VALUES (%s, %s, %s, 'created', %s)",
(board["id"], row["id"], author["login"], title),
)
full = conn.execute(
_NOTES_SELECT + " WHERE n.id = %s", (row["id"],)
).fetchone()
return _note_row(full)
def note_update(note_id: int, user: dict, board: dict, fields: dict) -> dict | None:
"""Частичная правка тем, кто видит доску. status='done' проставляет
done_at, уход из done — сбрасывает; updated_at двигается всегда.
Журнал — только по реально изменившимся полям."""
with db() as conn:
old = conn.execute(
"SELECT status, assignee_login FROM notes WHERE id = %s", (note_id,)
).fetchone()
if old is None:
return None
sets, args = [], []
if "title" in fields:
sets.append("title = %s")
args.append(fields["title"])
if "body" in fields:
sets.append("body = %s")
args.append(fields["body"])
if "important" in fields:
sets.append("important = %s")
args.append(fields["important"])
if "due_date" in fields:
sets.append("due_date = %s")
args.append(fields["due_date"])
if "assignee_login" in fields:
sets.append("assignee_login = %s")
args.append(fields["assignee_login"])
status = fields.get("status")
if status is not None:
sets.append("done_at = %s")
args.append(utcnow() if status == "done" else None)
sets.append("status = %s")
args.append(status)
sets.append("updated_at = clock_timestamp()")
args.append(note_id)
conn.execute(f"UPDATE notes SET {', '.join(sets)} WHERE id = %s", args)
if any(k in fields for k in ("title", "body", "important", "due_date")):
conn.execute(
"INSERT INTO note_activity (board_id, note_id, user_login, action)"
" VALUES (%s, %s, %s, 'edited')",
(board["id"], note_id, user["login"]),
)
if status is not None and status != old["status"]:
conn.execute(
"INSERT INTO note_activity (board_id, note_id, user_login, action, details)"
" VALUES (%s, %s, %s, 'status', %s)",
(board["id"], note_id, user["login"], status),
)
if "assignee_login" in fields and fields["assignee_login"] != old["assignee_login"]:
conn.execute(
"INSERT INTO note_activity (board_id, note_id, user_login, action, details)"
" VALUES (%s, %s, %s, 'assigned', %s)",
(board["id"], note_id, user["login"], fields["assignee_login"] or ""),
)
full = conn.execute(
_NOTES_SELECT + " WHERE n.id = %s", (note_id,)
).fetchone()
return _note_row(full)
def note_delete(note_id: int, user_login: str) -> dict | None:
"""Жёсткое удаление (права проверяет хендлер). В журнале остаётся
запись с id заметки в details — по нему клиенты чистят кэш."""
with db() as conn:
row = conn.execute(
"DELETE FROM notes WHERE id = %s RETURNING id, title, board_id",
(note_id,),
).fetchone()
if row is None:
return None
conn.execute(
"INSERT INTO note_activity (board_id, user_login, action, details)"
" VALUES (%s, %s, 'deleted', %s)",
(row["board_id"], user_login, str(row["id"])),
)
return dict(row)
def board_activity(board_id: int, limit: int = 50) -> list[dict]:
"""Последние события доски — для ленты «кто что сделал»."""
with db() as conn:
rows = conn.execute(
"""
SELECT a.id, a.action, a.details, a.created_at,
COALESCE(u.name, a.user_login) AS user_name
FROM note_activity a LEFT JOIN crm_users u ON u.login = a.user_login
WHERE a.board_id = %s
ORDER BY a.id DESC LIMIT %s
""",
(board_id, limit),
).fetchall()
out = []
for r in rows:
d = dict(r)
d["created_at"] = d["created_at"].astimezone().isoformat()
out.append(d)
return out
# ---------------------------------------------------------------- демо-заказы
def _maybe_seed_demo_orders() -> None:
if os.environ.get("CRM_SEED_DEMO_ORDERS", "0") != "1":
return
with db() as conn:
if conn.execute("SELECT 1 FROM orders LIMIT 1").fetchone():
return
insert = """
INSERT INTO orders (number, week_start, table_name, waiter, status, price, items,
created_at, accepted_at, delivered_at, duration_seconds)
VALUES (%(number)s, %(week)s, %(table)s, %(waiter)s, %(status)s, %(price)s,
%(items)s::jsonb, %(created)s, %(accepted)s, %(delivered)s, %(duration)s)
"""
for row in _demo_rows():
conn.execute(insert, row)