# -*- 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"]), )