From 6f05d9c34c3d1a34bd7055442f6f24b7bccf0be8 Mon Sep 17 00:00:00 2001 From: shaerware Date: Mon, 28 Sep 2026 18:43:18 +0500 Subject: [PATCH] =?UTF-8?q?fix(procurement):=20=D1=81=D1=80=D0=BE=D0=BA=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=83=20=D0=BF=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D1=89=D0=B8=D0=BA=D0=B0,=20=D0=B0=20=D0=BD=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B0=D0=B9=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После смены темы магазина (28.09.2026) на товарах появились пер-поставщиковые меты: поле на склад плюс срок рядом — `EKF_Stock_0` + `EKF_Stock_0_delivery` («1-2 дня»), `Chint_Stock_0` + «3-4 дня», `Axima_Stock_0`/`Axima_Stock_2` + «1 день». Сводка `_remote_stock_blocks`, которую читал #848, с ними разъехалась и врёт в обе стороны: - срок в ней прибит константой `1-2 дн.` для КАЖДОГО поставщика, хотя у Chint собственное поле говорит «3-4 дня» — так в 39 случаях из 40; - количество расходится с исходными полями в 67 случаях из 77, вплоть до «AXIMA 28 шт.» там, где оба склада Аксимы дают ноль. На живой выборке 1200 товаров новый разбор даёт 51 позицию с наличием и сроки 4 дн. (31) / 2 дн. (16) / 1 день (4). По прежней логике все 51 получили бы «до 2 дней» — то есть 31 позиция уехала бы клиенту с вдвое заниженным сроком. - `_availability()` читает пер-поставщиковые меты; срок — верхняя граница строки («3-4 дня» → 4), обещать быстрее поставщика нельзя. - Позиция у нескольких поставщиков: берём самого быстрого из имеющих её в наличии и показываем его остаток с его же сроком. Склады разных поставщиков не суммируются — «12 шт. за 2 дня» означало бы, что приедут и те семь, что лежат у четырёхдневного. - Все известные поставщики по нулям → `None` («уточняется»), а не `False`: позицию может возить тот, чьих полей на сайте нет. - Сводке оставлен один ПРОМСИТЕХ, у которого своих полей нет; срок оттуда не берётся никогда. Попутно: HTML сайта закрыт антибот-заглушкой «Проверка безопасности» — разбор страниц как запасной путь больше недоступен, парсер работает через authenticated v3 API, который эти меты и отдаёт (публичный Store API — нет). ## NEWS ⏱️ **Ассистент называет реальный срок поставки, а не усреднённый** Магазин начал хранить остатки и сроки отдельно по каждому поставщику — и оказалось, что общая сводка на карточке всем подряд обещала «1-2 дня», хотя у части товаров доставка занимает 3-4 дня. Теперь ассистент берёт срок у того поставщика, у которого товар действительно есть: где-то это день, где-то четыре. И если позиция лежит у нескольких — выбирает самого быстрого и показывает именно его остаток, а не сумму всех складов. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 2 +- modules/procurement/site_adapter.py | 203 +++++++++++++++++++------- tests/unit/test_site_remote_stock.py | 205 +++++++++++++++++++-------- 3 files changed, 300 insertions(+), 110 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8229e673..58a25ab5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -332,7 +332,7 @@ Static marketing site served at `https://ai-sekretar24.ru/` — no build step, n ### Unified product search (`modules/procurement/`) -Code-pipeline «единый поиск позиции» для торгового ассистента (StalkerElectric). Модель `ProductOffer` (table `product_offers`) — единое структурное представление оффера из любого источника: `source` (`site`/`ekf`/`supplier`), `article`, `name`, `price`, `in_stock`, `lead_time_days`, `url` + `UniqueConstraint(source, source_key)` для upsert. `OfferService.replace_source_offers()` делает полный ре-синк одного источника (схлопывает дубли `source_key` внутри одной выборки — пагинация WooCommerce сдвигается, если каталог меняется на ходу, и товар попадает на две страницы; раньше весь синк падал на `UNIQUE(source, source_key)` и сутки оставались без обновления); `OfferService.search()` детерминированно ранжирует (порядок ранга ниже) и **возвращает `[]`, если ничего не найдено — не выдумывает позиций** (жёсткое требование клиента реализовано архитектурно, а не промптом). `OfferService.search()` фильтрует стоп-слова (мета-запросы «запрос/стоимость/счёт», «из наличия/помоги/найти/подобрать», единицы «квт/вт/ква»), делает префикс-стемминг (`_stem` — 70 % длины слова, но не короче 6: контактор→контакт, электродвигатель→электродвига; плоские 6 символов превращали «электродвигатель» в «электр» и тянули в выдачу всю электроустановочную продукцию) + расширяет аббревиатуры (`_SYNONYMS`: ЧРП/ПЧ/частотник→преобразователь частоты; УЗО/УПП/дифавтомат). **Порядок ранга**: article_exact → article_partial → не-аксессуар (`_is_accessory_for_query` — «Катушка управления ДЛЯ КОНТАКТОРА» ниже контакторов, но не когда просят саму катушку) → lead-токен (название начинается с первого значимого слова запроса: сам товар выше того, что его лишь упоминает) → **близость номинального тока** (`_amperage`/`_amp_bucket`: «18А»/«120A», но не «NC1-1810» и не «220В»; на запрос «20А 2НО» позиция на 225 А с 2НО не должна обгонять 18-амперную с 1НО+1НЗ — для электротехники номинал определяющий; позиция без указанного тока идёт ниже подходящих, но выше явно чужих; если ток не назван в запросе — измерение нейтрально) → совпадения по целому слову (`strong` — «Контактный зажим» за 293 ₸ не обгоняет «Контактор» за 6 237 ₸) → число совпавших токенов → head-match. **Тай-брейк**: in_stock → **есть ли цена вообще** (треть site-каталога синкается с `price=0`, и при сортировке по возрастанию цены эти строки вытесняли реальные позиции из выдачи) → `SOURCE_PRIORITY` site0` → `in_stock=True` + `stock_qty` + `lead_time_days` = ХУДШИЙ срок из блоков (обещать быстрее нельзя) + сырые блоки в `extra`; пустой блок → `None` («уточняется»), а не `False`. Собственный учёт WooCommerce бесполезен и наличием НЕ считается: `manage_stock` выключен у всех товаров, `stock_status=instock` у всех 30 163, `outofstock`/`onbackorder` — ноль; прежнее `in_stock=True` заставляло ассистента писать «✅ В наличии» про любую позицию каталога, что прямо запрещено §5.5/§44.12 MASTER WORKFLOW) + `suppliers/` (config-driven парсер, 6 поставщиков). **`modules/procurement/suppliers/`**: `registry.py` (per-supplier конфиги по реестру клиента Reestr v3 лист5 — format/header_row/cols/currency/markup_pct/stock_file; форматы: `xlsx`/`xls`(xlrd,1С)/`pdf_lines`/`pdf_table`), `parser.py` (openpyxl+xlrd+pdfplumber; `parse_number`; мерж остатков; трекинг категорий 1С), `adapter.py` (`sync_supplier`/`sync_all_suppliers` → `replace_source_offers(..., scope_key=cfg["key"])` — удаление по префиксу `{key}#%`, устойчиво к переименованию). **«Остаток не указан» ≠ «нет в наличии»**: парсер ставит ключ `stock` только когда остаток реально прочитан, адаптер пишет `in_stock=None` при неизвестном и `False` только при явном нуле. Файлы остатков перечисляют ЛИШЬ то, что есть (у SunWell/EKF 838 артикулов против 20 728 строк прайса; у Аксимы/Chint пустые ячейки без единого явного нуля), и прежнее `(row.get("stock") or 0) > 0` помечало 20 738 строк как «НЕТ в наличии» без оснований — зеркальное нарушение того же §5.5. Поставщики (наценка per-supplier из реестра): sunwell ×1.30, aksima ×1.30/max-РРЦ, aksima_chint_rrc (РРЦ), xtrade ×1.40, elektrokomplekt/ЭКТ ×1.25, megazakaz (USD, ×1.20×1.16). Прайсы из env `SUPPLIER_PRICES_DIR`; файлы прайсов НЕ в git (`docs/файлысталкера/` в .gitignore — дилерские цены). **Чат-интеграция** (`modules/chat/facade.py`): `_inject_offer_context` для procurement-сессий (коллекция в env `PROCUREMENT_CATALOG_COLLECTIONS`, default {6}). Поисковый запрос строится **из диалога**, а не из последней реплики (`modules/procurement/query_builder.py`): один дешёвый LLM-вызов на последних 6 репликах возвращает `{query, exclude}`. Без этого уточнение «модульный не подходит, нужен промышленный» искалось по слову «модульный» — то есть по тому, что клиент только что отверг, — и ассистент отвечал «в каталоге только модульные», хотя силовых контакторов на сайте 556. Первое сообщение сессии вызов пропускает (сырой текст и есть запрос); любой сбой LLM/парсинга — фолбэк на сырой текст, чат не ломается. `exclude` вычитается из выдачи в `OfferService.search`. `OfferService._attach_supply()` дополняет позиции сайта тем, что известно о них у поставщиков (18 015 из 30 163 артикулов каталога есть в прайсах): в `offer["supply"]` идут `available`/`supplier_name`/`qty`/`as_of`, и `facade._supply_label()` превращает это в «есть у поставщика — уточнить только СРОК» (клиенту без имени поставщика, менеджеру с именем, остатком и датой прайса). Поиск по индексированной колонке `article` вариантами регистра, не `upper(trim(...))` — иначе каждое сообщение чата сканирует прайсы целиком. Инъектит ТОЧНЫЙ keyword-поиск офферов (цены/артикулы/КП — принцип клиента: КП только точное) + SUPPLEMENTARY семантику каталога через `wiki_rag.retrieve_async(collection_id=6)` (богатые описания/смысл для вагих запросов типа «чем питать насос»→ПЧ; голые офферы НЕ индексируем в вектор — замер 2.8ч+шум); ГЕЙТ видимости — `session.source=='admin'` видит supplier-дилерские цены, клиентские каналы (widget/telegram/mobile) только site-розницу. Провайдер `claude_bridge` без tools → поиск = код-шаг. Роутер `/admin/procurement/{status,search,sync,sync-suppliers,price,kp,route}` (RBAC `sales`). **Маршрутизация** (`routing.py`, Reestr лист3): `SUPPLIER_META` (7 поставщиков вкл. promsitech/eltech без файлов, типы A/B/C), `CATEGORIES` (14 категорий keyword→упорядоченные поставщики), `classify`/`route(query,city)` (Атырау→ЭКТ первым, ELTECH competitor). Эндпоинт `GET /admin/procurement/route`; чат «не найдено» (менеджер) подсказывает у кого запросить. **amoCRM-триаж** (`modules/crm/triage.py` + `GET /admin/crm/triage`): неразобранные лиды (`get_unsorted_leads`, добавляет `_body`=`metadata.content_summary` для mail) → матчинг/КП по ТЕМЕ (точно, confident по значимым токенам ≥4 — спам не проходит), маршрутизация по тема+тело (recall), `body_preview` для менеджера → {routing, matches[client_price], suggestion}; ловит мёртвый токен → `{ok:false, reason:reauth_needed}` (`GET /admin/crm/auth-url`). Дальше: тело чатов (get_chat_history) + LLM-извлечение позиций из длинного письма. Задача `procurement-site-sync` (daily 23:30 UTC). **Генерация КП** (`kp.py`): `build_kp`/`build_kp_for_queries` — спецификация из priced-офферов → markdown (позиции, Итого без НДС/НДС 16%/с НДС, курс+дата если USD, строка доставки по факту веса, срок действия; поставщиков клиенту НЕ раскрывает, Мегазаказ как бренд Stalker, zero-margin/нерасчитанные → flags не в итог; черновик на директора). Эндпоинт `POST /admin/procurement/kp` (body items/client_name/quote_date); менеджерский чат-инъект даёт КП-подсказку. **Ценовой движок**: `rate_service.py` (курс USD/KZT с mig.kz buy-курс, кэш на день, фолбэк env `PROCUREMENT_USD_KZT`/last-known+флаг stale→спросить директора) + `pricing.py` (`compute_pricing`/`price_offer` — per-supplier закупка+цена клиенту по Reestr лист2: KZT-дилер raw×(1+markup), Chint РРЦ, Аксима max(РРЦ,raw×1.30), USD Мегазаказ $×курс×1.20×1.16, НДС не удваивать при vat_included, флаг zero_margin≥→директору). Менеджерский чат-инъект показывает «закуп→клиенту»+курс/дата; клиентам только розница. Ещё не сделано: ПРОМСИТЕХ (файла нет) + EKF API + генерация КП-документа + авто-загрузка прайсов из почты (Reestr правило 4) + цена у 11 378 site-офферов (38 %) приходит нулевой — это **НЕ** вариативные товары (все `type: simple`), и прайсами поставщиков они не покрыты (совпадает 1 артикул), т.е. цену взять неоткуда (issue #841). План работ по MASTER WORKFLOW v1.2 — `docs/stalker-workflow-plan.md`. +Code-pipeline «единый поиск позиции» для торгового ассистента (StalkerElectric). Модель `ProductOffer` (table `product_offers`) — единое структурное представление оффера из любого источника: `source` (`site`/`ekf`/`supplier`), `article`, `name`, `price`, `in_stock`, `lead_time_days`, `url` + `UniqueConstraint(source, source_key)` для upsert. `OfferService.replace_source_offers()` делает полный ре-синк одного источника (схлопывает дубли `source_key` внутри одной выборки — пагинация WooCommerce сдвигается, если каталог меняется на ходу, и товар попадает на две страницы; раньше весь синк падал на `UNIQUE(source, source_key)` и сутки оставались без обновления); `OfferService.search()` детерминированно ранжирует (порядок ранга ниже) и **возвращает `[]`, если ничего не найдено — не выдумывает позиций** (жёсткое требование клиента реализовано архитектурно, а не промптом). `OfferService.search()` фильтрует стоп-слова (мета-запросы «запрос/стоимость/счёт», «из наличия/помоги/найти/подобрать», единицы «квт/вт/ква»), делает префикс-стемминг (`_stem` — 70 % длины слова, но не короче 6: контактор→контакт, электродвигатель→электродвига; плоские 6 символов превращали «электродвигатель» в «электр» и тянули в выдачу всю электроустановочную продукцию) + расширяет аббревиатуры (`_SYNONYMS`: ЧРП/ПЧ/частотник→преобразователь частоты; УЗО/УПП/дифавтомат). **Порядок ранга**: article_exact → article_partial → не-аксессуар (`_is_accessory_for_query` — «Катушка управления ДЛЯ КОНТАКТОРА» ниже контакторов, но не когда просят саму катушку) → lead-токен (название начинается с первого значимого слова запроса: сам товар выше того, что его лишь упоминает) → **близость номинального тока** (`_amperage`/`_amp_bucket`: «18А»/«120A», но не «NC1-1810» и не «220В»; на запрос «20А 2НО» позиция на 225 А с 2НО не должна обгонять 18-амперную с 1НО+1НЗ — для электротехники номинал определяющий; позиция без указанного тока идёт ниже подходящих, но выше явно чужих; если ток не назван в запросе — измерение нейтрально) → совпадения по целому слову (`strong` — «Контактный зажим» за 293 ₸ не обгоняет «Контактор» за 6 237 ₸) → число совпавших токенов → head-match. **Тай-брейк**: in_stock → **есть ли цена вообще** (треть site-каталога синкается с `price=0`, и при сортировке по возрастанию цены эти строки вытесняли реальные позиции из выдачи) → `SOURCE_PRIORITY` site 0` помечало 20 738 строк как «НЕТ в наличии» без оснований — зеркальное нарушение того же §5.5. Поставщики (наценка per-supplier из реестра): sunwell ×1.30, aksima ×1.30/max-РРЦ, aksima_chint_rrc (РРЦ), xtrade ×1.40, elektrokomplekt/ЭКТ ×1.25, megazakaz (USD, ×1.20×1.16). Прайсы из env `SUPPLIER_PRICES_DIR`; файлы прайсов НЕ в git (`docs/файлысталкера/` в .gitignore — дилерские цены). **Чат-интеграция** (`modules/chat/facade.py`): `_inject_offer_context` для procurement-сессий (коллекция в env `PROCUREMENT_CATALOG_COLLECTIONS`, default {6}). Поисковый запрос строится **из диалога**, а не из последней реплики (`modules/procurement/query_builder.py`): один дешёвый LLM-вызов на последних 6 репликах возвращает `{query, exclude}`. Без этого уточнение «модульный не подходит, нужен промышленный» искалось по слову «модульный» — то есть по тому, что клиент только что отверг, — и ассистент отвечал «в каталоге только модульные», хотя силовых контакторов на сайте 556. Первое сообщение сессии вызов пропускает (сырой текст и есть запрос); любой сбой LLM/парсинга — фолбэк на сырой текст, чат не ломается. `exclude` вычитается из выдачи в `OfferService.search`. `OfferService._attach_supply()` дополняет позиции сайта тем, что известно о них у поставщиков (18 015 из 30 163 артикулов каталога есть в прайсах): в `offer["supply"]` идут `available`/`supplier_name`/`qty`/`as_of`, и `facade._supply_label()` превращает это в «есть у поставщика — уточнить только СРОК» (клиенту без имени поставщика, менеджеру с именем, остатком и датой прайса). Поиск по индексированной колонке `article` вариантами регистра, не `upper(trim(...))` — иначе каждое сообщение чата сканирует прайсы целиком. Инъектит ТОЧНЫЙ keyword-поиск офферов (цены/артикулы/КП — принцип клиента: КП только точное) + SUPPLEMENTARY семантику каталога через `wiki_rag.retrieve_async(collection_id=6)` (богатые описания/смысл для вагих запросов типа «чем питать насос»→ПЧ; голые офферы НЕ индексируем в вектор — замер 2.8ч+шум); ГЕЙТ видимости — `session.source=='admin'` видит supplier-дилерские цены, клиентские каналы (widget/telegram/mobile) только site-розницу. Провайдер `claude_bridge` без tools → поиск = код-шаг. Роутер `/admin/procurement/{status,search,sync,sync-suppliers,price,kp,route}` (RBAC `sales`). **Маршрутизация** (`routing.py`, Reestr лист3): `SUPPLIER_META` (7 поставщиков вкл. promsitech/eltech без файлов, типы A/B/C), `CATEGORIES` (14 категорий keyword→упорядоченные поставщики), `classify`/`route(query,city)` (Атырау→ЭКТ первым, ELTECH competitor). Эндпоинт `GET /admin/procurement/route`; чат «не найдено» (менеджер) подсказывает у кого запросить. **amoCRM-триаж** (`modules/crm/triage.py` + `GET /admin/crm/triage`): неразобранные лиды (`get_unsorted_leads`, добавляет `_body`=`metadata.content_summary` для mail) → матчинг/КП по ТЕМЕ (точно, confident по значимым токенам ≥4 — спам не проходит), маршрутизация по тема+тело (recall), `body_preview` для менеджера → {routing, matches[client_price], suggestion}; ловит мёртвый токен → `{ok:false, reason:reauth_needed}` (`GET /admin/crm/auth-url`). Дальше: тело чатов (get_chat_history) + LLM-извлечение позиций из длинного письма. Задача `procurement-site-sync` (daily 23:30 UTC). **Генерация КП** (`kp.py`): `build_kp`/`build_kp_for_queries` — спецификация из priced-офферов → markdown (позиции, Итого без НДС/НДС 16%/с НДС, курс+дата если USD, строка доставки по факту веса, срок действия; поставщиков клиенту НЕ раскрывает, Мегазаказ как бренд Stalker, zero-margin/нерасчитанные → flags не в итог; черновик на директора). Эндпоинт `POST /admin/procurement/kp` (body items/client_name/quote_date); менеджерский чат-инъект даёт КП-подсказку. **Ценовой движок**: `rate_service.py` (курс USD/KZT с mig.kz buy-курс, кэш на день, фолбэк env `PROCUREMENT_USD_KZT`/last-known+флаг stale→спросить директора) + `pricing.py` (`compute_pricing`/`price_offer` — per-supplier закупка+цена клиенту по Reestr лист2: KZT-дилер raw×(1+markup), Chint РРЦ, Аксима max(РРЦ,raw×1.30), USD Мегазаказ $×курс×1.20×1.16, НДС не удваивать при vat_included, флаг zero_margin≥→директору). Менеджерский чат-инъект показывает «закуп→клиенту»+курс/дата; клиентам только розница. Ещё не сделано: ПРОМСИТЕХ (файла нет) + EKF API + генерация КП-документа + авто-загрузка прайсов из почты (Reestr правило 4) + цена у 11 378 site-офферов (38 %) приходит нулевой — это **НЕ** вариативные товары (все `type: simple`), и прайсами поставщиков они не покрыты (совпадает 1 артикул), т.е. цену взять неоткуда (issue #841). План работ по MASTER WORKFLOW v1.2 — `docs/stalker-workflow-plan.md`. ## Code Patterns diff --git a/modules/procurement/site_adapter.py b/modules/procurement/site_adapter.py index 0e719f2e..095cffef 100644 --- a/modules/procurement/site_adapter.py +++ b/modules/procurement/site_adapter.py @@ -7,6 +7,7 @@ import json import logging +import re from typing import Any, Optional from app.services.woocommerce_service import get_all_products @@ -49,66 +50,170 @@ def _category(product: dict) -> Optional[str]: return ", ".join(cats)[:300] if cats else None -# Мета, которую сайт заполняет остатками поставщиков и показывает в карточке -# блоком «Наличие · Удалённый склад · 2 шт. Срок поставки 1-2 дн.». -# Формат значения: [{"warehouse": "Удалённый склад", "supplier": "PRSTH", -# "qty": 2, "lead_time_min": 1, "lead_time_max": 2}]. Пустой список — карточка -# пишет «По запросу», то есть на складе позиции нет. +# Остатки поставщиков сайт держит двумя способами, и они расходятся. +# +# 1) Пер-поставщиковые меты — по полю на склад плюс срок доставки рядом: +# `EKF_Stock_0` + `EKF_Stock_0_delivery` («1-2 дня»), `Chint_Stock_0` + +# `Chint_Stock_0_delivery` («3-4 дня»), `Axima_Stock_0`/`Axima_Stock_2` + +# их `_delivery` («1 день»). Остаток — строка с целым числом. +# 2) `_remote_stock_blocks` — сводка, которую рисует тема. +# +# Читаем ПЕРВЫЕ. Сводка после смены темы разъехалась с исходными полями и +# врёт в обе стороны: срок в ней прибит константой `1-2 дн.` для КАЖДОГО +# поставщика (у Chint собственное поле говорит «3-4 дня» — так в 39 случаях +# из 40), а количество расходится с пер-поставщиковыми полями в 67 случаях +# из 77 — вплоть до «AXIMA 28 шт.» там, где оба склада Аксимы дают ноль. +# Сводке оставлена одна работа: поставщики, у которых своей меты нет +# (ПРОМСИТЕХ). Срок оттуда не берём никогда — это не данные, а константа. _REMOTE_STOCK_META = "_remote_stock_blocks" +# supplier -> (поля остатков по складам, поля срока) +_SUPPLIER_STOCK: dict[str, tuple[tuple[str, ...], tuple[str, ...]]] = { + # В сводке этот поставщик подписан SUNWELL — это тот же партнёр. + "EKF": (("EKF_Stock_0",), ("EKF_Stock_0_delivery",)), + "Chint": (("Chint_Stock_0",), ("Chint_Stock_0_delivery",)), + "Axima": ( + ("Axima_Stock_0", "Axima_Stock_2"), + ("Axima_Stock_0_delivery", "Axima_Stock_2_delivery"), + ), +} + +# Как поставщик подписан в сводке — чтобы не подобрать оттуда того, чьи +# собственные поля мы уже прочитали. +_BLOCK_SUPPLIER_ALIASES = {"SUNWELL": "EKF", "EKF": "EKF", "CHINT": "Chint", "AXIMA": "Axima"} + + +def _meta(product: dict) -> dict: + """Меты товара словарём. Ключи не уникальны только у повторяющихся полей, + которых здесь нет, поэтому последнее значение выигрывает.""" + out: dict = {} + for m in product.get("meta_data") or []: + key = m.get("key") + if key is not None: + out[str(key)] = m.get("value") + return out + + +def _int_or_none(value) -> Optional[int]: + if value in (None, ""): + return None + try: + return int(float(str(value).strip().replace(",", "."))) + except (TypeError, ValueError): + return None + + +def _delivery_days(value) -> Optional[int]: + """«1 день» → 1, «1-2 дня» → 2, «3-4 дня» → 4. + + Берём ВЕРХНЮЮ границу: обещать клиенту быстрее, чем сказал поставщик, + нельзя. Строка без чисел — срока не знаем. + """ + if not isinstance(value, str): + return None + numbers = [int(x) for x in re.findall(r"\d+", value)] + return max(numbers) if numbers else None + + +def _supplier_readings(meta: dict) -> list[dict]: + """Что каждый поставщик говорит про эту позицию. + + Возвращает только тех, у кого поле вообще есть: отсутствие поля — это + «не знаем», а не «нет на складе». + """ + readings = [] + for supplier, (qty_keys, delivery_keys) in _SUPPLIER_STOCK.items(): + present = [k for k in qty_keys if k in meta] + if not present: + continue + qty = sum(_int_or_none(meta[k]) or 0 for k in present) + days = [d for d in (_delivery_days(meta.get(k)) for k in delivery_keys) if d is not None] + readings.append( + { + "supplier": supplier, + "qty": qty, + # Худший срок среди складов этого поставщика. + "lead_time_days": max(days) if days else None, + } + ) + return readings + def _remote_stock_blocks(product: dict) -> Optional[list]: """Разобрать `_remote_stock_blocks`. None — меты нет / она не читается.""" - for m in product.get("meta_data") or []: - if m.get("key") != _REMOTE_STOCK_META: + value = _meta(product).get(_REMOTE_STOCK_META) + if isinstance(value, str): + try: + value = json.loads(value) + except (TypeError, ValueError): + return None + return value if isinstance(value, list) else None + + +def _untracked_block_qty(product: dict) -> Optional[int]: + """Остаток из сводки — только по поставщикам без собственных полей. + + Сегодня это ПРОМСИТЕХ. Срок из сводки не возвращаем: там константа. + """ + total = 0 + found = False + for block in _remote_stock_blocks(product) or []: + if not isinstance(block, dict): continue - val = m.get("value") - if isinstance(val, str): - try: - val = json.loads(val) - except (TypeError, ValueError): - return None - return val if isinstance(val, list) else None - return None + supplier = str(block.get("supplier") or "").upper() + if supplier in _BLOCK_SUPPLIER_ALIASES: + continue + qty = _int_or_none(block.get("qty")) + if qty is not None: + found = True + total += qty + return total if found else None def _availability(product: dict) -> dict[str, Any]: - """Наличие и срок поставки — или «неизвестно», если магазин их не даёт. - - Собственный учёт остатков в каталоге stalkerelectric.kz выключен: у всех - 30 тыс. товаров ``manage_stock: false`` и ``stock_quantity: null``, а - ``stock_status`` равен "instock" просто по умолчанию — принимать это за - наличие нельзя (MASTER WORKFLOW §5.5, §44.12: прайс не является - подтверждением наличия). Единственные настоящие данные о наличии на сайте — - блоки остатков поставщиков в мете ``_remote_stock_blocks``: у позиций с - непустым блоком известны и количество, и срок поставки. У остальных карточка - пишет «По запросу» — это НЕ подтверждённое отсутствие, а «уточняется», - поэтому возвращаем None, а не False. + """Наличие и срок поставки — или «неизвестно», если сайт их не даёт. + + Собственный складской учёт магазина бесполезен: `manage_stock` выключен у + всех товаров, `stock_status` равен "instock" по умолчанию у всех 30 тыс. + Принимать это за наличие нельзя (MASTER WORKFLOW §5.5, §44.12). + + Когда позиция есть у нескольких поставщиков, берём САМОГО БЫСТРОГО из тех, + у кого она реально в наличии, и показываем его остаток с его же верхней + границей срока. Суммировать склады разных поставщиков нельзя: «12 шт., + срок до 2 дн.» означало бы, что все двенадцать приедут за два дня, хотя + семь из них лежат у поставщика с четырёхдневной доставкой. + + Все известные поставщики ответили «ноль» — это НЕ «нет в наличии»: + позицию может возить тот, чьих полей на сайте нет. Возвращаем None, то + есть «уточняется», а не отказ клиенту. """ out: dict[str, Any] = {"in_stock": None, "stock_qty": None, "lead_time_days": None} - blocks = _remote_stock_blocks(product) - if blocks: - qty = 0.0 - leads = [] - for b in blocks: - if not isinstance(b, dict): - continue - try: - qty += float(b.get("qty") or 0) - except (TypeError, ValueError): - pass - for key in ("lead_time_max", "lead_time_min"): - v = b.get(key) - if isinstance(v, (int, float)): - leads.append(int(v)) - break - if qty > 0: - out["in_stock"] = True - out["stock_qty"] = qty - # Берём худший срок из блоков — обещать более быстрый нельзя. - out["lead_time_days"] = max(leads) if leads else None - out["extra"] = {"remote_stock": blocks} - return out + meta = _meta(product) + readings = _supplier_readings(meta) + in_stock_now = [r for r in readings if r["qty"] > 0] + + if in_stock_now: + # Без срока — в конец: позиция с известным сроком полезнее клиенту. + best = min(in_stock_now, key=lambda r: (r["lead_time_days"] is None, r["lead_time_days"])) + out["in_stock"] = True + out["stock_qty"] = best["qty"] + out["lead_time_days"] = best["lead_time_days"] + out["extra"] = {"suppliers": readings} + return out + + fallback_qty = _untracked_block_qty(product) + if fallback_qty is not None and fallback_qty > 0: + out["in_stock"] = True + out["stock_qty"] = fallback_qty + # Срок сводки — константа, а не данные: пусть менеджер уточнит. + out["extra"] = {"remote_stock": _remote_stock_blocks(product)} + return out + + if readings: + # Поля есть и все по нулям — знаем только то, что у ЭТИХ поставщиков + # позиции нет. Про товар в целом это ещё не «нет в наличии». + out["extra"] = {"suppliers": readings} + status = product.get("stock_status") if status in ("outofstock", "onbackorder"): out["in_stock"] = False diff --git a/tests/unit/test_site_remote_stock.py b/tests/unit/test_site_remote_stock.py index b085c60f..77fc4830 100644 --- a/tests/unit/test_site_remote_stock.py +++ b/tests/unit/test_site_remote_stock.py @@ -1,94 +1,179 @@ -"""Наличие и срок поставки с сайта: мета `_remote_stock_blocks`. +"""Наличие и срок поставки с сайта: пер-поставщиковые меты товара. -Карточка товара на stalkerelectric.kz показывает блок «Наличие · Удалённый -склад · 2 шт. Срок поставки 1-2 дн.» — это остатки поставщика, которые сайт -хранит в мете `_remote_stock_blocks`. Адаптер её игнорировал, и единственные -настоящие данные о наличии, которые у нас вообще есть, пропадали; у остальных -карточек в этом месте написано «По запросу» — не «нет», а «уточняется». +После смены темы (28.09.2026) магазин пишет остатки по полю на склад и срок +рядом: `EKF_Stock_0` + `EKF_Stock_0_delivery` («1-2 дня»), `Chint_Stock_0` + +«3-4 дня», `Axima_Stock_0`/`Axima_Stock_2` + «1 день». + +Читать сводку `_remote_stock_blocks` больше нельзя: срок в ней прибит +константой «1-2 дн.» для каждого поставщика (у Chint собственное поле говорит +«3-4 дня» — так в 39 случаях из 40), а количество расходится с исходными +полями в 67 случаях из 77. Сводке оставлен только ПРОМСИТЕХ, у которого своих +полей нет, и срок оттуда не берётся. """ import json -from modules.procurement.site_adapter import _availability, _to_offer - +from modules.procurement.site_adapter import _availability, _delivery_days, _to_offer -BLOCK = { - "warehouse": "Удалённый склад", - "supplier": "PRSTH", - "qty": 2, - "lead_time_min": 1, - "lead_time_max": 2, -} - -def _product(meta_value, **kw): +def _product(meta: dict, **kw): p = { - "id": 109832, - "name": "Редуктор червячный INNORED IRWD075-50-80B14", - "sku": "IRWD075-50-80B14", - "price": "107340", + "id": 97896, + "name": "Дифференциальный автомат NXBLE-63 2P C63 30mA", + "sku": "819983", + "price": "6347", "stock_status": "instock", "manage_stock": False, "stock_quantity": None, - "meta_data": [{"key": "_remote_stock_blocks", "value": meta_value}], + "meta_data": [{"key": k, "value": v} for k, v in meta.items()], } p.update(kw) return p -def test_filled_block_gives_stock_and_lead_time(): - a = _availability(_product([BLOCK])) - assert a["in_stock"] is True - assert a["stock_qty"] == 2 - assert a["lead_time_days"] == 2, "обещать срок короче максимального нельзя" - - -def test_block_may_arrive_json_encoded(): - a = _availability(_product(json.dumps([BLOCK]))) - assert a["in_stock"] is True and a["stock_qty"] == 2 +def test_delivery_string_takes_the_upper_bound(): + """Обещать быстрее, чем сказал поставщик, нельзя.""" + assert _delivery_days("1 день") == 1 + assert _delivery_days("1-2 дня") == 2 + assert _delivery_days("3-4 дня") == 4 + assert _delivery_days("уточняется") is None + assert _delivery_days(None) is None -def test_several_warehouses_sum_up_and_take_worst_lead_time(): - second = {**BLOCK, "warehouse": "Склад 2", "qty": 5, "lead_time_max": 7} - a = _availability(_product([BLOCK, second])) - assert a["stock_qty"] == 7 - assert a["lead_time_days"] == 7 +def test_supplier_stock_gives_quantity_and_its_own_lead_time(): + a = _availability(_product({"Chint_Stock_0": "54", "Chint_Stock_0_delivery": "3-4 дня"})) + assert a["in_stock"] is True + assert a["stock_qty"] == 54 + assert a["lead_time_days"] == 4, "срок Chint — 3-4 дня, а не константа сводки" + + +def test_two_warehouses_of_one_supplier_sum_up(): + a = _availability( + _product( + { + "Axima_Stock_0": "3", + "Axima_Stock_2": "5", + "Axima_Stock_0_delivery": "1 день", + "Axima_Stock_2_delivery": "1 день", + } + ) + ) + assert a["stock_qty"] == 8 + assert a["lead_time_days"] == 1 + + +def test_fastest_supplier_with_stock_wins(): + """Склады разных поставщиков не суммируются: иначе «12 шт. за 2 дня» + означало бы, что приедут и те семь, что лежат у четырёхдневного.""" + a = _availability( + _product( + { + "EKF_Stock_0": "5", + "EKF_Stock_0_delivery": "1-2 дня", + "Chint_Stock_0": "7", + "Chint_Stock_0_delivery": "3-4 дня", + } + ) + ) + assert a["in_stock"] is True + assert a["stock_qty"] == 5, "показываем остаток самого быстрого, а не сумму" + assert a["lead_time_days"] == 2 + + +def test_supplier_without_stock_is_skipped(): + a = _availability( + _product( + { + "Axima_Stock_0": "0", + "Axima_Stock_2": "0", + "Axima_Stock_0_delivery": "1 день", + "Chint_Stock_0": "12", + "Chint_Stock_0_delivery": "3-4 дня", + } + ) + ) + assert a["stock_qty"] == 12 + assert a["lead_time_days"] == 4 + + +def test_all_known_suppliers_at_zero_is_not_a_refusal(): + """Позицию может возить тот, чьих полей на сайте нет, — это «уточняется».""" + a = _availability(_product({"Axima_Stock_0": "0", "Axima_Stock_2": "0"})) + assert a["in_stock"] is None + assert a["lead_time_days"] is None -def test_empty_block_is_on_request_not_out_of_stock(): - """«По запросу» в карточке — это «уточняется», а не подтверждённое «нет».""" - a = _availability(_product([])) +def test_stale_summary_never_overrides_supplier_fields(): + """Сводка уверяет, что у AXIMA 28 шт., хотя оба её склада дают ноль.""" + a = _availability( + _product( + { + "Axima_Stock_0": "0", + "Axima_Stock_2": "0", + "_remote_stock_blocks": [ + { + "warehouse": "Удалённый склад", + "supplier": "AXIMA", + "qty": 28, + "lead_time_min": 1, + "lead_time_max": 2, + }, + ], + } + ) + ) assert a["in_stock"] is None - assert a["lead_time_days"] is None + assert a["stock_qty"] is None + + +def test_summary_is_used_only_for_suppliers_without_their_own_fields(): + """ПРОМСИТЕХ живёт только в сводке — остаток берём, срок нет.""" + a = _availability( + _product( + { + "_remote_stock_blocks": [ + { + "warehouse": "Удалённый склад", + "supplier": "PRSTH", + "qty": 2, + "lead_time_min": 1, + "lead_time_max": 2, + }, + ], + } + ) + ) + assert a["in_stock"] is True + assert a["stock_qty"] == 2 + assert a["lead_time_days"] is None, "срок в сводке — константа, а не данные" -def test_missing_meta_falls_back_to_unknown(): - p = _product([]) - p["meta_data"] = [] - assert _availability(p)["in_stock"] is None +def test_summary_json_encoded_is_parsed(): + blocks = [{"supplier": "PRSTH", "qty": 3, "lead_time_min": 1, "lead_time_max": 2}] + a = _availability(_product({"_remote_stock_blocks": json.dumps(blocks)})) + assert a["in_stock"] is True and a["stock_qty"] == 3 def test_default_instock_status_is_still_not_a_claim(): - """У всех 30 тыс. товаров stock_status='instock' по умолчанию — это не наличие.""" - p = _product([]) - p["meta_data"] = [] - p["stock_status"] = "instock" - assert _availability(p)["in_stock"] is None + """У всех 30 тыс. товаров stock_status='instock' по умолчанию.""" + assert _availability(_product({}))["in_stock"] is None def test_explicit_out_of_stock_status_is_honoured(): - p = _product([]) - p["stock_status"] = "outofstock" - assert _availability(p)["in_stock"] is False + assert _availability(_product({}, stock_status="outofstock"))["in_stock"] is False -def test_malformed_block_does_not_crash(): - assert _availability(_product("не json"))["in_stock"] is None - assert _availability(_product([{"qty": "два"}]))["in_stock"] is None +def test_malformed_values_do_not_crash(): + assert _availability(_product({"_remote_stock_blocks": "не json"}))["in_stock"] is None + assert _availability(_product({"EKF_Stock_0": "две штуки"}))["in_stock"] is None + assert ( + _availability(_product({"_remote_stock_blocks": [{"supplier": "PRSTH"}]}))["in_stock"] + is None + ) -def test_offer_carries_stock_lead_time_and_raw_blocks(): - o = _to_offer(_product([BLOCK])) +def test_offer_carries_stock_lead_time_and_raw_readings(): + o = _to_offer(_product({"Chint_Stock_0": "54", "Chint_Stock_0_delivery": "3-4 дня"})) assert o["in_stock"] is True - assert o["lead_time_days"] == 2 - assert o["extra"]["remote_stock"][0]["supplier"] == "PRSTH" + assert o["lead_time_days"] == 4 + assert o["extra"]["suppliers"][0]["supplier"] == "Chint"