diff --git a/backend-py/app/config.py b/backend-py/app/config.py index e86a6b4..17bf343 100644 --- a/backend-py/app/config.py +++ b/backend-py/app/config.py @@ -26,6 +26,9 @@ class Config: # Внутренний секрет для вызовов бота → бэкенда (без initData) internal_secret: str + # Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода) + shipments_file_id: str + def _required(name: str) -> str: val = os.getenv(name) @@ -50,4 +53,5 @@ def get_config() -> Config: proxy_static_list=os.getenv("PROXY_STATIC_LIST", ""), proxy_list_file=os.getenv("PROXY_LIST_FILE", ""), internal_secret=os.getenv("INTERNAL_SECRET", ""), + shipments_file_id=os.getenv("SHIPMENTS_FILE_ID", "1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E"), ) diff --git a/backend-py/app/drive.py b/backend-py/app/drive.py new file mode 100644 index 0000000..42a3cb9 --- /dev/null +++ b/backend-py/app/drive.py @@ -0,0 +1,64 @@ +"""Загрузка файлов из Google Drive через service account. + +Использует google-api-python-client (уже в requirements.txt). +Scope drive.readonly — только чтение файлов, к которым у сервисного аккаунта есть доступ. +""" +from __future__ import annotations + +import io +import threading +import time +from typing import Any + +from googleapiclient.discovery import build # type: ignore +from googleapiclient.http import MediaIoBaseDownload # type: ignore +from google.oauth2.service_account import Credentials # type: ignore + +from .config import get_config + +_SCOPES = [ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.readonly", +] + +_lock = threading.Lock() +_service: Any = None + +# Простой in-memory кэш: {file_id: (bytes, timestamp)} +_cache: dict[str, tuple[bytes, float]] = {} +_CACHE_TTL = 300 # 5 минут + + +def _get_service() -> Any: + global _service + with _lock: + if _service is None: + cfg = get_config() + creds = Credentials.from_service_account_file( + cfg.google_credentials_path, scopes=_SCOPES + ) + _service = build("drive", "v3", credentials=creds, cache_discovery=False) + return _service + + +def download_file_bytes(file_id: str) -> bytes: + """Скачивает файл из Google Drive по его ID и возвращает байты. + + Кэширует результат на 5 минут, чтобы не качать xlsx на каждый запрос дашборда. + """ + now = time.monotonic() + cached = _cache.get(file_id) + if cached and (now - cached[1]) < _CACHE_TTL: + return cached[0] + + service = _get_service() + request = service.files().get_media(fileId=file_id) + fh = io.BytesIO() + downloader = MediaIoBaseDownload(fh, request) + done = False + while not done: + _, done = downloader.next_chunk() + + data = fh.getvalue() + _cache[file_id] = (data, now) + return data diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 4a72596..510e37d 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -16,7 +16,7 @@ from fastapi.responses import FileResponse, JSONResponse from .config import get_config from .auth import verify_init_data -from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder +from . import sheets, ai, telegram as tg, proxy_pool, catalog, geocoder, drive from . import parsers from .parsers import dns as parser_dns, wb as parser_wb, ozon as parser_ozon, yamarket as parser_ym, citilink as parser_cl @@ -359,6 +359,14 @@ async def api_daily_reminders(request: Request): return JSONResponse(_handle_daily_reminders()) +@app.post("/api/shipments") +async def api_shipments(request: Request): + """Отгрузки из ОТГРУЗКИ.xlsx (Google Drive). Только для менеджера.""" + body = await _safe_json(request) + import asyncio + return JSONResponse(await asyncio.to_thread(_handle_shipments, body)) + + def _handle_daily_reminders() -> dict[str, Any]: """Находит клиентов с годовщиной договора сегодня по МСК. Дедуплицирует: один менеджер + один клиент = одно уведомление, @@ -3123,6 +3131,155 @@ def _initial(name: str) -> str: return ((name or "").strip()[:1] or "?").upper() +def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]: + """Читает ОТГРУЗКИ.xlsx из Google Drive и возвращает позиции, сгруппированные по дате отгрузки с завода. + + Листы Excel называются "ЗОВ ДД.ММ.ГГ" — дата отгрузки с завода. + Колонки: №, Товар (Заказ/Дозаказ), договор №, Срок, Кол мест, + Фурн-ра СПБ, Панели/техника СПБ, (empty), Продавец, + Сборщик, Примечание, Дата отгрузки, Кто забрал. + """ + import io + try: + import openpyxl + except ImportError: + return {"error": "openpyxl_not_installed"} + + cfg = get_config() + + # Auth — только менеджер + 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"): + return {"error": "only_manager"} + + file_id = cfg.shipments_file_id + if not file_id: + return {"ok": True, "shipments": [], "note": "file_not_configured"} + + try: + file_bytes = drive.download_file_bytes(file_id) + except Exception as e: + log.exception("shipments: не удалось скачать файл drive=%s", file_id) + return {"error": f"drive_error: {str(e)}"} + + try: + wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) + except Exception as e: + log.exception("shipments: не удалось распарсить xlsx") + return {"error": f"xlsx_parse_error: {str(e)}"} + + groups: list[dict[str, Any]] = [] + + for sheet_name in wb.sheetnames: + if not sheet_name.upper().startswith("ЗОВ "): + continue + date_part = sheet_name[4:].strip() + try: + if len(date_part) == 8: + factory_date = datetime.strptime(date_part, "%d.%m.%y").date() + elif len(date_part) == 10: + factory_date = datetime.strptime(date_part, "%d.%m.%Y").date() + else: + continue + except ValueError: + continue + + ws = wb[sheet_name] + all_rows = list(ws.iter_rows(values_only=True)) + if len(all_rows) < 2: + continue + + # Заголовки — первая строка + raw_headers = all_rows[0] + headers = [str(h).strip() if h is not None else "" for h in raw_headers] + + items: list[dict[str, Any]] = [] + for row in all_rows[1:]: + # Пропускаем полностью пустые строки + if not any(v for v in row if v is not None and str(v).strip()): + continue + + rd: dict[str, Any] = {} + for i, val in enumerate(row): + key = headers[i] if i < len(headers) else f"_col{i}" + rd[key] = val + + tovar = str(rd.get("Товар") or "").strip() + if not tovar or tovar.lower() in ("none", "товар", ""): + continue + + # Договор № — может быть в колонке с пустым заголовком (C) или "договор №" + contract = "" + for h in headers: + if "договор" in h.lower() or "дог" in h.lower(): + contract = str(rd.get(h) or "").strip() + break + if not contract and len(headers) > 2: + contract = str(rd.get(headers[2]) or "").strip() + contract = "" if contract in ("None", "none") else contract + + def _clean(v: Any) -> str: + s = str(v or "").strip() + return "" if s in ("None", "none") else s + + items.append({ + "num": _clean(rd.get("№") or rd.get("№")), + "tovar": tovar, # "Заказ" | "Дозаказ" + "contract": contract, + "deadline": _parse_xlsx_date(rd.get("Срок")), + "places": _clean(rd.get("Кол мест") or rd.get("Кол. мест") or rd.get("Кол.мест")), + "furn_spb": _clean(rd.get("Фурн-ра СПБ") or rd.get("Фурн СПБ")), + "panels_spb": _clean(rd.get("Панели/техника СПБ") or rd.get("Панели СПБ")), + "seller": _clean(rd.get("Продавец")), + "assembler": _clean(rd.get("Сборщик")), + "note": _clean(rd.get("Примечание")), + "delivery_date": _parse_xlsx_date(rd.get("Дата отгрузки")), + "picked_up_by": _clean(rd.get("Кто забрал")), + }) + + if items: + groups.append({ + "factory_date": factory_date.strftime("%d.%m.%Y"), + "factory_date_iso": factory_date.isoformat(), + "sheet_name": sheet_name, + "count": len(items), + "count_zakazov": sum(1 for i in items if "Заказ" in i["tovar"] and "Доз" not in i["tovar"]), + "count_dozakazov": sum(1 for i in items if "Доз" in i["tovar"]), + "items": items, + }) + + groups.sort(key=lambda x: x["factory_date_iso"]) + return {"ok": True, "shipments": groups} + + +def _parse_xlsx_date(val: Any) -> str: + """Парсит дату из ячейки Excel — datetime, date или строка.""" + if val is None: + return "" + from datetime import date as date_t + if isinstance(val, datetime): + return val.strftime("%d.%m.%Y") + if isinstance(val, date_t): + return val.strftime("%d.%m.%Y") + s = str(val).strip() + if not s or s.lower() in ("none", ""): + return "" + for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%y"): + try: + return datetime.strptime(s, fmt).strftime("%d.%m.%Y") + except ValueError: + pass + return s # вернём как есть если не распознали + + def _now_iso() -> str: return datetime.now(timezone.utc).isoformat() diff --git a/backend-py/requirements.txt b/backend-py/requirements.txt index 4926da3..d813915 100644 --- a/backend-py/requirements.txt +++ b/backend-py/requirements.txt @@ -9,3 +9,4 @@ python-dotenv>=1.0.0 beautifulsoup4>=4.12.0 lxml>=5.2.0 playwright>=1.45.0 +openpyxl>=3.1.0 diff --git a/deploy/.env.example b/deploy/.env.example index 2d53d72..f6e8e1c 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -28,3 +28,6 @@ GRACE_PERIOD_DAYS=14 # Внутренний секрет бот → бэкенд (годовщины договоров, не публичный) INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t + +# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода) +SHIPMENTS_FILE_ID=1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index aa762c5..6810d2f 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -170,32 +170,34 @@ async function renderManagerHome(me) { const projectsContainer = el(`
`); app.appendChild(projectsContainer); + // Контейнер для отгрузок (под активными проектами) + const shipmentsContainer = el(``); + app.appendChild(shipmentsContainer); + renderBottomNav("home", { unreadChats: 0 }); // Контейнер для карточек «Замер готов — что делать с подбором?» const pendingContainer = el(``); app.insertBefore(pendingContainer, todayContainer); - // Параллельно грузим реальные данные + // Параллельно грузим реальные данные (измерения + pending + отгрузки) try { - const [resM, resP] = await Promise.all([ - fetch(`${BACKEND_URL}/api/measurements`, { - method: "POST", - body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }), - }), - fetch(`${BACKEND_URL}/api/manager_pending`, { - method: "POST", - body: JSON.stringify({ initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }), - }), + const authBody = { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }; + const [resM, resP, resS] = await Promise.all([ + fetch(`${BACKEND_URL}/api/measurements`, { method: "POST", body: JSON.stringify(authBody) }), + fetch(`${BACKEND_URL}/api/manager_pending`, { method: "POST", body: JSON.stringify(authBody) }), + fetch(`${BACKEND_URL}/api/shipments`, { method: "POST", body: JSON.stringify(authBody) }), ]); const data = await resM.json(); const pendingData = await resP.json(); + const shipmentsData = await resS.json(); const measurements = (data.measurements || []); const pending = (pendingData.pending || []); renderManagerPending(pendingContainer, pending); renderManagerToday(todayContainer, measurements, firstName, greetingEl); renderManagerProjects(projectsContainer, measurements); + renderManagerShipments(shipmentsContainer, shipmentsData.shipments || []); } catch (e) { todayContainer.innerHTML = `