1935 lines
83 KiB
Python
1935 lines
83 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
SQLite-хранилище НЕОН CRM — локальный режим без PostgreSQL.
|
|
|
|
Включается переменной CRM_DB=sqlite: storage.py сам подменяет свои
|
|
функции функциями этого модуля. API полностью повторяет Postgres-версию,
|
|
поэтому server.py не знает, какой бэкенд под ним работает.
|
|
|
|
Файл базы: crm/data/crm.db — создаётся при первом запуске.
|
|
Продакшн (Docker) всегда работает на PostgreSQL; SQLite — только для
|
|
локальной разработки и демонстрации.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import secrets
|
|
import sqlite3
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import storage
|
|
|
|
DB_PATH = storage.DATA_DIR / "crm.db"
|
|
|
|
|
|
# ---------------------------------------------------------------- postgres-only
|
|
# Часть функций существует только в postgres-режиме (боевом). В sqlite-режиме
|
|
# они отвечали 500 «internal error» — теперь честный 501 с текстом (F-11).
|
|
def _postgres_only(name):
|
|
def _stub(*_a, **_k):
|
|
raise storage.PostgresOnlyFeature(
|
|
f"«{name}» недоступно в sqlite-режиме — этот раздел работает"
|
|
" только в боевом postgres-режиме (docker)"
|
|
)
|
|
return _stub
|
|
|
|
|
|
board_activity = _postgres_only("доски: лента событий")
|
|
board_create = _postgres_only("новая доска")
|
|
board_delete = _postgres_only("удаление доски")
|
|
board_guard = _postgres_only("доступ к доске")
|
|
board_rename = _postgres_only("переименование доски")
|
|
boards_list = _postgres_only("доски заметок")
|
|
game_create = _postgres_only("игры: добавление")
|
|
game_delete = _postgres_only("игры: удаление")
|
|
game_get = _postgres_only("игры: карточка")
|
|
game_set_deleted = _postgres_only("игры: корзина")
|
|
game_update = _postgres_only("игры: правка")
|
|
games_list = _postgres_only("библиотека игр")
|
|
hall_reserve = _postgres_only("брони столов")
|
|
hall_reserve_cancel = _postgres_only("брони столов: отмена")
|
|
note_assignee_ok = _postgres_only("заметки: исполнитель")
|
|
note_create = _postgres_only("заметки: создание")
|
|
note_delete = _postgres_only("заметки: удаление")
|
|
note_get = _postgres_only("заметки: карточка")
|
|
note_update = _postgres_only("заметки: правка")
|
|
notes_list = _postgres_only("заметки: список")
|
|
reset_order_numbering = _postgres_only("сброс нумерации заказов")
|
|
set_menu_accent = _postgres_only("подсветка карточек на сайте")
|
|
|
|
|
|
# ---------------------------------------------------------------- служебное
|
|
|
|
def _iso(dt: datetime | None) -> str | None:
|
|
return dt.astimezone(timezone.utc).isoformat() if dt else None
|
|
|
|
|
|
def _parse(ts: str | None) -> datetime | None:
|
|
return datetime.fromisoformat(ts) if ts else None
|
|
|
|
|
|
@contextmanager
|
|
def db():
|
|
"""Соединение с автокоммитом по выходу из блока.
|
|
|
|
psycopg-версия отдаёт соединение из пула; здесь — новое соединение
|
|
на каждый вызов, для локальной нагрузки этого достаточно.
|
|
"""
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
try:
|
|
yield conn
|
|
conn.commit()
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def init_pool() -> None:
|
|
pass # пул не нужен: соединения открываются на запрос в db()
|
|
|
|
|
|
def close_pool() -> None:
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------- схема
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS orders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
number INTEGER NOT NULL,
|
|
week_start TEXT NOT NULL, -- «неделя нумерации» (вторник, см. storage.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 TEXT NOT NULL DEFAULT '[]',
|
|
comment TEXT,
|
|
track_code TEXT,
|
|
stock_applied INTEGER NOT NULL DEFAULT 0, -- штучные позиции уже списаны (done)
|
|
created_at TEXT NOT NULL,
|
|
accepted_at TEXT,
|
|
delivered_at TEXT,
|
|
duration_seconds INTEGER
|
|
);
|
|
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (status, number DESC);
|
|
|
|
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 INTEGER NOT NULL DEFAULT 0,
|
|
custom INTEGER NOT NULL DEFAULT 0, -- 1: добавлена владельцем через CRM
|
|
deleted INTEGER NOT NULL DEFAULT 0, -- 1: удалена владельцем (мягко)
|
|
stock INTEGER, -- поштучный учёт: NULL — выключен, число — остаток
|
|
auto_stopped INTEGER NOT NULL DEFAULT 0, -- 1: в стоп ушло само (stock = 0)
|
|
created_at TEXT NOT NULL
|
|
);
|
|
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 INTEGER NOT NULL DEFAULT 0,
|
|
stopped INTEGER NOT NULL DEFAULT 0,
|
|
custom INTEGER NOT NULL DEFAULT 0, -- 1: добавлен владельцем через CRM
|
|
deleted INTEGER NOT NULL DEFAULT 0, -- 1: удалён владельцем (мягко)
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS tobacco_stopped_idx ON tobacco_flavors (stopped);
|
|
|
|
-- Миксы владельца: состав из вкусов табаков без долей — пропорции
|
|
-- подбирает кальянщик. Отдаются на сайт через /api/catalog.
|
|
CREATE TABLE IF NOT EXISTS mix_recipes (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
flavors TEXT NOT NULL DEFAULT '[]', -- JSON [{id, brand, flavor}]
|
|
note TEXT,
|
|
strength INTEGER, -- крепость 1–5
|
|
cooling INTEGER, -- холодок 0–3
|
|
price INTEGER, -- цена, ₽
|
|
tags TEXT NOT NULL DEFAULT '[]', -- JSON ["ягоды", ...] — ярлыки для AI-генератора
|
|
created_at TEXT NOT NULL,
|
|
deleted INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
-- Правки витринных миксов сайта («Авторские миксы сайта», seed/mixes.json):
|
|
-- строка на микс появляется, только когда владелец его отредактировал.
|
|
-- NULL — поле не менялось, сайт берёт исходное значение из своего каталога.
|
|
-- flavors NULL — состав не менялся; иначе JSON [{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 TEXT, -- JSON ["ягоды", ...] или NULL
|
|
flavors TEXT -- JSON [{id, brand, flavor}] или NULL
|
|
);
|
|
|
|
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: мягкое удаление (см. postgres-версию)
|
|
deleted INTEGER NOT NULL DEFAULT 0
|
|
);
|
|
|
|
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,
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS shifts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE,
|
|
started_at TEXT NOT NULL,
|
|
ended_at TEXT
|
|
);
|
|
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 INTEGER NOT NULL DEFAULT 0,
|
|
busy_by TEXT,
|
|
busy_until TEXT,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
|
|
-- Справочник столов и залов: владелец добавляет и удаляет столы из CRM.
|
|
-- storage.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 INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
-- Append-only журнал операций с заказами (NF-03) — зеркало postgres-версии.
|
|
CREATE TABLE IF NOT EXISTS orders_audit (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
order_id INTEGER NOT NULL,
|
|
user_login TEXT NOT NULL,
|
|
action TEXT NOT NULL, -- 'status' | 'waiter'
|
|
old_value TEXT,
|
|
new_value TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS orders_audit_order_idx ON orders_audit (order_id, id);
|
|
"""
|
|
|
|
|
|
def init_schema() -> None:
|
|
with db() as conn:
|
|
conn.executescript(SCHEMA)
|
|
# стоп-лист переехал с брендов на вкусы: старая таблица больше не нужна
|
|
conn.execute("DROP TABLE IF EXISTS tobacco_brands")
|
|
_migrate_catalog_columns(conn)
|
|
_migrate_order_columns(conn)
|
|
_migrate_mix_columns(conn)
|
|
_migrate_order_week(conn)
|
|
_migrate_crm_users_role(conn)
|
|
_migrate_crm_users_deleted(conn)
|
|
_migrate_shift_unique(conn)
|
|
_seed_users()
|
|
_seed_catalog()
|
|
_seed_halls()
|
|
_maybe_seed_demo_orders()
|
|
|
|
|
|
def _migrate_crm_users_deleted(conn) -> None:
|
|
"""NF-11: колонка deleted в crm_users на живых базах. Идемпотентно."""
|
|
cols = _table_columns(conn, "crm_users")
|
|
if "deleted" not in cols:
|
|
conn.execute("ALTER TABLE crm_users ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0")
|
|
|
|
|
|
def _migrate_shift_unique(conn) -> None:
|
|
"""F-20: одна открытая смена на логин. Двойной clock-in в гонке двух
|
|
устройств не создаёт вторую открытую смену: partial unique индекс +
|
|
перехват IntegrityError в clock_in. Старые дубли (до индекса) закрываем,
|
|
оставляя самую раннюю."""
|
|
conn.execute(
|
|
"UPDATE shifts SET ended_at = ?"
|
|
" WHERE ended_at IS NULL"
|
|
" AND id NOT IN (SELECT MIN(id) FROM shifts WHERE ended_at IS NULL GROUP BY login)",
|
|
(_iso(storage.utcnow()),),
|
|
)
|
|
conn.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS shifts_open_unique_idx"
|
|
" ON shifts (login) WHERE ended_at IS NULL"
|
|
)
|
|
|
|
|
|
def _seed_halls() -> None:
|
|
"""Стартовый справочник столов: сид не перетирает правки владельца."""
|
|
now = _iso(storage.utcnow())
|
|
with db() as conn:
|
|
for i, h in enumerate(storage.HALLS):
|
|
conn.execute(
|
|
"INSERT INTO halls_def (name, sub, sort, created_at) VALUES (?, ?, ?, ?)"
|
|
" ON CONFLICT(name) DO NOTHING",
|
|
(h["name"], h["sub"], i, now),
|
|
)
|
|
|
|
|
|
def _table_columns(conn, table: str) -> set[str]:
|
|
return {r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
|
|
|
|
|
def _migrate_order_columns(conn) -> None:
|
|
"""Колонки comment/track_code/stock_applied добавлены позже — дополняем живую таблицу.
|
|
|
|
Индекс создаём здесь, а не в SCHEMA: на старой базе колонки ещё нет,
|
|
когда выполняется SCHEMA. IF NOT EXISTS идемпотентен.
|
|
"""
|
|
cols = _table_columns(conn, "orders")
|
|
if "comment" not in cols:
|
|
conn.execute("ALTER TABLE orders ADD COLUMN comment TEXT")
|
|
if "track_code" not in cols:
|
|
conn.execute("ALTER TABLE orders ADD COLUMN track_code TEXT")
|
|
if "stock_applied" not in cols:
|
|
conn.execute("ALTER TABLE orders ADD COLUMN stock_applied INTEGER NOT NULL DEFAULT 0")
|
|
conn.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS orders_track_code_idx ON orders (track_code)"
|
|
" WHERE track_code IS NOT NULL"
|
|
)
|
|
|
|
|
|
def _migrate_mix_columns(conn) -> None:
|
|
"""mix_recipes до появления расширенной информации микса: strength/cooling/price/tags."""
|
|
cols = _table_columns(conn, "mix_recipes")
|
|
if "strength" not in cols:
|
|
conn.execute("ALTER TABLE mix_recipes ADD COLUMN strength INTEGER")
|
|
if "cooling" not in cols:
|
|
conn.execute("ALTER TABLE mix_recipes ADD COLUMN cooling INTEGER")
|
|
if "price" not in cols:
|
|
conn.execute("ALTER TABLE mix_recipes ADD COLUMN price INTEGER")
|
|
if "tags" not in cols:
|
|
conn.execute("ALTER TABLE mix_recipes ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'")
|
|
|
|
|
|
def _migrate_order_week(conn) -> None:
|
|
"""Еженедельная нумерация заказов.
|
|
|
|
На старых базах number был глобально уникален (column UNIQUE) — SQLite
|
|
не умеет снимать ограничение без пересоздания таблицы: переносим данные
|
|
в новую таблицу с week_start и уникальностью (week_start, number).
|
|
"""
|
|
if "week_start" in _table_columns(conn, "orders"):
|
|
conn.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS orders_week_number_idx"
|
|
" ON orders (week_start, number)"
|
|
)
|
|
return
|
|
rows = [dict(r) for r in conn.execute("SELECT * FROM orders").fetchall()]
|
|
conn.execute("PRAGMA foreign_keys = OFF")
|
|
conn.executescript(
|
|
"""
|
|
DROP TABLE orders;
|
|
CREATE TABLE orders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
number INTEGER NOT NULL,
|
|
week_start TEXT NOT NULL DEFAULT '',
|
|
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 TEXT NOT NULL DEFAULT '[]',
|
|
comment TEXT,
|
|
track_code TEXT,
|
|
stock_applied INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL,
|
|
accepted_at TEXT,
|
|
delivered_at TEXT,
|
|
duration_seconds INTEGER
|
|
);
|
|
CREATE INDEX IF NOT EXISTS orders_status_idx ON orders (status, number DESC);
|
|
"""
|
|
)
|
|
for d in rows:
|
|
created = _parse(d.get("created_at"))
|
|
week = storage.order_week_start(created).isoformat() if created else ""
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO orders (id, number, week_start, table_name, guest_name, guest_phone,
|
|
source, waiter, status, price, items, comment, track_code,
|
|
created_at, accepted_at, delivered_at, duration_seconds)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(d["id"], d["number"], week, d["table_name"], d.get("guest_name"),
|
|
d.get("guest_phone"), d["source"], d.get("waiter"), d["status"], d["price"],
|
|
d["items"], d.get("comment"), d.get("track_code"), d["created_at"],
|
|
d.get("accepted_at"), d.get("delivered_at"), d["duration_seconds"]),
|
|
)
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
# пересоздание сносит все индексы старой таблицы — восстанавливаем оба
|
|
conn.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS orders_track_code_idx ON orders (track_code)"
|
|
" WHERE track_code IS NOT NULL"
|
|
)
|
|
conn.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS orders_week_number_idx ON orders (week_start, number)"
|
|
)
|
|
|
|
|
|
def _migrate_catalog_columns(conn) -> None:
|
|
"""Колонки custom/created_at/deleted/stock добавлены позже — дополняем живые таблицы."""
|
|
cols = _table_columns(conn, "menu_items")
|
|
if "custom" not in cols:
|
|
conn.execute("ALTER TABLE menu_items ADD COLUMN custom INTEGER NOT NULL DEFAULT 0")
|
|
if "created_at" not in cols:
|
|
conn.execute("ALTER TABLE menu_items ADD COLUMN created_at TEXT NOT NULL DEFAULT ''")
|
|
if "deleted" not in cols:
|
|
conn.execute("ALTER TABLE menu_items ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0")
|
|
if "stock" not in cols:
|
|
conn.execute("ALTER TABLE menu_items ADD COLUMN stock INTEGER")
|
|
if "auto_stopped" not in cols:
|
|
conn.execute("ALTER TABLE menu_items ADD COLUMN auto_stopped INTEGER NOT NULL DEFAULT 0")
|
|
cols = _table_columns(conn, "tobacco_flavors")
|
|
if "custom" not in cols:
|
|
conn.execute("ALTER TABLE tobacco_flavors ADD COLUMN custom INTEGER NOT NULL DEFAULT 0")
|
|
if "created_at" not in cols:
|
|
conn.execute("ALTER TABLE tobacco_flavors ADD COLUMN created_at TEXT NOT NULL DEFAULT ''")
|
|
if "deleted" not in cols:
|
|
conn.execute("ALTER TABLE tobacco_flavors ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0")
|
|
|
|
|
|
def _migrate_crm_users_role(conn) -> None:
|
|
"""Если в CHECK роли нет какой-то из текущих ролей (база до
|
|
нововведения) — пересоздаём таблицу. Сессии эфемерны, их сбрасываем."""
|
|
row = conn.execute(
|
|
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'crm_users'"
|
|
).fetchone()
|
|
sql = row["sql"] or "" if row else ""
|
|
if row and "'owner'" in sql and "'hookah'" in sql:
|
|
return
|
|
conn.execute("PRAGMA foreign_keys = OFF")
|
|
conn.executescript(
|
|
"""
|
|
DROP TABLE IF EXISTS sessions;
|
|
DROP TABLE crm_users;
|
|
CREATE TABLE crm_users (
|
|
login TEXT PRIMARY KEY,
|
|
password_hash TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'waiter', 'hookah'))
|
|
);
|
|
CREATE TABLE sessions (
|
|
token TEXT PRIMARY KEY,
|
|
login TEXT NOT NULL REFERENCES crm_users(login) ON DELETE CASCADE,
|
|
role TEXT NOT NULL,
|
|
expires_at TEXT NOT NULL
|
|
);
|
|
"""
|
|
)
|
|
conn.execute("PRAGMA foreign_keys = ON")
|
|
|
|
|
|
# ---------------------------------------------------------------- пользователи/сессии
|
|
|
|
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 OR IGNORE INTO crm_users (login, password_hash, name, role)"
|
|
" VALUES (?, ?, ?, ?)",
|
|
(login, storage.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 = ?", (login,)).fetchone()
|
|
if exists:
|
|
return None
|
|
name_taken = conn.execute("SELECT 1 FROM crm_users WHERE name = ?", (name,)).fetchone()
|
|
if name_taken:
|
|
return "name_taken"
|
|
conn.execute(
|
|
"INSERT INTO crm_users (login, password_hash, name, role) VALUES (?, ?, ?, ?)",
|
|
(login, storage.hash_password(password), name, role),
|
|
)
|
|
return {"login": login, "name": name, "role": role}
|
|
|
|
|
|
def delete_user(login: str) -> bool:
|
|
"""Мягкое удаление (NF-11): помечаем deleted и гасим сессии в одной
|
|
транзакции; смены и заказы бывшего сотрудника остаются в базе."""
|
|
with db() as conn:
|
|
cur = conn.execute(
|
|
"UPDATE crm_users SET deleted = 1"
|
|
" WHERE login = ? AND role IN ('admin', 'waiter', 'hookah') AND NOT deleted",
|
|
(login,),
|
|
)
|
|
if cur.rowcount == 0:
|
|
return False
|
|
conn.execute("DELETE FROM sessions WHERE login = ?", (login,))
|
|
return True
|
|
|
|
|
|
def login_user(login: str, password: str) -> dict | None:
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
# NF-11: удалённого сотрудника не пускаем — неотличимо от
|
|
# несуществующего логина (dummy-хэш ниже)
|
|
"SELECT login, password_hash, name, role FROM crm_users"
|
|
" WHERE login = ? AND NOT deleted",
|
|
(login,),
|
|
).fetchone()
|
|
if row is None:
|
|
# та же стоимость, что у существующего логина (анти-enumeration)
|
|
storage.verify_password(password, storage._DUMMY_HASH)
|
|
return None
|
|
if not storage.verify_password(password, row["password_hash"]):
|
|
return None
|
|
|
|
token = secrets.token_urlsafe(32)
|
|
now = storage.utcnow()
|
|
with db() as conn:
|
|
expired = [
|
|
r["token"] for r in conn.execute("SELECT token, expires_at FROM sessions").fetchall()
|
|
if (_parse(r["expires_at"]) or now) < now
|
|
]
|
|
conn.executemany("DELETE FROM sessions WHERE token = ?", [(t,) for t in expired])
|
|
conn.execute(
|
|
"INSERT INTO sessions (token, login, role, expires_at) VALUES (?, ?, ?, ?)",
|
|
(token, row["login"], row["role"], _iso(now + storage.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 = ?
|
|
AND NOT u.deleted -- ревью NF-11: гонка login_user→delete_user
|
|
""",
|
|
(token,),
|
|
).fetchone()
|
|
if row is None or (_parse(row["expires_at"]) or storage.utcnow()) <= storage.utcnow():
|
|
return None
|
|
# скольжение сессии для сменных ролей (см. postgres-версию):
|
|
# планшет, который опрашивает API, не разлогинится посреди смены
|
|
if row["role"] in ("waiter", "hookah"):
|
|
remaining = (_parse(row["expires_at"]) or storage.utcnow()) - storage.utcnow()
|
|
if remaining < timedelta(hours=2):
|
|
conn.execute(
|
|
"UPDATE sessions SET expires_at = ? WHERE token = ?",
|
|
((storage.utcnow() + storage.SESSION_TTL).isoformat(), token),
|
|
)
|
|
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 = ?", (token,))
|
|
|
|
|
|
def weak_default_logins(default: str = "neon2026") -> list[str]:
|
|
"""Логины, чей пароль в БД фактически совпадает с известным ПУБЛИЧНЫМ
|
|
дефолтом (F-13) — см. postgres-версию в storage.py."""
|
|
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(storage.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) — контракт postgres-версии."""
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT password_hash FROM crm_users WHERE login = ?", (login,)
|
|
).fetchone()
|
|
if row is None or not storage.verify_password(current_password, row["password_hash"]):
|
|
return False
|
|
conn.execute(
|
|
"UPDATE crm_users SET password_hash = ? WHERE login = ?",
|
|
(storage.hash_password(new_password), login),
|
|
)
|
|
conn.execute(
|
|
"DELETE FROM sessions WHERE login = ? AND token IS NOT ?",
|
|
(login, keep_token),
|
|
)
|
|
return True
|
|
|
|
|
|
def reset_password(login: str, new_password: str) -> bool:
|
|
"""Сброс пароля чужой учётки владельцем — контракт postgres-версии."""
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT 1 FROM crm_users WHERE login = ? AND NOT deleted", (login,)
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
conn.execute(
|
|
"UPDATE crm_users SET password_hash = ? WHERE login = ?",
|
|
(storage.hash_password(new_password), login),
|
|
)
|
|
conn.execute("DELETE FROM sessions WHERE login = ?", (login,))
|
|
return True
|
|
|
|
|
|
def is_known_staff_name(name: str) -> bool:
|
|
"""Действующий сотрудник с таким именем/логином существует (валидация
|
|
waiter, F-15); удалённые (NF-11) не считаются."""
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT 1 FROM crm_users WHERE NOT deleted AND (name = ? OR login = ?)",
|
|
(name, name),
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
# ---------------------------------------------------------------- каталог и стоп-лист
|
|
|
|
def _seed_catalog() -> None:
|
|
"""Тот же контракт, что и Postgres-версия: обновить каталог,
|
|
сохранив стоп-листы, и удалить позиции, которых больше нет на сайте.
|
|
Seed читается строго (F-6): битый файл роняет старт до записи в БД."""
|
|
menu = storage._read_seed("menu.json", strict=True)
|
|
tobacco = storage._read_seed("tobacco.json", strict=True)
|
|
|
|
with db() as conn:
|
|
# сеянные позиции — не «новинки»: их created_at всегда на 30 дней в прошлом
|
|
old_iso = _iso(storage.utcnow() - timedelta(days=30))
|
|
menu = menu + [dict(h) for h in storage.HOOKAH_ITEMS]
|
|
if menu:
|
|
for m in menu:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO menu_items (id, name, category, price, portion, created_at, custom)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
name = excluded.name, category = excluded.category,
|
|
price = excluded.price, portion = excluded.portion
|
|
""",
|
|
(m["id"], m["name"], m["category"], m["price"], m.get("portion"), old_iso),
|
|
)
|
|
marks = ",".join("?" * len(menu))
|
|
# custom-позиции владельца не трогаем и не удаляем — их на сайте нет
|
|
conn.execute(
|
|
f"DELETE FROM menu_items WHERE custom = 0 AND id NOT IN ({marks})",
|
|
[m["id"] for m in menu],
|
|
)
|
|
conn.execute("UPDATE menu_items SET created_at = ? WHERE custom = 0", (old_iso,))
|
|
if tobacco:
|
|
for t in tobacco:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO tobacco_flavors (id, brand, flavor, note, strength, cooling, created_at, custom)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
brand = excluded.brand, flavor = excluded.flavor,
|
|
note = excluded.note, strength = excluded.strength,
|
|
cooling = excluded.cooling
|
|
""",
|
|
(t["id"], t["brand"], t["flavor"], t.get("note"), t.get("strength", 2),
|
|
1 if t.get("cooling") else 0, old_iso),
|
|
)
|
|
marks = ",".join("?" * len(tobacco))
|
|
conn.execute(
|
|
f"DELETE FROM tobacco_flavors WHERE custom = 0 AND id NOT IN ({marks})",
|
|
[t["id"] for t in tobacco],
|
|
)
|
|
conn.execute("UPDATE tobacco_flavors SET created_at = ? WHERE custom = 0", (old_iso,))
|
|
|
|
|
|
def _menu_row(r) -> dict:
|
|
d = dict(r)
|
|
for key in ("stopped", "custom", "deleted", "cooling"):
|
|
if key in d:
|
|
d[key] = bool(d[key])
|
|
return d
|
|
|
|
|
|
def list_menu() -> list[dict]:
|
|
with db() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, name, category, price, portion, stopped, custom, deleted, stock, created_at"
|
|
" FROM menu_items ORDER BY category, name"
|
|
).fetchall()
|
|
out = []
|
|
for r in rows:
|
|
d = _menu_row(r)
|
|
d["has_photo"] = storage.photo_for_item(d["id"]) is not None
|
|
out.append(d)
|
|
return out
|
|
|
|
|
|
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 = ?, auto_stopped = 0
|
|
WHERE id = ? AND deleted = 0
|
|
RETURNING id, name, category, price, portion, stopped, stock
|
|
""",
|
|
(1 if stopped else 0, item_id),
|
|
).fetchone()
|
|
return _menu_row(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 = ? WHERE id = ?
|
|
RETURNING id, name, category, price, portion, stopped, custom, deleted
|
|
""",
|
|
(1 if deleted else 0, item_id),
|
|
).fetchone()
|
|
return _menu_row(row) if row else None
|
|
|
|
|
|
def purge_menu_item(item_id: str):
|
|
"""Безвозвратное удаление позиции меню (см. storage.purge_menu_item):
|
|
только кастомные позиции, фото стирается, история заказов не трогается.
|
|
True — удалена; False — не найдена; 'seed' — стартовая позиция сайта."""
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT custom FROM menu_items WHERE id = ?", (item_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
if not row["custom"]:
|
|
return "seed"
|
|
conn.execute("DELETE FROM menu_items WHERE id = ?", (item_id,))
|
|
storage.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 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)
|
|
created = _iso(storage.utcnow())
|
|
with db() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO menu_items (id, name, category, price, portion, stopped, custom,
|
|
stock, created_at)
|
|
VALUES (?, ?, ?, ?, ?, 0, 1, ?, ?)
|
|
""",
|
|
(item_id, name, category, price, portion, stock, created),
|
|
)
|
|
row = conn.execute("SELECT * FROM menu_items WHERE id = ?", (item_id,)).fetchone()
|
|
return _menu_row(row)
|
|
|
|
|
|
def update_menu_item(item_id: str, fields: dict, conn=None) -> dict | str | None:
|
|
"""Правка позиции владельцем: имя/категория/цена/порция (только переданные поля).
|
|
'seed' — стартовая позиция сайта (правится в коде сайта); None — не найдена;
|
|
иначе обновлённая позиция. NF-07: conn — внешняя транзакция мульти-полевого
|
|
PATCH каталога."""
|
|
sets, vals = [], []
|
|
for col in ("name", "category", "price", "portion"):
|
|
if col in fields:
|
|
sets.append(f"{col} = ?")
|
|
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)
|
|
row = conn.execute("SELECT custom FROM menu_items WHERE id = ?", (item_id,)).fetchone()
|
|
if row is None:
|
|
return None
|
|
if not row["custom"]:
|
|
return "seed"
|
|
row = conn.execute(
|
|
f"UPDATE menu_items SET {', '.join(sets)} WHERE id = ?"
|
|
" RETURNING id, name, category, price, portion, stock, created_at",
|
|
(*vals, item_id),
|
|
).fetchone()
|
|
return _menu_row(row)
|
|
|
|
|
|
def set_menu_stock(item_id: str, new_stock: int | None = None, add: int | None = None,
|
|
conn=None) -> dict | None:
|
|
"""Поштучный учёт позиции (владелец) — контракт postgres-версии storage.py.
|
|
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) + ?,
|
|
stopped = CASE WHEN COALESCE(stock, 0) + ? > 0 AND auto_stopped = 1
|
|
THEN 0 ELSE stopped END,
|
|
auto_stopped = CASE WHEN COALESCE(stock, 0) + ? > 0 THEN 0 ELSE auto_stopped END
|
|
WHERE id = ? AND deleted = 0 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 = 1 THEN 0 ELSE stopped END,
|
|
auto_stopped = 0
|
|
WHERE id = ? AND deleted = 0
|
|
RETURNING id, name, category, price, portion, stopped, stock
|
|
""",
|
|
(item_id,),
|
|
).fetchone()
|
|
else:
|
|
row = conn.execute(
|
|
"""
|
|
UPDATE menu_items SET
|
|
stock = ?,
|
|
stopped = CASE
|
|
WHEN ? > 0 THEN CASE WHEN auto_stopped = 1 THEN 0 ELSE stopped END
|
|
ELSE 1 END,
|
|
auto_stopped = CASE WHEN ? = 0 THEN 1 ELSE 0 END
|
|
WHERE id = ? AND deleted = 0
|
|
RETURNING id, name, category, price, portion, stopped, stock
|
|
""",
|
|
(new_stock, new_stock, new_stock, item_id),
|
|
).fetchone()
|
|
return _menu_row(row) if row else None
|
|
|
|
|
|
def create_custom_tobacco(
|
|
brand: str, flavor: str, note: str | None, strength: int, cooling: bool
|
|
) -> dict:
|
|
"""Новый вкус табака от владельца. Виден на сайте как «новинка» неделю."""
|
|
flavor_id = "custom-" + secrets.token_hex(4)
|
|
created = _iso(storage.utcnow())
|
|
with db() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO tobacco_flavors (id, brand, flavor, note, strength, cooling, stopped, custom, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0, 1, ?)
|
|
""",
|
|
(flavor_id, brand, flavor, note, strength, 1 if cooling else 0, created),
|
|
)
|
|
row = conn.execute("SELECT * FROM tobacco_flavors WHERE id = ?", (flavor_id,)).fetchone()
|
|
return _menu_row(row)
|
|
|
|
|
|
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()
|
|
return [_menu_row(r) for r in rows]
|
|
|
|
|
|
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 = ? WHERE id = ? AND deleted = 0
|
|
RETURNING id, brand, flavor, note, strength, cooling, stopped
|
|
""",
|
|
(1 if stopped else 0, flavor_id),
|
|
).fetchone()
|
|
return _menu_row(row) if row else None
|
|
|
|
|
|
# ---------------------------------------------------------------- миксы владельца
|
|
|
|
def _mix_row(r) -> dict:
|
|
d = dict(r)
|
|
d["flavors"] = json.loads(d["flavors"])
|
|
if "tags" in d:
|
|
d["tags"] = json.loads(d["tags"] or "[]")
|
|
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 = 0 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 —
|
|
расширенная информация для сайта, tags — ярлыки для AI-генерации.
|
|
"""
|
|
with db() as conn:
|
|
marks = ",".join("?" * len(flavor_ids))
|
|
rows = conn.execute(
|
|
f"SELECT id, brand, flavor FROM tobacco_flavors"
|
|
f" WHERE id IN ({marks}) AND deleted = 0",
|
|
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]
|
|
mix_id = "mix-" + secrets.token_hex(4)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO mix_recipes (id, name, flavors, note, strength, cooling, price,
|
|
tags, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(mix_id, name, json.dumps(flavors, ensure_ascii=False), note,
|
|
strength, cooling, price,
|
|
json.dumps(tags or [], ensure_ascii=False), _iso(storage.utcnow())),
|
|
)
|
|
row = conn.execute("SELECT * FROM mix_recipes WHERE id = ?", (mix_id,)).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,
|
|
) -> dict | str | None:
|
|
"""Правка микса: название, состав и расширенные поля — тот же набор, что
|
|
при создании (см. create_custom_mix). 'not_found' — микса нет или он удалён;
|
|
'bad_flavors' — в справочнике меньше двух живых вкусов; иначе обновлённый."""
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT id FROM mix_recipes WHERE id = ? AND deleted = 0", (mix_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
return "not_found"
|
|
marks = ",".join("?" * len(flavor_ids))
|
|
rows = conn.execute(
|
|
f"SELECT id, brand, flavor FROM tobacco_flavors"
|
|
f" WHERE id IN ({marks}) AND deleted = 0",
|
|
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 = ?, flavors = ?, note = ?, strength = ?,
|
|
cooling = ?, price = ?, tags = ?
|
|
WHERE id = ? AND deleted = 0
|
|
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 _site_edit_row(r) -> dict:
|
|
d = dict(r)
|
|
if isinstance(d.get("tags"), str):
|
|
d["tags"] = json.loads(d["tags"] or "[]")
|
|
if isinstance(d.get("flavors"), str):
|
|
d["flavors"] = json.loads(d["flavors"]) if d["flavors"] else None
|
|
return d
|
|
|
|
|
|
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 [_site_edit_row(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 storage.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):
|
|
"""Сохранить правку витринного микса (владелец): поля — тот же словарь,
|
|
что отдал _handle_site_mix_patch; flavors None = состав не менялся.
|
|
Возвращает сохранённую строку правки."""
|
|
with db() as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO site_mix_edits (id, name, note, strength, cooling, price, tags, flavors)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
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
|
|
""",
|
|
(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 = ?", (mix_id,))
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def purge_tobacco_item(flavor_id: str):
|
|
"""Безвозвратное удаление вкуса (см. storage.purge_menu_item):
|
|
только кастомные вкусы; фото у вкусов нет, история заказов не трогается.
|
|
True — удалён; False — не найден; 'seed' — стартовый вкус сайта."""
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT custom FROM tobacco_flavors WHERE id = ?", (flavor_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
if not row["custom"]:
|
|
return "seed"
|
|
conn.execute("DELETE FROM tobacco_flavors WHERE id = ?", (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 = ?", (mix_id,)
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
conn.execute("DELETE FROM mix_recipes WHERE id = ?", (mix_id,))
|
|
return True
|
|
|
|
|
|
def set_mix_deleted(mix_id: str, deleted: bool) -> dict | None:
|
|
"""Мягкое удаление/восстановление микса (только владелец)."""
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT id, name, flavors, note, strength, cooling, price, tags, created_at"
|
|
" FROM mix_recipes WHERE id = ? AND deleted <> ?",
|
|
(mix_id, 1 if deleted else 0),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
conn.execute(
|
|
"UPDATE mix_recipes SET deleted = ? WHERE id = ?",
|
|
(1 if deleted else 0, mix_id),
|
|
)
|
|
return _mix_row(row)
|
|
|
|
|
|
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 = ? WHERE id = ?
|
|
RETURNING id, brand, flavor, note, strength, cooling, stopped, custom, deleted
|
|
""",
|
|
(1 if deleted else 0, flavor_id),
|
|
).fetchone()
|
|
return _menu_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 = 1"
|
|
).fetchall()]
|
|
tobacco = [r["id"] for r in conn.execute(
|
|
"SELECT id FROM tobacco_flavors WHERE stopped = 1"
|
|
).fetchall()]
|
|
return {"stopped_menu": menu, "stopped_tobacco": tobacco}
|
|
|
|
|
|
# ---------------------------------------------------------------- заказы
|
|
|
|
def _order_json(r) -> dict:
|
|
def iso(ts: str | None) -> str | None:
|
|
dt = _parse(ts)
|
|
return dt.astimezone().isoformat() if dt else None
|
|
|
|
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": json.loads(r["items"]),
|
|
"comment": r["comment"],
|
|
"code": r["track_code"],
|
|
"created_at": iso(r["created_at"]),
|
|
"accepted_at": iso(r["accepted_at"]),
|
|
"delivered_at": iso(r["delivered_at"]),
|
|
"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_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:
|
|
"""Цену считаем на сервере по меню — как в Postgres-версии.
|
|
|
|
Номер — внутри «недели нумерации» (MAX+1 по week_start, сбрасывается
|
|
каждую неделю), код — для отслеживания гостем.
|
|
"""
|
|
prices = menu_prices()
|
|
prepared = []
|
|
for i in items:
|
|
# цену берём только из каталога — цену клиента игнорируем даже
|
|
# для неизвестного id (см. комментарий в storage.create_order)
|
|
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 = storage.order_week_start().isoformat()
|
|
|
|
with db() as conn:
|
|
for _ in range(5):
|
|
# коллизии номера (гонка двух заказов) или кода практически
|
|
# невероятны — страхуемся повтором с новыми значениями
|
|
number = conn.execute(
|
|
"SELECT COALESCE(MAX(number), 0) + 1 AS n FROM orders WHERE week_start = ?",
|
|
(week,),
|
|
).fetchone()["n"]
|
|
code = storage.generate_track_code()
|
|
try:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO orders (number, week_start, table_name, guest_name, guest_phone,
|
|
source, status, price, items, comment, track_code, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'new', ?, ?, ?, ?, ?)
|
|
""",
|
|
(number, week, table_name, guest_name, guest_phone, source, total,
|
|
json.dumps(prepared, ensure_ascii=False), comment, code,
|
|
_iso(storage.utcnow())),
|
|
)
|
|
break
|
|
except sqlite3.IntegrityError:
|
|
continue
|
|
else:
|
|
raise RuntimeError("не удалось сгенерировать номер/код заказа")
|
|
row = conn.execute("SELECT * FROM orders WHERE id = last_insert_rowid()").fetchone()
|
|
return _order_json(row)
|
|
|
|
|
|
def find_order_by_code(code: str) -> dict | None:
|
|
"""Заказ по коду отслеживания (публичная страница статуса на сайте)."""
|
|
with db() as conn:
|
|
row = conn.execute("SELECT * FROM orders WHERE track_code = ?", (code,)).fetchone()
|
|
return _order_json(row) if row else None
|
|
|
|
|
|
def _stock_touch(conn, items, sign: int) -> list[str]:
|
|
"""Списать/вернуть штучные позиции заказа — контракт postgres-версии
|
|
(см. storage._stock_touch). Возвращает id затронутых позиций.
|
|
Без клампа (F-17): минус — честный дефицит, фантомных банок нет."""
|
|
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 - ?"
|
|
" WHERE id = ? AND stock IS NOT NULL AND deleted = 0"
|
|
" RETURNING id, stock",
|
|
(qty, item_id),
|
|
).fetchone()
|
|
else: # возврат (заказ ушёл из «Выдан»)
|
|
row = conn.execute(
|
|
"UPDATE menu_items SET stock = stock + ?"
|
|
" WHERE id = ? AND stock IS NOT NULL AND deleted = 0"
|
|
" 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: машина переходов и журнал orders_audit — контракт postgres-версии.
|
|
'bad_transition' — переход недоступен роли actor; повтор того же статуса
|
|
без смены официанта — no-op (повторный done не перезаписывает delivered_at).
|
|
|
|
Штучный учёт: переход в «Выдан» списывает позиции с включённым учётом
|
|
(однократно, флаг stock_applied), вышедшие из «Выдан» — возвращает.
|
|
Позиции с остатком 0 уходят в стоп-лист автоматически."""
|
|
with db() as conn:
|
|
row = conn.execute("SELECT * FROM orders WHERE id = ?", (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_json(row) # повторный PATCH без изменений — no-op
|
|
|
|
# ревью NF-03: сменная роль не может переатрибутить заказ «на месте»
|
|
# (см. postgres-версию)
|
|
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 storage.ORDER_TRANSITIONS_STAFF:
|
|
return "bad_transition"
|
|
|
|
if status != old:
|
|
ts = storage.utcnow()
|
|
accepted = _parse(row["accepted_at"])
|
|
delivered = _parse(row["delivered_at"])
|
|
duration = row["duration_seconds"]
|
|
if status == "new":
|
|
accepted = delivered = None
|
|
duration = None
|
|
elif status == "active":
|
|
accepted = accepted or ts
|
|
delivered = None
|
|
duration = None
|
|
elif status == "cancelled":
|
|
# отмена: историю принятия оставляем, выдачи и длительности нет
|
|
delivered = None
|
|
duration = None
|
|
else: # done
|
|
delivered = ts
|
|
duration = int((ts - accepted).total_seconds()) if accepted else None
|
|
else:
|
|
accepted = _parse(row["accepted_at"])
|
|
delivered = _parse(row["delivered_at"])
|
|
duration = row["duration_seconds"]
|
|
|
|
if waiter is None:
|
|
waiter = old_waiter
|
|
|
|
conn.execute(
|
|
"""
|
|
UPDATE orders SET status = ?, waiter = ?, accepted_at = ?,
|
|
delivered_at = ?, duration_seconds = ?
|
|
WHERE id = ?
|
|
""",
|
|
(status, waiter, _iso(accepted), _iso(delivered), duration, order_id),
|
|
)
|
|
|
|
# журнал (NF-03): по строке на каждую фактическую смену
|
|
if status != old:
|
|
conn.execute(
|
|
"INSERT INTO orders_audit (order_id, user_login, action, old_value, new_value)"
|
|
" VALUES (?, ?, 'status', ?, ?)",
|
|
(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 (?, ?, 'waiter', ?, ?)",
|
|
(order_id, actor_login, old_waiter, waiter),
|
|
)
|
|
|
|
was_done = old == "done"
|
|
now_done = status == "done"
|
|
applied = bool(row["stock_applied"])
|
|
if now_done and not was_done and not applied:
|
|
# списание под атомарным предикатом на флаге (гонка дублирующего PATCH):
|
|
# конкурентный запрос увидит 0 строк и не спишет товар второй раз
|
|
claimed = conn.execute(
|
|
"UPDATE orders SET stock_applied = 1 WHERE id = ? AND stock_applied = 0",
|
|
(order_id,),
|
|
)
|
|
if claimed.rowcount == 1:
|
|
touched = _stock_touch(conn, json.loads(row["items"]), sign=1)
|
|
if touched:
|
|
marks = ",".join("?" * len(touched))
|
|
conn.execute(
|
|
f"UPDATE menu_items SET stopped = 1, auto_stopped = 1"
|
|
f" WHERE id IN ({marks}) AND stock <= 0 AND stopped = 0",
|
|
touched,
|
|
)
|
|
elif was_done and not now_done and applied:
|
|
# заказ вернули из «Выдан» — товар вернулся на полку
|
|
released = conn.execute(
|
|
"UPDATE orders SET stock_applied = 0 WHERE id = ? AND stock_applied = 1",
|
|
(order_id,),
|
|
)
|
|
if released.rowcount == 1:
|
|
touched = _stock_touch(conn, json.loads(row["items"]), sign=-1)
|
|
if touched:
|
|
marks = ",".join("?" * len(touched))
|
|
conn.execute(
|
|
f"UPDATE menu_items SET stopped = 0, auto_stopped = 0"
|
|
f" WHERE id IN ({marks}) AND auto_stopped = 1 AND stock > 0",
|
|
touched,
|
|
)
|
|
updated = conn.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone()
|
|
return _order_json(updated)
|
|
|
|
|
|
def cancel_order_as_guest(order_id: int) -> dict | None:
|
|
"""Гостевая отмена по коду: атомарно ТОЛЬКО из статуса «Новый» (гонка
|
|
с PATCH персонала закрыта предикатом в UPDATE) — контракт postgres-версии.
|
|
None — заказ уже ушёл из «Новый», гостю отказ."""
|
|
with db() as conn:
|
|
cur = conn.execute(
|
|
"UPDATE orders SET status = 'cancelled', delivered_at = NULL,"
|
|
" duration_seconds = NULL"
|
|
" WHERE id = ? AND status = 'new'",
|
|
(order_id,),
|
|
)
|
|
if cur.rowcount != 1:
|
|
return None
|
|
row = conn.execute("SELECT * FROM orders WHERE id = ?", (order_id,)).fetchone()
|
|
return _order_json(row)
|
|
|
|
|
|
# ---------------------------------------------------------------- отчёты и каталог владельца
|
|
|
|
def orders_report(dt_from, dt_to) -> dict:
|
|
"""Отчёт за период [dt_from, dt_to) — timezone-aware UTC.
|
|
|
|
Объём данных локальной базы невелик, поэтому агрегируем в Python
|
|
вместо специфичных для SQLite JSON-функций.
|
|
"""
|
|
with db() as conn:
|
|
rows = conn.execute("SELECT * FROM orders ORDER BY number").fetchall()
|
|
|
|
statuses: dict[str, dict] = {}
|
|
by_day: dict[str, dict] = {}
|
|
top: dict[str, dict] = {}
|
|
durations: list[int] = []
|
|
raw: list[dict] = []
|
|
for r in rows:
|
|
created = _parse(r["created_at"])
|
|
if created is None or not (dt_from <= created < dt_to):
|
|
continue
|
|
raw.append({"status": r["status"], "source": r["source"] or "site",
|
|
"price": r["price"], "table_name": r["table_name"],
|
|
"created_at": created})
|
|
status = r["status"]
|
|
acc = statuses.setdefault(status, {"status": status, "cnt": 0, "total": 0})
|
|
acc["cnt"] += 1
|
|
acc["total"] += r["price"]
|
|
|
|
day = created.astimezone().date().isoformat()
|
|
d = by_day.setdefault(day, {"day": day, "orders": 0, "revenue": 0, "cancelled": 0})
|
|
d["orders"] += 1
|
|
d["cancelled"] += 1 if status == "cancelled" else 0
|
|
d["revenue"] += r["price"] if status == "done" else 0
|
|
|
|
if status != "cancelled":
|
|
for item in json.loads(r["items"]):
|
|
t = top.setdefault(item["name"], {"name": item["name"], "qty": 0, "revenue": 0})
|
|
t["qty"] += item["qty"]
|
|
t["revenue"] += item["qty"] * item["price"]
|
|
|
|
if r["duration_seconds"] is not None:
|
|
durations.append(r["duration_seconds"])
|
|
|
|
top_list = sorted(top.values(), key=lambda x: (-x["revenue"], -x["qty"]))[:10]
|
|
avg_duration = sum(durations) / len(durations) if durations else None
|
|
report = storage._assemble_report(list(statuses.values()), list(by_day.values()),
|
|
top_list, avg_duration)
|
|
report.update(storage._report_extras(raw))
|
|
return report
|
|
|
|
|
|
def first_order_dt() -> datetime | None:
|
|
"""Самый ранний заказ — для отчёта «за всё время». None, если заказов нет.
|
|
|
|
created_at в разных форматах ISO — сравнивать строки ненадёжно,
|
|
парсим все и берём минимум (объём локальной базы невелик)."""
|
|
with db() as conn:
|
|
rows = conn.execute("SELECT created_at FROM orders").fetchall()
|
|
parsed = [dt for dt in (_parse(r["created_at"]) for r in rows) if dt is not None]
|
|
return min(parsed) if parsed else None
|
|
|
|
|
|
def staff_report(dt_from, dt_to) -> list[dict]:
|
|
"""Статистика по каждому сотруднику за период — как в storage.staff_report.
|
|
|
|
Заказы атрибутируются по orders.waiter (имя принявшего), смены — по
|
|
shifts.login; открытая смена считается до текущего момента. Забытая
|
|
смена сначала автозакрывается (F-19) — иначе фантомные часы в отчёте."""
|
|
storage.auto_close_shifts()
|
|
now = storage.utcnow()
|
|
with db() as conn:
|
|
users = [dict(r) for r in conn.execute(
|
|
"SELECT login, name, role FROM crm_users WHERE NOT deleted"
|
|
).fetchall()]
|
|
orders = [dict(r) for r in conn.execute(
|
|
"SELECT waiter, status, source, price, duration_seconds, created_at FROM orders"
|
|
).fetchall()]
|
|
shifts = [dict(r) for r in conn.execute(
|
|
"SELECT login, started_at, ended_at FROM shifts"
|
|
).fetchall()]
|
|
out = []
|
|
for u in users:
|
|
mine = [o for o in orders
|
|
if o["waiter"] == u["name"]
|
|
and (dt := _parse(o["created_at"])) is not None
|
|
and dt_from <= dt < dt_to]
|
|
done = [o for o in mine if o["status"] == "done"]
|
|
durations = [o["duration_seconds"] for o in mine
|
|
if o["duration_seconds"] is not None]
|
|
my_shifts = [s for s in shifts
|
|
if s["login"] == u["login"]
|
|
and (st := _parse(s["started_at"])) is not None
|
|
and dt_from <= st < dt_to]
|
|
seconds = 0
|
|
for s in my_shifts:
|
|
ended = _parse(s["ended_at"]) or now
|
|
started = _parse(s["started_at"])
|
|
if started and ended > started:
|
|
seconds += (ended - started).total_seconds()
|
|
revenue = sum(o["price"] for o in done)
|
|
out.append({
|
|
"login": u["login"],
|
|
"name": u["name"],
|
|
"role": u["role"],
|
|
"orders_total": len(mine),
|
|
"orders_done": len(done),
|
|
"orders_cancelled": sum(1 for o in mine if o["status"] == "cancelled"),
|
|
"revenue": revenue,
|
|
"avg_check": round(revenue / len(done)) if done else None,
|
|
"avg_duration_seconds": round(sum(durations) / len(durations)) if durations else None,
|
|
"site_orders": sum(1 for o in mine if o["source"] == "site"),
|
|
"hall_orders": sum(1 for o in mine if o["source"] == "hall"),
|
|
"shifts": len(my_shifts),
|
|
"hours_seconds": round(seconds),
|
|
})
|
|
out.sort(key=lambda r: (-r["orders_done"], -r["revenue"], r["name"]))
|
|
return out
|
|
|
|
|
|
def custom_catalog() -> dict:
|
|
"""Публичный список позиций, добавленных владельцем через CRM, —
|
|
сайт подмешивает их в меню и табаки и помечает «новинку» на неделю.
|
|
Удалённые позиции сюда не попадают: их id сайт фильтрует из каталога."""
|
|
with db() as conn:
|
|
menu = conn.execute(
|
|
"SELECT id, name, category, price, portion, created_at FROM menu_items"
|
|
" WHERE custom = 1 AND deleted = 0 ORDER BY created_at DESC"
|
|
).fetchall()
|
|
tobacco = conn.execute(
|
|
"SELECT id, brand, flavor, note, strength, cooling, created_at"
|
|
" FROM tobacco_flavors WHERE custom = 1 AND deleted = 0"
|
|
" 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 = 0 ORDER BY created_at DESC"
|
|
).fetchall()
|
|
deleted_menu = conn.execute(
|
|
"SELECT id FROM menu_items WHERE deleted = 1"
|
|
).fetchall()
|
|
deleted_tobacco = conn.execute(
|
|
"SELECT id FROM tobacco_flavors WHERE deleted = 1"
|
|
).fetchall()
|
|
|
|
def with_iso(rows, bool_keys=()):
|
|
out = []
|
|
for r in rows:
|
|
d = dict(r)
|
|
for key in bool_keys:
|
|
d[key] = bool(d.get(key))
|
|
dt = _parse(d["created_at"])
|
|
d["created_at"] = dt.astimezone().isoformat() if dt else None
|
|
out.append(d)
|
|
return out
|
|
|
|
menu_rows = with_iso(menu)
|
|
for d in menu_rows:
|
|
d["image"] = storage.photo_for_item(d["id"])
|
|
tobacco_rows = with_iso(tobacco, bool_keys=("cooling",))
|
|
mix_rows = with_iso(mixes)
|
|
for d in mix_rows:
|
|
d["flavors"] = json.loads(d["flavors"])
|
|
d["tags"] = json.loads(d.get("tags") or "[]")
|
|
return {
|
|
"menu": menu_rows,
|
|
"tobacco": tobacco_rows,
|
|
"mixes": mix_rows,
|
|
"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],
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------- смены официантов
|
|
|
|
def auto_close_shifts() -> int:
|
|
"""Автоматически закрывает смены, чьё окно уже завершилось
|
|
(забыл нажать «Закончить смену» — смена закроется сама)."""
|
|
now = storage.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:
|
|
started = _parse(r["started_at"])
|
|
end = storage.auto_close_cutoff(started, now) if started else None
|
|
if end is not None:
|
|
conn.execute(
|
|
"UPDATE shifts SET ended_at = ? WHERE id = ? AND ended_at IS NULL",
|
|
(_iso(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 = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1",
|
|
(login,),
|
|
).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def clock_in(login: str) -> tuple[dict | None, str]:
|
|
ok, reason = storage.clock_in_check()
|
|
if not ok:
|
|
return None, reason
|
|
if get_open_shift(login):
|
|
return None, "Вы уже на смене"
|
|
started = _iso(storage.utcnow())
|
|
with db() as conn:
|
|
try:
|
|
cur = conn.execute(
|
|
"INSERT INTO shifts (login, started_at) VALUES (?, ?)",
|
|
(login, started),
|
|
)
|
|
except sqlite3.IntegrityError:
|
|
# partial unique index (см. _migrate_shift_unique): двойной
|
|
# clock-in в гонке двух устройств не проходит (F-20)
|
|
return None, "Вы уже на смене"
|
|
row = conn.execute("SELECT id, login, started_at, ended_at FROM shifts WHERE id = ?",
|
|
(cur.lastrowid,)).fetchone()
|
|
return dict(row), ""
|
|
|
|
|
|
def clock_out(login: str) -> dict | None:
|
|
with db() as conn:
|
|
# закрываем ВСЕ открытые смены логина: до unique-индекса гонка могла
|
|
# оставить дубль — его надо закрыть тоже, а не оставить висеть (F-20)
|
|
rows = conn.execute(
|
|
"SELECT id, login, started_at, ended_at FROM shifts"
|
|
" WHERE login = ? AND ended_at IS NULL ORDER BY started_at",
|
|
(login,),
|
|
).fetchall()
|
|
if not rows:
|
|
return None
|
|
ended = _iso(storage.utcnow())
|
|
for r in rows:
|
|
conn.execute("UPDATE shifts SET ended_at = ? WHERE id = ?", (ended, r["id"]))
|
|
updated = conn.execute(
|
|
"SELECT id, login, started_at, ended_at FROM shifts WHERE id = ?",
|
|
(rows[0]["id"],),
|
|
).fetchone()
|
|
return dict(updated)
|
|
|
|
|
|
def admin_clock_in(login: str) -> dict | None:
|
|
"""Ручная запись на смену владельцем/админом — без проверки окна."""
|
|
started = _iso(storage.utcnow())
|
|
with db() as conn:
|
|
cur = conn.execute(
|
|
"INSERT INTO shifts (login, started_at) VALUES (?, ?)",
|
|
(login, started),
|
|
)
|
|
row = conn.execute("SELECT id, login, started_at, ended_at FROM shifts WHERE id = ?",
|
|
(cur.lastrowid,)).fetchone()
|
|
return dict(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 [dict(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 deleted = 0")
|
|
+ " 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 deleted = 0 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 — активный стол с таким именем уже есть;
|
|
удалённый с тем же именем восстанавливается с новой подписью.
|
|
Восстановленный стол освобождается (см. storage.hall_create)."""
|
|
now = _iso(storage.utcnow())
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT name, deleted FROM halls_def WHERE name = ?", (name,)
|
|
).fetchone()
|
|
if row is not None:
|
|
if row["deleted"]:
|
|
conn.execute(
|
|
"UPDATE halls_def SET deleted = 0, sub = ? WHERE name = ?",
|
|
(sub, name),
|
|
)
|
|
else:
|
|
return None
|
|
else:
|
|
nxt = conn.execute("SELECT COALESCE(MAX(sort), 0) + 1 AS n FROM halls_def").fetchone()
|
|
conn.execute(
|
|
"INSERT INTO halls_def (name, sub, sort, created_at) VALUES (?, ?, ?, ?)",
|
|
(name, sub, nxt["n"], now),
|
|
)
|
|
# сброс зависшей занятости (для нового стола — просто свободная строка)
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at)
|
|
VALUES (?, 0, NULL, NULL, ?)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
busy = 0, busy_by = NULL, busy_until = NULL, updated_at = excluded.updated_at
|
|
""",
|
|
(name, now),
|
|
)
|
|
out = conn.execute(
|
|
"SELECT name, sub, sort, deleted FROM halls_def WHERE name = ?", (name,)
|
|
).fetchone()
|
|
d = dict(out)
|
|
d["deleted"] = bool(d["deleted"])
|
|
return d
|
|
|
|
|
|
def hall_delete(name: str) -> bool:
|
|
"""Мягкое удаление стола: исчезает из списков и валидации,
|
|
история занятости остаётся. Вернуть можно повторным созданием."""
|
|
with db() as conn:
|
|
cur = conn.execute(
|
|
"UPDATE halls_def SET deleted = 1 WHERE name = ? AND deleted = 0", (name,)
|
|
)
|
|
return (cur.rowcount or 0) > 0
|
|
|
|
|
|
def _hall_row(r) -> dict:
|
|
d = dict(r)
|
|
d["busy"] = bool(d.get("busy"))
|
|
return d
|
|
|
|
|
|
def hall_status() -> list[dict]:
|
|
"""Состояние всех столов и зон; таймеры с истёкшим сроком освобождаются сами."""
|
|
now = storage.utcnow()
|
|
active = halls_active()
|
|
with db() as conn:
|
|
busy_rows = conn.execute(
|
|
"SELECT name, busy_until FROM hall_status WHERE busy = 1"
|
|
).fetchall()
|
|
expired = [
|
|
r["name"] for r in busy_rows
|
|
if r["busy_until"] and (_parse(r["busy_until"]) or now) <= now
|
|
]
|
|
if expired:
|
|
marks = ",".join("?" * len(expired))
|
|
conn.execute(
|
|
f"UPDATE hall_status SET busy = 0, busy_by = NULL, busy_until = NULL"
|
|
f" WHERE name IN ({marks})",
|
|
expired,
|
|
)
|
|
rows = conn.execute(
|
|
"SELECT name, busy, busy_by, busy_until, updated_at FROM hall_status"
|
|
).fetchall()
|
|
by_name = {}
|
|
for r in rows:
|
|
d = _hall_row(r)
|
|
by_name[d["name"]] = d
|
|
return [
|
|
{"name": h["name"], "sub": h["sub"], **by_name.get(h["name"], {
|
|
"busy": False, "busy_by": None, "busy_until": None, "updated_at": None,
|
|
})}
|
|
for h in active
|
|
]
|
|
|
|
|
|
def set_hall_busy(name: str, busy: bool, minutes: int | None, by: str) -> dict | None:
|
|
"""Занять стол/зону (опционально на minutes минут) или освободить.
|
|
|
|
Продление: если стол уже занят с активным таймером, минуты добавляются
|
|
к текущему дедлайну; занят без таймера — отсчёт идёт от сейчас.
|
|
"""
|
|
now = storage.utcnow()
|
|
with db() as conn:
|
|
if not busy:
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at)
|
|
VALUES (?, 0, NULL, NULL, ?)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
busy = 0, busy_by = NULL, busy_until = NULL, updated_at = excluded.updated_at
|
|
""",
|
|
(name, _iso(now)),
|
|
)
|
|
else:
|
|
until = None
|
|
if minutes is not None:
|
|
base = now
|
|
current = conn.execute(
|
|
"SELECT busy_until FROM hall_status WHERE name = ? AND busy = 1", (name,)
|
|
).fetchone()
|
|
if current and current["busy_until"] and (_parse(current["busy_until"]) or now) > now:
|
|
base = _parse(current["busy_until"])
|
|
until = _iso(base + timedelta(minutes=minutes))
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO hall_status (name, busy, busy_by, busy_until, updated_at)
|
|
VALUES (?, 1, ?, ?, ?)
|
|
ON CONFLICT(name) DO UPDATE SET
|
|
busy = 1, busy_by = excluded.busy_by,
|
|
busy_until = excluded.busy_until, updated_at = excluded.updated_at
|
|
""",
|
|
(name, by, until, _iso(now)),
|
|
)
|
|
row = conn.execute(
|
|
"SELECT name, busy, busy_by, busy_until, updated_at FROM hall_status WHERE name = ?",
|
|
(name,),
|
|
).fetchone()
|
|
return _hall_row(row)
|
|
|
|
|
|
# ---------------------------------------------------------------- демо-заказы
|
|
|
|
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
|
|
for r in storage._demo_rows():
|
|
conn.execute(
|
|
"""
|
|
INSERT INTO orders (number, week_start, table_name, waiter, status, price, items,
|
|
created_at, accepted_at, delivered_at, duration_seconds)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(r["number"], r["week"].isoformat(), r["table"], r["waiter"], r["status"],
|
|
r["price"], r["items"], _iso(r["created"]), _iso(r["accepted"]),
|
|
_iso(r["delivered"]), r["duration"]),
|
|
)
|