Optom / app.py
Kgshop's picture
Rename app (17).py to app.py
5e28f18 verified
Raw History Blame Contribute Delete
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>&times;</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>&times;</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>&times;</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>&times;</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 = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
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">&times;</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: перезагрузка без повторной отправки
# --------------------------------- роуты ------------------------------------
@APP.get("/")
def root():
return redirect(url_for("catalog_retail"))
@APP.get("/catalog/retail")
def catalog_retail():
return render_panel("public", None, "retail", "Розничный каталог")
@APP.get("/catalog/wholesale")
def catalog_wholesale():
return render_panel("public", None, "wholesale", "Оптовый каталог")
@APP.route("/admin", methods=["GET", "POST"])
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", "Админ панель")
@APP.route("/emp/<token>", methods=["GET", "POST"])
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", "Панель сотрудника")
@APP.get("/photo/<pid>")
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)
@APP.get("/healthz")
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))
# ------------------------------- сжатие ответов -----------------------------
@APP.after_request
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>"""
@APP.errorhandler(404)
def on_404(_e):
if request.path.startswith("/photo/"):
return Response(status=404)
return render_template_string(ERROR_HTML, code=404, msg="Страница не найдена"), 404
@APP.errorhandler(403)
def on_403(_e):
return render_template_string(ERROR_HTML, code=403, msg="Доступ запрещён"), 403
@APP.errorhandler(413)
def on_413(_e):
return render_template_string(ERROR_HTML, code=413,
msg="Слишком большой запрос (фото больше 8 МБ)"), 413
@APP.errorhandler(500)
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)