этап 1 · окружение · данные в PostgreSQL · 8 контрольных запросов
Статус: сделано, перепроверено с чистого листа 24.10.2026. Границы этапа (без ядра, HTTP/MCP, контейнера и вызовов модели) соблюдены — их и не трогали.
duckdb, boto3) плюс
psycopg и sqlglot, нужные для работы с PostgreSQL на следующих этапах. Команда одна:
bash scripts/setup_env.sh.Команда:
.venv/bin/python reference_impl/qa/test_handler.py
Итоговая строка реального прогона:
Пройдено 85 из 85
Окружение перед прогоном было пересобрано с нуля (rm -rf .venv && bash scripts/setup_env.sh) —
это не повтор старого результата, а свежий прогон на чистом окружении.
ИНН «Альфа-Снаб Екатеринбург» (используется в запросах 1 и 4), определён запросом по
наименованию — 6600370124, одинаковый в обеих базах.
| № | Запрос | DuckDB | PostgreSQL | Совпало |
|---|---|---|---|---|
| 1 | Сумма закупок по ИНН «Альфа-Снаб Екатеринбург» за 2025: позиций, написаний, сумма без НДС | 4858 поз., 3 написания, 1 357 249 732,08 руб. | 4858 поз., 3 написания, 1 357 249 732,08 руб. | да |
| 2 | Сумма закупок БЕ 1100 за 2025: позиций, заказов, сумма без НДС | 23 718 поз., 11 778 заказов, 28 076 423 313,02 руб. | 23 718 поз., 11 778 заказов, 28 076 423 313,02 руб. | да |
| 3 | Поставщики класса 001_OIL за 2025: написаний, ИНН, записей без ИНН | 31 написание, 18 ИНН, 9 записей без ИНН | 31 написание, 18 ИНН, 9 записей без ИНН | да |
| 4 | Юрлица холдинга «Альфа-Снаб»: уникальных ИНН, записей справочника | 6 ИНН, 9 записей | 6 ИНН, 9 записей | да |
| 5 | Дизельное топливо за 2025, тонн: по классу МДМ / по группе материалов | 333 421,171 / 338 707,218 | 333 421,171 / 338 707,218 | да |
| 6 | Материалы по фрагменту «ШПАЛА»: материалов, групп, классов, без класса | 14 / 3 / 3 / 2 | 14 / 3 / 3 / 2 | да |
| 7 | Позиции свыше 10 млн руб. за 2026: позиций, БЕ, сумма | 1150 поз., 10 БЕ, 34 949 335 628,35 руб. | 1150 поз., 10 БЕ, 34 949 335 628,35 руб. | да |
| 8 | Позиции без класса МДМ, БЕ 7400, 2025: всего, без класса, сумма всего, сумма без класса, доля | 15 445 / 4327, 15 015 978 659,21 / 4 190 072 105,89 руб., 28,02 % | 15 445 / 4327, 15 015 978 659,21 / 4 190 072 105,89 руб., 28,02 % | да |
Итоговая строка скрипта сверки: совпадает, код возврата 0.
При переносе запросов на PostgreSQL обойдена известная ловушка из документации комплекта:
сумма закупок — поле с плавающей точкой, а округление до копеек на PostgreSQL для такого поля
без явного приведения к типу numeric не работает (функция round(double precision, integer)
не существует). Во всех запросах округление сделано как ROUND(сумма::numeric, 2).
Сверке можно доверять только если она умеет не только подтверждать совпадение, но и ловить расхождение. Проверено явно в обе стороны, оба прогона — свежие, после перезагрузки PostgreSQL.
1. Честный прогон — данные не трогали, ожидается «совпадает»:
.venv/bin/python scripts/compare_pg_duckdb.py
Итоговая строка: «совпадает», код возврата 0. Все 8 запросов — «да» (таблица выше).
2. Прогон с искажением — один результат PostgreSQL намеренно подменён, ожидается «НЕ совпадает»:
.venv/bin/python scripts/compare_pg_duckdb.py --self-check-poison
Искажены два запроса: сумма закупок по ИНН «Альфа-Снаб Екатеринбург» (на 0,01 руб., с
1 357 249 732,08 до 1 357 249 732,09 руб.) и число позиций по БЕ 1100 (на 1 штуку, с 23 718 до
23 719). Итоговая строка: «НЕ совпадает», код возврата 1; оба искажённых запроса в таблице
отмечены «НЕТ», остальные шесть — «да» (сверка не «краснит» всё подряд, а точечно находит именно
испорченные строки).
Отдельно проверено более крупное и округлое искажение по прямому требованию заказчика — ровно
1 рубль на сумме около 28 млрд (запрос 2: 28 076 423 313,02 → 28 076 423 314,02 руб.) — и оно
тоже распознано как «НЕТ»: точность сравнения (Decimal, округление ровно до 2 знаков, равенство
без допуска) не теряет расхождение даже на крупной сумме. Подробности и вывод этой отдельной
проверки — в reports/archive/etap-4-priemka-chistyi-progon.md, раздел 6.
Вывод: сверка работает в обе стороны — подтверждает совпадение, когда данные верны, и находит расхождение, когда данные испорчены. Если бы самопроверка этого не умела, доверять зелёной галочке «совпадает» на настоящих данных было бы нельзя.
Проверено прямым запросом count(*) к своей PostgreSQL после загрузки — совпадает с числом строк,
которое выгрузил export_csv.py из purch_demo.duckdb.
| Таблица | Строк |
|---|---|
dm_purch.order_purch_h2 |
297 362 |
dm_purch.request_purch_h2 |
311 732 |
dm_purch.mrp_order_h2 |
288 641 |
dm_purch.receipt_agg_h2 |
138 471 |
dm_purch.mdm_class |
1 048 |
dm_purch.dim_material |
3 071 |
dds_v_purch.dim_contragent_h2 |
412 |
dds_v_purch.dim_matgroup_h2 |
104 |
dds_v_purch.dim_holding_purch_h2 |
3 |
dds_v_purch.dim_comparea_purch_h2 |
3 |
dds_v_purch.dim_compcode_sort_ord_h1 |
10 |
Сверх минимума этапа 1 дополнительно поднята роль purch_ro — у неё есть только SELECT на
схемы витрины (dm_purch, dds_v_purch, dm_v_purch) и нет прав на запись. Проверено 7 разных
попыток записи под этой ролью (INSERT/UPDATE/DELETE/CREATE/DROP/TRUNCATE, в таблицу и во вью) —
все 7 отклонены сервером. Под той же ролью выполнены вживую все 17 фиксированных SQL-запросов,
встречающихся в коде шлюза (справочники резолвера + все запросы шаблонной справки по
контрагенту): 11 выполнились и вернули результат, 6 — нет, все по одной и той же причине —
функция округления суммы (ROUND(SUM(...), 2)) в коде справки написана для DuckDB и не
существует в PostgreSQL для чисел с плавающей точкой без явного приведения к numeric. Это
дефект диалекта в самом коде purch_card.py, а не в загрузке данных или в правах роли (этим
же способом доступа справочники и первый запрос справки читаются без проблем); чинить код ядра
на этапе 1 не входило в задачу — правка (привести 6 мест к ::numeric) нужна на этапе переноса
ядра в свой пакет сервиса, находка зафиксирована, чтобы не потерять её. Подробный протокол,
полный список запросов и точный текст ошибок — reports/etap-1-shag2-rol-chteniya.md.
| № | Требование | Проверено | Результат |
|---|---|---|---|
| 1 | Комплект в корне репозитория, .gitignore первым коммитом |
Содержимое корня репозитория, история коммитов | Выполнено |
| 2 | Прочитать документы 01–06, ACCESS.md | Документы прочитаны | Выполнено |
| 3 | Окружение: venv + зависимости + psycopg, sqlglot | Собрано с нуля, без ошибок | Выполнено |
| 4 | Офлайн-тесты референса на DuckDB — 85 из 85 | reference_impl/qa/test_handler.py, прогон с чистого листа |
Пройдено 85 из 85 |
| 5 | PostgreSQL по ACCESS.md, загрузка синтетики по 02 (раздел 5), сверка 8 запросов до копейки и штуки, скриптом | scripts/load_postgres.sh, scripts/compare_pg_duckdb.py |
Все 8 — «да», совпадает, код 0 |
| 5а | Сверка точная, самопроверка ловит искажения, в т.ч. 1 рубль на ~28 млрд | --self-check-poison и отдельная проверка на 1 рубль |
«НЕ совпадает», код 1; 1 рубль на 28 млрд распознан |
| 6 | Остановить PostgreSQL в конце | pg_ctl ... stop |
server stopped |
Полный протокол с построчным выводом команд этого прогона — reports/archive/etap-4-priemka-chistyi-progon.md.
rm -rf .venv
bash scripts/setup_env.sh
.venv/bin/python reference_impl/qa/test_handler.py
bash scripts/load_postgres.sh
.venv/bin/python scripts/compare_pg_duckdb.py
.venv/bin/python scripts/compare_pg_duckdb.py --self-check-poison
.venv/bin/python scripts/check_ro_role.py
PATH=/usr/lib/postgresql/16/bin:$PATH pg_ctl -D .pgdata stop
На этом этапе (1 из 5) не проверялось, отвечает ли ассистент на реальные вопросы о закупках — это требует вызова модели и запуска ядра сервиса (классификатор, генерация SQL, сводка), а заказчик прямо исключил вызовы модели из объёма этапа 1. Эта проверка относится к этапам 6–7 (регрессия на модели, живая приёмка) и будет сделана там.
Больше ничего из объёма этапа 1 не сорвалось: офлайн-тесты 85 из 85, загрузка и сверка
PostgreSQL — все 8 запросов совпали, самопроверка и проверка на 1 рубль/28 млрд подтверждены,
PostgreSQL остановлена. Дополнительно (сверх минимума) проверена роль только на чтение — не
получилось выполнить без ошибки 6 из 17 запросов кода справки по контрагенту из-за диалектного
дефекта округления, не относящегося к этому этапу (подробности выше и в
reports/etap-1-shag2-rol-chteniya.md); это находка для этапа переноса ядра, а не незачёт
этапа 1.