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(`
| Месяц | +Заказов | +Сумма сборок | +Сборщиков | +
|---|