466 lines
18 KiB
Python
466 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Синхронизация прайса сайта с Excel.
|
|
|
|
Как пользоваться:
|
|
1. Откройте prices.xlsx и правьте таблицу на листе "Цены"
|
|
2. Запустите файл "обновить-цены.bat" (двойной клик)
|
|
3. Залейте на хостинг обновлённый prices-data.js
|
|
|
|
Колонки Excel:
|
|
A Раздел | B Подкатегория | C Название | D Цена (число) | E Ед.изм | F Кол-во | G Показывать (Да/Нет)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
import openpyxl
|
|
from openpyxl.styles import Alignment, Font, PatternFill, Border, Side
|
|
from openpyxl.utils import get_column_letter
|
|
from openpyxl.worksheet.datavalidation import DataValidation
|
|
except ImportError:
|
|
print("Нужна библиотека openpyxl. Установите: pip install openpyxl")
|
|
sys.exit(1)
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
XLSX_PATH = ROOT / "prices.xlsx"
|
|
JS_PATH = ROOT / "prices-data.js"
|
|
|
|
YES_VALUES = {"да", "yes", "y", "1", "true", "+", "д"}
|
|
|
|
|
|
def js_str(value: str) -> str:
|
|
return json.dumps(value, ensure_ascii=False)
|
|
|
|
|
|
def parse_prices_js(text: str) -> list[dict]:
|
|
"""Parse controlled prices-data.js format into nested Python structure."""
|
|
cats: list[dict] = []
|
|
|
|
# Top-level categories: either with children or with items
|
|
# Split by top-level objects roughly via brace matching after window assignment
|
|
body = re.sub(r"^[\s\S]*?window\.PRICES_DATA\s*=\s*", "", text.strip())
|
|
body = re.sub(r";\s*$", "", body).strip()
|
|
if not (body.startswith("[") and body.endswith("]")):
|
|
raise ValueError("Некорректный формат prices-data.js")
|
|
|
|
# Tokenize top-level category objects by scanning braces
|
|
inner = body[1:-1]
|
|
objects: list[str] = []
|
|
depth = 0
|
|
start = None
|
|
for i, ch in enumerate(inner):
|
|
if ch == "{":
|
|
if depth == 0:
|
|
start = i
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0 and start is not None:
|
|
objects.append(inner[start : i + 1])
|
|
start = None
|
|
|
|
def parse_items(blob: str) -> list[dict]:
|
|
items = []
|
|
for item in re.finditer(
|
|
r'\{\s*name:\s*("(?:\\.|[^"\\])*")\s*,\s*price:\s*("(?:\\.|[^"\\])*")\s*,\s*amount:\s*("(?:\\.|[^"\\])*")\s*,\s*unit:\s*("(?:\\.|[^"\\])*")\s*\}',
|
|
blob,
|
|
):
|
|
items.append(
|
|
{
|
|
"name": json.loads(item.group(1)),
|
|
"price": json.loads(item.group(2)),
|
|
"amount": json.loads(item.group(3)),
|
|
"unit": json.loads(item.group(4)),
|
|
}
|
|
)
|
|
return items
|
|
|
|
for obj in objects:
|
|
title_m = re.search(r'title:\s*("(?:\\.|[^"\\])*")', obj)
|
|
if not title_m:
|
|
continue
|
|
title = json.loads(title_m.group(1))
|
|
children_m = re.search(r"children:\s*\[([\s\S]*)\]\s*$", obj.strip().rstrip("}"))
|
|
# Better: find children array bounds
|
|
children_idx = obj.find("children:")
|
|
|
|
if children_idx != -1:
|
|
ch_start = obj.find("[", children_idx)
|
|
# find matching closing bracket for children array
|
|
depth = 0
|
|
ch_end = None
|
|
for i in range(ch_start, len(obj)):
|
|
if obj[i] == "[":
|
|
depth += 1
|
|
elif obj[i] == "]":
|
|
depth -= 1
|
|
if depth == 0:
|
|
ch_end = i
|
|
break
|
|
children_blob = obj[ch_start + 1 : ch_end] if ch_end else ""
|
|
children = []
|
|
# child objects
|
|
depth = 0
|
|
start = None
|
|
for i, ch in enumerate(children_blob):
|
|
if ch == "{":
|
|
if depth == 0:
|
|
start = i
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0 and start is not None:
|
|
child_obj = children_blob[start : i + 1]
|
|
ctitle_m = re.search(r'title:\s*("(?:\\.|[^"\\])*")', child_obj)
|
|
if not ctitle_m:
|
|
continue
|
|
children.append(
|
|
{
|
|
"title": json.loads(ctitle_m.group(1)),
|
|
"items": parse_items(child_obj),
|
|
}
|
|
)
|
|
start = None
|
|
cats.append({"title": title, "children": children})
|
|
else:
|
|
cats.append({"title": title, "items": parse_items(obj)})
|
|
|
|
return cats
|
|
|
|
|
|
def fmt_price_number(value) -> str:
|
|
try:
|
|
num = float(value)
|
|
if num.is_integer():
|
|
num = int(num)
|
|
return f"{num:,}".replace(",", " ")
|
|
except Exception:
|
|
return str(value).strip()
|
|
|
|
|
|
def extract_number_from_price(price: str):
|
|
"""'от 1 200 ₽/м²' -> 1200"""
|
|
if price is None:
|
|
return ""
|
|
if isinstance(price, (int, float)):
|
|
return price
|
|
s = str(price)
|
|
s = re.sub(r"^от\s+", "", s, flags=re.I)
|
|
s = re.sub(r"\s*₽.*$", "", s)
|
|
s = s.replace(" ", "").replace("\u00a0", "")
|
|
s = s.replace(",", ".")
|
|
try:
|
|
num = float(s)
|
|
return int(num) if num.is_integer() else num
|
|
except Exception:
|
|
return s
|
|
|
|
|
|
def build_price_string(number, unit: str) -> str:
|
|
unit = (unit or "").strip()
|
|
num = fmt_price_number(number)
|
|
if unit and unit != "—":
|
|
return f"от {num} ₽/{unit}"
|
|
return f"от {num} ₽"
|
|
|
|
|
|
def flatten_for_excel(data: list[dict]) -> list[dict]:
|
|
rows = []
|
|
for cat in data:
|
|
section = cat.get("title", "")
|
|
children = cat.get("children") or []
|
|
if children:
|
|
for child in children:
|
|
sub = child.get("title", "")
|
|
for item in child.get("items") or []:
|
|
rows.append(
|
|
{
|
|
"section": section,
|
|
"subcategory": sub,
|
|
"name": item.get("name", ""),
|
|
"price": extract_number_from_price(item.get("price", "")),
|
|
"unit": item.get("unit", ""),
|
|
"amount": item.get("amount", "1"),
|
|
"show": "Да",
|
|
}
|
|
)
|
|
else:
|
|
for item in cat.get("items") or []:
|
|
rows.append(
|
|
{
|
|
"section": section,
|
|
"subcategory": "",
|
|
"name": item.get("name", ""),
|
|
"price": extract_number_from_price(item.get("price", "")),
|
|
"unit": item.get("unit", ""),
|
|
"amount": item.get("amount", "1"),
|
|
"show": "Да",
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
def export_xlsx_from_js() -> None:
|
|
if not JS_PATH.exists():
|
|
print(f"Не найден {JS_PATH.name}")
|
|
sys.exit(1)
|
|
|
|
data = parse_prices_js(JS_PATH.read_text(encoding="utf-8"))
|
|
rows = flatten_for_excel(data)
|
|
|
|
wb = openpyxl.Workbook()
|
|
ws = wb.active
|
|
ws.title = "Цены"
|
|
|
|
headers = ["Раздел", "Подкатегория", "Название работы", "Цена", "Ед.изм", "Кол-во", "Показывать"]
|
|
header_fill = PatternFill("solid", fgColor="1F4E79")
|
|
header_font = Font(color="FFFFFF", bold=True)
|
|
thin = Border(
|
|
left=Side(style="thin", color="BDD7EE"),
|
|
right=Side(style="thin", color="BDD7EE"),
|
|
top=Side(style="thin", color="BDD7EE"),
|
|
bottom=Side(style="thin", color="BDD7EE"),
|
|
)
|
|
|
|
for col, title in enumerate(headers, start=1):
|
|
cell = ws.cell(1, col, title)
|
|
cell.fill = header_fill
|
|
cell.font = header_font
|
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
|
|
for i, row in enumerate(rows, start=2):
|
|
values = [
|
|
row["section"],
|
|
row["subcategory"],
|
|
row["name"],
|
|
row["price"],
|
|
row["unit"],
|
|
row["amount"],
|
|
row["show"],
|
|
]
|
|
for col, value in enumerate(values, start=1):
|
|
cell = ws.cell(i, col, value)
|
|
cell.border = thin
|
|
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
|
if col == 4:
|
|
cell.alignment = Alignment(horizontal="right", vertical="center")
|
|
|
|
widths = [34, 18, 70, 12, 10, 10, 12]
|
|
for i, width in enumerate(widths, start=1):
|
|
ws.column_dimensions[get_column_letter(i)].width = width
|
|
|
|
ws.freeze_panes = "A2"
|
|
ws.auto_filter.ref = f"A1:G{len(rows) + 1}"
|
|
|
|
dv = DataValidation(type="list", formula1='"Да,Нет"', allow_blank=False)
|
|
dv.error = "Выберите Да или Нет"
|
|
dv.errorTitle = "Показывать"
|
|
ws.add_data_validation(dv)
|
|
dv.add(f"G2:G{max(2, len(rows) + 200)}")
|
|
|
|
# Instruction sheet
|
|
info = wb.create_sheet("Инструкция", 0)
|
|
info["A1"] = "Как обновлять цены на сайте"
|
|
info["A1"].font = Font(bold=True, size=14)
|
|
lines = [
|
|
"",
|
|
"1) Редактируйте только лист «Цены».",
|
|
"2) Можно менять названия, цены, единицы, количество.",
|
|
"3) Чтобы временно скрыть позицию с сайта — поставьте в колонке «Показывать» значение «Нет».",
|
|
"4) Чтобы удалить позицию навсегда — удалите строку.",
|
|
"5) Чтобы добавить новую позицию — добавьте новую строку в конец таблицы.",
|
|
"6) Для разделов с подкатегориями заполняйте и «Раздел», и «Подкатегория»",
|
|
" (например: Раздел = Демонтажные работы, Подкатегория = Полы).",
|
|
"7) Для разделов без подкатегорий оставьте «Подкатегория» пустой",
|
|
" (например: Электромонтажные работы).",
|
|
"8) В колонке «Цена» пишите только число, например: 650",
|
|
" На сайте автоматически станет: от 650 ₽/шт (если ед.изм = шт).",
|
|
"9) После правок закройте Excel и запустите файл «обновить-цены.bat».",
|
|
"10) На хостинг REG.RU залейте обновлённый файл prices-data.js",
|
|
" (и при желании сам prices.xlsx — для вашего удобного хранения).",
|
|
"",
|
|
"Важно: сайт сам Excel не читает. Сначала bat обновляет prices-data.js, потом этот файл идёт на сайт.",
|
|
]
|
|
for idx, line in enumerate(lines, start=2):
|
|
info[f"A{idx}"] = line
|
|
info.column_dimensions["A"].width = 110
|
|
|
|
wb.save(XLSX_PATH)
|
|
print(f"Создан/обновлён Excel: {XLSX_PATH.name} ({len(rows)} строк)")
|
|
|
|
|
|
def nest_from_rows(rows: list[dict]) -> list[dict]:
|
|
"""Build nested PRICES_DATA from flat excel rows, preserving order."""
|
|
result: list[dict] = []
|
|
section_map: dict[str, dict] = {}
|
|
child_map: dict[tuple[str, str], dict] = {}
|
|
|
|
for row in rows:
|
|
section = row["section"]
|
|
sub = row["subcategory"]
|
|
item = {
|
|
"name": row["name"],
|
|
"price": build_price_string(row["price"], row["unit"]),
|
|
"amount": str(row["amount"] or "1"),
|
|
"unit": row["unit"] or "—",
|
|
}
|
|
|
|
if section not in section_map:
|
|
section_obj = {"title": section}
|
|
section_map[section] = section_obj
|
|
result.append(section_obj)
|
|
|
|
section_obj = section_map[section]
|
|
|
|
if sub:
|
|
key = (section, sub)
|
|
if key not in child_map:
|
|
if "children" not in section_obj:
|
|
section_obj["children"] = []
|
|
child = {"title": sub, "items": []}
|
|
child_map[key] = child
|
|
section_obj["children"].append(child)
|
|
child_map[key]["items"].append(item)
|
|
else:
|
|
if "items" not in section_obj:
|
|
section_obj["items"] = []
|
|
section_obj["items"].append(item)
|
|
|
|
# Cleanup: if a section has children, drop empty items key
|
|
for section_obj in result:
|
|
if section_obj.get("children"):
|
|
section_obj.pop("items", None)
|
|
elif "items" not in section_obj:
|
|
section_obj["items"] = []
|
|
|
|
return result
|
|
|
|
|
|
def write_prices_js(data: list[dict]) -> None:
|
|
parts = ["window.PRICES_DATA = ["]
|
|
for ci, cat in enumerate(data):
|
|
parts.append(" {")
|
|
parts.append(f" title: {js_str(cat['title'])},")
|
|
if cat.get("children"):
|
|
parts.append(" children: [")
|
|
for si, sub in enumerate(cat["children"]):
|
|
parts.append(" {")
|
|
parts.append(f" title: {js_str(sub['title'])},")
|
|
parts.append(" items: [")
|
|
for ii, item in enumerate(sub["items"]):
|
|
comma = "," if ii < len(sub["items"]) - 1 else ""
|
|
parts.append(
|
|
" { "
|
|
f"name: {js_str(item['name'])}, "
|
|
f"price: {js_str(item['price'])}, "
|
|
f"amount: {js_str(item['amount'])}, "
|
|
f"unit: {js_str(item['unit'])} "
|
|
f"}}{comma}"
|
|
)
|
|
parts.append(" ]")
|
|
parts.append(" }" + ("," if si < len(cat["children"]) - 1 else ""))
|
|
parts.append(" ]")
|
|
else:
|
|
parts.append(" items: [")
|
|
for ii, item in enumerate(cat.get("items") or []):
|
|
comma = "," if ii < len(cat["items"]) - 1 else ""
|
|
parts.append(
|
|
" { "
|
|
f"name: {js_str(item['name'])}, "
|
|
f"price: {js_str(item['price'])}, "
|
|
f"amount: {js_str(item['amount'])}, "
|
|
f"unit: {js_str(item['unit'])} "
|
|
f"}}{comma}"
|
|
)
|
|
parts.append(" ]")
|
|
parts.append(" }" + ("," if ci < len(data) - 1 else ""))
|
|
parts.append("];")
|
|
parts.append("")
|
|
JS_PATH.write_text("\n".join(parts), encoding="utf-8")
|
|
|
|
|
|
def import_xlsx_to_js() -> None:
|
|
if not XLSX_PATH.exists():
|
|
print(f"Не найден {XLSX_PATH.name}. Сначала создайте его командой: python update-prices.py --init")
|
|
sys.exit(1)
|
|
|
|
wb = openpyxl.load_workbook(XLSX_PATH, data_only=True)
|
|
if "Цены" not in wb.sheetnames:
|
|
print("В Excel нет листа «Цены»")
|
|
sys.exit(1)
|
|
ws = wb["Цены"]
|
|
|
|
rows = []
|
|
skipped = 0
|
|
for r in range(2, ws.max_row + 1):
|
|
section = ws.cell(r, 1).value
|
|
sub = ws.cell(r, 2).value
|
|
name = ws.cell(r, 3).value
|
|
price = ws.cell(r, 4).value
|
|
unit = ws.cell(r, 5).value
|
|
amount = ws.cell(r, 6).value
|
|
show = ws.cell(r, 7).value
|
|
|
|
section = str(section).strip() if section is not None else ""
|
|
sub = str(sub).strip() if sub is not None else ""
|
|
name = str(name).strip() if name is not None else ""
|
|
unit = str(unit).strip() if unit is not None else ""
|
|
amount = str(amount).strip() if amount is not None else "1"
|
|
show_s = str(show).strip().lower() if show is not None else "да"
|
|
|
|
if not section and not name:
|
|
continue
|
|
if not name:
|
|
skipped += 1
|
|
continue
|
|
if show_s not in YES_VALUES:
|
|
skipped += 1
|
|
continue
|
|
if price in (None, ""):
|
|
skipped += 1
|
|
continue
|
|
|
|
rows.append(
|
|
{
|
|
"section": section or "Прочее",
|
|
"subcategory": sub,
|
|
"name": name,
|
|
"price": price,
|
|
"unit": unit,
|
|
"amount": amount or "1",
|
|
}
|
|
)
|
|
|
|
data = nest_from_rows(rows)
|
|
write_prices_js(data)
|
|
total_items = sum(
|
|
len(c.get("items") or [])
|
|
+ sum(len(ch.get("items") or []) for ch in (c.get("children") or []))
|
|
for c in data
|
|
)
|
|
print(f"Готово: {JS_PATH.name}")
|
|
print(f"Разделов: {len(data)}")
|
|
print(f"Позиций на сайте: {total_items}")
|
|
if skipped:
|
|
print(f"Пропущено строк (скрытые/пустые): {skipped}")
|
|
print("Теперь залейте prices-data.js на хостинг REG.RU")
|
|
|
|
|
|
def main() -> None:
|
|
args = sys.argv[1:]
|
|
if "--init" in args or "--export" in args or not XLSX_PATH.exists():
|
|
export_xlsx_from_js()
|
|
if "--init" in args or "--export" in args:
|
|
return
|
|
import_xlsx_to_js()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|