Перейти к основному содержимому

Передача 10.08.2026 — интерфейс для менеджеров, скрытие старого, разбор потери данных

Это вход для новой сессии. Прочитать целиком до первой правки. Постоянный вход в проект — ../../AGENTS.md и ../../for-ai-agents/START-HERE.md.

Предыдущее состояние: HANDOFF-2026-08-09.md — разворот JSON-блобов приёма в колонки и предел сторожа покрытия.

1. ⚠️ ПЕРВЫМ ДЕЛОМ: старый интерфейс СКРЫТ — как вернуть

Решение владельца 09.08.2026: для менеджера старого интерфейса больше нет. Сам он жив и не удалён, скрыта только публичная дорога к нему.

Что сделано. Правка одного файла на s2 — /data/hypertime/app/docker/nginx/conf.d/50-gwptd.conf, конфиг примонтирован, пересборка не нужна:

АдресБылоСтало
mp.hyp.ru/302 → /admin302 → /new
mp.hyp.ru/adminпроксировался в gwptd-app302 → /new
mp.hyp.ru/old302 → /admin302 → /new

Как вернуть старый интерфейс — три способа, от быстрого к чистому.

Способ первый, мгновенный откат целиком (бэкап снят перед правкой):

ssh s2-int 'F=/data/hypertime/app/docker/nginx/conf.d/50-gwptd.conf
cp "$F.bak-pered-skrytiem-admin-20260809" "$F" \
&& docker exec new_hypertime_nginx nginx -t \
&& docker exec new_hypertime_nginx nginx -s reload'

Способ второй, вернуть только /admin, оставив корень на /new — если нужны оба интерфейса параллельно. Убрать из файла блок:

    location ^~ /admin {
return 302 https://mp.hyp.ru/new;
}

и перечитать конфиг (nginx -t && nginx -s reload). Тогда /admin снова проксируется в gwptd-app:8000 общим правилом location /, а корень продолжает вести на /new.

Способ третий, доступ только для себя, без публичной дороги — туннелем, ничего не меняя в конфиге:

ssh -L 8100:gwptd-app:8000 s2-int

затем http://127.0.0.1:8100/admin.

⚠️ Проверять выдачей, а не конфигом:

for u in / /admin /old /new; do
printf "%-8s %s %s\n" "$u" \
"$(curl -s -o /dev/null -w '%{http_code}' https://mp.hyp.ru$u)" \
"$(curl -s -o /dev/null -w '%{redirect_url}' https://mp.hyp.ru$u)"
done

Почему это может понадобиться. Старый контур остаётся эталоном чисел: пока новый интерфейс не сверен по всем площадкам, сравнивать не с чем. И часть возможностей старого интерфейса в новый не перенесена — реестр того, что закрыто заглушкой, в §3.

2. Что теперь видит менеджер

Адрес один: https://mp.hyp.ru/new. Работают полностью три площадки:

ПлощадкаРейтингОборачиваемостьОтгрузки
WB FBO (mp1)
Ozon FBS (mp3)
Lamoda (mp9)

Всё остальное закрыто заглушкой «в разработке». Заглушка закрывает три поверхности: страницу, обращение к Data API (отдаёт отказ, а не пустой успешный ответ) и задание в расписании. Пустой массив читался бы как «ноль продаж» — это хуже отказа.

На страницах расчётов видна дата данных и пометка, если отставание больше трёх суток.

3. Реестр заглушек — единственный источник правды

config/gwptd.php, ключ stubs. У каждой записи площадка, причина и основание (замер). Добавить заглушку = дописать строку, снять = убрать.

ПлощадкаЧто закрытоПочему
WB FBS (2), Ozon rFBS (4), YM FBY (8)всё трив фактах ядра пусто
Ozon FBO (5)оборачиваемостьнет данных в fact_turnover
YM FBS (6)отгрузкинет данных для расчёта
Золотое яблоко (10)всё трисм. ниже

⚠️ Золотое яблоко — главная находка. Отгрузки там БЫЛИ: две партии от 07.08, 1713 строк, 75 штук на 1,25 млн ₽. И у всех 1713 строк рейтинг нулевой, у 1671 нулевые продажи. Отгрузка ранжирует по рейтингу, а ранжировать было нечем. Показать это менеджеру значило бы дать ему отгрузить по выдуманным числам.

Механизм: app/Services/StubService.php, app/Filament/Concerns/RedirectIfStubbed.php, app/Filament/Pages/DevelopmentStubPage.php. Тесты — tests/Feature/Stubs/.

4. Разбор потери данных: 16 300 строк за сутки

Сбор получал строки и не раскладывал их. Ядро отказывалось публиковать факты (rows_parsed_received_mismatch), поэтому оборачиваемость стояла с 3 августа. Отказ ядра сработал как защита: неверных чисел никто не увидел.

