Download app.py from vahidauto/Optom: direct link, hf CLI and curl.
- Browser
- Download file 98.6 kB
-
https://huggingface.co/spaces/vahidauto/Optom/resolve/main/app.py
- Command line
-
hf download hf://spaces/vahidauto/Optom/app.py
-
curl -L -o app.py https://huggingface.co/spaces/vahidauto/Optom/resolve/main/app.py
98.6 kB
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| ============================================================================ | |
| УЧЁТ ТОВАРОВ — один файл (Flask + JSON + резервная копия на Hugging Face) | |
| ============================================================================ | |
| Оптимизированная версия. Что изменилось по сравнению с исходником: | |
| 1. База больше не встраивается в HTML целиком. Фотографии (это 46 МБ из | |
| 47 МБ файла!) вынесены в отдельный эндпоинт /photo/<id> с вечным | |
| кэшированием браузером и ленивой загрузкой. Страница «худеет» с ~47 МБ | |
| до ~200 КБ (в сжатом виде ~50 КБ) и грузится мгновенно. | |
| 2. Данные держатся в памяти и не перечитываются с диска на каждый запрос. | |
| 3. Запись файла — атомарная (tmp + os.replace) и в фоновом потоке, | |
| с «склейкой» частых изменений; выгрузка на HF ограничена по частоте. | |
| 4. Клиент: порционная отрисовка каталога, делегированные события, | |
| ленивые картинки, debounce поиска — интерфейс не «зависает» на | |
| тысячах товаров. | |
| 5. Исправлены найденные ошибки (см. отчет): XSS, потеря локальных | |
| изменений при рестарте, int('') → ValueError, ID/токены сотрудников, | |
| деление дат, удаление «мусорных» коллекций в настройках и др. | |
| 6. Все POST-действия валидируются на сервере, у каждого действия есть | |
| проверка прав и границ значений. | |
| Запуск: python app.py | |
| Переменные окружения (все необязательные): | |
| HF_TOKEN / HF_TOKEN_READ / HF_TOKEN_WRITE - доступ к датасету на HF | |
| HF_REPO_ID="Kgshop/vahid" - репозиторий бэкапа | |
| DATA_FILE="data_fabrics.json" - файл базы | |
| BACKUP_INTERVAL="300" - минимум секунд между выгрузками на HF | |
| PORT="7860" - порт | |
| ВАЖНО: состояние хранится в памяти процесса, поэтому запускать нужно | |
| ОДНИМ процессом (gunicorn -w 1 --threads 8 app:app), иначе воркеры | |
| будут расходиться по данным. | |
| ============================================================================ | |
| """ | |
| from __future__ import annotations | |
| import atexit | |
| import gzip | |
| import json | |
| import logging | |
| import os | |
| import re | |
| import secrets | |
| import signal | |
| import threading | |
| import time | |
| from base64 import b64decode | |
| from datetime import datetime | |
| from hashlib import md5 | |
| from urllib.parse import quote | |
| from zoneinfo import ZoneInfo | |
| from flask import (Flask, Response, jsonify, make_response, redirect, | |
| render_template_string, request, url_for) | |
| from werkzeug.middleware.proxy_fix import ProxyFix | |
| # ----------------------------- настройки ------------------------------------ | |
| DATA_FILE = os.getenv("DATA_FILE", "data_fabrics.json") | |
| REPO_ID = os.getenv("HF_REPO_ID", "Kgshop/vahid") | |
| HF_TOKEN_WRITE = os.getenv("HF_TOKEN") or os.getenv("HF_TOKEN_WRITE") or "" | |
| HF_TOKEN_READ = os.getenv("HF_TOKEN_READ") or HF_TOKEN_WRITE | |
| BACKUP_INTERVAL = max(30, int(os.getenv("BACKUP_INTERVAL", "300") or 300)) | |
| PORT = int(os.getenv("PORT", "7860")) | |
| TZ = ZoneInfo("Asia/Almaty") | |
| HISTORY_LIMIT = 1000 # сколько последних операций храним | |
| MAX_QTY = 10_000_000 # защита от абсурдных чисел | |
| MAX_PRICE = 1_000_000_000 | |
| logging.basicConfig(level=logging.INFO, | |
| format="%(asctime)s %(levelname)-7s %(message)s", | |
| datefmt="%H:%M:%S") | |
| log = logging.getLogger("warehouse") | |
| try: # необязательная зависимость: pip install huggingface_hub | |
| from huggingface_hub import HfApi, hf_hub_download | |
| _HAS_HF = bool(HF_TOKEN_READ) | |
| except Exception: # pragma: no cover | |
| HfApi = hf_hub_download = None | |
| _HAS_HF = False | |
| def almaty_now() -> str: | |
| """Текущее время по Алматы в формате дд.мм.гггг чч:мм:сс.""" | |
| return datetime.now(TZ).strftime("%d.%m.%Y %H:%M:%S") | |
| # ----------------------------- утилиты -------------------------------------- | |
| _NUM_RE = re.compile(r"[^0-9,.\-]") | |
| def to_int(value, default: int = 0, lo: int | None = None, hi: int | None = None) -> int: | |
| """Мягкий парсер чисел: '1 250 ₸', '12,5', '', None → int. | |
| В исходном коде было int(req.form[...]) сразу в куче мест — любое пустое | |
| поле или «12 500» роняло запрос с ValueError. | |
| """ | |
| if value is None or isinstance(value, bool): | |
| return default | |
| if isinstance(value, (int, float)): | |
| n = int(value) | |
| else: | |
| s = _NUM_RE.sub("", str(value).strip()).replace(",", ".") | |
| if s in ("", "-", "+", ".", "-."): | |
| return default | |
| try: | |
| n = int(float(s)) | |
| except ValueError: | |
| return default | |
| if lo is not None and n < lo: | |
| n = lo | |
| if hi is not None and n > hi: | |
| n = hi | |
| return n | |
| def clean_text(value, limit: int) -> str: | |
| return str(value or "").strip()[:limit] | |
| def new_id(prefix: str = "p") -> str: | |
| """Уникальный идентификатор без коллизий (исходный str(time.time()) мог | |
| совпасть при двух товарах, добавленных в одну микросекунду).""" | |
| return f"{prefix}{int(time.time() * 1000):x}{secrets.token_hex(3)}" | |
| def decode_photo(value: str): | |
| """'data:image/jpeg;base64,....' → (bytes, mime) либо None.""" | |
| if not value: | |
| return None | |
| mime = "image/jpeg" | |
| payload = value | |
| if value.startswith("data:"): | |
| head, _, payload = value.partition(",") | |
| if not _: | |
| return None | |
| mime = (head[5:].split(";")[0] or "image/jpeg").strip().lower() | |
| if mime not in ("image/jpeg", "image/png", "image/webp", "image/gif"): | |
| mime = "image/jpeg" | |
| try: | |
| raw = b64decode(payload, validate=False) | |
| except Exception: | |
| return None | |
| if not raw: | |
| return None | |
| return raw, mime | |
| def empty_state() -> dict: | |
| return { | |
| "settings": { | |
| "warehouses": [{"id": "w_main", "name": "Основной склад"}], | |
| "shops": [{"id": "s_main", "name": "Основной магазин"}], | |
| "employees": [], | |
| }, | |
| "products": [], | |
| "history": [], | |
| } | |
| def norm_loc_list(value, fallback_name: str) -> list: | |
| out, seen = [], set() | |
| if isinstance(value, list): | |
| for item in value: | |
| if not isinstance(item, dict): | |
| continue | |
| lid = clean_text(item.get("id"), 64) | |
| if not lid or lid in seen: | |
| continue | |
| seen.add(lid) | |
| out.append({"id": lid, "name": clean_text(item.get("name"), 120) or lid}) | |
| if not out: | |
| out = [{"id": new_id("loc_"), "name": fallback_name}] | |
| return out | |
| def norm_product(raw: dict) -> dict: | |
| """Приводит товар к единому виду (заодно переносит старый формат colors).""" | |
| if not isinstance(raw, dict): | |
| raw = {} | |
| legacy_price = to_int(raw.get("price"), 0, 0, MAX_PRICE) | |
| variants = [] | |
| for v in raw.get("variants") or []: | |
| if not isinstance(v, dict): | |
| continue | |
| name = clean_text(v.get("name"), 120) | |
| if not name: | |
| continue | |
| stocks = {} | |
| if isinstance(v.get("stocks"), dict): | |
| for loc, amt in v["stocks"].items(): | |
| loc = clean_text(loc, 64) | |
| if loc: | |
| stocks[loc] = to_int(amt, 0, 0, MAX_QTY) | |
| variants.append({"name": name, | |
| "price": to_int(v.get("price"), 0, 0, MAX_PRICE), | |
| "stocks": stocks}) | |
| stocks = {} | |
| for c in raw.get("colors") or []: # старый формат | |
| if isinstance(c, dict) and isinstance(c.get("stocks"), dict): | |
| for loc, amt in c["stocks"].items(): | |
| loc = clean_text(loc, 64) | |
| if loc: | |
| stocks[loc] = stocks.get(loc, 0) + to_int(amt, 0, 0, MAX_QTY) | |
| if isinstance(raw.get("stocks"), dict): | |
| for loc, amt in raw["stocks"].items(): | |
| loc = clean_text(loc, 64) | |
| if loc: | |
| stocks[loc] = stocks.get(loc, 0) + to_int(amt, 0, 0, MAX_QTY) | |
| return { | |
| "id": clean_text(raw.get("id"), 64) or new_id(), | |
| "name": clean_text(raw.get("name"), 200) or "Без названия", | |
| "description": clean_text(raw.get("description"), 2000), | |
| "price_retail": to_int(raw.get("price_retail", legacy_price), 0, 0, MAX_PRICE), | |
| "price_wholesale": to_int(raw.get("price_wholesale", legacy_price), 0, 0, MAX_PRICE), | |
| "variants": variants, | |
| "stocks": {} if variants else stocks, | |
| "box_qty": max(1, to_int(raw.get("box_qty"), 1, 1, MAX_QTY)), | |
| } | |
| def norm_history(raw) -> list: | |
| out = [] | |
| if isinstance(raw, list): | |
| for h in raw[:HISTORY_LIMIT]: | |
| if not isinstance(h, dict): | |
| continue | |
| out.append({ | |
| "time": clean_text(h.get("time"), 32), | |
| "action": clean_text(h.get("action"), 32), | |
| "product": clean_text(h.get("product"), 240), | |
| "amount": to_int(h.get("amount"), 0, 0, MAX_QTY), | |
| "price": to_int(h.get("price"), 0, 0, MAX_PRICE), | |
| "from_loc_name": clean_text(h.get("from_loc_name"), 120), | |
| "to_loc_name": clean_text(h.get("to_loc_name"), 120), | |
| "emp_name": clean_text(h.get("emp_name"), 120), | |
| }) | |
| return out | |
| def normalize_state(raw) -> tuple[dict, dict]: | |
| """(state, photos). photos: {product_id: data-url}.""" | |
| if not isinstance(raw, dict): | |
| return empty_state(), {} | |
| state = empty_state() | |
| settings = raw.get("settings") if isinstance(raw.get("settings"), dict) else {} | |
| state["settings"]["warehouses"] = norm_loc_list(settings.get("warehouses"), "Основной склад") | |
| state["settings"]["shops"] = norm_loc_list(settings.get("shops"), "Основной магазин") | |
| seen_ids = set() | |
| products, photos = [], {} | |
| if isinstance(raw.get("products"), list): | |
| for item in raw["products"]: | |
| p = norm_product(item) | |
| if p["id"] in seen_ids: # битые дубли id в базе | |
| p["id"] = new_id() | |
| seen_ids.add(p["id"]) | |
| photos[p["id"]] = str((item or {}).get("photo") or "") if isinstance(item, dict) else "" | |
| products.append(p) | |
| state["products"] = products | |
| employees, seen_tokens = [], set() | |
| for e in (settings.get("employees") or []): | |
| if not isinstance(e, dict): | |
| continue | |
| token = clean_text(e.get("token") or e.get("id"), 64) | |
| if not token or token in seen_tokens: # дубли токенов = чужой доступ | |
| token = secrets.token_urlsafe(18) | |
| seen_tokens.add(token) | |
| employees.append({ | |
| "id": clean_text(e.get("id"), 32) or token[:8], | |
| "name": clean_text(e.get("name"), 120) or "Сотрудник", | |
| "token": token, | |
| "shop_id": clean_text(e.get("shop_id"), 64), | |
| }) | |
| state["settings"]["employees"] = employees | |
| state["history"] = norm_history(raw.get("history")) | |
| return state, photos | |
| # ------------------------------- склад данных ------------------------------- | |
| class Store: | |
| """Состояние приложения в памяти + фоновая атомарная запись и бэкап.""" | |
| def __init__(self, path: str): | |
| self.path = path | |
| self.lock = threading.RLock() | |
| self.data = empty_state() | |
| self.photo_raw: dict[str, str] = {} # id -> исходная data-url (для файла на HF) | |
| self.photo_bin: dict[str, tuple[bytes, str]] = {} | |
| self.photo_ver: dict[str, str] = {} | |
| self.rev = 1 | |
| self.hf_enabled = bool(_HAS_HF and HF_TOKEN_WRITE) | |
| self._dirty = False | |
| self._upload_pending = False | |
| self._last_upload = 0.0 | |
| self._wake = threading.Event() | |
| self._stop = threading.Event() | |
| self._thread: threading.Thread | None = None | |
| # ---------------- загрузка ---------------- | |
| def load(self) -> None: | |
| raw = None | |
| if os.path.exists(self.path): | |
| try: | |
| with open(self.path, "r", encoding="utf-8") as fh: | |
| raw = json.load(fh) | |
| log.info("База загружена из файла %s", self.path) | |
| except Exception as exc: | |
| log.error("Файл %s повреждён (%s) — работаю с копией HF", self.path, exc) | |
| try: | |
| os.replace(self.path, self.path + ".broken") | |
| except OSError: | |
| pass | |
| # Скачиваем с HF ТОЛЬКО если локального файла нет: иначе можно | |
| # потерять изменения, которые ещё не успели уехать в бэкап (баг старой версии). | |
| if raw is None and _HAS_HF: | |
| try: | |
| p = hf_hub_download(repo_id=REPO_ID, filename=os.path.basename(self.path), | |
| repo_type="dataset", token=HF_TOKEN_READ, | |
| local_dir=".") | |
| with open(p, "r", encoding="utf-8") as fh: | |
| raw = json.load(fh) | |
| log.info("База скачана с Hugging Face (%s)", REPO_ID) | |
| except Exception as exc: | |
| log.warning("Не удалось скачать базу с HF: %s", exc) | |
| state, photos = normalize_state(raw) | |
| self.data = state | |
| self.photo_raw = photos | |
| self._rebuild_photo_cache() | |
| self.rev = int(time.time() * 1000) | |
| log.info("Товаров: %d, операций в истории: %d, фото: %d", | |
| len(state["products"]), len(state["history"]), | |
| sum(1 for v in photos.values() if v)) | |
| def _rebuild_photo_cache(self) -> None: | |
| self.photo_bin.clear() | |
| self.photo_ver.clear() | |
| for pid, url in self.photo_raw.items(): | |
| if not url: | |
| continue | |
| dec = decode_photo(url) | |
| if dec: | |
| self.photo_bin[pid] = dec | |
| self.photo_ver[pid] = md5(url.encode("utf-8", "ignore")).hexdigest()[:10] | |
| # ---------------- чтение ---------------- | |
| def get_photo(self, pid: str): | |
| return self.photo_bin.get(pid), self.photo_ver.get(pid, "") | |
| def loc_name(self, loc_id: str) -> str: | |
| st = self.data["settings"] | |
| for group in ("warehouses", "shops"): | |
| for loc in st[group]: | |
| if loc["id"] == loc_id: | |
| return loc["name"] | |
| return loc_id or "" | |
| def valid_loc(self, loc_id) -> str: | |
| loc_id = clean_text(loc_id, 64) | |
| st = self.data["settings"] | |
| for group in ("warehouses", "shops"): | |
| for loc in st[group]: | |
| if loc["id"] == loc_id: | |
| return loc_id | |
| return "" | |
| def first_loc(self) -> str: | |
| st = self.data["settings"] | |
| return (st["warehouses"][0]["id"] if st["warehouses"] | |
| else (st["shops"][0]["id"] if st["shops"] else "")) | |
| def summary(self, role: str, emp: dict | None, price_type: str) -> tuple[dict, int]: | |
| """Компактный JSON для страницы: БЕЗ фото и служебных полей. | |
| Возвращает (payload, rev) — rev снят под тем же замком, поэтому ETag | |
| страницы всегда соответствует отданным данным. | |
| """ | |
| with self.lock: | |
| return self._build_summary(role, emp, price_type), self.rev | |
| def _build_summary(self, role: str, emp: dict | None, price_type: str) -> dict: | |
| data = self.data | |
| locs = ([{"i": w["id"], "n": w["name"], "k": "w"} for w in data["settings"]["warehouses"]] + | |
| [{"i": s["id"], "n": s["name"], "k": "s"} for s in data["settings"]["shops"]]) | |
| products = [] | |
| for p in data["products"]: | |
| products.append({ | |
| "q": p["id"], | |
| "n": p["name"], | |
| "d": p["description"], | |
| "r": p["price_retail"], | |
| "w": p["price_wholesale"], | |
| "b": p["box_qty"], | |
| "v": [{"n": v["name"], "p": v["price"], "s": v["stocks"]} for v in p["variants"]], | |
| "s": p["stocks"], | |
| "ph": self.photo_ver.get(p["id"], ""), | |
| }) | |
| out = { | |
| "role": role, | |
| "pt": price_type, | |
| "emp": ({"name": emp["name"], "shop_id": emp["shop_id"]} if emp else None), | |
| "locs": locs, | |
| "p": products, | |
| "hf": self.hf_enabled, | |
| } | |
| if role == "admin": | |
| out["h"] = [[h["time"], h["action"], h["product"], h["amount"], h["price"], | |
| h["from_loc_name"], h["to_loc_name"], h["emp_name"]] | |
| for h in data["history"]] | |
| out["emps"] = [{"i": e["id"], "n": e["name"], "t": e["token"], "s": e["shop_id"]} | |
| for e in data["settings"]["employees"]] | |
| return out | |
| # ---------------- изменение ---------------- | |
| def touch(self) -> None: | |
| """Данные изменились: новый rev, отметка «грязно», будим писателя.""" | |
| with self.lock: | |
| self.rev += 1 | |
| self._dirty = True | |
| self._wake.set() | |
| def set_photo(self, pid: str, data_url: str) -> None: | |
| with self.lock: | |
| self.photo_raw[pid] = data_url or "" | |
| if data_url: | |
| dec = decode_photo(data_url) | |
| if dec: | |
| self.photo_bin[pid] = dec | |
| self.photo_ver[pid] = md5(data_url.encode("utf-8", "ignore")).hexdigest()[:10] | |
| else: | |
| self.photo_bin.pop(pid, None) | |
| self.photo_ver.pop(pid, None) | |
| else: | |
| self.photo_bin.pop(pid, None) | |
| self.photo_ver.pop(pid, None) | |
| def drop_photo(self, pid: str) -> None: | |
| self.set_photo(pid, "") | |
| def add_history(self, action: str, product: str, amount: int = 0, price: int = 0, | |
| from_loc: str = "", to_loc: str = "", emp_name: str = "") -> None: | |
| hist = self.data["history"] | |
| hist.insert(0, { | |
| "time": almaty_now(), "action": action, "product": product, | |
| "amount": int(amount), "price": int(price), | |
| "from_loc_name": from_loc, "to_loc_name": to_loc, "emp_name": emp_name, | |
| }) | |
| del hist[HISTORY_LIMIT:] | |
| # ---------------- запись на диск / бэкап ---------------- | |
| def _serialize(self) -> str: | |
| """Собирает JSON так, как его ждёт HF (фото внутри товара).""" | |
| with self.lock: | |
| products = [dict(p, photo=self.photo_raw.get(p["id"], "")) | |
| for p in self.data["products"]] | |
| payload = {"settings": self.data["settings"], | |
| "products": products, | |
| "history": self.data["history"]} | |
| return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) | |
| def flush(self) -> bool: | |
| with self.lock: | |
| if not self._dirty: | |
| return False | |
| self._dirty = False | |
| try: | |
| blob = self._serialize() | |
| tmp = f"{self.path}.tmp" | |
| with open(tmp, "w", encoding="utf-8") as fh: | |
| fh.write(blob) | |
| os.replace(tmp, self.path) # атомарная замена | |
| self._upload_pending = self.hf_enabled | |
| return True | |
| except Exception as exc: | |
| log.error("Не удалось сохранить базу: %s", exc) | |
| with self.lock: | |
| self._dirty = True # попробуем ещё раз | |
| return False | |
| def maybe_upload(self, force: bool = False) -> None: | |
| if not self.hf_enabled or not (self._upload_pending or force): | |
| return | |
| if not force and (time.time() - self._last_upload) < BACKUP_INTERVAL: | |
| return | |
| try: | |
| HfApi().upload_file( | |
| path_or_fileobj=self.path, | |
| path_in_repo=os.path.basename(self.path), | |
| repo_id=REPO_ID, repo_type="dataset", token=HF_TOKEN_WRITE, | |
| commit_message=f"Backup {almaty_now()}") | |
| self._last_upload = time.time() | |
| self._upload_pending = False | |
| log.info("Резервная копия выгружена на HF") | |
| except Exception as exc: | |
| log.warning("Бэкап на HF не удался: %s", exc) | |
| def start(self) -> None: | |
| if self._thread: | |
| return | |
| self._thread = threading.Thread(target=self._loop, name="store-writer", daemon=True) | |
| self._thread.start() | |
| atexit.register(self.shutdown) | |
| if threading.current_thread() is threading.main_thread(): | |
| try: # при остановке контейнера (SIGTERM) успеваем сбросить данные на диск | |
| signal.signal(signal.SIGTERM, self._on_signal) | |
| except (ValueError, OSError): # pragma: no cover | |
| pass | |
| def _on_signal(self, _signum, _frame): # pragma: no cover | |
| self.shutdown() | |
| raise SystemExit(0) | |
| def _loop(self) -> None: | |
| while not self._stop.wait(2.0): | |
| with self.lock: | |
| dirty = self._dirty | |
| if dirty: | |
| self._stop.wait(1.5) # склеиваем серию правок | |
| self.flush() | |
| self.maybe_upload() | |
| def shutdown(self) -> None: | |
| if self._stop.is_set(): | |
| return | |
| self._stop.set() | |
| try: | |
| self.flush() | |
| self.maybe_upload(force=True) | |
| except Exception: | |
| pass | |
| STORE = Store(DATA_FILE) | |
| # ------------------------------ действия (POST) ----------------------------- | |
| ADMIN_ACTIONS = {"add", "edit", "delete", "income", "transfer", | |
| "add_loc", "del_loc", "add_emp", "del_emp"} | |
| def _variant_pairs(form) -> list[tuple[str, int, int]]: | |
| """Читает строки вариантов (v_name[] / v_price[] / v_stock[]) с валидацией.""" | |
| out = [] | |
| for name, price, stock in zip(form.getlist("v_name[]"), | |
| form.getlist("v_price[]"), | |
| form.getlist("v_stock[]")): | |
| name = clean_text(name, 120) | |
| if name: | |
| out.append((name, to_int(price, 0, 0, MAX_PRICE), to_int(stock, 0, 0, MAX_QTY))) | |
| return out | |
| def _merge_variants(product: dict, pairs, loc_id: str) -> list: | |
| """Пересобирает список вариантов, сохраняя остатки по «старым» складам.""" | |
| old = product.get("variants") or [] | |
| old_by_name = {v["name"]: v for v in old} | |
| result = [] | |
| for i, (name, price, stock) in enumerate(pairs): | |
| stocks = {} | |
| if name in old_by_name: | |
| stocks = dict(old_by_name[name]["stocks"]) | |
| elif i < len(old): | |
| stocks = dict(old[i]["stocks"]) | |
| if loc_id: | |
| stocks[loc_id] = stock | |
| result.append({"name": name, "price": price, "stocks": stocks}) | |
| return result | |
| def apply_action(form, role: str, emp: dict | None) -> dict: | |
| """Единая точка обработки POST. Всегда возвращает dict (ok / error).""" | |
| action = clean_text(form.get("action"), 32) | |
| emp_name = emp["name"] if emp else "Админ" | |
| if action in ADMIN_ACTIONS and role != "admin": | |
| return {"ok": False, "error": "Недостаточно прав"} | |
| if action == "checkout" and role not in ("admin", "employee"): | |
| return {"ok": False, "error": "Недостаточно прав"} | |
| data = STORE.data | |
| result: dict = {"ok": False, "error": "Неизвестное действие"} | |
| changed = False | |
| with STORE.lock: | |
| products = data["products"] | |
| # ----------------------------- добавить товар ------------------------ | |
| if action == "add": | |
| name = clean_text(form.get("name"), 200) | |
| if not name: | |
| return {"ok": False, "error": "Укажите название товара"} | |
| loc_id = STORE.valid_loc(form.get("loc_id")) or STORE.first_loc() | |
| photo = form.get("photo") or "" | |
| if len(photo) > 6_000_000: # ~4.5 МБ картинки | |
| photo = "" | |
| pairs = _variant_pairs(form) | |
| pid = new_id() | |
| product = { | |
| "id": pid, | |
| "name": name, | |
| "description": clean_text(form.get("description"), 2000), | |
| "price_retail": to_int(form.get("price_retail"), 0, 0, MAX_PRICE), | |
| "price_wholesale": to_int(form.get("price_wholesale"), 0, 0, MAX_PRICE), | |
| "variants": [{"name": n, "price": pr, | |
| "stocks": ({loc_id: st} if loc_id else {})} for n, pr, st in pairs], | |
| "stocks": {}, | |
| "box_qty": max(1, to_int(form.get("box_qty"), 1, 1, MAX_QTY)), | |
| } | |
| if not product["variants"]: | |
| product["stocks"] = {loc_id: to_int(form.get("rolls"), 0, 0, MAX_QTY)} if loc_id else {} | |
| products.insert(0, product) | |
| if photo: | |
| STORE.set_photo(pid, photo) | |
| else: | |
| STORE.photo_raw[pid] = "" | |
| changed = True | |
| result = {"ok": True, "id": pid} | |
| # ----------------------------- изменить товар ------------------------ | |
| elif action == "edit": | |
| idx = to_int(form.get("index"), -1) | |
| if not (0 <= idx < len(products)): | |
| return {"ok": False, "error": "Товар не найден"} | |
| p = products[idx] | |
| name = clean_text(form.get("name"), 200) | |
| if name: | |
| p["name"] = name | |
| if form.get("description") is not None: | |
| p["description"] = clean_text(form.get("description"), 2000) | |
| if form.get("price_retail") is not None: | |
| p["price_retail"] = to_int(form.get("price_retail"), p["price_retail"], 0, MAX_PRICE) | |
| if form.get("price_wholesale") is not None: | |
| p["price_wholesale"] = to_int(form.get("price_wholesale"), p["price_wholesale"], 0, MAX_PRICE) | |
| if form.get("box_qty") is not None: | |
| p["box_qty"] = max(1, to_int(form.get("box_qty"), p["box_qty"], 1, MAX_QTY)) | |
| loc_id = STORE.valid_loc(form.get("edit_loc_id")) | |
| pairs = _variant_pairs(form) | |
| if pairs: | |
| p["variants"] = _merge_variants(p, pairs, loc_id) | |
| p["stocks"] = {} | |
| else: | |
| p["variants"] = [] | |
| if loc_id and form.get("rolls") is not None: | |
| p.setdefault("stocks", {}) | |
| p["stocks"][loc_id] = to_int(form.get("rolls"), 0, 0, MAX_QTY) | |
| photo = form.get("photo") or "" | |
| if photo and len(photo) <= 6_000_000: | |
| STORE.set_photo(p["id"], photo) | |
| changed = True | |
| result = {"ok": True} | |
| # ----------------------------- удалить товар ------------------------- | |
| elif action == "delete": | |
| idx = to_int(form.get("index"), -1) | |
| if not (0 <= idx < len(products)): | |
| return {"ok": False, "error": "Товар не найден"} | |
| removed = products.pop(idx) | |
| STORE.drop_photo(removed["id"]) | |
| STORE.add_history("Удаление", removed["name"], 0, 0, "", "", emp_name) | |
| changed = True | |
| result = {"ok": True} | |
| # ----------------------------- приход -------------------------------- | |
| elif action == "income": | |
| idx = to_int(form.get("product_index"), -1) | |
| amount = to_int(form.get("rolls"), 0, 1, MAX_QTY) | |
| loc_id = STORE.valid_loc(form.get("loc_id")) | |
| if not (0 <= idx < len(products)): | |
| return {"ok": False, "error": "Товар не найден"} | |
| if not loc_id: | |
| return {"ok": False, "error": "Выберите склад/магазин"} | |
| if amount <= 0: | |
| return {"ok": False, "error": "Количество должно быть больше нуля"} | |
| p = products[idx] | |
| variants = p.get("variants") or [] | |
| prod_name = p["name"] | |
| if variants: | |
| v_idx = to_int(form.get("v_index"), -1) | |
| if not (0 <= v_idx < len(variants)): | |
| return {"ok": False, "error": "Выберите вариант товара"} | |
| v = variants[v_idx] | |
| v.setdefault("stocks", {}) | |
| v["stocks"][loc_id] = to_int(v["stocks"].get(loc_id), 0, 0, MAX_QTY) + amount | |
| prod_name += f" ({v['name']})" | |
| else: | |
| p.setdefault("stocks", {}) | |
| p["stocks"][loc_id] = to_int(p["stocks"].get(loc_id), 0, 0, MAX_QTY) + amount | |
| STORE.add_history("Приход", prod_name, amount, p.get("price_wholesale", 0), | |
| "", STORE.loc_name(loc_id), emp_name) | |
| changed = True | |
| result = {"ok": True, "amount": amount} | |
| # ----------------------------- перемещение --------------------------- | |
| elif action == "transfer": | |
| idx = to_int(form.get("pIndex"), -1) | |
| v_idx = to_int(form.get("vIndex"), -1) | |
| amount = to_int(form.get("amt"), 0, 0, MAX_QTY) | |
| from_loc = STORE.valid_loc(form.get("from_loc")) | |
| to_loc = STORE.valid_loc(form.get("to_loc")) | |
| if not (0 <= idx < len(products)): | |
| return {"ok": False, "error": "Товар не найден"} | |
| if not from_loc or not to_loc: | |
| return {"ok": False, "error": "Выберите склады"} | |
| if from_loc == to_loc: | |
| return {"ok": False, "error": "Откуда и куда совпадают"} | |
| if amount <= 0: | |
| return {"ok": False, "error": "Укажите количество"} | |
| p = products[idx] | |
| variants = p.get("variants") or [] | |
| prod_name = p["name"] | |
| bucket = None | |
| if variants: | |
| if not (0 <= v_idx < len(variants)): | |
| return {"ok": False, "error": "Выберите вариант товара"} | |
| bucket = variants[v_idx].setdefault("stocks", {}) | |
| prod_name += f" ({variants[v_idx]['name']})" | |
| else: | |
| bucket = p.setdefault("stocks", {}) | |
| available = to_int(bucket.get(from_loc), 0, 0, MAX_QTY) | |
| amount = min(amount, available) | |
| if amount <= 0: | |
| return {"ok": False, "error": "На складе-источнике нет остатка"} | |
| bucket[from_loc] = available - amount | |
| bucket[to_loc] = to_int(bucket.get(to_loc), 0, 0, MAX_QTY) + amount | |
| STORE.add_history("Перемещение", prod_name, amount, 0, | |
| STORE.loc_name(from_loc), STORE.loc_name(to_loc), emp_name) | |
| changed = True | |
| result = {"ok": True, "amount": amount} | |
| # ----------------------------- списание (продажа) -------------------- | |
| elif action == "checkout": | |
| try: | |
| cart = json.loads(form.get("cart") or "[]") | |
| except Exception: | |
| cart = [] | |
| if not isinstance(cart, list): | |
| cart = [] | |
| sold, errors, total = [], [], 0 | |
| for item in cart[:200]: | |
| if not isinstance(item, dict): | |
| continue | |
| idx = to_int(item.get("pIndex"), -1) | |
| v_idx = to_int(item.get("vIndex"), -1) | |
| amount = to_int(item.get("amt"), 0, 0, MAX_QTY) | |
| price = to_int(item.get("price"), 0, 0, MAX_PRICE) | |
| loc_id = STORE.valid_loc(item.get("loc")) | |
| name = clean_text(item.get("pName"), 240) or "Товар" | |
| if amount <= 0: | |
| continue | |
| if not (0 <= idx < len(products)): | |
| errors.append(f"{name}: товар не найден") | |
| continue | |
| if not loc_id: | |
| errors.append(f"{name}: не выбрана локация") | |
| continue | |
| p = products[idx] | |
| variants = p.get("variants") or [] | |
| if variants: | |
| if not (0 <= v_idx < len(variants)): | |
| errors.append(f"{name}: не выбран вариант") | |
| continue | |
| bucket = variants[v_idx].setdefault("stocks", {}) | |
| else: | |
| bucket = p.setdefault("stocks", {}) | |
| available = to_int(bucket.get(loc_id), 0, 0, MAX_QTY) | |
| amount = min(amount, available) | |
| if amount <= 0: | |
| errors.append(f"{name}: нет остатка") | |
| continue | |
| bucket[loc_id] = available - amount | |
| STORE.add_history("Продажа", name, amount, price, | |
| STORE.loc_name(loc_id), "", emp_name) | |
| sold.append({"name": name, "amt": amount, "sum": amount * price}) | |
| total += amount * price | |
| changed = bool(sold) | |
| result = {"ok": bool(sold), "sold": sold, "errors": errors, "total": total} | |
| if not sold and not errors: | |
| result["error"] = "Пустая корзина" | |
| # ----------------------------- склады / магазины --------------------- | |
| elif action == "add_loc": | |
| l_type = clean_text(form.get("l_type"), 16) | |
| l_name = clean_text(form.get("l_name"), 120) | |
| if l_type not in ("warehouses", "shops") or not l_name: | |
| return {"ok": False, "error": "Неверные данные локации"} | |
| data["settings"][l_type].append({"id": new_id("loc_"), "name": l_name}) | |
| changed = True | |
| result = {"ok": True} | |
| elif action == "del_loc": | |
| l_type = clean_text(form.get("l_type"), 16) | |
| l_id = clean_text(form.get("l_id"), 64) | |
| if l_type not in ("warehouses", "shops"): | |
| return {"ok": False, "error": "Неверный тип локации"} | |
| if len(data["settings"][l_type]) <= 1: | |
| return {"ok": False, "error": "Нельзя удалить последнюю локацию"} | |
| before = len(data["settings"][l_type]) | |
| data["settings"][l_type] = [x for x in data["settings"][l_type] if x["id"] != l_id] | |
| if len(data["settings"][l_type]) == before: | |
| return {"ok": False, "error": "Локация не найдена"} | |
| for p in products: # чистим остатки удалённой локации | |
| p["stocks"].pop(l_id, None) | |
| for v in p.get("variants") or []: | |
| v["stocks"].pop(l_id, None) | |
| for e in data["settings"]["employees"]: # отвязываем сотрудников | |
| if e["shop_id"] == l_id: | |
| e["shop_id"] = "" | |
| changed = True | |
| result = {"ok": True} | |
| # ----------------------------- сотрудники ---------------------------- | |
| elif action == "add_emp": | |
| e_name = clean_text(form.get("e_name"), 120) | |
| e_shop = STORE.valid_loc(form.get("e_shop")) | |
| if not e_name: | |
| return {"ok": False, "error": "Укажите имя сотрудника"} | |
| token = secrets.token_urlsafe(18) # криптостойкая ссылка доступа | |
| data["settings"]["employees"].append({ | |
| "id": new_id("e"), "name": e_name, "token": token, "shop_id": e_shop or "", | |
| }) | |
| changed = True | |
| result = {"ok": True, "token": token} | |
| elif action == "del_emp": | |
| e_id = clean_text(form.get("e_id"), 64) | |
| before = len(data["settings"]["employees"]) | |
| data["settings"]["employees"] = [x for x in data["settings"]["employees"] | |
| if x["id"] != e_id and x["token"] != e_id] | |
| if len(data["settings"]["employees"]) == before: | |
| return {"ok": False, "error": "Сотрудник не найден"} | |
| changed = True | |
| result = {"ok": True} | |
| if changed: | |
| STORE.touch() | |
| return result | |
| # ============================== страница ==================================== | |
| MAIN_HTML = r'''<!DOCTYPE html> | |
| <html lang="ru"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <meta name="robots" content="noindex,nofollow"> | |
| <title>Учёт товаров</title> | |
| <link rel="icon" href="data:,"> | |
| <style> | |
| :root{--bg:#f3f4f6;--surface:#fff;--primary:#4f46e5;--success:#10b981;--danger:#ef4444;--warn:#eab308;--text:#111827;--muted:#6b7280;--border:#e5e7eb} | |
| *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent} | |
| [hidden]{display:none!important} /* атрибут hidden должен побеждать любые display:flex в этом файле */ | |
| html{-webkit-text-size-adjust:100%} | |
| body{background:var(--bg);color:var(--text);font:15px/1.35 system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;padding:14px 14px 90px} | |
| .container{max-width:1200px;margin:0 auto} | |
| h1{text-align:center;font-size:1.35rem;font-weight:700;margin-bottom:18px} | |
| h3{font-size:1rem} | |
| .btn{flex:1;padding:11px;border:0;border-radius:12px;font-size:.95rem;font-weight:600;color:#fff;cursor:pointer;display:flex;justify-content:center;align-items:center;gap:8px;transition:transform .1s,opacity .2s} | |
| .btn:active{transform:scale(.98)} | |
| .btn[disabled]{opacity:.6;cursor:not-allowed} | |
| .btn-primary{background:var(--primary)} | |
| .btn-success{background:var(--success)} | |
| .btn-danger{background:var(--danger)} | |
| .btn-warn{background:var(--warn)} | |
| .top-actions{display:flex;gap:12px;margin-bottom:14px;flex-wrap:wrap} | |
| .main-nav{display:flex;gap:8px;margin-bottom:14px;overflow-x:auto;padding-bottom:4px} | |
| .nav-btn{flex:0 0 auto;padding:10px 16px;border-radius:8px;border:0;background:#e5e7eb;color:var(--muted);font-weight:600;font-size:.9rem;cursor:pointer} | |
| .nav-btn.active{background:var(--primary);color:#fff} | |
| .loc-nav{display:flex;gap:8px;margin-bottom:12px;overflow-x:auto;padding-bottom:4px} | |
| .loc-btn{flex:0 0 auto;padding:8px 14px;border-radius:20px;border:1px solid var(--border);background:var(--surface);color:var(--text);font-weight:500;font-size:.85rem;cursor:pointer;transition:background .2s,color .2s} | |
| .loc-btn.active{background:var(--text);color:#fff;border-color:var(--text)} | |
| .loc-btn.has-search-stock{border-color:var(--success);color:#065f46;background:#ecfdf5;font-weight:700} | |
| .loc-btn.has-search-stock.active{background:var(--success);color:#fff;border-color:var(--success)} | |
| .search-box{width:100%;padding:13px;border:1px solid var(--border);border-radius:12px;outline:0;font-size:1rem;margin-bottom:10px} | |
| .search-box:focus{border-color:var(--primary)} | |
| .list-meta{font-size:.8rem;color:var(--muted);margin:0 2px 10px} | |
| #catalog{display:flex;flex-direction:column;gap:10px} | |
| .product{background:var(--surface);border-radius:10px;box-shadow:0 1px 2px rgba(0,0,0,.08);padding:10px;display:flex;flex-direction:column;gap:8px;content-visibility:auto;contain-intrinsic-size:auto 120px} | |
| .p-main{display:flex;gap:12px;align-items:flex-start} | |
| .p-img-box{position:relative;width:52px;height:52px;flex:0 0 auto;background:#f9fafb;border-radius:8px;overflow:hidden;display:flex;align-items:center;justify-content:center;font-size:.55rem;color:#9ca3af;text-align:center;line-height:1.1} | |
| .p-img-box>span{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding:2px} | |
| .p-img-box.zoomable{cursor:zoom-in} | |
| .p-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block} | |
| .p-info{flex:1;min-width:0} | |
| .p-title{font-size:.95rem;font-weight:600;line-height:1.2;overflow-wrap:anywhere} | |
| .p-desc{font-size:.75rem;color:var(--muted)} | |
| .p-prices{display:flex;flex-wrap:wrap;gap:4px 14px;align-items:center;font-size:.8rem;font-weight:600;color:var(--primary);margin:2px 0} | |
| .price-pair{display:inline-flex;align-items:center;gap:4px;white-space:nowrap} | |
| .p-price-in{width:80px;padding:3px 5px;border:1px solid var(--border);border-radius:5px;font-weight:600;font-size:.8rem;text-align:right;color:var(--primary)} | |
| .p-sub{font-size:.75rem;color:var(--muted)} | |
| .p-vars{margin-top:5px;background:#f9fafb;border:1px solid var(--border);border-radius:6px;padding:6px;font-size:.78rem;color:var(--muted)} | |
| .p-actions{display:flex;flex-direction:column;gap:6px} | |
| .row{display:flex;gap:6px;align-items:center} | |
| .sell-row .btn,.adm-row .btn{padding:8px 6px;font-size:.75rem} | |
| .qty{width:64px;flex:0 0 auto;padding:7px;border:1px solid var(--border);border-radius:6px;text-align:center;font-weight:600;font-size:.85rem;outline:0} | |
| .vsel{flex:1;min-width:0;padding:7px;border:1px solid var(--border);border-radius:6px;font-size:.8rem;outline:0;background:#fff;color:var(--text)} | |
| .hint{font-size:.8rem;color:var(--muted);text-align:center;margin:2px 0} | |
| .empty{text-align:center;color:var(--muted);padding:40px 0} | |
| .more-wrap{margin-top:14px;display:flex;justify-content:center} | |
| .more-wrap .btn{max-width:320px} | |
| .hist-filters{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px} | |
| .hist-filters .form-control{flex:1;min-width:132px;margin:0} | |
| .total-sales{font-weight:700;color:var(--primary);margin-bottom:12px} | |
| .history-item{background:var(--surface);border-radius:12px;padding:14px;margin-bottom:10px;box-shadow:0 1px 2px rgba(0,0,0,.05)} | |
| .history-header{display:flex;justify-content:space-between;gap:8px;font-size:.8rem;color:var(--muted);margin-bottom:6px;flex-wrap:wrap} | |
| .history-body{display:flex;justify-content:space-between;gap:10px;align-items:flex-start} | |
| .history-prod{font-weight:600;font-size:.9rem;overflow-wrap:anywhere} | |
| .history-sum{font-weight:700;text-align:right;white-space:nowrap;font-size:.9rem} | |
| .badge{padding:3px 9px;border-radius:6px;font-size:.75rem;font-weight:600;white-space:nowrap} | |
| .badge-green{background:#d1fae5;color:#065f46} | |
| .badge-orange{background:#ffedd5;color:#9a3412} | |
| .settings-grid{display:grid;gap:14px;grid-template-columns:1fr} | |
| .card{background:var(--surface);border-radius:12px;padding:16px;box-shadow:0 1px 2px rgba(0,0,0,.05)} | |
| .card h3{margin-bottom:8px} | |
| .set-item{display:flex;flex-direction:column;gap:8px;padding:10px 0;border-bottom:1px solid var(--border)} | |
| .set-item:last-child{border-bottom:0;padding-bottom:0} | |
| .set-head{display:flex;justify-content:space-between;align-items:center;gap:8px} | |
| .set-head .btn,.set-item form .btn{flex:0 0 auto;padding:7px 12px;font-size:.82rem} | |
| .link-box{display:flex;gap:8px;align-items:center} | |
| .link-input{flex:1;min-width:0;padding:9px;border:1px solid var(--border);border-radius:8px;font-size:.82rem;background:var(--bg);color:var(--text);outline:0} | |
| .inline-form{display:flex;gap:8px;margin-top:10px} | |
| .inline-form .btn{padding:10px 18px} | |
| .overlay{display:none;position:fixed;inset:0;background:rgba(17,24,39,.6);z-index:1000;justify-content:center;align-items:flex-end} | |
| .overlay.active{display:flex} | |
| .sheet{background:var(--surface);padding:20px;border-radius:18px 18px 0 0;width:100%;max-width:620px;max-height:92vh;overflow-y:auto;position:relative;-webkit-overflow-scrolling:touch} | |
| .close{position:absolute;top:12px;right:12px;width:34px;height:34px;border:0;border-radius:17px;background:var(--bg);font-size:20px;line-height:1;cursor:pointer} | |
| .form-control{width:100%;padding:10px;border:1px solid var(--border);border-radius:8px;font-size:.95rem;outline:0;margin-bottom:10px;background:#fff;color:var(--text)} | |
| .form-control:focus{border-color:var(--primary)} | |
| textarea.form-control{min-height:64px;resize:vertical} | |
| .lbl{display:block;margin:6px 0;font-weight:600;font-size:.88rem} | |
| .lbl small{font-weight:400;color:var(--muted)} | |
| .two{display:flex;gap:8px;margin-bottom:10px} | |
| .two .form-control{margin:0} | |
| .var-row{display:flex;gap:6px;margin-bottom:8px;align-items:center} | |
| .var-row .form-control{margin:0} | |
| .var-row .v-name{flex:1;min-width:0} | |
| .var-row .v-num{width:88px;flex:0 0 auto} | |
| .var-del{border:0;background:var(--danger);color:#fff;border-radius:8px;padding:11px 12px;cursor:pointer;flex:0 0 auto;font-size:14px;line-height:1} | |
| .preview{display:none;width:100%;height:190px;object-fit:cover;border-radius:12px;margin-bottom:14px} | |
| #zoom-img{max-width:92vw;max-height:86vh;object-fit:contain;border-radius:8px;margin:auto} | |
| .loader{position:fixed;inset:0;background:rgba(255,255,255,.75);z-index:9999;display:flex;justify-content:center;align-items:center} | |
| .spinner{width:46px;height:46px;border:4px solid #e5e7eb;border-top-color:var(--primary);border-radius:50%;animation:spin 1s linear infinite} | |
| @keyframes spin{to{transform:rotate(360deg)}} | |
| .toast{position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);background:#111827;color:#fff;padding:11px 18px;border-radius:10px;font-size:.88rem;z-index:10001;opacity:0;transition:.25s;max-width:90vw;text-align:center} | |
| .toast.show{opacity:1;transform:translate(-50%,0)} | |
| .toast.err{background:var(--danger)} | |
| .foot{text-align:center;font-size:.75rem;color:var(--muted);margin-top:26px} | |
| @media(min-width:768px){ | |
| body{padding:22px 22px 60px} | |
| .top-actions{max-width:420px} | |
| .settings-grid{grid-template-columns:repeat(2,1fr)} | |
| .overlay{align-items:center} | |
| .sheet{border-radius:18px;max-width:540px} | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="loader" class="loader" hidden><div class="spinner"></div></div> | |
| <div class="container"> | |
| <h1 id="title">{{ TITLE }}</h1> | |
| <div class="top-actions" id="top-actions" hidden> | |
| <button class="btn btn-primary" data-open="mAdd" type="button">Новый товар</button> | |
| <button class="btn btn-success" data-open="mIncome" type="button">Приход</button> | |
| </div> | |
| <div class="main-nav" id="main-nav" hidden> | |
| <button class="nav-btn active" type="button" data-nav="catalog">Каталог</button> | |
| <button class="nav-btn" type="button" data-nav="reports" data-admin-only>Отчёты</button> | |
| <button class="nav-btn" type="button" data-nav="settings" data-admin-only>Настройки</button> | |
| </div> | |
| <section id="sec-catalog"> | |
| <div class="loc-nav" id="locnav"></div> | |
| <input type="text" id="search" class="search-box" placeholder="Поиск товара..." autocomplete="off"> | |
| <div class="list-meta" id="catalog-meta"></div> | |
| <div id="catalog"></div> | |
| <div id="empty" class="empty" hidden>Ничего не найдено</div> | |
| <div class="more-wrap" id="more-wrap" hidden><button class="btn btn-primary" type="button" id="more">Показать ещё</button></div> | |
| </section> | |
| <section id="sec-reports" hidden> | |
| <div class="hist-filters"> | |
| <input type="date" id="hist-from" class="form-control"> | |
| <input type="date" id="hist-to" class="form-control"> | |
| </div> | |
| <div class="hist-filters"> | |
| <select id="hist-emp" class="form-control"></select> | |
| <select id="hist-action" class="form-control"> | |
| <option value="all">Все операции</option> | |
| <option value="Продажа">Все продажи</option> | |
| <option value="Продажа|Розн">Продажи (Розница)</option> | |
| <option value="Продажа|Опт">Продажи (Опт)</option> | |
| <option value="Приход">Приходы</option> | |
| <option value="Перемещение">Перемещения</option> | |
| <option value="Удаление">Удаления</option> | |
| </select> | |
| </div> | |
| <div class="total-sales" id="total-sales"></div> | |
| <div id="history"></div> | |
| <div class="more-wrap" id="hist-more-wrap" hidden><button class="btn btn-primary" type="button" id="hist-more">Показать ещё</button></div> | |
| </section> | |
| <section id="sec-settings" hidden> | |
| <div class="settings-grid"> | |
| <div class="card"> | |
| <h3>Публичные каталоги</h3> | |
| <div class="set-item"> | |
| <div class="set-head"><span style="font-weight:600">Розничный каталог</span></div> | |
| <div class="link-box"> | |
| <input type="text" class="link-input" id="link-retail" readonly> | |
| <button class="btn btn-primary" type="button" data-copy="link-retail">Копировать</button> | |
| </div> | |
| </div> | |
| <div class="set-item"> | |
| <div class="set-head"><span style="font-weight:600">Оптовый каталог</span></div> | |
| <div class="link-box"> | |
| <input type="text" class="link-input" id="link-wholesale" readonly> | |
| <button class="btn btn-primary" type="button" data-copy="link-wholesale">Копировать</button> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h3>Склады</h3> | |
| <div id="set-wh"></div> | |
| <form class="inline-form js-post" method="POST"> | |
| <input type="hidden" name="action" value="add_loc"> | |
| <input type="hidden" name="l_type" value="warehouses"> | |
| <input type="text" name="l_name" class="form-control" style="margin:0" placeholder="Название" required> | |
| <button type="submit" class="btn btn-primary">+</button> | |
| </form> | |
| </div> | |
| <div class="card"> | |
| <h3>Магазины</h3> | |
| <div id="set-sh"></div> | |
| <form class="inline-form js-post" method="POST"> | |
| <input type="hidden" name="action" value="add_loc"> | |
| <input type="hidden" name="l_type" value="shops"> | |
| <input type="text" name="l_name" class="form-control" style="margin:0" placeholder="Название" required> | |
| <button type="submit" class="btn btn-primary">+</button> | |
| </form> | |
| </div> | |
| <div class="card"> | |
| <h3>Сотрудники</h3> | |
| <div id="set-emp"></div> | |
| <form class="js-post" method="POST" style="margin-top:10px"> | |
| <input type="hidden" name="action" value="add_emp"> | |
| <input type="text" name="e_name" class="form-control" placeholder="Имя сотрудника" required> | |
| <select name="e_shop" id="emp-shop" class="form-control" required></select> | |
| <button type="submit" class="btn btn-primary" style="width:100%">Добавить сотрудника</button> | |
| </form> | |
| </div> | |
| <div class="card"> | |
| <h3>Резервная копия</h3> | |
| <div style="font-size:.85rem;color:var(--muted)" id="backup-info"></div> | |
| </div> | |
| </div> | |
| </section> | |
| <div class="foot">Учёт товаров · <span id="foot-info"></span></div> | |
| </div> | |
| <div class="overlay" id="mZoom" data-close><img id="zoom-img" alt=""></div> | |
| <div class="overlay" id="mAdd" data-close> | |
| <div class="sheet"> | |
| <button class="close" type="button" data-close>×</button> | |
| <h3>Добавить товар</h3> | |
| <form class="js-post" method="POST" style="margin-top:14px"> | |
| <input type="hidden" name="action" value="add"> | |
| <input type="text" name="name" class="form-control" placeholder="Название" required> | |
| <textarea name="description" class="form-control" placeholder="Описание"></textarea> | |
| <div class="two"> | |
| <input type="number" name="price_retail" class="form-control" placeholder="Розничная цена (₸)" required> | |
| <input type="number" name="price_wholesale" class="form-control" placeholder="Оптовая цена (₸)" required> | |
| </div> | |
| <select name="loc_id" id="add-loc" class="form-control" required></select> | |
| <span class="lbl">Варианты товара <small>(не обязательно)</small></span> | |
| <div id="add-vars"></div> | |
| <button class="btn btn-warn" type="button" style="width:100%;margin-bottom:10px" data-addvar="add-vars">+ Добавить вариант</button> | |
| <div id="add-base-wrap"> | |
| <input type="number" name="rolls" id="add-rolls" class="form-control" placeholder="Количество при создании" value="0" required> | |
| </div> | |
| <input type="number" name="box_qty" class="form-control" placeholder="Штук в коробке" value="1" required> | |
| <span class="lbl">Фото товара <small>(не обязательно)</small></span> | |
| <input type="file" accept="image/*" class="form-control" data-photo="add"> | |
| <input type="hidden" name="photo" id="add-photo"> | |
| <img class="preview" id="add-preview" alt=""> | |
| <button type="submit" class="btn btn-primary" style="width:100%">Сохранить</button> | |
| </form> | |
| </div> | |
| </div> | |
| <div class="overlay" id="mEdit" data-close> | |
| <div class="sheet"> | |
| <button class="close" type="button" data-close>×</button> | |
| <h3>Изменить товар</h3> | |
| <form class="js-post" method="POST" style="margin-top:14px"> | |
| <input type="hidden" name="action" value="edit"> | |
| <input type="hidden" name="index" id="edit-index"> | |
| <input type="text" name="name" id="edit-name" class="form-control" placeholder="Название" required> | |
| <textarea name="description" id="edit-desc" class="form-control" placeholder="Описание"></textarea> | |
| <div class="two"> | |
| <input type="number" name="price_retail" id="edit-pr" class="form-control" placeholder="Розничная цена (₸)" required> | |
| <input type="number" name="price_wholesale" id="edit-pw" class="form-control" placeholder="Оптовая цена (₸)" required> | |
| </div> | |
| <span class="lbl">Остатки по локациям</span> | |
| <select name="edit_loc_id" id="edit-loc" class="form-control"></select> | |
| <div id="edit-base-wrap"> | |
| <input type="number" name="rolls" id="edit-rolls" class="form-control" placeholder="Общий остаток (без вариантов)"> | |
| </div> | |
| <span class="lbl">Варианты товара</span> | |
| <div id="edit-vars"></div> | |
| <button class="btn btn-warn" type="button" style="width:100%;margin-bottom:10px" data-addvar="edit-vars">+ Добавить вариант</button> | |
| <input type="number" name="box_qty" id="edit-box" class="form-control" placeholder="Штук в коробке" required> | |
| <span class="lbl">Новое фото <small>(не обязательно)</small></span> | |
| <input type="file" accept="image/*" class="form-control" data-photo="edit"> | |
| <input type="hidden" name="photo" id="edit-photo"> | |
| <img class="preview" id="edit-preview" alt=""> | |
| <button type="submit" class="btn btn-primary" style="width:100%">Сохранить изменения</button> | |
| </form> | |
| </div> | |
| </div> | |
| <div class="overlay" id="mIncome" data-close> | |
| <div class="sheet"> | |
| <button class="close" type="button" data-close>×</button> | |
| <h3>Приход</h3> | |
| <form class="js-post" method="POST" style="margin-top:14px"> | |
| <input type="hidden" name="action" value="income"> | |
| <select name="loc_id" id="inc-loc" class="form-control" required></select> | |
| <input type="text" id="inc-search" class="form-control" placeholder="Фильтр по названию товара" autocomplete="off"> | |
| <select name="product_index" id="inc-product" class="form-control" required></select> | |
| <select name="v_index" id="inc-variant" class="form-control" hidden></select> | |
| <input type="number" name="rolls" class="form-control" placeholder="Количество" min="1" required> | |
| <button type="submit" class="btn btn-success" style="width:100%">Принять</button> | |
| </form> | |
| </div> | |
| </div> | |
| <div class="overlay" id="mTransfer" data-close> | |
| <div class="sheet"> | |
| <button class="close" type="button" data-close>×</button> | |
| <h3>Перемещение</h3> | |
| <form class="js-post" method="POST" style="margin-top:14px"> | |
| <input type="hidden" name="action" value="transfer"> | |
| <input type="hidden" name="pIndex" id="tr-index"> | |
| <input type="hidden" name="vIndex" id="tr-vindex"> | |
| <input type="hidden" name="from_loc" id="tr-from"> | |
| <p id="tr-info" style="margin-bottom:12px;font-weight:600"></p> | |
| <select name="to_loc" id="tr-to" class="form-control" required></select> | |
| <input type="number" name="amt" id="tr-amt" class="form-control" placeholder="Количество" min="1" required> | |
| <button type="submit" class="btn btn-primary" style="width:100%">Переместить</button> | |
| </form> | |
| </div> | |
| </div> | |
| <form id="del-form" method="POST" hidden> | |
| <input type="hidden" name="action" value="delete"> | |
| <input type="hidden" name="index" id="del-index"> | |
| </form> | |
| <script>window.__BOOT__ = {{ BOOT|safe }};</script> | |
| <script> | |
| (function(){ | |
| 'use strict'; | |
| var B = window.__BOOT__ || {}; | |
| var ROLE = B.role || 'public'; | |
| var PT = B.pt || 'retail'; | |
| var EMP = B.emp || null; | |
| var ADMIN = ROLE === 'admin'; | |
| var STAFF = ROLE === 'admin' || ROLE === 'employee'; | |
| var PRODUCTS = B.p || []; | |
| var LOCS = B.locs || []; | |
| var EMPS = B.emps || []; | |
| var HRAW = B.h || null; | |
| var ON_EDGE = /(^|\.)hf\.space$/.test(location.hostname); | |
| /* --------------------------------- утилиты -------------------------------- */ | |
| function $(s, r){ return (r || document).querySelector(s); } | |
| function $$(s, r){ return Array.prototype.slice.call((r || document).querySelectorAll(s)); } | |
| var EMAP = {'&':'&','<':'<','>':'>','"':'"',"'":'''}; | |
| function esc(v){ return String(v == null ? '' : v).replace(/[&<>"']/g, function(c){ return EMAP[c]; }); } | |
| function num(v, d){ var n = parseInt(v, 10); return isNaN(n) ? (d || 0) : n; } | |
| function el(id){ return document.getElementById(id); } | |
| function fmt(n){ return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ' '); } | |
| var toastTimer = null; | |
| function toast(msg, isErr){ | |
| var t = el('toast-box'); | |
| if(!t){ t = document.createElement('div'); t.id = 'toast-box'; t.className = 'toast'; document.body.appendChild(t); } | |
| t.textContent = msg; | |
| t.className = 'toast' + (isErr ? ' err' : ''); | |
| setTimeout(function(){ t.classList.add('show'); }, 10); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(function(){ | |
| t.classList.remove('show'); | |
| setTimeout(function(){ t.remove(); }, 300); | |
| }, isErr ? 4000 : 2200); | |
| } | |
| function busy(on){ el('loader').hidden = !on; } | |
| function openModal(id){ var m = el(id); if(m) m.classList.add('active'); } | |
| function closeModal(id){ var m = el(id); if(m) m.classList.remove('active'); } | |
| /* --------------------------- локации и остатки ---------------------------- */ | |
| var LOC_NAME = {}, LOC_KIND = {}; | |
| LOCS.forEach(function(l){ LOC_NAME[l.i] = l.n; LOC_KIND[l.i] = l.k; }); | |
| PRODUCTS.forEach(function(p){ p._h = ((p.n || '') + ' ' + (p.d || '')).toLowerCase(); }); | |
| var activeLoc = 'all'; | |
| var viewLocs = []; | |
| function refreshViewLocs(){ | |
| if(activeLoc !== 'all'){ viewLocs = [activeLoc]; return; } | |
| if(ROLE === 'employee' && EMP && EMP.shop_id && LOC_NAME[EMP.shop_id]){ | |
| viewLocs = [EMP.shop_id]; | |
| LOCS.forEach(function(l){ if(l.k === 'w') viewLocs.push(l.i); }); | |
| } else { | |
| viewLocs = LOCS.map(function(l){ return l.i; }); | |
| } | |
| } | |
| function sumStock(st){ | |
| if(!st) return 0; | |
| var s = 0, i; | |
| for(i = 0; i < viewLocs.length; i++){ var v = st[viewLocs[i]]; if(v) s += v; } | |
| return s; | |
| } | |
| function locStock(st, loc){ return (st && st[loc]) ? st[loc] : 0; } | |
| function itemStock(p, vIdx){ return (vIdx >= 0 && p.v[vIdx]) ? sumStock(p.v[vIdx].s) : sumStock(p.s); } | |
| function totalStock(p){ | |
| if(p.v && p.v.length){ var s = 0, i; for(i = 0; i < p.v.length; i++) s += sumStock(p.v[i].s); return s; } | |
| return sumStock(p.s); | |
| } | |
| function photoUrl(p){ return p.ph ? ('/photo/' + encodeURIComponent(p.q) + '?v=' + p.ph) : ''; } | |
| function rowOf(node){ while(node && node !== document.body){ if(node.classList && node.classList.contains('product')) return node; node = node.parentNode; } return null; } | |
| function prodByRow(row){ return PRODUCTS[num(row.getAttribute('data-i'), 0)]; } | |
| /* ------------------------------- каталог ---------------------------------- */ | |
| var filtered = [], shown = 0, PAGE = 120, searchQ = ''; | |
| function buildLocNav(){ | |
| var box = el('locnav'); | |
| if(!box) return; | |
| var html = '<button class="loc-btn" type="button" data-loc="all">Все локации</button>'; | |
| LOCS.forEach(function(l){ | |
| if(ROLE === 'employee' && l.k === 's' && !(EMP && EMP.shop_id === l.i)) return; | |
| html += '<button class="loc-btn" type="button" data-loc="' + esc(l.i) + '">' + esc(l.n) + '</button>'; | |
| }); | |
| box.innerHTML = html; | |
| } | |
| function paintLocButtons(locsWithStock){ | |
| $$('.loc-btn').forEach(function(b){ | |
| var id = b.getAttribute('data-loc'); | |
| b.classList.toggle('active', id === activeLoc); | |
| b.classList.toggle('has-search-stock', !!(id !== 'all' && locsWithStock && locsWithStock[id])); | |
| }); | |
| } | |
| function setLoc(id){ | |
| if(id !== 'all' && !LOC_NAME[id]) id = 'all'; | |
| activeLoc = id; | |
| try { localStorage.setItem('activeLoc', id); } catch(e){} | |
| refreshViewLocs(); | |
| renderCatalog(); | |
| } | |
| function priceHtml(p, i){ | |
| if(STAFF){ | |
| return '<div class="p-prices">' + | |
| '<span class="price-pair">Розн: <input class="p-price-in" type="number" data-f="pr" value="' + p.r + '"> ₸</span>' + | |
| '<span class="price-pair">Опт: <input class="p-price-in" type="number" data-f="pw" value="' + p.w + '"> ₸</span>' + | |
| '</div>'; | |
| } | |
| if(PT === 'both') return '<div class="p-prices"><span class="price-pair">Розница: ' + fmt(p.r) + ' ₸</span><span class="price-pair">Опт: ' + fmt(p.w) + ' ₸</span></div>'; | |
| if(PT === 'wholesale') return '<div class="p-prices"><span class="price-pair">' + fmt(p.w) + ' ₸</span></div>'; | |
| return '<div class="p-prices"><span class="price-pair">' + fmt(p.r) + ' ₸</span></div>'; | |
| } | |
| function variantListHtml(p){ | |
| if(!(p.v && p.v.length)) return ''; | |
| var parts = [], i, st; | |
| for(i = 0; i < p.v.length; i++){ | |
| st = itemStock(p, i); | |
| if(activeLoc === 'all' || st > 0) parts.push('<div>• ' + esc(p.v[i].n) + ': ' + st + ' шт</div>'); | |
| } | |
| if(!parts.length) return ''; | |
| return '<div class="p-vars">' + parts.join('') + '</div>'; | |
| } | |
| function actionsHtml(p, i, total){ | |
| if(!STAFF) return ''; | |
| if(activeLoc === 'all'){ | |
| var h = '<div class="p-actions"><div class="hint">Выберите локацию для действий</div>'; | |
| if(ADMIN){ | |
| h += '<div class="row adm-row"><button class="btn btn-warn" type="button" data-act="edit" data-i="' + i + '">Изменить</button>' + | |
| '<button class="btn btn-danger" type="button" data-act="del" data-i="' + i + '">Удалить</button></div>'; | |
| } | |
| return h + '</div>'; | |
| } | |
| if(total <= 0) return ''; | |
| var out = '<div class="p-actions">', first = -1, k; | |
| if(p.v && p.v.length){ | |
| var opts = ''; | |
| for(k = 0; k < p.v.length; k++){ | |
| var vst = itemStock(p, k); | |
| if(vst > 0){ | |
| if(first < 0) first = k; | |
| opts += '<option value="' + k + '">' + esc(p.v[k].n) + ' (+' + fmt(p.v[k].p) + ' ₸) — ' + vst + ' шт</option>'; | |
| } | |
| } | |
| if(opts) out += '<select class="vsel" data-i="' + i + '">' + opts + '</select>'; | |
| } | |
| var max0 = first >= 0 ? itemStock(p, first) : itemStock(p, -1); | |
| out += '<div class="row sell-row">' + | |
| '<input class="qty" type="number" min="1" step="1" value="1" max="' + max0 + '" data-i="' + i + '">' + | |
| '<button class="btn btn-danger" type="button" data-act="sell" data-pt="retail" data-i="' + i + '">Списать Розн</button>' + | |
| '<button class="btn btn-danger" type="button" data-act="sell" data-pt="wholesale" data-i="' + i + '">Списать Опт</button>' + | |
| '</div>'; | |
| if(ADMIN){ | |
| out += '<div class="row adm-row">' + | |
| '<button class="btn btn-primary" type="button" data-act="transfer" data-i="' + i + '">Переместить</button>' + | |
| '<button class="btn btn-warn" type="button" data-act="edit" data-i="' + i + '">Изменить</button>' + | |
| '<button class="btn btn-danger" type="button" data-act="del" data-i="' + i + '">Удалить</button>' + | |
| '</div>'; | |
| } | |
| return out + '</div>'; | |
| } | |
| function rowHtml(i){ | |
| var p = PRODUCTS[i]; | |
| var tot = totalStock(p); | |
| var img = '<div class="p-img-box' + (p.ph ? ' zoomable' : '') + '"' + (p.ph ? ' data-act="zoom" data-i="' + i + '"' : '') + '>' + | |
| '<span>нет фото</span>' + | |
| (p.ph ? '<img class="p-img" src="' + photoUrl(p) + '" alt="" width="52" height="52" loading="lazy" decoding="async" onerror="this.style.display=\'none\'">' : '') + | |
| '</div>'; | |
| var selBox = (STAFF && activeLoc !== 'all' && p.v && p.v.length) | |
| ? ' <span data-selstock>· выбранный вариант: <b data-selqty>0</b> шт</span>' : ''; | |
| return '<div class="product" data-i="' + i + '">' + | |
| '<div class="p-main">' + img + | |
| '<div class="p-info">' + | |
| '<div class="p-title">' + esc(p.n) + '</div>' + | |
| (p.d ? '<div class="p-desc">' + esc(p.d) + '</div>' : '') + | |
| priceHtml(p, i) + | |
| '<div class="p-sub">Общий остаток: <b data-tot>' + tot + '</b> шт · в коробке: ' + (p.b || 1) + ' шт' + selBox + '</div>' + | |
| variantListHtml(p) + | |
| '</div>' + | |
| '</div>' + actionsHtml(p, i, tot) + '</div>'; | |
| } | |
| function filterProducts(){ | |
| var locsWith = searchQ ? {} : null; | |
| filtered = []; | |
| for(var i = 0; i < PRODUCTS.length; i++){ | |
| var p = PRODUCTS[i]; | |
| if(searchQ && p._h.indexOf(searchQ) === -1) continue; | |
| if(activeLoc !== 'all' && totalStock(p) <= 0) continue; | |
| if(locsWith){ | |
| if(p.v && p.v.length){ | |
| for(var k = 0; k < p.v.length; k++){ var st = p.v[k].s, key; for(key in st){ if(st[key] > 0) locsWith[key] = 1; } } | |
| } else { | |
| var s2 = p.s, key2; for(key2 in s2){ if(s2[key2] > 0) locsWith[key2] = 1; } | |
| } | |
| } | |
| filtered.push(i); | |
| } | |
| return locsWith; | |
| } | |
| function updateMeta(){ | |
| var m = el('catalog-meta'); | |
| if(m) m.textContent = 'Показано ' + Math.min(shown, filtered.length) + ' из ' + filtered.length + ' товаров' + | |
| (activeLoc !== 'all' ? ' · ' + (LOC_NAME[activeLoc] || '') : ''); | |
| } | |
| function renderMore(){ | |
| var box = el('catalog'); | |
| if(shown >= filtered.length){ el('more-wrap').hidden = true; return; } | |
| var from = shown; | |
| var end = Math.min(shown + PAGE, filtered.length); | |
| var html = ''; | |
| for(var k = from; k < end; k++) html += rowHtml(filtered[k]); | |
| shown = end; | |
| box.insertAdjacentHTML('beforeend', html); | |
| el('more-wrap').hidden = shown >= filtered.length; | |
| updateMeta(); | |
| for(var n = from; n < end; n++){ // максимум количества в новых строках | |
| var row = box.children[n]; | |
| if(!row || !STAFF || activeLoc === 'all') continue; | |
| var p = PRODUCTS[filtered[n]]; | |
| if(!p) continue; | |
| var sel = $('.vsel', row); | |
| var max = sel ? itemStock(p, num(sel.value, -1)) : itemStock(p, -1); | |
| var q = $('.qty', row); | |
| if(q) q.max = max; | |
| var d = $('[data-selqty]', row); | |
| if(d) d.textContent = max; | |
| } | |
| } | |
| function renderCatalog(){ | |
| searchQ = (el('search').value || '').trim().toLowerCase(); | |
| var locsWith = filterProducts(); | |
| el('catalog').innerHTML = ''; | |
| shown = 0; | |
| el('empty').hidden = filtered.length > 0; | |
| paintLocButtons(locsWith); | |
| renderMore(); | |
| } | |
| /* ------------------------------ действия строки --------------------------- */ | |
| function updateMax(sel){ | |
| var row = rowOf(sel), p = prodByRow(row); | |
| if(!row || !p) return; | |
| var max = itemStock(p, num(sel.value, -1)); | |
| var q = $('.qty', row); | |
| if(q){ | |
| q.max = max; | |
| if(num(q.value, 1) > max) q.value = max > 0 ? max : 1; | |
| } | |
| var d = $('[data-selqty]', row); | |
| if(d) d.textContent = max; | |
| } | |
| function sell(i, ptype){ | |
| var p = PRODUCTS[i], row = $('.product[data-i="' + i + '"]'); | |
| if(!row) return; | |
| if(activeLoc === 'all'){ toast('Выберите локацию для списания', true); return; } | |
| var sel = $('.vsel', row); | |
| var vIdx = sel ? num(sel.value, -1) : -1; | |
| var amt = num($('.qty', row).value, 0); | |
| if(amt <= 0){ toast('Неверное количество', true); return; } | |
| var stockSt = (vIdx >= 0 && p.v[vIdx]) ? p.v[vIdx].s : p.s; | |
| var max = locStock(stockSt, activeLoc); | |
| if(amt > max){ toast('Превышен остаток (доступно ' + max + ')', true); return; } | |
| var base = ptype === 'wholesale' ? p.w : p.r; | |
| var inp = $('[data-f="' + (ptype === 'wholesale' ? 'pw' : 'pr') + '"]', row); | |
| if(inp && inp.value !== ''){ var typed = num(inp.value, base); if(typed > 0) base = typed; } | |
| var v = (vIdx >= 0) ? p.v[vIdx] : null; | |
| var price = base + (v ? num(v.p, 0) : 0); | |
| var fname = p.n + (v ? ' (' + v.n + ')' : '') + (ptype === 'wholesale' ? ' [Опт]' : ' [Розн]'); | |
| if(!confirm('Списать ' + amt + ' шт. товара "' + fname + '"?\nСумма: ' + fmt(amt * price) + ' ₸')) return; | |
| var fd = new FormData(); | |
| fd.append('action', 'checkout'); | |
| fd.append('cart', JSON.stringify([{ pIndex: i, vIndex: vIdx, pName: fname, price: price, amt: amt, loc: activeLoc }])); | |
| busy(true); | |
| fetch(location.pathname, { method: 'POST', body: fd, headers: { 'X-Requested-With': 'fetch' } }) | |
| .then(function(r){ return r.json().catch(function(){ return {}; }); }) | |
| .then(function(res){ | |
| if(res && res.errors && res.errors.length) toast(res.errors.join('; '), true); | |
| if(res && res.ok){ | |
| var s = res.sold[0]; | |
| toast('Списано: ' + s.name + ' × ' + s.amt + ' = ' + fmt(s.sum) + ' ₸'); | |
| setTimeout(function(){ location.reload(); }, 600); | |
| } else { | |
| busy(false); | |
| if(!res || !res.errors || !res.errors.length) toast('Не удалось списать', true); | |
| } | |
| }) | |
| .catch(function(){ busy(false); toast('Ошибка сети', true); }); | |
| } | |
| function delProd(i){ | |
| var p = PRODUCTS[i]; | |
| if(!confirm('Удалить товар "' + p.n + '"?')) return; | |
| el('del-index').value = i; | |
| busy(true); | |
| el('del-form').submit(); | |
| } | |
| function openTransfer(i){ | |
| var row = $('.product[data-i="' + i + '"]'); | |
| var p = PRODUCTS[i]; | |
| if(!row || activeLoc === 'all') return; | |
| var sel = $('.vsel', row); | |
| var vIdx = sel ? num(sel.value, -1) : -1; | |
| var v = (vIdx >= 0) ? p.v[vIdx] : null; | |
| var max = locStock(v ? v.s : p.s, activeLoc); | |
| if(max <= 0){ toast('Нет в наличии', true); return; } | |
| el('tr-index').value = i; | |
| el('tr-vindex').value = vIdx; | |
| el('tr-from').value = activeLoc; | |
| el('tr-info').textContent = p.n + (v ? ' (' + v.n + ')' : '') + ' — из "' + (LOC_NAME[activeLoc] || '') + '", доступно ' + max + ' шт'; | |
| var amt = el('tr-amt'); | |
| amt.max = max; amt.value = 1; | |
| el('tr-to').innerHTML = LOCS.filter(function(l){ return l.i !== activeLoc; }) | |
| .map(function(l){ return '<option value="' + esc(l.i) + '">' + esc(l.n) + '</option>'; }).join(''); | |
| openModal('mTransfer'); | |
| } | |
| function zoom(i){ | |
| var p = PRODUCTS[i]; | |
| if(!p.ph) return; | |
| el('zoom-img').src = photoUrl(p); | |
| openModal('mZoom'); | |
| } | |
| function editPhotoPreview(i){ | |
| var p = PRODUCTS[i]; | |
| var img = el('edit-preview'); | |
| el('edit-photo').value = ''; | |
| if(p.ph){ img.src = photoUrl(p); img.style.display = 'block'; } | |
| else { img.removeAttribute('src'); img.style.display = 'none'; } | |
| } | |
| function loadEditStocks(){ | |
| var i = num(el('edit-index').value, -1); | |
| var p = PRODUCTS[i]; | |
| if(!p) return; | |
| var loc = el('edit-loc').value; | |
| if(p.v && p.v.length){ | |
| var inputs = $$('#edit-vars .v-stock'); | |
| p.v.forEach(function(v, k){ if(inputs[k]) inputs[k].value = locStock(v.s, loc); }); | |
| el('edit-base-wrap').style.display = 'none'; | |
| } else { | |
| el('edit-rolls').value = locStock(p.s, loc); | |
| el('edit-base-wrap').style.display = 'block'; | |
| } | |
| } | |
| function openEdit(i){ | |
| var p = PRODUCTS[i]; | |
| el('edit-index').value = i; | |
| el('edit-name').value = p.n; | |
| el('edit-desc').value = p.d || ''; | |
| el('edit-pr').value = p.r || 0; | |
| el('edit-pw').value = p.w || 0; | |
| el('edit-box').value = p.b || 1; | |
| el('edit-loc').innerHTML = LOCS.map(function(l){ return '<option value="' + esc(l.i) + '">' + esc(l.n) + '</option>'; }).join(''); | |
| var last = null; | |
| try { last = localStorage.getItem('lastFormLoc'); } catch(e){} | |
| if(activeLoc !== 'all' && LOC_NAME[activeLoc]) el('edit-loc').value = activeLoc; | |
| else if(last && LOC_NAME[last]) el('edit-loc').value = last; | |
| el('edit-vars').innerHTML = ''; | |
| (p.v || []).forEach(function(v){ addVarRow('edit-vars', v.n, v.p, ''); }); | |
| editPhotoPreview(i); | |
| loadEditStocks(); | |
| openModal('mEdit'); | |
| } | |
| /* ------------------------------ варианты в формах ------------------------- */ | |
| function addVarRow(containerId, name, price, stock){ | |
| var box = el(containerId); | |
| var div = document.createElement('div'); | |
| div.className = 'var-row'; | |
| div.innerHTML = '<input type="text" name="v_name[]" class="form-control v-name" placeholder="Название" value="' + esc(name || '') + '" required>' + | |
| '<input type="number" name="v_price[]" class="form-control v-num" placeholder="Цена" value="' + (price === '' ? '' : (price || 0)) + '" required>' + | |
| '<input type="number" name="v_stock[]" class="form-control v-num v-stock" placeholder="Остаток" value="' + (stock === '' ? '' : (stock || 0)) + '" required>' + | |
| '<button class="var-del" type="button">×</button>'; | |
| $('.var-del', div).addEventListener('click', function(){ div.remove(); syncVarBlocks(containerId); }); | |
| box.appendChild(div); | |
| syncVarBlocks(containerId); | |
| } | |
| function syncVarBlocks(containerId){ | |
| var has = el(containerId).children.length > 0; | |
| if(containerId === 'add-vars'){ | |
| el('add-base-wrap').style.display = has ? 'none' : 'block'; | |
| var r = el('add-rolls'); | |
| if(has) r.removeAttribute('required'); else r.setAttribute('required', 'required'); | |
| } else if(containerId === 'edit-vars'){ | |
| el('edit-base-wrap').style.display = has ? 'none' : 'block'; | |
| } | |
| } | |
| /* -------------------------------- картинки -------------------------------- */ | |
| function processImage(input, hiddenId, previewId){ | |
| var file = input.files && input.files[0]; | |
| if(!file) return; | |
| if(file.size > 8 * 1024 * 1024){ toast('Файл больше 8 МБ — выберите фото меньше', true); input.value = ''; return; } | |
| var reader = new FileReader(); | |
| reader.onload = function(e){ | |
| var img = new Image(); | |
| img.onload = function(){ | |
| var w = img.width, h = img.height, MAX = 640; | |
| if(w > MAX || h > MAX){ | |
| if(w > h){ h = Math.round(h * MAX / w); w = MAX; } else { w = Math.round(w * MAX / h); h = MAX; } | |
| } | |
| var cv = document.createElement('canvas'); | |
| cv.width = w; cv.height = h; | |
| cv.getContext('2d').drawImage(img, 0, 0, w, h); | |
| var b64 = cv.toDataURL('image/jpeg', 0.62); | |
| el(hiddenId).value = b64; | |
| var prev = el(previewId); | |
| prev.src = b64; prev.style.display = 'block'; | |
| }; | |
| img.onerror = function(){ toast('Не удалось прочитать изображение', true); }; | |
| img.src = e.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| /* --------------------------------- отчёты --------------------------------- */ | |
| var HIST = null, histFiltered = [], histShown = 0, HIST_PAGE = 120; | |
| function parseTs(t){ | |
| var parts = String(t || '').split(' '); | |
| if(parts.length < 2) return 0; | |
| var d = parts[0].split('.'), tm = parts[1].split(':'); | |
| if(d.length < 3) return 0; | |
| return new Date(+d[2], +d[1] - 1, +d[0], +tm[0] || 0, +tm[1] || 0, +tm[2] || 0).getTime(); | |
| } | |
| function buildHist(){ | |
| if(HIST) return HIST; | |
| HIST = (HRAW || []).map(function(r){ | |
| return { t: r[0], ts: parseTs(r[0]), a: r[1] || '', p: r[2] || '', amt: r[3] || 0, | |
| price: r[4] || 0, f: r[5] || '', to: r[6] || '', e: r[7] || '' }; | |
| }); | |
| HIST.sort(function(x, y){ return y.ts - x.ts; }); | |
| return HIST; | |
| } | |
| function isoLocal(d){ | |
| var x = new Date(d.getTime() - d.getTimezoneOffset() * 60000); | |
| return x.toISOString().slice(0, 10); | |
| } | |
| function initHistDates(){ | |
| var now = new Date(); | |
| el('hist-from').value = isoLocal(new Date(now.getFullYear(), now.getMonth(), 1)); | |
| el('hist-to').value = isoLocal(now); | |
| var sel = el('hist-emp'); | |
| var names = {}; | |
| (HRAW || []).forEach(function(r){ if(r[7]) names[r[7]] = 1; }); | |
| var html = '<option value="all">Все сотрудники</option><option value="Админ">Админ</option>'; | |
| Object.keys(names).sort().forEach(function(n){ html += '<option value="' + esc(n) + '">' + esc(n) + '</option>'; }); | |
| sel.innerHTML = html; | |
| } | |
| function histMore(){ | |
| var box = el('history'); | |
| if(histShown >= histFiltered.length){ el('hist-more-wrap').hidden = true; return; } | |
| var end = Math.min(histShown + HIST_PAGE, histFiltered.length); | |
| var html = ''; | |
| for(var k = histShown; k < end; k++){ | |
| var h = histFiltered[k]; | |
| var green = h.a.indexOf('Приход') !== -1; | |
| var loc = h.a === 'Перемещение' ? (h.f + ' ➔ ' + h.to) : (h.f || h.to); | |
| html += '<div class="history-item">' + | |
| '<div class="history-header"><span>' + esc(h.t) + ' | ' + esc(h.e) + '</span>' + | |
| '<span class="badge ' + (green ? 'badge-green' : 'badge-orange') + '">' + esc(h.a) + '</span></div>' + | |
| '<div class="history-body"><span class="history-prod">' + esc(h.p) + | |
| (loc ? '<br><small style="color:var(--muted)">' + esc(loc) + '</small>' : '') + '</span>' + | |
| '<span class="history-sum">' + h.amt + ' шт' + | |
| (h.price > 0 ? '<br><small style="color:var(--primary)">' + fmt(h.amt * h.price) + ' ₸</small>' : '') + | |
| '</span></div></div>'; | |
| } | |
| histShown = end; | |
| box.insertAdjacentHTML('beforeend', html); | |
| el('hist-more-wrap').hidden = histShown >= histFiltered.length; | |
| } | |
| function renderReports(){ | |
| buildHist(); | |
| var fromV = el('hist-from').value, toV = el('hist-to').value; | |
| var from = fromV ? new Date(fromV + 'T00:00:00').getTime() : 0; | |
| var to = toV ? new Date(toV + 'T23:59:59').getTime() : 8640000000000; | |
| var empF = el('hist-emp').value; | |
| var actF = el('hist-action').value; | |
| var parts = actF.split('|'); | |
| var total = 0, count = 0; | |
| histFiltered = []; | |
| HIST.forEach(function(h){ | |
| if(h.ts < from || h.ts > to) return; | |
| if(empF !== 'all' && h.e !== empF) return; | |
| if(parts[0] !== 'all'){ | |
| if(h.a !== parts[0]) return; | |
| if(parts[1] && h.p.indexOf('[' + parts[1] + ']') === -1) return; | |
| } | |
| if(h.a === 'Продажа'){ total += h.amt * h.price; count++; } | |
| histFiltered.push(h); | |
| }); | |
| el('history').innerHTML = ''; | |
| histShown = 0; | |
| el('total-sales').textContent = 'Итого продаж: ' + fmt(total) + ' ₸ (' + count + ' шт. позиций, ' + histFiltered.length + ' операций)'; | |
| histMore(); | |
| } | |
| /* ------------------------------- настройки -------------------------------- */ | |
| function copyText(text){ | |
| function done(){ toast('Ссылка скопирована'); } | |
| function fallback(){ | |
| var ta = document.createElement('textarea'); | |
| ta.value = text; document.body.appendChild(ta); ta.select(); | |
| try { document.execCommand('copy'); done(); } catch(e){ toast('Скопируйте вручную', true); } | |
| ta.remove(); | |
| } | |
| if(navigator.clipboard && navigator.clipboard.writeText){ | |
| navigator.clipboard.writeText(text).then(done, fallback); | |
| } else fallback(); | |
| } | |
| function buildSettings(){ | |
| var origin = location.origin; | |
| el('link-retail').value = origin + '/catalog/retail'; | |
| el('link-wholesale').value = origin + '/catalog/wholesale'; | |
| el('backup-info').innerHTML = B.hf | |
| ? 'Автовыгрузка в датасет HF включена (не чаще 1 раза в ' + Math.round({{ BACKUP_INTERVAL }} / 60) + ' мин). Кнопки «Копировать» и ссылки сотрудников работают как раньше.' | |
| : 'Автовыгрузка на Hugging Face выключена: не задан токен HF_TOKEN. Данные сохраняются только в файл на сервере.'; | |
| var w = LOCS.filter(function(l){ return l.k === 'w'; }); | |
| var s = LOCS.filter(function(l){ return l.k === 's'; }); | |
| el('set-wh').innerHTML = locItems(w, 'warehouses'); | |
| el('set-sh').innerHTML = locItems(s, 'shops'); | |
| el('set-emp').innerHTML = EMPS.length ? EMPS.map(function(e){ | |
| var link = origin + '/emp/' + e.t; | |
| return '<div class="set-item"><div class="set-head">' + | |
| '<span style="font-weight:600">' + esc(e.n) + ' <small style="color:var(--muted)">(' + esc(LOC_NAME[e.s] || 'не привязан') + ')</small></span>' + | |
| '<form class="js-post" method="POST"><input type="hidden" name="action" value="del_emp">' + | |
| '<input type="hidden" name="e_id" value="' + esc(e.i) + '">' + | |
| '<button type="submit" class="btn btn-danger">Удалить</button></form></div>' + | |
| '<div class="link-box"><input class="link-input" id="emp-link-' + esc(e.i) + '" readonly value="' + esc(link) + '">' + | |
| '<button class="btn btn-primary" type="button" data-copy="emp-link-' + esc(e.i) + '">Копировать</button></div></div>'; | |
| }).join('') : '<div style="color:var(--muted);font-size:.85rem">Сотрудников нет</div>'; | |
| el('emp-shop').innerHTML = '<option value="">-- Выберите магазин --</option>' + | |
| s.map(function(l){ return '<option value="' + esc(l.i) + '">' + esc(l.n) + '</option>'; }).join(''); | |
| } | |
| function locItems(list, type){ | |
| if(!list.length) return '<div style="color:var(--muted);font-size:.85rem">Список пуст</div>'; | |
| return list.map(function(l){ | |
| return '<div class="set-item"><div class="set-head"><span style="font-weight:600">' + esc(l.n) + '</span>' + | |
| '<form class="js-post" method="POST" data-confirm="Удалить локацию «' + esc(l.n) + '» и её остатки?">' + | |
| '<input type="hidden" name="action" value="del_loc"><input type="hidden" name="l_type" value="' + type + '">' + | |
| '<input type="hidden" name="l_id" value="' + esc(l.i) + '">' + | |
| '<button type="submit" class="btn btn-danger">Удалить</button></form></div></div>'; | |
| }).join(''); | |
| } | |
| function buildLocSelects(){ | |
| var opts = LOCS.map(function(l){ return '<option value="' + esc(l.i) + '">' + esc(l.n) + '</option>'; }).join(''); | |
| var a = el('add-loc'), i = el('inc-loc'); | |
| if(a) a.innerHTML = opts; | |
| if(i) i.innerHTML = opts; | |
| var last = null; | |
| try { last = localStorage.getItem('lastFormLoc'); } catch(e){} | |
| if(last && LOC_NAME[last]){ | |
| if(a) a.value = last; | |
| if(i) i.value = last; | |
| } | |
| } | |
| function buildIncProducts(){ | |
| var sel = el('inc-product'); | |
| sel.innerHTML = '<option value="">-- Товар --</option>' + PRODUCTS.map(function(p, i){ | |
| return '<option value="' + i + '">' + esc(p.n) + '</option>'; | |
| }).join(''); | |
| } | |
| function filterIncProducts(){ | |
| var q = (el('inc-search').value || '').trim().toLowerCase(); | |
| var sel = el('inc-product'); | |
| var html = '<option value="">-- Товар --</option>'; | |
| PRODUCTS.forEach(function(p, i){ if(!q || p._h.indexOf(q) !== -1) html += '<option value="' + i + '">' + esc(p.n) + '</option>'; }); | |
| sel.innerHTML = html; | |
| updateIncVariants(); | |
| } | |
| function updateIncVariants(){ | |
| var sel = el('inc-product'), vsel = el('inc-variant'); | |
| var i = num(sel.value, -1); | |
| var p = PRODUCTS[i]; | |
| if(!p || !(p.v && p.v.length)){ | |
| vsel.innerHTML = ''; vsel.hidden = true; vsel.removeAttribute('required'); | |
| return; | |
| } | |
| vsel.innerHTML = p.v.map(function(v, k){ return '<option value="' + k + '">' + esc(v.n) + '</option>'; }).join(''); | |
| vsel.hidden = false; vsel.setAttribute('required', 'required'); | |
| } | |
| function switchNav(nav){ | |
| ['catalog', 'reports', 'settings'].forEach(function(id){ | |
| var s = el('sec-' + id); | |
| if(s) s.hidden = (id !== nav); | |
| }); | |
| $$('.nav-btn').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-nav') === nav); }); | |
| if(nav === 'reports') renderReports(); | |
| } | |
| /* ------------------------------- инициализация ----------------------------- */ | |
| var io = null; | |
| function init(){ | |
| if(ADMIN){ el('top-actions').hidden = false; } | |
| if(STAFF){ | |
| el('main-nav').hidden = false; | |
| if(!ADMIN) $$('[data-admin-only]').forEach(function(b){ b.remove(); }); | |
| } | |
| buildLocNav(); | |
| var saved = null; | |
| try { saved = localStorage.getItem('activeLoc'); } catch(e){} | |
| if(!saved || (saved !== 'all' && !LOC_NAME[saved])) saved = 'all'; | |
| activeLoc = saved; | |
| refreshViewLocs(); | |
| el('foot-info').textContent = PRODUCTS.length + ' товаров · ' + LOCS.length + ' локаций' + (ON_EDGE ? ' · Hugging Face Space' : ''); | |
| if(ADMIN){ | |
| buildSettings(); buildLocSelects(); buildIncProducts(); buildEmpSelectInit(); initHistDates(); | |
| } | |
| renderCatalog(); | |
| paintLocButtons(null); | |
| if(window.IntersectionObserver){ | |
| io = new IntersectionObserver(function(entries){ | |
| if(entries[0].isIntersecting && !el('more-wrap').hidden) renderMore(); | |
| }, { rootMargin: '800px' }); | |
| io.observe(el('more-wrap')); | |
| } | |
| } | |
| function buildEmpSelectInit(){ /* отчёты: селект сотрудников строится в initHistDates */ } | |
| /* --------------------------------- события -------------------------------- */ | |
| document.addEventListener('click', function(e){ | |
| var t = e.target; | |
| var openBtn = t.closest ? t.closest('[data-open]') : null; | |
| if(openBtn){ openModal(openBtn.getAttribute('data-open')); return; } | |
| var copyBtn = t.closest ? t.closest('[data-copy]') : null; | |
| if(copyBtn){ | |
| var src = el(copyBtn.getAttribute('data-copy')); | |
| if(src){ if(src.select) src.select(); copyText(src.value); } | |
| return; | |
| } | |
| var varBtn = t.closest ? t.closest('[data-addvar]') : null; | |
| if(varBtn){ addVarRow(varBtn.getAttribute('data-addvar'), '', '', 0); return; } | |
| var navBtn = t.closest ? t.closest('[data-nav]') : null; | |
| if(navBtn){ switchNav(navBtn.getAttribute('data-nav')); return; } | |
| var locBtn = t.closest ? t.closest('.loc-btn') : null; | |
| if(locBtn){ setLoc(locBtn.getAttribute('data-loc')); return; } | |
| if(t.classList && t.classList.contains('overlay') && t.hasAttribute('data-close')){ t.classList.remove('active'); return; } | |
| if(t.closest && t.closest('[data-close]') && !t.closest('.sheet')){ t.closest('.overlay').classList.remove('active'); return; } | |
| if(t.closest && t.closest('button.close')){ t.closest('.overlay').classList.remove('active'); return; } | |
| var act = t.closest ? t.closest('[data-act]') : null; | |
| if(!act) return; | |
| var i = num(act.getAttribute('data-i'), -1); | |
| var kind = act.getAttribute('data-act'); | |
| if(kind === 'sell') sell(i, act.getAttribute('data-pt')); | |
| else if(kind === 'edit') openEdit(i); | |
| else if(kind === 'del') delProd(i); | |
| else if(kind === 'transfer') openTransfer(i); | |
| else if(kind === 'zoom') zoom(i); | |
| }); | |
| document.addEventListener('change', function(e){ | |
| var t = e.target; | |
| if(t.classList && t.classList.contains('vsel')) { updateMax(t); e.stopPropagation(); } | |
| }); | |
| document.addEventListener('submit', function(e){ | |
| var f = e.target; | |
| if(!f.classList || !f.classList.contains('js-post')) return; | |
| if(f.dataset.busy === '1'){ e.preventDefault(); return; } | |
| if(f.dataset.confirm && !confirm(f.dataset.confirm)){ e.preventDefault(); return; } | |
| f.dataset.busy = '1'; | |
| busy(true); | |
| }); | |
| var debounce = null, histDebounce = null; | |
| function wireEvents(){ | |
| // при возврате на страницу из кэша браузера (кнопка «назад») спиннер мог остаться | |
| // висеть с прошлого визита — принудительно скрываем | |
| window.addEventListener('pageshow', function(){ busy(false); }); | |
| var s = el('search'); | |
| if(s) s.addEventListener('input', function(){ | |
| clearTimeout(debounce); | |
| debounce = setTimeout(renderCatalog, 180); | |
| }); | |
| el('more').addEventListener('click', renderMore); | |
| el('hist-more').addEventListener('click', histMore); | |
| ['hist-from', 'hist-to'].forEach(function(id){ el(id).addEventListener('change', renderReports); }); | |
| el('hist-emp').addEventListener('change', renderReports); | |
| el('hist-action').addEventListener('change', renderReports); | |
| if(ADMIN){ | |
| ['add-loc', 'inc-loc'].forEach(function(id){ | |
| el(id).addEventListener('change', function(){ | |
| try { localStorage.setItem('lastFormLoc', this.value); } catch(err){} | |
| }); | |
| }); | |
| el('edit-loc').addEventListener('change', function(){ | |
| try { localStorage.setItem('lastFormLoc', this.value); } catch(err){} | |
| loadEditStocks(); | |
| }); | |
| el('inc-search').addEventListener('input', function(){ | |
| clearTimeout(histDebounce); | |
| histDebounce = setTimeout(filterIncProducts, 150); | |
| }); | |
| el('inc-product').addEventListener('change', updateIncVariants); | |
| $$('[data-photo]').forEach(function(inp){ | |
| inp.addEventListener('change', function(){ | |
| processImage(this, this.getAttribute('data-photo') + '-photo', this.getAttribute('data-photo') + '-preview'); | |
| }); | |
| }); | |
| } | |
| } | |
| init(); | |
| wireEvents(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |
| ''' | |
| # ============================== Flask-приложение ============================= | |
| APP = Flask(__name__) | |
| APP.config.update( | |
| MAX_CONTENT_LENGTH=24 * 1024 * 1024, # фото в base64 + поля формы | |
| TEMPLATES_AUTO_RELOAD=False, | |
| JSON_SORT_KEYS=False, | |
| ) | |
| try: # Flask >= 2.2 | |
| APP.json.ensure_ascii = False | |
| except Exception: # pragma: no cover | |
| APP.config["JSON_AS_ASCII"] = False | |
| APP.wsgi_app = ProxyFix(APP.wsgi_app, x_for=1, x_proto=1, x_host=1) | |
| _TPL = None | |
| def get_template(): | |
| """Шаблон компилируется один раз (render_template_string компилирует каждый раз).""" | |
| global _TPL | |
| if _TPL is None: | |
| _TPL = APP.jinja_env.from_string(MAIN_HTML) | |
| return _TPL | |
| def wants_json() -> bool: | |
| return request.headers.get("X-Requested-With") == "fetch" | |
| def render_panel(role: str, emp: dict | None, price_type: str, title: str): | |
| summary, rev = STORE.summary(role, emp, price_type) | |
| boot = json.dumps(summary, ensure_ascii=False, separators=(",", ":")) | |
| # '<' экранируем, чтобы в скрипт нельзя было вставить '</script>' (XSS) | |
| boot = boot.replace("<", "\\u003c").replace("\u2028", "\\u2028").replace("\u2029", "\\u2029") | |
| html = get_template().render(BOOT=boot, TITLE=title, BACKUP_INTERVAL=BACKUP_INTERVAL) | |
| resp = make_response(html) | |
| resp.headers["Cache-Control"] = "no-cache" # браузер спрашивает «не изменилось?» | |
| resp.set_etag(f"v{rev}-{role}-{price_type}-{emp['id'] if emp else '-'}") | |
| return resp.make_conditional(request) # ответит 304, если данные те же | |
| def post_response(result: dict, back: str): | |
| if wants_json(): | |
| return jsonify(result), (200 if result.get("ok") else 400) | |
| if not result.get("ok"): | |
| err = (result.get("error") or "Не удалось выполнить действие") | |
| back = f"{back}{'&' if '?' in back else '?'}err={quote(err)}" | |
| return redirect(back, 303) # PRG: перезагрузка без повторной отправки | |
| # --------------------------------- роуты ------------------------------------ | |
| def root(): | |
| return redirect(url_for("catalog_retail")) | |
| def catalog_retail(): | |
| return render_panel("public", None, "retail", "Розничный каталог") | |
| def catalog_wholesale(): | |
| return render_panel("public", None, "wholesale", "Оптовый каталог") | |
| def admin_panel(): | |
| if request.method == "POST": | |
| result = apply_action(request.form, "admin", None) | |
| return post_response(result, url_for("admin_panel")) | |
| return render_panel("admin", None, "both", "Админ панель") | |
| def employee_panel(token: str): | |
| emp = next((e for e in STORE.data["settings"]["employees"] if e["token"] == token), None) | |
| if not emp: | |
| return render_template_string(ERROR_HTML, code=403, | |
| msg="Ссылка недействительна или сотрудник удалён"), 403 | |
| if request.method == "POST": | |
| result = apply_action(request.form, "employee", emp) | |
| return post_response(result, url_for("employee_panel", token=token)) | |
| return render_panel("employee", emp, "both", "Панель сотрудника") | |
| def photo(pid: str): | |
| """Отдаёт фото товара из памяти. Так 46 МБ картинок не летят в HTML.""" | |
| found, ver = STORE.get_photo(pid) | |
| if not found: | |
| return Response(status=404) | |
| raw, mime = found | |
| resp = Response(raw, mimetype=mime) | |
| resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" | |
| resp.set_etag(ver) | |
| return resp.make_conditional(request) | |
| def healthz(): | |
| return jsonify(ok=True, products=len(STORE.data["products"]), | |
| history=len(STORE.data["history"]), rev=STORE.rev, | |
| hf=STORE.hf_enabled, backup_file=os.path.basename(DATA_FILE)) | |
| # ------------------------------- сжатие ответов ----------------------------- | |
| def compress_and_secure(resp): | |
| resp.headers["X-Content-Type-Options"] = "nosniff" | |
| resp.headers["Referrer-Policy"] = "same-origin" | |
| if resp.status_code < 300 and not resp.direct_passthrough \ | |
| and not resp.headers.get("Content-Encoding") \ | |
| and "gzip" in (request.headers.get("Accept-Encoding") or "").lower() \ | |
| and resp.mimetype in ("text/html", "application/json", "text/css", | |
| "application/javascript", "text/plain"): | |
| data = resp.get_data() | |
| if len(data) >= 1024: | |
| resp.set_data(gzip.compress(data, 5)) | |
| resp.headers["Content-Encoding"] = "gzip" | |
| resp.headers["Content-Length"] = str(len(resp.get_data())) | |
| resp.headers.add("Vary", "Accept-Encoding") | |
| return resp | |
| # ------------------------------- страницы ошибок ---------------------------- | |
| ERROR_HTML = """<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"><title>{{ code }}</title> | |
| <style>body{font-family:system-ui,Arial,sans-serif;background:#f3f4f6;display:flex;min-height:100vh; | |
| align-items:center;justify-content:center;margin:0;text-align:center}h1{font-size:2.4rem;margin:0 0 8px} | |
| p{color:#6b7280;margin:0 0 18px}a{display:inline-block;padding:10px 18px;background:#4f46e5;color:#fff; | |
| text-decoration:none;border-radius:10px;font-weight:600}</style></head><body><div> | |
| <h1>{{ code }}</h1><p>{{ msg }}</p><a href="/catalog/retail">В каталог</a></div></body></html>""" | |
| def on_404(_e): | |
| if request.path.startswith("/photo/"): | |
| return Response(status=404) | |
| return render_template_string(ERROR_HTML, code=404, msg="Страница не найдена"), 404 | |
| def on_403(_e): | |
| return render_template_string(ERROR_HTML, code=403, msg="Доступ запрещён"), 403 | |
| def on_413(_e): | |
| return render_template_string(ERROR_HTML, code=413, | |
| msg="Слишком большой запрос (фото больше 8 МБ)"), 413 | |
| def on_500(err): | |
| log.exception("Внутренняя ошибка: %s", err) | |
| return render_template_string(ERROR_HTML, code=500, msg="Внутренняя ошибка сервера"), 500 | |
| # ---------------------------------- запуск ---------------------------------- | |
| STORE.load() | |
| STORE.start() | |
| def print_banner() -> None: | |
| log.info("=" * 62) | |
| log.info("Учёт товаров запущен: http://0.0.0.0:%d", PORT) | |
| log.info("Админка: /admin Розница: /catalog/retail Опт: /catalog/wholesale") | |
| log.info("Товаров: %d | фото: %d | история: %d", | |
| len(STORE.data["products"]), | |
| sum(1 for v in STORE.photo_ver.values()), | |
| len(STORE.data["history"])) | |
| if STORE.hf_enabled: | |
| log.info("Бэкап на HF включён (%s, не чаще 1 раза в %d c)", REPO_ID, BACKUP_INTERVAL) | |
| else: | |
| log.warning("Бэкап на HF выключен: не задан HF_TOKEN/HF_TOKEN_READ.") | |
| log.info("=" * 62) | |
| print_banner() | |
| if __name__ == "__main__": | |
| APP.run(host="0.0.0.0", port=PORT, debug=False, threaded=True) | |