1. № замера подбирается автоматически:
- POST /api/measurement_next_no возвращает max(zamer_no) + 1
- Wizard при открытии вызывает endpoint и заполняет input
- Менеджер может переписать вручную (поле редактируемое)
- Подпись «Подобран автоматически — можно изменить»
2. Поле «Стяжка / нулевой пол» удалено из формы:
- По логике пользователя — стяжка пишется на самих фото с замером
- Backend колонка floor_base остаётся для backward compat (старые записи)
3. Чек-лист стал интерактивным:
- Каждый [ ] item теперь .cl-item с cursor:pointer
- Тап переключает галочку (☐ ↔ ☑) + страйкаут текста
- Состояние сохраняется в localStorage по measurement_id (или draft)
- Sticky прогресс-бар сверху: «N из M · X%» + градиентная полоса
- Кнопка ↺ в шапке — сбросить все галочки
- Hapt-фидбэк на каждый тап
Cache bust v=20260513m.
По чек-листу ЗАМЕРОВ (D:\!!! GOOGLE DISK\ЗАМЕРЫ\...\ЧЕКЛИСТ_ЗАМЕРА.md):
каждая стена снимается отдельно, имя файла отражает тип.
Wizard:
- Каждое фото получает dropdown «Что это»:
Стена 1, 2, 3, 4 · План комнаты · Общий вид · Деталь
- Авто-предложение типа: w1 → w2 → w3 → w4 → plan → general
- Добавлены поля общей инфы:
· № замера (опционально)
· Дата замера (auto-сегодня)
· Стяжка / нулевой пол (default «0,000 = +88 мм над плитой»)
- В шапке кнопка 📋 — открывает чек-лист отдельной страницей
- Inline-рендер markdown с поддержкой заголовков, списков, таблиц, code
Backend:
- _save_measurement_photo принимает kind+kind_seq → имена файлов
структурные: w1.jpg, w2.jpg, plan.jpg, general_2.jpg, detail_1.jpg.
Это упрощает дальнейшую обработку для генерации DWG.
- Расширена схема Measurements: zamer_no, zamer_date, floor_base, photos_meta.
- /api/measurement_detail отдаёт новые поля.
Cache bust v=20260513l.
В Telegram Desktop при открытии MiniApp в side-panel (boxed mode)
WebApp.initData приходит пустой. Backend не может проверить подпись.
Временный fallback: если initData пустой, доверяем initDataUnsafe.user
для определения роли. Action-endpoints (grant_role, measurement,
podbor) продолжают требовать подписанный initData.
Cache bust v=20260513i.
При входе менеджером (?role=manager) get_or_create_user обновлял CSV
в Sheets, но в памяти существующего dict обновлял только roles[],
а старое поле role оставалось со старым значением. _handle_me читал
role и не находил 'manager' → fallback на client cabinet.
Теперь после grant_role перечитываем строку из Sheets и обновляем
оба поля (role + roles). Плюс в _handle_me предпочитаем roles[]
если он уже распарсен.
End-to-end поток:
1. Менеджер на главной тапает «Заказать замер» → #/request → форма:
ФИО · телефон · адрес · dropdown «Кому назначить» · заметки.
Submit → POST /api/measurement_request → строка в Measurements
со status=requested + assigned_to_tg_id. Бот шлёт DM замерщику.
2. Замерщик открывает кабинет (?role=staff) → видит inbox с заявкой.
Тап → #/inbox/<id> → карточка с реквизитами + поле datetime-local.
Сохранить дату → POST /api/measurement_schedule → status=scheduled.
Бот уведомляет менеджера.
3. В нужный день замерщик тапает «📐 Сделать замер сейчас» →
wizard открывается в update-mode (#/measure?id=<id>), pre-fill
client_name/phone из заявки, пропускает шаг «Клиент». После submit
→ backend обновляет ту же строку (status=completed) + DM менеджеру.
Backend changes:
- Расширена схема Measurements: assigned_to_tg_id, requested_by_tg_id,
scheduled_at, address, client_name, client_phone (отдельные колонки).
ensure_measurements_sheet() автоматически дополняет колонки.
- _handle_measurement переписан под 2 режима (create/update).
- 3 новые ручки: /api/measurement_request, /api/measurement_inbox,
/api/measurement_schedule. Все с правильной проверкой ролей.
- Telegram-уведомления на каждом переходе статуса.
MiniApp:
- Новый модуль request.js — wizard заявки с dropdown замерщиков
(грузится из /api/staff_list?role=measurer).
- renderStaff теперь грузит реальный инбокс из /api/measurement_inbox.
- renderInboxDetail — карточка заявки с datetime-picker.
- В quick-actions менеджера: «Заказать замер» (primary) +
«Замер сейчас» (legacy direct fill).
- measurements.js поддерживает update-mode через ?id=.
Cache bust v=20260513g.
Users.role теперь хранит CSV-список ролей: 'manager,measurer'.
Парсим, добавляем, отзываем — все через sheets.parse_roles / grant_role /
revoke_role / list_users_with_role. Старые однострочные значения работают
как раньше (legacy compat).
Backend:
- /api/me возвращает roles[] (массив), role (главная для legacy-UI),
plus capabilities {measurer, assembler} для staff
- /api/grant_role (admin-only) — добавить/отозвать роль
- /api/staff_list (manager-only) — список сотрудников по роли
(будет использоваться в dropdown «выбрать замерщика»)
- При role=staff отдаём отдельный кабинет; если у юзера нет measurer/
assembler — возвращаем error=no_staff_role
Bot:
- /start — 3-я reply-кнопка [🔧 Я сотрудник]. При тапе MiniApp получает
?role=staff и решает кабинет по capabilities.
- /whoami — сотрудник присылает свой Telegram ID, пересылает куратору
чтобы тот выдал роль через /api/grant_role.
MiniApp:
- renderStaff() — заглушка кабинета сотрудника с шапкой (имя, аватар,
список ролей) и пустым inbox («Пока пусто»). Если есть measurer —
быстрая кнопка «Сделать новый замер».
- При error=no_staff_role — экран с инструкцией как получить роль.
- CSS .staff-head / .staff-no-role.
Cache bust v=20260513f.
Wizard: new 'photos' step (6 total) — camera/gallery input, client-side
canvas compression to 1600px @ ~78% JPEG, max 12 photos. Thumbnails
with delete in step; preview in summary.
Backend: POST /api/measurement now decodes data-URL photos and saves
to /app/photos/<id>/N.jpg (volume-mounted). New GET /api/photo/{id}/{n}
serves files with path-traversal protection. New POST /api/measurement_detail
returns full measurement record (walls/openings/photos/notes/...).
Clients page: measurement rows now clickable → renderMeasurement detail
view with key-value grid + photo gallery + 'Скачать PDF / Печать'.
Print stylesheet (@media print) hides navigation/buttons/uploaders and
prints clean A4-friendly layout.
Podbor report: existing 'Печать → PDF' now falls back to inline
window.print() inside Telegram WebApp (popups are blocked there).
Cache bust v=20260513a.
5 LAYOUT PICTOGRAMS (podbor.picts.js):
- linear: одна стена с гарнитуром
- l_shape: Г-образная, две стены с подсвеченным углом
- u_shape: П-образная, три стены
- island: линейный гарнитур + отдельный остров посередине
- peninsula: Г-образная + барная стойка-полуостров
Все в стиле D · top-down view, walnut stroke, теплые градиенты
MEASUREMENTS.JS WIZARD (5 шагов):
1. client_info — имя + телефон (валидация)
2. layout — pict-карточки 5 типов
3. size — длины стен (1-3 по layout), площадь, потолок (мм)
4. openings — окно / дверь / коммуникации / заметки
5. summary — обзор + Сохранить → POST /api/measurement
BACKEND (main.py):
- New /api/measurements (POST) для списка замеров менеджера
с опц. фильтрами по client_tg_id
- _handle_measurement теперь дописывает имя+телефон клиента в notes
(если client_tg_id не зарегистрирован — это новый клиент без аккаунта)
- handlers dispatcher: 'measurements' route added
ROUTING (app.js):
- Quick-action 'Новый замер' wired to '#/measure'
- routeByHash: Measurements.mount on #/measure
CLIENT PROFILE (clients.js):
- New section 'Замеры · N' on client history page
- fetchMeasurements() filters by client_tg_id or name match in notes
- layoutLabel() shows Russian label (Прямая / Угловая Г / etc.)
- Cache bump v=20260512c
USER REPORT: 'после прогона всей техники, перед запросом опять вопрос про фильтр для вытяжки в конце'
ROOT CAUSE: Infra-шаг спрашивает 'Вытяжка → вентшахта?', хотя у hood-категории уже есть шаг 'Подключение' с вариантами Отвод/Рециркуляция/Универсальная. Это дубликат.
FIX:
- renderInfra: убран блок vent. Шаг показывается только если выбрана варочная.
- Auto-skip infra если нет hob (раньше требовался hob ИЛИ hood, теперь только hob)
- renderSummary: убрана строка 'Вентиляция'
- summaryBack: 'infra' только если cats.includes('hob')
AI PROMPT:
- Новый блок: режим вытяжки читать из per_cat.hood.answers.mode
- exhaust → обычная установка
- recirc → ОБЯЗАТЕЛЬНО упомянуть 'Угольный фильтр в комплекте/докупаем' в pros
+ в первой строке pros указать 'для квартир без вентшахты'
- combi → упомянуть универсальность
- 'Если recirc и фильтр не предложен — это ОШИБКА'
Cache: v=20260512b
NEW HANDLERS:
- _handle_clients: groups manager's Leads by client (name or tg_id), returns
list with leads_count, last_lead_at, client_phone (extracted from checklist),
and full leads array per client. Sorted by recency desc.
- _handle_lead: fetches single lead with parsed checklist + ai_response JSON.
Validates ownership (manager_tg_id matches caller).
NEW ENDPOINTS:
- POST /api/clients — list of manager's clients with summary
- POST /api/lead — single lead detail with ai_response for re-render
Both accept initData auth, only manager role can call.
Apps Script compat: ?path=clients and ?path=lead also work.
NEW MODULE app/catalog.py:
- refresh_catalog(cats, sources, per_brand, delay) — runs parsers for seed brand+category pairs
- list_catalog(cat, tier, brand) — reads from Sheets
- list_for_ai(cats, tiers) — compact text for AI prompt context
- SEED_BRANDS_BY_TIER + CATEGORY_QUERIES — 22 brands × 8 cats = 176 combos
- Saves top-2 relevant results per (brand × cat), filters by brand presence in title
- Dedup by title hash within (cat, brand) bucket
SHEETS:
- ensure_sheet(name, headers) — auto-creates Catalog tab on first refresh
- Schema: id, category, brand, tier, model_name, search_query, price_min/max, image_url, source, url, last_seen_at
ENDPOINTS:
- POST /api/catalog/refresh?cat=X&per_brand=N — manual refresh (1 cat ~2-5 min)
- GET /api/catalog/list?cat=&tier=&brand= — read with filters
- GET /api/catalog/preview_ai?cats=fridge — debug what AI receives
AI PROMPT:
- Rule #0: if catalog passed in user prompt — MUST select only from there
- _build_catalog_context: filters by checklist.budget_preset → tier subset
(luxe→premium, premium→premium+middle, middle→middle, budget→middle+budget)
_handle_podbor:
- Loads catalog subset, appends to user_prompt as 'ДОСТУПНЫЙ КАТАЛОГ МОДЕЛЕЙ'
- AI 'выбирай ТОЛЬКО из этого списка' rule reinforced
NEXT: trigger refresh manually for 1 category (~3 min), then real podbor test
to verify AI uses catalog models instead of hallucinating SKUs
USER FEEDBACK:
'Особенности везде убрать, их можно в SWOT анализе приводить в качестве примечания
не акцентируя на них особого внимания. Современные фичи на 95% одинаковые.'
REMOVED features step from:
- fridge (NoFrost, Inverter, Wi-Fi, etc.)
- hob (Booster, FlexZone, FFD, Hob2Hood, etc.)
- oven (Wi-Fi, autoprogram, probe, softclose, etc.)
- dw (Wi-Fi, AutoOpen, AutoDose, AquaStop, Inverter)
- hood (touch, LED, auto, silent, turbo, wifi, perimeter)
- microwave (Wi-Fi, humid sensor, defrost, antibac)
- coffee (Wi-Fi, touch, grinder, autoclean)
- washer (inverter, steam, wifi, autodose, silent, aquastop)
KEPT: hood.color (about visible material/aesthetics, not feature)
KEPT: oven.location (where in kitchen — design-relevant)
NEW STEP COUNTS:
- fridge: 3 (was 4)
- hob: 4 (was 5)
- oven: 3-4 (was 4-5)
- dw: 3 (was 4)
- hood: 3-4 (was 4-5)
- microwave: 3 (was 4)
- coffee: 1-4 (was 2-5)
- washer: 5 (was 6)
AI PROMPT updated:
- Features no longer come from user — AI mentions important ones in highlights/pros
- Emphasis on MEASURABLE advantages in pros (N dB quieter, Y l more, N% cheaper)
- Не делать акцент на стандартных фичах — 95% одинаковые
USER WIZARD теперь короче и проще: тип → размер → ключевые параметры → готово
WB sometimes responds with 1-2 unrelated products instead of 429 status.
Was returning 'Платье вечернее' on 'Haier холодильник' query.
Fix: _is_relevant(product, query) checks that at least 1 significant query word (>=3 chars)
appears in product name or brand. Discards full result if zero matches.
Tradeoff: may sometimes reject valid product if query is overly specific (e.g. exact SKU).
But that's OK — we fall through to next query variant.
DISCOVERED in real test:
- WB API v9 (/exactmatch/ru/common/v9/search) теперь возвращает только метаданные
(name, query, shardKey, filters, search_result={}) — products пусто
- WB API v18 (/exactmatch/ru/common/v18/search) — рабочий
Структура: {metadata, products, total} — products НА ВЕРХНЕМ уровне (не data.products)
- Подтверждено: query='Haier холодильник' → 100 products via v18
CHANGES:
1. _SEARCH_URL → v18 endpoint
2. Парсинг products: сначала data.products (legacy fallback), потом products top-level
3. _build_item: цены теперь читаются из sizes[].price.{product, total, basic}
(v18 формат), с fallback на priceU/salePriceU (v9 legacy)
4. _generate_query_variants: добавлен brand+category fallback
('Bosch холодильник' если не нашли по модели)
TEST: Haier холодильник → 100 results (first: 'Холодильник двухкамерный C2F619CFU1')
User reported clicking matrix prices led to 'Произошла ошибка!' on OZON home page.
Cause: parsers captured /product/?sponsored=1&cpc=Jtiito95... links that died after few hours.
Fix:
- ozon.py: skip href with 'sponsored=1', '/promo/', 'cpc='. Strip query string from final URL.
- yamarket.py: skip 'sponsored=1', 'cpc=', 'advUuid' (Я.Маркет sponsored marker)
- citilink.py: strip query string from final URL (defensive)
Now matrix links go to canonical product pages that don't expire.
1. MODEL COUNT SELECTOR (strategy step):
- new PODBOR_MODEL_COUNTS [3/5/7]
- state.model_count default '5'
- UI on strategy page with description (быстро/оптимально/максимум)
2. AI PROMPT EXPANDED:
- new field: manual_search_query — for Google search instruction PDF
- new specs object per model: dimensions_mm/volume_l/weight_kg/noise_db/energy_class/color
- 'specs ОБЯЗАТЕЛЬНЫ для проектирования кухни' explicit rule
- reads checklist.model_count to determine how many models per category
- max_tokens 4000 → 8000 (room for richer responses)
3. MODEL CARD RICHER:
- _renderSpecsBlock — characteristics in 2-col grid, dimensions highlighted
- _renderUtilityLinks — Google search buttons for инструкция (PDF) + Схема установки
- Specs critical for ZOV kitchen design (manager needs to verify niche fits)
4. EXPORT BUTTONS:
- 'Скачать HTML' — generates standalone HTML with inline styles, downloads as file
- 'Печать → PDF' — opens new window with cleaned layout + auto-prints
- User can save as PDF via system print dialog
5. PREVIEW updated with realistic specs/manual_query for all 3 fridges
- DNS: использовали httpx + proxy_pool но Qrator кидал 401 даже с residential
→ теперь Playwright + residential — браузер сам решает JS challenge
- OZON: теперь проверяем только <title>='Доступ ограничен' (точная), а не подстроку '/robotcheck/'
Я.Маркет рендерит SnippetConstructor виджет с JSON-стейтом ВНУТРИ a-тега.
Поэтому link.get_text() возвращает мусор типа {'widgets':{...}}.
Фикс:
- copy.copy(card) и удаление <script>/<noscript>/<noframes>/<template>
- Title теперь берётся из URL slug первым приоритетом (всегда чистый)
- _slug_to_title: транслитерация и капитализация
'bosch-kgn39ul30u-dvukhkamernyy-kholodilnik-no-frost-seryy-metallik' →
'Bosch KGN39UL30U Двухкамерный Холодильник NoFrost Серый Металлик'
- Old /product--{id} URLs deprecated
- Walks up from a[href*='/card/'] to nearest article/zone-div
- Extracts title from link text or h2/h3/itemprop=name
- Price: min from card text (with sanity bounds 100..10M)
- Image filters yastatic / _next placeholders
- Rating: '4.7★' or '4.7 N оценок' pattern
- Reviews: 'N отзывов' / 'N оценок'
- Stores count: 'от N магазинов / предложений'
- New use_proxy param (default True)
- Per-request random proxy from pool
- _parse_proxy_url_for_playwright converts http://user:pass@host:port to playwright.proxy dict
- New env: PROXY_LIST_FILE — path to file with one proxy per line
- _normalize_proxy_entry accepts: http://user:pass@host:port, host:port:user:pass (Proxys.io format), host:port
- _load_from_file reads file, dedup with static list
- /api/proxy_status returns file_path, file_loaded count, sample (first 3 masked)
DIAGNOSTIC RESULTS:
- OZON: 19 product links via Playwright on naked VPS-IP ✓
- Citilink: 112 data-meta-name Snippets ✓
- Wildberries: JSON API works with delays ✓
- Я.Маркет, DNS: blocked by ASN (need residential proxy)
OZON PARSER:
- Pure Playwright DOM (composer-api dropped — was blocked)
- Selects a[href*='/product/'], walks up to card div, extracts title/price/img
- Filters fake 'titles' like Распродажа, Скидка
CITILINK PARSER (new):
- Selects [data-meta-name*='Snippet'] or ProductCard markers
- Multiple title selectors fallback chain
- Filters out non-product hits
PARSERS/__init__.py:
- DEFAULT_SOURCES = (ozon, citilink, wb) — all work without proxy
- Я.Маркет, DNS kept but not default — usable when residential proxy added
NEW ENDPOINT:
- GET /api/parse_citilink?q=...&limit=N
- proxy_pool now loads from both PROXY_STATIC_LIST (env, comma-separated) and PROXY6_TOKEN (API)
- Static list has priority, merged with API list (dedup by URL)
- /api/proxy_status returns masked proxy URLs for diagnostic (passwords hidden)
- Supports formats: 'http://user:pass@host:port' or 'host:port' (assumed http://)
PROXY POOL (app/proxy_pool.py):
- Loads active proxies from Proxy6.net API every 10 min
- Random rotation per request via proxied_client(timeout, headers)
- Graceful fallback to direct HTTP if PROXY6_TOKEN not set
- Config: PROXY6_TOKEN env var
PARSERS (app/parsers/):
- dns.py — refactored to use proxy_pool with retry+rotation on Qrator block
- wb.py — Wildberries JSON API (search.wb.ru), retries on 429
- ozon.py — OZON composer-api JSON (widgetStates extraction)
- yamarket.py — Я.Маркет HTML + embedded JSON parser
- __init__.py — enrich_one() fans out to all sources, aggregates min/max prices, max rating, sum reviews
- enrich_models() — batch enrich for AI by_category output
NEW DIAGNOSTIC ENDPOINTS (main.py):
- GET /api/parse_wb?q=...&limit=N
- GET /api/parse_ozon?q=...&limit=N
- GET /api/parse_yamarket?q=...&limit=N
- GET /api/parse_all?q=... — fan-out + aggregate
- GET /api/proxy_status — pool diagnostics (count, token configured, age)
PODBOR (main.py):
- _enrich_ai_with_dns -> _enrich_ai_marketplaces (uses all sources)
DEPLOY: needs PROXY6_TOKEN in /opt/zov-tech/deploy/.env on VPS, then docker compose build + up -d backend
AI PROMPT (ai.py):
- Документирует новую форму checklist (per_cat.answers, brand_strategy, single_brand, brands, budget_preset, pick_strategies)
- Просит вернуть 3-5 моделей по КАЖДОЙ категории (не одну)
- Новый формат ответа: by_category[cat].models[] с brand/model/price_min/price_max/search_query/pros/cons/tier
- Подробные правила для бренд-стратегий (single → вся техника одной марки; different → preferred/acceptable/avoid)
- Бюджет-пресеты с авто-распределением по категориям (fridge ~25%, hob ~12% и т.д.)
DNS PARSER (parsers/dns.py):
- search_dns(query, limit) — HTTP + BeautifulSoup
- Реалистичный User-Agent, фолбэк на JSON-LD если HTML-селекторы не сработали
- enrich_models(models) — обогащает список моделей от AI, добавляя dns: {title, price, image, url, rating, reviews}
- Вежливая задержка 0.4с между запросами
MAIN.PY:
- /api/parse_dns?q=... — тестовый эндпоинт для проверки парсера
- _handle_podbor теперь после AI вызывает _enrich_ai_with_dns для каждой модели
- _format_podbor_for_telegram переписан под новый формат by_category — выводит 3-5 моделей в каждой категории с pros/cons
- Fallback на старый формат items[] для совместимости
REQUIREMENTS:
- + beautifulsoup4 >= 4.12
- + lxml >= 5.2
DEPLOY: после пуша на VPS нужно пересобрать backend контейнер (docker compose up --build -d backend)