Четыре причины, все — следствие доверия выборке:

  1. Список уходил параметром. _bind разворачивал список в отдельные параметры вместо одного значения — четыре разных сообщения об ошибке от одной причины.
  2. Тип выведен по сотне записей. У Ozon dimensions[].id бывает датой рядом с числовым артикулом; у Золотого яблока опознаватели — UUID, а цена приходит объектом.
  3. Дата площадки объявлена как DATE, а приходит строкой ISO с Z. Поле площадки хранится КАК ПРИШЛО: преобразование — производное.
  4. parent_id/item_index были обязательными. Это понятие ЛОКАЛЬНОЙ раскладки; сборщик связывает потомков деловым ключом и их не заполняет — терялось 108 из 108.

Записано миграцией marketplace-collector-v3/kernel/schemas/migrations/2026-08-09_intake_types_from_real_values.sql, идемпотентность проверена двумя прогонами. ⚠️ ADD COLUMN IF NOT EXISTS — синтаксис MariaDB, MySQL 8 отвечает 1064.

Итог: все затронутые эндпоинты разбираются чисто, ошибок разбора ноль.

5. Договор вывода: правка описания требует ОДНОГО свежего сбора

Ядро сверяет расписку прогона с текущим договором. Правка описания или схемы обесценивает все прежние расписки, и ядро честно отказывает, пока эндпоинт не пересобран.

Причины идут ступенями; смена причины означает, что предыдущая закрыта:

СообщениеЧто означает
spec_digest_mismatchописание изменилось, данные по старому
parser_errors_nonzeroразбор падает
output_digest_mismatchдобавлены колонки
output_contract_mismatchнабор таблиц в расписке ≠ объявленному

Список затронутых даёт сам отказ: grep -oE "incomplete: .*" в журнале пересчёта.

Дефект, найденный 10.08 и починенный. Движок вложенного разворота писал в дочерние таблицы, а расписка их не объявляла — add_output в spec_runtime/runner.py не спускался в explode.children. Прогон записывал две таблицы при четырёх. Починено рекурсией плюс kernel/etl/build_input_evidence.py, где ожидаемый список строился только из tables, без intake_only.

От этого зависели и приведены в соответствие: манифест Золотого яблока (specs/kernel/goldapple-kernel-inputs.yaml), функциональный снимок схемы docs/collectors/SCHEMA-SNAPSHOT-2026-07-13.sql (в нём не было 63 дочерних таблиц вовсе), рубеж матрицы стоков, жёсткие ожидания в тестах расписки.

6. Выкладка s3: роняет службу на последнем шаге

Четыре раза подряд одно и то же. Внутренняя выкладка завершается успешно — код доезжает, контейнеры собираются и здоровы. Гибнет обёртка после: переиндексация справки (activate-s3-mcp-gwptd.sh, 20 713 документов, 30–40 мин) не укладывается в срок. Обработчик выхода пишет completion_unproven, изымает расписание, гасит контейнеры.

Восстановление (проверено):

ssh s3-int 'docker start gwptd-data-api gwptd-app-new
docker update --restart unless-stopped gwptd-data-api gwptd-app-new
crontab /opt/gwptd-analytics/repo/marketplace-collector-v3/scripts/crontab.s3-next.txt
T=/etc/gwptd/state/s3-next-deploy-transaction.env
[ -e "$T" ] && mv "$T" "$T.razobran-$(date +%H%M)"'

Расписки откладывать с пометкой, не удалять — они доказательство произошедшего.

⚠️ Перед повторной выкладкой обёртка требует точного состояния текущего рантайма: образ = метка :next, здоровье healthy, политика перезапуска unless-stopped. После ручного подъёма политика остаётся no, и выкладка отказывает с «runtime identity/health is not exact». Лечится docker update --restart unless-stopped — и это не формальность: при no контейнеры не поднимутся после перезагрузки хоста.

⚠️ SHA в --expected-sha брать целиком из git rev-parse HEAD. Совпадение первых восьми знаков ничего не значит: обёртка сверяет все сорок и отказывает с «fetched branch SHA differs».

Нерешённый вопрос владельца: убрать переиндексацию из пути выкладки. Она снята с автозапуска как ненужная, но каждую выкладку роняет службу. Это правка защищённой обёртки на сервере — без слова владельца не делать.

7. Состояние на утро 10.08.2026

Код на s3257fb492
Контейнерыоба живы, политика unless-stopped
Расписание80 строк, сторож автономности активен
/new публичноотвечает
Заглушкипроверены в живом контейнере
Набор тестов сборщика17 падений (2 требуют живой базы)
Набор интерфейса176 зелёных
Оборачиваемость3 августа — ждёт пересчёта
Рейтингавгуст
Не покрыто листьев23 из 1266

