diff --git a/CLAUDE.md b/CLAUDE.md index c8933f52..435351ec 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()` **сворачивает омоглифы** (`_fold` — визуально неотличимые кириллические буквы в латиницу, только для сопоставления: каталог расколот почти пополам, 11 501 позиция с кириллической А/Р/В после цифры против 10 167 с латинской, и запрос «АВР 630А» видел лишь свою половину — «ввод резерва АВР NXZM-630S/3B 3P 630A» с латинской A выпадал из выдачи целиком при 36 штуках на складе; буквы б/г/з в таблицу НЕ входят, «б» это не «6»; сворачивать токены надо ПОСЛЕ стоп-слов и синонимов, иначе свёрнутое слово в `_STOPWORDS` не найдётся), фильтрует стоп-слова (мета-запросы «запрос/стоимость/счёт», «из наличия/помоги/найти/подобрать», единицы «квт/вт/ква» и «ампер» — на «нужен АВР 630 ампер» выдача состояла из АМПЕРМЕТРОВ, потому что «ампер» был единственным значимым токеном и попадал в «амперметр», а «авр» и «630» коротки и значимыми не считаются), делает префикс-стемминг (`_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 — дилерские цены). На проде каталог постоянный и **вне репозитория** — `/var/lib/ai-secretary/supplier-prices` (прежний `/tmp/stalker_prices` слетал при ребуте, а внутри репо его снёс бы `git clean`). Файлов нет — `sync_all_suppliers` логирует FileNotFoundError по каждому поставщику и не роняет остальных. **Чат-интеграция** (`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/парсинга — фолбэк на сырой текст, чат не ломается. **Слова, которые ввёл в оборот сам ассистент, из запроса вычищаются кодом** (`_drop_words_only_the_assistant_used`), а не только запрещаются промптом: клиент спросил «АВР есть на 630А?», ассистент предложил «Устройство АВР ТСР1/ТСM … EKF PROxima», клиент ответил «мне нужен из наличия» — и билдер собрал `устройство АВР 630А 3Р EKF PROxima`, подставив бренд и слово «устройство» из ответа САМОГО ассистента. Поиск вернул только EKF, три позиции «Устройство АВР …» забрали всё окно выдачи, и Chint NXZM-630S/3B 630A (36 шт. на складе) до клиента не доехал — воронка: что ассистент показал первым, то и становилось требованием навсегда. Критерий именно «сказал ассистент, но не клиент», а не «нет у клиента»: нормализацию модель делает своими словами («нужен промышленный» → «контактор силовой»), и «силовой» не произносил никто — такие слова нужны. Числовые номиналы («630а», «3р», «220в») не отбираются никогда, даже от ассистента: он мог уточнить «трёхполюсный?» — «да». `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-розницу. **Остатки из прайсов клиенту тоже НЕ уходят**: `search(..., with_supply=manager_ctx)` отключает `_attach_supply` для клиентских каналов. Прайс — внутренний срез на дату файла, и он устаревает: 02.10.2026 по блокам питания DR-*-24 сайт остатка не подтверждал, а июльский прайс SunWell/EKF показывал 34 шт., ассистент выдал «✅ В наличии», и заказчик ответил «их же нет в наличии» и потребовал брать цены и остатки ТОЛЬКО с сайта. В ярлыке для менеджера дата пишется как «прайс, загружен DATE», а не «прайс от DATE»: `supply.as_of` это `ProductOffer.updated`, то есть когда МЫ разобрали файл, а не дата самого файла («ОСТАТКИ 14.07» могли перечитать сегодня). Провайдер `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) и `procurement-supplier-sync` (daily 23:50 UTC — после каталога: оба источника пишут в одну таблицу). Без второй прайсы разбирались только руками через `POST /admin/procurement/sync-suppliers`, и положенный в папку свежий файл никто не подхватывал — офферы поставщиков простояли нетронутыми с 24.07 по 28.09.2026. **Генерация КП** (`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()` **сворачивает омоглифы** (`_fold` — визуально неотличимые кириллические буквы в латиницу, только для сопоставления: каталог расколот почти пополам, 11 501 позиция с кириллической А/Р/В после цифры против 10 167 с латинской, и запрос «АВР 630А» видел лишь свою половину — «ввод резерва АВР NXZM-630S/3B 3P 630A» с латинской A выпадал из выдачи целиком при 36 штуках на складе; буквы б/г/з в таблицу НЕ входят, «б» это не «6»; сворачивать токены надо ПОСЛЕ стоп-слов и синонимов, иначе свёрнутое слово в `_STOPWORDS` не найдётся), фильтрует стоп-слова (мета-запросы «запрос/стоимость/счёт», «из наличия/помоги/найти/подобрать», единицы «квт/вт/ква» и «ампер» — на «нужен АВР 630 ампер» выдача состояла из АМПЕРМЕТРОВ, потому что «ампер» был единственным значимым токеном и попадал в «амперметр», а «авр» и «630» коротки и значимыми не считаются), делает префикс-стемминг (`_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 — дилерские цены). На проде каталог постоянный и **вне репозитория** — `/var/lib/ai-secretary/supplier-prices` (прежний `/tmp/stalker_prices` слетал при ребуте, а внутри репо его снёс бы `git clean`). Файлов нет — `sync_all_suppliers` логирует FileNotFoundError по каждому поставщику и не роняет остальных. **Чат-интеграция** (`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/парсинга — фолбэк на сырой текст, чат не ломается. **Слова, которые ввёл в оборот сам ассистент, из запроса вычищаются кодом** (`_drop_words_only_the_assistant_used`), а не только запрещаются промптом: клиент спросил «АВР есть на 630А?», ассистент предложил «Устройство АВР ТСР1/ТСM … EKF PROxima», клиент ответил «мне нужен из наличия» — и билдер собрал `устройство АВР 630А 3Р EKF PROxima`, подставив бренд и слово «устройство» из ответа САМОГО ассистента. Поиск вернул только EKF, три позиции «Устройство АВР …» забрали всё окно выдачи, и Chint NXZM-630S/3B 630A (36 шт. на складе) до клиента не доехал — воронка: что ассистент показал первым, то и становилось требованием навсегда. Критерий именно «сказал ассистент, но не клиент», а не «нет у клиента»: нормализацию модель делает своими словами («нужен промышленный» → «контактор силовой»), и «силовой» не произносил никто — такие слова нужны. Числовые номиналы («630а», «3р», «220в») не отбираются никогда, даже от ассистента: он мог уточнить «трёхполюсный?» — «да». `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-розницу. **Остатки из прайсов идут и клиенту, но называются иначе.** 02.10.2026 по блокам питания DR-*-24 сайт остатка не подтверждал, а прайс SunWell/EKF показывал 34 шт., ассистент выдал «✅ В наличии», и заказчик ответил «их же нет в наличии». Сначала источник отключили для клиентских каналов (`with_supply=manager_ctx`), но 03.10.2026 директор подтвердил, что файлы актуальны, и попросил вернуть — «есть у поставщика, срок уточняется» это ровно то, что он просил показывать с самого начала. Вывод: **разводить склад и прайс надо СЛОВАМИ, а не отключением источника**. Поэтому клиентский ярлык звучит «есть у поставщика (не на нашем складе) — уточнить только срок поставки», а ✅ и слово «в наличии» разрешены ТОЛЬКО для пометки «в наличии N шт.» (подтверждённый остаток сайта). `with_supply=False` остался для мест, где прайс не к месту (КП, триаж писем). Имя поставщика и закупочная цена по-прежнему только менеджеру. В ярлыке для менеджера дата пишется как «прайс, загружен DATE», а не «прайс от DATE»: `supply.as_of` это `ProductOffer.updated`, то есть когда МЫ разобрали файл, а не дата самого файла («ОСТАТКИ 14.07» могли перечитать сегодня). Провайдер `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) и `procurement-supplier-sync` (daily 23:50 UTC — после каталога: оба источника пишут в одну таблицу). Без второй прайсы разбирались только руками через `POST /admin/procurement/sync-suppliers`, и положенный в папку свежий файл никто не подхватывал — офферы поставщиков простояли нетронутыми с 24.07 по 28.09.2026. **Генерация КП** (`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/chat/facade.py b/modules/chat/facade.py index 665af9e5..d2e68e63 100644 --- a/modules/chat/facade.py +++ b/modules/chat/facade.py @@ -431,7 +431,9 @@ def _supply_label(supply: dict | None, manager_ctx: bool) -> str: f"есть у поставщика ({supply.get('supplier_name') or '—'}{qty}," f" прайс{as_of}) — уточнить только срок" ) - return "есть у поставщика — уточнить только срок поставки" + # «не на нашем складе» прямо в тексте: прежнюю формулировку модель + # сжимала в «✅ В наличии», и клиент отвечал «их же нет в наличии». + return "есть у поставщика (не на нашем складе) — уточнить только срок поставки" if supply.get("available") is False: return ( f"у поставщика остатка не было (прайс{as_of}) — только под заказ, срок уточнить" @@ -479,13 +481,14 @@ async def _inject_offer_context( search_query, exclude = await build_search_query(llm, history or [], content) # Exact keyword search for offers (prices/stock/articles — КП-exact rule). - # Остатки из прайсов поставщиков — только менеджеру. Клиенту наличие и - # цены идут ТОЛЬКО с сайта: прайс это внутренний срез на дату файла, и - # июльский срез выдавал «✅ В наличии» по позициям, которых на складе - # нет (02.10.2026 — клиент: «их же нет в наличии»). - offers = await offer_service.search( - search_query, limit=8, exclude=exclude, with_supply=manager_ctx - ) + # Остатки из прайсов идут и клиенту: директор подтвердил (03.10.2026), + # что файлы актуальны, а «есть у поставщика, срок уточняется» — ровно + # то, что он просил показывать. Беда 02.10 была не в источнике, а в + # формулировке: пометку сжимали в «✅ В наличии», и клиент справедливо + # отвечал «их же нет в наличии». Поэтому склад и поставщик теперь + # разведены словами (см. `_supply_label` и правила ниже), а имя + # поставщика и закупочная цена по-прежнему только менеджеру. + offers = await offer_service.search(search_query, limit=8, exclude=exclude) # Supplementary meaning-based context from the catalog collection (rich # descriptions) — helps vague/functional queries («чем питать насос»). @@ -594,20 +597,18 @@ async def _inject_offer_context( "Позицию с пометкой «НЕТ в наличии» так и назови — нет в наличии — и сразу предложи " "из этого же списка технически подходящую замену (близкий номинал, то же напряжение " "и тип контактов), прямо сказав, что это замена и чем она отличается. " - "«в наличии N шт.» — единственная пометка, которую можно называть наличием. " - "«наличие уточняется» значит, что остаток НЕ подтверждён: за наличие не выдавай, " - "галочку «✅ в наличии» не ставь и количество не придумывай. " + "«в наличии N шт.» — ЕДИНСТВЕННАЯ пометка, при которой можно сказать «в наличии» " + "и поставить ✅: это подтверждённый остаток. " + "«есть у поставщика» / «есть в прайсе поставщика» — позиция поставляемая, но это " + "НЕ наш склад: пиши «есть у поставщика, срок уточним», без ✅ и без слова «наличии». " + "Сам факт поставляемости под сомнение не ставь и не отказывай — уточнить надо только срок. " + "«наличие уточняется» значит, что остаток НЕ подтверждён ни складом, ни прайсом: " + "за наличие не выдавай, галочку не ставь и количество не придумывай. " "«цена не указана» значит, что цены у тебя нет: не называй её и не оценивай — " "такую позицию предлагай только через менеджера. " "Если подходящего нет вообще — честно скажи " "«не нашёл» и предложи передать запрос менеджеру." ) - if manager_ctx: - block += ( - " «есть у поставщика» / «есть в прайсе поставщика» значит, что позиция" - " поставляемая: уточнять надо срок и актуальность прайса, а не сам факт." - " Клиенту эти пометки не уходят — у него наличие только с сайта." - ) if manager_ctx: block += ( " «клиенту» — цена для КП (наценка+НДС уже учтены); «закуп» — закупочная, " diff --git a/modules/procurement/service.py b/modules/procurement/service.py index 0312af34..098d8acd 100644 --- a/modules/procurement/service.py +++ b/modules/procurement/service.py @@ -418,12 +418,13 @@ async def search( («модульный не подходит») — see ``query_builder.build_search_query``. ``with_supply=False`` отключает догрузку остатков из прайсов поставщиков - (`_attach_supply`). Клиентским каналам её давать нельзя: прайсы — - внутренний срез на дату файла, и он устаревает. Реальный случай - (02.10.2026): по блокам питания DR-*-24 сайт остатка не подтверждал, - а июльский прайс SunWell/EKF показывал 34 шт. — клиент получил - «✅ В наличии» и ответил «их же нет в наличии». Наличие и цены клиенту - идут ТОЛЬКО с сайта; прайсы остаются менеджеру, который видит и дату. + (`_attach_supply`) — нужно там, где прайс не к месту (КП считается по + своим правилам, триаж писем смотрит на совпадение темы). В чате она + включена и для клиента: директор подтвердил (03.10.2026), что файлы + актуальны, а «есть у поставщика, срок уточняется» — ровно то, что он + просил показывать. Разводить склад и прайс надо СЛОВАМИ, а не + отключением источника: 02.10.2026 пометку «есть у поставщика» модель + сжала в «✅ В наличии», и клиент ответил «их же нет в наличии». """ q_norm = _norm(query) # Токены сворачиваем ПОСЛЕ стоп-слов и синонимов: в `_STOPWORDS` лежат diff --git a/tests/unit/test_supplier_stock_unknown.py b/tests/unit/test_supplier_stock_unknown.py index 2b00642a..7b274b2e 100644 --- a/tests/unit/test_supplier_stock_unknown.py +++ b/tests/unit/test_supplier_stock_unknown.py @@ -224,19 +224,27 @@ async def test_scope_key_does_not_touch_a_neighbour_supplier(svc): assert "Реле Аксима" in names and "Реле Санвел" in names -async def test_client_channel_gets_site_stock_only(svc): - """Остатки из прайсов поставщиков клиенту не уходят. +async def test_client_also_gets_supplier_stock_but_never_the_supplier_name(svc): + """Остатки из прайсов идут и клиенту — директор подтвердил актуальность файлов. - Реальный случай (02.10.2026): по блокам питания DR-*-24 сайт остатка не - подтверждал, а июльский прайс SunWell/EKF показывал 34 шт. — клиент получил - «✅ В наличии» и ответил «их же нет в наличии». Прайс это внутренний срез на - дату файла; наличие и цены клиенту идут ТОЛЬКО с сайта. + Беда 02.10.2026 была не в источнике, а в формулировке: пометку «есть у + поставщика» модель сжимала в «✅ В наличии», и клиент отвечал «их же нет в + наличии». Поэтому источник включён, а склад и прайс разведены словами — + и имя поставщика клиенту всё равно не уходит. """ - site_only = await svc.search("дифференциальный автомат NXBLE", limit=5, with_supply=False) - assert site_only, "поиск ничего не вернул" - assert all("supply" not in o for o in site_only if o["source"] == "site") - - # Менеджеру — по-прежнему с прайсом. - for_manager = await svc.search("дифференциальный автомат NXBLE", limit=5, with_supply=True) - site_rows = [o for o in for_manager if o["source"] == "site" and o["article"] == "819983"] - assert site_rows and site_rows[0]["supply"]["available"] is True + offers = await svc.search("дифференциальный автомат NXBLE", limit=5) + site = [o for o in offers if o["source"] == "site" and o["article"] == "819983"] + assert site and site[0]["supply"]["available"] is True + + label = _supply_label(site[0]["supply"], False) + assert "поставщика" in label + assert "SunWell" not in label, "имя поставщика клиенту раскрывать нельзя" + assert "наличии" not in label, "формулировку нельзя прочитать как наш склад" + assert "срок" in label + + +async def test_search_can_still_skip_supplier_lookup(svc): + """`with_supply=False` остаётся для мест, где прайс не к месту (КП, триаж).""" + offers = await svc.search("дифференциальный автомат NXBLE", limit=5, with_supply=False) + assert offers + assert all("supply" not in o for o in offers if o["source"] == "site")