3165 lines
158 KiB
Python
3165 lines
158 KiB
Python
# -*- 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)
|