Идёт прямо сейчас: пересбор тринадцати эндпоинтов под новым договором, за ним пересчёт ядра — цепочка /tmp/recollect-then-kernel.sh на s3, журнал /tmp/recollect.log. Это последнее, что отделяет три площадки от свежих чисел.

8. Локальная база разбора — отдельная работа, готова

mp_hyp_ru на 127.0.0.1:33061 (контейнер mp-workshop-mysql): 175 таблиц, 3455 колонок, 1 054 194 строки, собрана переразбором накопленного сырья без опроса площадок.

Смотреть — Adminer http://127.0.0.1:8011, учётная запись mp_viewer (только чтение), пароль в ~/.gwptd/mp_hyp_ru-viewer.txt.

Программы: marketplace-collector-v3/scripts/generate_schema_from_json.py (порождает схему из анализа JSON), replay_raw_into_schema.py (раскладывает сырьё по порождённому перечню), json_leaf_walk.py (общий обход листьев со значениями).

⚠️ Предел строки InnoDB — 8126 байт, а не объявленные MySQL 65535, плюс накладные на каждую колонку. Широкие varchar переводятся в text.

10. ⚠️ ЕДИНСТВЕННОЕ, ЧТО ОСТАЛОСЬ: решение владельца по конфликту опознавателя

Ядро прошло все заставы входов — пять классов отказа закрыты подряд, и каждый сменялся следующим, то есть закрывался. Теперь оно падает ВНУТРИ расчёта, на шаге resolve_identifiers:

IdentifierProjectionError: candidate identifier conflicts with an existing mapping: 1
build_all stopped after failed step: resolve_identifiers

Что это значит. Ровно ОДИН опознаватель товара, вычисленный этим прогоном, спорит с уже сохранённой привязкой: та же площадка, тот же тип опознавателя, то же значение — но другая модель. Резолвер отказывается молча перезаписать привязку и останавливает сборку. Это правильно: durable-привязка артикул↔модель — то, на чём держится весь учёт.

Есть штатный путь, и он аудируемый. Флаг окружения KERNEL_ALLOW_IDENTIFIER_REMAP (marketplace-collector-v3/kernel/etl/resolve_identifiers.py) включает перенос привязок, и включает его ИЗБИРАТЕЛЬНО: переезжают только конфликты, объяснённые pick-ом того же прогона (сохранённая модель ПРОИГРАЛА детерминированному выбору по тому же идентификатору). Необъяснённые конфликты по-прежнему валят сборку — механизм fails closed, а не «разрешить всё».

Известный предшественник (21.07.2026): marketSku Яндекса на два shopSku при перевыставлении карточки — для Яндекс.Маркета это структурно легально. Тогда и появился детерминированный picker: 1) uuid_1c IS NOT NULL (настоящая модель 1С) важнее lazy-promoted, 2) MIN(model_id) — базовый артикул важнее варианта «_N».

Решение владельца, которое нужно назвать явно: включать ли KERNEL_ALLOW_IDENTIFIER_REMAP для этого прогона.

  • За: это единственное, что отделяет три площадки от свежих чисел; путь штатный, аудируемый и не пропускает необъяснённые конфликты; предшественник признан легальным.
  • Против: перенос привязки меняет, к какой модели относится артикул площадки. Если конфликт НЕ из-за перевыставления карточки, а из-за ошибки сопоставления, перенос закрепит ошибку в durable-таблице.

Что я делать не буду без слова владельца: включать флаг. Это не технический выбор, а решение о том, чей артикул чьей моделью считать.

Что можно сделать без решения: посмотреть сам конфликт. Временная таблица кандидата удаляется вместе с прогоном, поэтому нужен прогон с сохранением кандидата — либо разбор dim_product_identifier против свежего приёма по типам marketSku/shopSku (mp6), nmID/vendorCode (mp1), offer_id/product_id/sku (mp3), barcode (mp0).

9. Что делать дальше по порядку

  1. Дождаться цепочки пересбора и убедиться, что оборачиваемость сдвинулась с 3 августа.
  2. Если ядро отказывает снова — взять список из incomplete: и пересобрать названные эндпоинты. Это конечная процедура, а не тупик.
  3. Закрыть два дефекта описаний, вскрывшихся из-под прежних ошибок: ozon.postings_fbo — «different field sets by kind»; goldapple.snapshot — расхождение двух компиляторов (load_runtime_productload_product).
  4. Решение владельца: убрать переиндексацию из пути выкладки (§6).
  5. Остаток 23 листьев — только по запросу бренд-менеджера. Принцип владельца: нужность определяет тот, кто работает с данными, а не мы.