From cc38782b85291c487d5d5062116c5504f1c7f5a2 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Sat, 16 May 2026 07:49:56 +0300 Subject: [PATCH] feat: arrivals module + refactor xlsx parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлен /api/arrivals для «Поступление заказов на склад СПб.xlsx» (Drive ID захардкожен в ARRIVALS_FILE_ID, дефолт = 1kgrDEIGcVMFnSdZs1Y...) - _parse_xlsx_groups() — единый парсер для обоих файлов: * находит строку заголовков динамически (первая строка с «Товар»), чтобы корректно работать с файлом «Поступление» (2 строки шапки перед хедером) * пропускает разделители «Кухни» / «Дозаказы» внутри листа * пропускает шаблонные пустые строки (Заказ/Дозаказ без данных) - _xlsx_auth_manager() — вынесена общая проверка initData + роль - config: поле arrivals_file_id - frontend: вторая секция «📥 Поступление в СПб» на дашборде менеджера; renderManagerShipments принимает label-параметр и переиспользуется для обоих файлов; оба запроса загружаются параллельно с измерениями Co-Authored-By: Claude Sonnet 4.6 --- backend-py/app/config.py | 5 +- backend-py/app/main.py | 171 +++++++++++++++++++++++++++------------ deploy/.env.example | 4 +- miniapp/assets/app.js | 31 ++++--- miniapp/index.html | 26 +++--- 5 files changed, 156 insertions(+), 81 deletions(-) diff --git a/backend-py/app/config.py b/backend-py/app/config.py index 17bf343..fd9357a 100644 --- a/backend-py/app/config.py +++ b/backend-py/app/config.py @@ -26,8 +26,10 @@ class Config: # Внутренний секрет для вызовов бота → бэкенда (без initData) internal_secret: str - # Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода) + # Google Drive ID файла ОТГРУЗКИ.xlsx (отгрузки с завода) shipments_file_id: str + # Google Drive ID файла «Поступление заказов на склад СПб.xlsx» + arrivals_file_id: str def _required(name: str) -> str: @@ -54,4 +56,5 @@ def get_config() -> Config: proxy_list_file=os.getenv("PROXY_LIST_FILE", ""), internal_secret=os.getenv("INTERNAL_SECRET", ""), shipments_file_id=os.getenv("SHIPMENTS_FILE_ID", "1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E"), + arrivals_file_id=os.getenv("ARRIVALS_FILE_ID", "1kgrDEIGcVMFnSdZs1Y_QHVhjqsXFQk2h"), ) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index 510e37d..381cd90 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -367,6 +367,14 @@ async def api_shipments(request: Request): return JSONResponse(await asyncio.to_thread(_handle_shipments, body)) +@app.post("/api/arrivals") +async def api_arrivals(request: Request): + """Поступления на склад СПб из «Поступление заказов на склад СПб.xlsx». Только для менеджера.""" + body = await _safe_json(request) + import asyncio + return JSONResponse(await asyncio.to_thread(_handle_arrivals, body)) + + def _handle_daily_reminders() -> dict[str, Any]: """Находит клиентов с годовщиной договора сегодня по МСК. Дедуплицирует: один менеджер + один клиент = одно уведомление, @@ -3131,57 +3139,51 @@ 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"} - +def _xlsx_auth_manager(body: dict[str, Any]) -> tuple[Any, dict[str, Any] | None]: + """Проверяет initData и возвращает (tg_id, user) для менеджера или (None, error_dict).""" 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"} + return None, {"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"} + return None, {"error": "only_manager"} + return tg_id, user - file_id = cfg.shipments_file_id - if not file_id: - return {"ok": True, "shipments": [], "note": "file_not_configured"} +def _parse_xlsx_groups(file_bytes: bytes, source_label: str) -> list[dict[str, Any]]: + """Общий парсер для ОТГРУЗКИ.xlsx и «Поступление заказов на склад СПб.xlsx». + + Оба файла содержат листы вида «ЗОВ ДД.ММ.ГГ» с одинаковыми столбцами: + №, Товар (Заказ/Дозаказ), договор №, Срок, Кол мест, Фурн-ра СПБ, + Панели/техника СПБ, (empty), Продавец, Сборщик, Примечание, Дата отгрузки, Кто забрал. + + «Поступление» дополнительно имеет: + - 2 строки-шапки перед заголовком (Накладная от…, Поставщик:…) + - разделители «Кухни» / «Дозаказы» между блоками данных + Функция находит строку заголовков динамически (первая строка с «Товар»). + """ + import io 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)}"} + import openpyxl + except ImportError: + raise RuntimeError("openpyxl_not_installed") + wb = openpyxl.load_workbook(io.BytesIO(file_bytes), data_only=True) groups: list[dict[str, Any]] = [] + # Метки секций, которые нужно пропускать как строки-данные + _SECTION_LABELS = {"кухни", "дозаказы", "кухня"} + for sheet_name in wb.sheetnames: - if not sheet_name.upper().startswith("ЗОВ "): + if not sheet_name.strip().upper().startswith("ЗОВ "): continue - date_part = sheet_name[4:].strip() + date_part = sheet_name.strip()[4:].strip() try: if len(date_part) == 8: factory_date = datetime.strptime(date_part, "%d.%m.%y").date() @@ -3197,47 +3199,63 @@ def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]: if len(all_rows) < 2: continue - # Заголовки — первая строка - raw_headers = all_rows[0] + # Динамически находим строку заголовков — первая строка, содержащая «Товар» + header_idx = None + for i, row in enumerate(all_rows): + cells = [str(c).strip().lower() if c is not None else "" for c in row] + if "товар" in cells: + header_idx = i + break + if header_idx is None: + continue # нет строки с заголовком + + raw_headers = all_rows[header_idx] headers = [str(h).strip() if h is not None else "" for h in raw_headers] + def _clean(v: Any) -> str: + s = str(v or "").strip() + return "" if s.lower() in ("none", "") else s + items: list[dict[str, Any]] = [] - for row in all_rows[1:]: + for row in all_rows[header_idx + 1:]: # Пропускаем полностью пустые строки - if not any(v for v in row if v is not None and str(v).strip()): + non_empty = [v for v in row if v is not None and str(v).strip()] + if not non_empty: 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", "товар", ""): + tovar_raw = str(rd.get("Товар") or "").strip() + if not tovar_raw or tovar_raw.lower() in ("none", "товар"): + continue + # Пропускаем разделители-секции («Кухни», «Дозаказы») + if tovar_raw.lower() in _SECTION_LABELS: + continue + # Пропускаем шаблонные пустые строки — Заказ/Дозаказ без прочих данных + if tovar_raw in ("Заказ", "Дозаказ") and len(non_empty) <= 2: continue - # Договор № — может быть в колонке с пустым заголовком (C) или "договор №" + # Договор № — колонка с заголовком «договор», или 3-я колонка (C) contract = "" for h in headers: if "договор" in h.lower() or "дог" in h.lower(): - contract = str(rd.get(h) or "").strip() + contract = _clean(rd.get(h)) 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 + contract = _clean(rd.get(headers[2])) items.append({ - "num": _clean(rd.get("№") or rd.get("№")), - "tovar": tovar, # "Заказ" | "Дозаказ" + "num": _clean(rd.get("№")), + "tovar": tovar_raw, "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("Панели СПБ")), + "furn_spb": _clean(rd.get("Фурн-ра СПБ") or rd.get("фурн-ра СПБ") or rd.get("Фурн СПБ")), + "panels_spb": _clean(rd.get("Панели/техника СПБ") or rd.get("панели и техника СПБ в заказе") or rd.get("Панели СПБ")), "seller": _clean(rd.get("Продавец")), "assembler": _clean(rd.get("Сборщик")), "note": _clean(rd.get("Примечание")), @@ -3249,14 +3267,61 @@ def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]: groups.append({ "factory_date": factory_date.strftime("%d.%m.%Y"), "factory_date_iso": factory_date.isoformat(), - "sheet_name": sheet_name, + "sheet_name": sheet_name.strip(), + "source": source_label, "count": len(items), - "count_zakazov": sum(1 for i in items if "Заказ" in i["tovar"] and "Доз" not in i["tovar"]), + "count_zakazov": sum(1 for i in items if "Доз" 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 groups + + +def _handle_shipments(body: dict[str, Any]) -> dict[str, Any]: + """ОТГРУЗКИ.xlsx — отгрузки с завода. Только для менеджера.""" + tg_id, err = _xlsx_auth_manager(body) + if err: + return err + + cfg = get_config() + 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: + groups = _parse_xlsx_groups(file_bytes, "shipments") + except Exception as e: + log.exception("shipments: ошибка парсинга xlsx") + return {"error": f"parse_error: {str(e)}"} + return {"ok": True, "shipments": groups} + + +def _handle_arrivals(body: dict[str, Any]) -> dict[str, Any]: + """«Поступление заказов на склад СПб.xlsx» — приход на склад. Только для менеджера.""" + tg_id, err = _xlsx_auth_manager(body) + if err: + return err + + cfg = get_config() + file_id = cfg.arrivals_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("arrivals: не удалось скачать drive=%s", file_id) + return {"error": f"drive_error: {str(e)}"} + try: + groups = _parse_xlsx_groups(file_bytes, "arrivals") + except Exception as e: + log.exception("arrivals: ошибка парсинга xlsx") + return {"error": f"parse_error: {str(e)}"} return {"ok": True, "shipments": groups} diff --git a/deploy/.env.example b/deploy/.env.example index f6e8e1c..ce93a30 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -29,5 +29,7 @@ GRACE_PERIOD_DAYS=14 # Внутренний секрет бот → бэкенд (годовщины договоров, не публичный) INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t -# Google Drive ID файла ОТГРУЗКИ.xlsx (склад/отгрузки с завода) +# Google Drive ID файла ОТГРУЗКИ.xlsx (отгрузки с завода) SHIPMENTS_FILE_ID=1fER4NmEgSznvPKJWXOqLDDkTxH6wm78E +# Google Drive ID файла «Поступление заказов на склад СПб.xlsx» +ARRIVALS_FILE_ID=1kgrDEIGcVMFnSdZs1Y_QHVhjqsXFQk2h diff --git a/miniapp/assets/app.js b/miniapp/assets/app.js index 6810d2f..606c925 100644 --- a/miniapp/assets/app.js +++ b/miniapp/assets/app.js @@ -170,34 +170,39 @@ async function renderManagerHome(me) { const projectsContainer = el(`
`); app.appendChild(projectsContainer); - // Контейнер для отгрузок (под активными проектами) + // Контейнер для отгрузок с завода (под активными проектами) const shipmentsContainer = el(`
`); app.appendChild(shipmentsContainer); + // Контейнер для поступлений на склад СПб + const arrivalsContainer = el(`
`); + app.appendChild(arrivalsContainer); + renderBottomNav("home", { unreadChats: 0 }); // Контейнер для карточек «Замер готов — что делать с подбором?» const pendingContainer = el(`
`); app.insertBefore(pendingContainer, todayContainer); - // Параллельно грузим реальные данные (измерения + pending + отгрузки) + // Параллельно грузим реальные данные (измерения + pending + отгрузки + поступления) try { const authBody = { initData: tg?.initData || "", initDataUnsafe: tg?.initDataUnsafe || null }; - const [resM, resP, resS] = await Promise.all([ + const [resM, resP, resS, resA] = 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) }), + fetch(`${BACKEND_URL}/api/arrivals`, { method: "POST", body: JSON.stringify(authBody) }), ]); - const data = await resM.json(); + const data = await resM.json(); const pendingData = await resP.json(); const shipmentsData = await resS.json(); - const measurements = (data.measurements || []); - const pending = (pendingData.pending || []); + const arrivalsData = await resA.json(); - renderManagerPending(pendingContainer, pending); - renderManagerToday(todayContainer, measurements, firstName, greetingEl); - renderManagerProjects(projectsContainer, measurements); - renderManagerShipments(shipmentsContainer, shipmentsData.shipments || []); + renderManagerPending(pendingContainer, pendingData.pending || []); + renderManagerToday(todayContainer, data.measurements || [], firstName, greetingEl); + renderManagerProjects(projectsContainer, data.measurements || []); + renderManagerShipments(shipmentsContainer, shipmentsData.shipments || [], "📦 Отгрузки с завода"); + renderManagerShipments(arrivalsContainer, arrivalsData.shipments || [], "📥 Поступление в СПб"); } catch (e) { todayContainer.innerHTML = `
Не удалось загрузить данные: ${escHtml(e.message)}
`; } @@ -481,8 +486,8 @@ function renderManagerProjects(container, measurements) { container.appendChild(list); } -/* ----------------- Менеджер: секция отгрузок (ОТГРУЗКИ.xlsx) ----------------- */ -function renderManagerShipments(container, groups) { +/* ----------------- Менеджер: секция отгрузок / поступлений на склад ----------------- */ +function renderManagerShipments(container, groups, label = "📦 Отгрузки") { container.innerHTML = ""; if (!groups || !groups.length) return; @@ -492,7 +497,7 @@ function renderManagerShipments(container, groups) { const totalItems = visible.reduce((s, g) => s + g.count, 0); container.appendChild(el(`
- 📦 Отгрузки · ${totalItems} поз. + ${escHtml(label)} · ${totalItems} поз.
`)); diff --git a/miniapp/index.html b/miniapp/index.html index 0b39e13..41ff08a 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -12,14 +12,14 @@ - - + +
- +
- - - - - - - - - - + + + + + + + + + +