diff --git a/backend-py/app/assembler_parser.py b/backend-py/app/assembler_parser.py new file mode 100644 index 0000000..7bb8391 --- /dev/null +++ b/backend-py/app/assembler_parser.py @@ -0,0 +1,253 @@ +""" +Парсер таблицы занятости сборщиков (Excel). + +Формат файла: + Col A: зона (север, охта, ...) + Col B: ФИО + телефоны (всё в одной ячейке) + Col C: тип строки ('сборка' / 'замер' / 'только замеры') + Col D+: ячейки заказов, одна колонка = один день + + Строка 1 (2026+): даты + Строка 2 (2026+): дни недели + --- VS --- + Строка 1 (до 2026): дни недели + Строка 2 (до 2026): даты + + Стоимость сборки — последнее число в тексте ячейки: + '1322Б Парголово ул.Заречная 10 кв105 89110064400 Алексеев А.К. 63900' + Составная: '6030+20100' → 26130 + Доделка без суммы: '' или 'Доделка ...' → 0 +""" +from __future__ import annotations +import os +import re +import json +import time +import logging +from pathlib import Path +from datetime import date, datetime +from typing import Any + +try: + import openpyxl +except ImportError: + openpyxl = None # type: ignore + +log = logging.getLogger("zov.assembler_parser") + +# Кэш: {path: {mtime, data}} +_cache: dict[str, dict] = {} + +_AMOUNT_RE = re.compile(r"([\d]+(?:[+][\d]+)*)\s*$") +_COMPOUND_RE = re.compile(r"^\d+(?:[+]\d+)+$") + + +def _extract_amount(text: str) -> int: + """Извлекает стоимость из конца текста ячейки.""" + text = (text or "").strip() + m = _AMOUNT_RE.search(text) + if not m: + return 0 + raw = m.group(1) + if _COMPOUND_RE.match(raw): + return sum(int(x) for x in raw.split("+")) + return int(raw) + + +def _extract_name(cell_b: str) -> str: + """Извлекает ФИО из первой строки или до первого телефона.""" + s = (cell_b or "").strip() + if not s: + return "" + # Первая строка + first_line = s.split("\n")[0].strip() + # Обрезаем по телефону (8-9xx, +7) + m = re.search(r"[\s,](?:8[-\s]?9|(?:\+7))\d", first_line) + if m: + first_line = first_line[: m.start()].strip() + return first_line or s.split("\n")[0] + + +def _is_date_row(row_vals: list) -> bool: + """Проверяет, содержит ли строка datetime-объекты (ряд с датами).""" + dates = [v for v in row_vals if isinstance(v, (datetime, date))] + return len(dates) >= 5 + + +def parse_sheet(ws) -> list[dict]: + """ + Парсит один лист. Возвращает список записей: + {name, zone, date, order_text, amount, sheet_title} + """ + if openpyxl is None: + return [] + + title = ws.title + rows = list(ws.iter_rows(min_row=1, max_row=ws.max_row, values_only=True)) + if len(rows) < 3: + return [] + + # Определяем строку с датами (row index 0 или 1) + date_row_idx = None + for i in (0, 1): + if _is_date_row(rows[i]): + date_row_idx = i + break + if date_row_idx is None: + return [] + + date_row = rows[date_row_idx] + # Строим словарь col_index → date + col_to_date: dict[int, date] = {} + for ci, v in enumerate(date_row): + if ci < 3: # A, B, C — не даты + continue + if isinstance(v, datetime): + col_to_date[ci] = v.date() + elif isinstance(v, date): + col_to_date[ci] = v + + if not col_to_date: + return [] + + records: list[dict] = [] + current_assembler: dict | None = None # {name, zone} + + for ri, row in enumerate(rows): + if ri <= date_row_idx: + continue + + col_a = str(row[0] or "").strip().lower() + col_b = str(row[1] or "").strip() + col_c = str(row[2] or "").strip().lower() + + # Строка сборщика + if col_b and col_c in ("сборка", "сборка "): + name = _extract_name(col_b) + zone = col_a or "" + current_assembler = {"name": name, "zone": zone} + + elif col_c in ("замер", "замер ", "только замеры"): + # Строка замеров — пропускаем (не относится к стоимости) + pass + + else: + current_assembler = None # Разрыв блока + continue + + if not current_assembler: + continue + + # Собираем заказы из ячеек (cols D+) + for ci, cell_val in enumerate(row): + if ci < 3: + continue + if ci not in col_to_date: + continue + text = str(cell_val or "").strip() + if not text: + continue + amount = _extract_amount(text) + if amount == 0 and not text: + continue + records.append({ + "assembler_name": current_assembler["name"], + "assembler_zone": current_assembler["zone"], + "date": col_to_date[ci].isoformat(), + "order_text": text[:120], + "amount": amount, + "sheet": title, + }) + + return records + + +def parse_file(xlsx_path: str, sheets_filter: list[str] | None = None) -> dict[str, Any]: + """ + Парсит Excel файл. Кэширует по mtime. + sheets_filter: список названий листов для парсинга; None = все. + """ + if openpyxl is None: + return {"error": "openpyxl not installed", "records": []} + + path = str(xlsx_path) + try: + mtime = os.path.getmtime(path) + except OSError: + return {"error": f"file_not_found: {path}", "records": []} + + if path in _cache and _cache[path]["mtime"] == mtime: + return _cache[path]["data"] + + log.info("Parsing assembler schedule: %s", path) + t0 = time.time() + try: + wb = openpyxl.load_workbook(path, data_only=True, read_only=True) + except Exception as e: + return {"error": str(e), "records": []} + + all_records: list[dict] = [] + parsed_sheets = [] + for sname in wb.sheetnames: + if sheets_filter and sname not in sheets_filter: + continue + # Пропускаем служебные листы + if sname.lower().startswith("лист") or sname.lower() == "образец": + continue + try: + ws = wb[sname] + recs = parse_sheet(ws) + all_records.extend(recs) + parsed_sheets.append({"sheet": sname, "records": len(recs)}) + except Exception as e: + log.warning("Sheet %s parse error: %s", sname, e) + + wb.close() + elapsed = round(time.time() - t0, 2) + log.info("Parsed %d records in %.2fs", len(all_records), elapsed) + + data = { + "records": all_records, + "parsed_sheets": parsed_sheets, + "elapsed_s": elapsed, + "parsed_at": datetime.utcnow().isoformat(), + } + _cache[path] = {"mtime": mtime, "data": data} + return data + + +def aggregate(records: list[dict]) -> dict[str, Any]: + """ + Агрегирует записи по сборщику и месяцу. + Возвращает: + by_assembler: {name: {year_month: {orders, total_amount}}} + by_month: {year_month: {total_amount, order_count, assemblers}} + """ + by_assembler: dict[str, dict] = {} + by_month: dict[str, dict] = {} + + for r in records: + name = r["assembler_name"] + dt = r["date"][:7] # YYYY-MM + amount = r["amount"] + + # by_assembler + if name not in by_assembler: + by_assembler[name] = {} + if dt not in by_assembler[name]: + by_assembler[name][dt] = {"orders": 0, "total_amount": 0, "zone": r.get("assembler_zone", "")} + by_assembler[name][dt]["orders"] += 1 + by_assembler[name][dt]["total_amount"] += amount + + # by_month + if dt not in by_month: + by_month[dt] = {"total_amount": 0, "order_count": 0, "assemblers": set()} + by_month[dt]["total_amount"] += amount + by_month[dt]["order_count"] += 1 + by_month[dt]["assemblers"].add(name) + + # Сериализуем sets + for k in by_month: + by_month[k]["assemblers"] = sorted(by_month[k]["assemblers"]) + + return {"by_assembler": by_assembler, "by_month": by_month} diff --git a/backend-py/app/config.py b/backend-py/app/config.py index bbc9240..6dd736e 100644 --- a/backend-py/app/config.py +++ b/backend-py/app/config.py @@ -30,6 +30,8 @@ class Config: shipments_file_id: str # Google Drive ID файла «Поступление заказов на склад СПб.xlsx» arrivals_file_id: str + # Google Drive ID «Таблица занятости сборщиков.xlsx» + assembler_schedule_file_id: str def _required(name: str) -> str: @@ -59,4 +61,6 @@ def get_config() -> Config: shipments_file_id=os.getenv("SHIPMENTS_FILE_ID", "1KCJUXjhVR2NWEz9bD0kjTaEADsxF8gI5GMzLwJ2bw84"), # Поступление заказов на склад СПб — тот же файл что ОТГРУЗКИ arrivals_file_id=os.getenv("ARRIVALS_FILE_ID", "1KCJUXjhVR2NWEz9bD0kjTaEADsxF8gI5GMzLwJ2bw84"), + # Таблица занятости сборщиков — передать file_id через env + assembler_schedule_file_id=os.getenv("ASSEMBLER_SCHEDULE_FILE_ID", ""), ) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 95110ad..8accba5 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -20,6 +20,7 @@ from .auth import verify_init_data from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder, drive from . import parsers from . import proposals as proposals_mod +from . import assembler_parser from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") @@ -151,6 +152,7 @@ async def _dispatch_post(request: Request): "assembly_rates_list": _handle_assembly_rates_list, "assembly_rate_save": _handle_assembly_rate_save, "assembly_rate_delete": _handle_assembly_rate_delete, + "assembler_analytics": _handle_assembler_analytics, "proposal_brief": proposals_mod.handle_brief, "proposal_create": proposals_mod.handle_create, "proposal_upsert_variant": proposals_mod.handle_upsert_variant, @@ -2831,6 +2833,138 @@ def _handle_assembly_rate_delete(body: dict[str, Any]) -> dict[str, Any]: return {"ok": True} +# ================================================================= +# Assembler Analytics — парсинг таблицы занятости сборщиков +# ================================================================= + +# Кэш распарсенного Excel в памяти (drive bytes → parse → aggregate) +_schedule_cache: dict = {"data": None, "ts": 0.0, "etag": ""} +_SCHEDULE_CACHE_TTL = 600 # 10 минут + + +_LOCAL_SCHEDULE_PATH = os.environ.get( + "ASSEMBLER_SCHEDULE_PATH", + "/app/data/assembler_schedule.xlsx" +) + + +def _get_schedule_data() -> dict: + """Парсит таблицу занятости сборщиков. Кэш 10 мин. + Источник: локальный файл (LOCAL) или Google Drive (DRIVE).""" + import time as _time + now = _time.monotonic() + + if _schedule_cache["data"] and (now - _schedule_cache["ts"]) < _SCHEDULE_CACHE_TTL: + return _schedule_cache["data"] + + # Пробуем локальный файл + if os.path.exists(_LOCAL_SCHEDULE_PATH): + log.info("assembler_schedule: using local file %s", _LOCAL_SCHEDULE_PATH) + parsed = assembler_parser.parse_file(_LOCAL_SCHEDULE_PATH) + else: + # Fallback: Google Drive + cfg = get_config() + file_id = cfg.assembler_schedule_file_id + if not file_id: + return {"error": "Файл не найден локально и ASSEMBLER_SCHEDULE_FILE_ID не задан"} + try: + xlsx_bytes = drive.download_file_bytes(file_id) + except Exception as e: + log.warning("assembler_schedule download error: %s", e) + return {"error": f"download_failed: {e}"} + import tempfile + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tf: + tf.write(xlsx_bytes) + tmp_path = tf.name + try: + parsed = assembler_parser.parse_file(tmp_path) + finally: + try: + os.unlink(tmp_path) + except Exception: + pass + + if "error" in parsed: + return parsed + + records = parsed.get("records", []) + agg = assembler_parser.aggregate(records) + + data = { + "ok": True, + "parsed_sheets": parsed.get("parsed_sheets", []), + "total_records": len(records), + "elapsed_s": parsed.get("elapsed_s"), + "parsed_at": parsed.get("parsed_at"), + "by_assembler": agg["by_assembler"], + "by_month": agg["by_month"], + } + _schedule_cache["data"] = data + _schedule_cache["ts"] = now + return data + + +def _handle_assembler_analytics(body: dict[str, Any]) -> dict[str, Any]: + """Возвращает аналитику занятости/стоимостей сборщиков. + body: {initData, year?: '2026', assembler_name?: 'Иванов И.И.'} + Доступен менеджеру.""" + cfg = get_config() + auth = verify_init_data(body.get("initData") or "", cfg.bot_token) + if not auth or not auth.get("user"): + unsafe = body.get("initDataUnsafe") or {} + if isinstance(unsafe, dict) and unsafe.get("user", {}).get("id"): + auth = {"user": unsafe["user"]} + else: + return {"error": "invalid_init_data"} + tg_id = auth["user"]["id"] + user = sheets.find_user(tg_id) + if not user or not (sheets.has_role(user, "manager") or sheets.has_role(user, "admin")): + return {"error": "forbidden"} + + data = _get_schedule_data() + if "error" in data: + return data + + # Фильтр по году (если указан) + year = str(body.get("year") or "").strip() + if year and year.isdigit(): + by_month = {k: v for k, v in data["by_month"].items() if k.startswith(year)} + by_assembler = {} + for name, months in data["by_assembler"].items(): + filtered = {k: v for k, v in months.items() if k.startswith(year)} + if filtered: + by_assembler[name] = filtered + else: + by_month = data["by_month"] + by_assembler = data["by_assembler"] + + # Топ-5 сборщиков по итоговой сумме + assembler_totals = [ + { + "name": name, + "total_amount": sum(m["total_amount"] for m in months.values()), + "total_orders": sum(m["orders"] for m in months.values()), + "months": months, + } + for name, months in by_assembler.items() + ] + assembler_totals.sort(key=lambda x: x["total_amount"], reverse=True) + + return { + "ok": True, + "parsed_at": data.get("parsed_at"), + "total_records": data.get("total_records"), + "by_month": by_month, + "assemblers": assembler_totals, + } + + +@app.post("/api/assembler_analytics") +async def api_assembler_analytics(request: Request): + body = await _safe_json(request) + return _handle_assembler_analytics(body) + + # ================================================================= # SignRequest — цифровая подпись акта сборки (ФЗ-63 ПЭП) # ================================================================= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6f44f29..0ca8937 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -11,6 +11,7 @@ services: volumes: - ./credentials.json:/app/credentials.json:ro - ./photos:/app/photos + - /opt/zov-tech/data:/app/data:ro networks: - web # внешняя сеть от deploy-стека (Caddy там) - internal diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index da01e94..f1426de 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -195,6 +195,7 @@ async function renderManagerHome(me) { { icon: "package", title: "Подбор техники", subtitle: "Встройка + AI", href: "#/podbor" }, { icon: "ruler", title: "Заказать замер", subtitle: "Назначить замерщика", href: "#/request" }, { icon: "wrench", title: "Ставки сборки", subtitle: "% клиент / сборщик", href: "#/admin/rates" }, + { icon: "folder", title: "Аналитика", subtitle: "Занятость сборщиков", href: "#/admin/assembler-analytics" }, ]; app.appendChild(el(`
Быстрые действия
`)); const grid = el(`
`); @@ -1696,6 +1697,9 @@ function routeByHash() { else init(); } else if (location.hash.startsWith("#/assembly")) { Assembly.mount(app); + } else if (location.hash === "#/admin/assembler-analytics") { + if (typeof AssemblerAnalytics !== "undefined") AssemblerAnalytics.mount(app); + else init(); } else if (location.hash.startsWith("#/admin/rates")) { if (typeof AdminRates !== "undefined") AdminRates.mount(app); else init(); diff --git a/miniapp/assets/assembler_analytics.js b/miniapp/assets/assembler_analytics.js new file mode 100644 index 0000000..d2bb343 --- /dev/null +++ b/miniapp/assets/assembler_analytics.js @@ -0,0 +1,236 @@ +/* ============================================================ + AssemblerAnalytics — аналитика занятости сборщиков + #/admin/assembler-analytics + Данные: «Таблица занятости сборщиков.xlsx» → backend parser + ============================================================ */ + +const AssemblerAnalytics = (function () { + "use strict"; + + function escHtml(s) { + return String(s == null ? "" : s) + .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function el(html) { + const t = document.createElement("template"); + t.innerHTML = html.trim(); + return t.content.firstChild; + } + function fmtMoney(n) { + return Math.round(n || 0).toLocaleString("ru-RU") + " ₽"; + } + function fmtMonth(ym) { + // "2026-05" → "Май 2026" + try { + const d = new Date(ym + "-01"); + return d.toLocaleDateString("ru-RU", { month: "long", year: "numeric" }); + } catch { return ym; } + } + + async function _api(path, body = {}) { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), 30000); // парсинг Excel — долгий + try { + const res = await fetch(`${BACKEND_URL}/api/${path}`, { + method: "POST", signal: ctrl.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + initData: (typeof Platform !== "undefined" ? Platform.initData : (window.tg?.initData || "")), + initDataUnsafe: (typeof Platform !== "undefined" ? Platform.initDataUnsafe : null), + ...body, + }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (e) { + if (e.name === "AbortError") throw new Error("Таймаут — файл большой, попробуй ещё раз"); + throw e; + } finally { clearTimeout(t); } + } + + /* ── Главный экран ─────────────────────────────────────────── */ + async function mount(container) { + container.innerHTML = ""; + document.body.classList.remove("has-bottom-nav"); + document.getElementById("bottom-nav")?.remove(); + + const h = el(` +
+ +
Аналитика сборщиков
+ +
+ `); + h.querySelector(".podbor-back").addEventListener("click", () => { + haptic && haptic("impact"); + history.back(); + }); + + const screen = el(`
`); + container.appendChild(h); + container.appendChild(screen); + + const yearEl = el(` +
+ + +
+ `); + container.insertBefore(yearEl, screen); + + const load = async (year) => { + screen.innerHTML = `
Парсим Excel… может занять 10–20 сек
`; + try { + const data = await _api("assembler_analytics", { year }); + if (data.error) { + screen.innerHTML = `
${escHtml(data.error)}
`; + return; + } + _render(screen, data, year); + } catch (e) { + screen.innerHTML = `
Ошибка: ${escHtml(e.message)}
`; + } + }; + + yearEl.querySelector("#yearSelect").addEventListener("change", function () { + load(this.value); + }); + h.querySelector("#reloadBtn").addEventListener("click", () => { + haptic && haptic("impact"); + load(yearEl.querySelector("#yearSelect").value); + }); + + load("2026"); + } + + /* ── Рендер данных ─────────────────────────────────────────── */ + function _render(screen, data, year) { + screen.innerHTML = ""; + + const parsedAt = data.parsed_at ? new Date(data.parsed_at).toLocaleString("ru-RU") : "—"; + screen.appendChild(el(` +
+ Обновлено: ${escHtml(parsedAt)} · Записей: ${escHtml(String(data.total_records || 0))} +
+ `)); + + // === Итоги по месяцам === + const byMonth = data.by_month || {}; + const months = Object.keys(byMonth).sort(); + if (months.length) { + screen.appendChild(el(`
📅 По месяцам
`)); + const monthWrap = el(`
`); + const table = el(` + + + + + + + + + + +
МесяцЗаказовСумма сборокСборщиков
+ `); + const tbody = table.querySelector("#monthTbody"); + let grandTotal = 0, grandOrders = 0; + for (const ym of months.reverse()) { + const m = byMonth[ym]; + grandTotal += m.total_amount || 0; + grandOrders += m.order_count || 0; + const tr = el(` + + ${escHtml(fmtMonth(ym))} + ${escHtml(String(m.order_count || 0))} + ${escHtml(fmtMoney(m.total_amount))} + ${escHtml(String((m.assemblers || []).length))} + + `); + tbody.appendChild(tr); + } + // Итого строка + tbody.appendChild(el(` + + ИТОГО + ${grandOrders} + ${escHtml(fmtMoney(grandTotal))} + + + `)); + monthWrap.appendChild(table); + screen.appendChild(monthWrap); + } + + // === Рейтинг сборщиков === + const assemblers = (data.assemblers || []); + if (assemblers.length) { + screen.appendChild(el(` +
+ 👷 Сборщики · ${assemblers.length} +
+ `)); + + const maxAmt = Math.max(...assemblers.map(a => a.total_amount)) || 1; + + assemblers.forEach((a, idx) => { + const barPct = Math.round((a.total_amount / maxAmt) * 100); + const avgPerOrder = a.total_orders ? Math.round(a.total_amount / a.total_orders) : 0; + // Раскладка по месяцам: последние 6 + const monthKeys = Object.keys(a.months || {}).sort().slice(-6); + const monthCells = monthKeys.map(ym => { + const mm = a.months[ym]; + return `
+
${ym.slice(5)}
+
${Math.round((mm.total_amount||0)/1000)}к
+
${mm.orders} зак.
+
`; + }).join(""); + + const card = el(` +
+
+
+
${idx + 1}. ${escHtml(a.name)}
+
+ ${a.total_orders} заказов · ср. ${escHtml(fmtMoney(avgPerOrder))} / заказ +
+
+
+
${escHtml(fmtMoney(a.total_amount))}
+
+
+ +
+
+
+ + ${monthCells ? `
${monthCells}
` : ""} +
+ `); + screen.appendChild(card); + }); + } + + if (!months.length && !assemblers.length) { + screen.innerHTML = ` +
+ Нет данных за выбранный период.
+ Попробуй выбрать другой год. +
+ `; + } + + screen.appendChild(el(`
`)); + } + + return { mount }; +})(); diff --git a/miniapp/index.html b/miniapp/index.html index 48e8e02..4e00ddc 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -54,6 +54,7 @@ +