Инженерный кейс · Python · PostgreSQL

Проверка сказала «сошлось». Данные были неверные.

Конвейер переносил данные CRM в аналитическое хранилище. Перед приёмкой выгрузки запускалась команда проверки. На наборе, где у пяти сделок сумма была завышена в десять раз, она возвращала код 0.

Python 3.12 PostgreSQL 16 FastAPI pytest · 257 Docker n8n
./scripts/run.sh check намеренно повреждённый набор
источник  сущность          raw   core  расхождение
demo      contacts           31     31  ок
demo      leads              45     45  ок
demo      users               5      5  ок

код возврата: 0

фактически в наборе:
5 сделок с суммой, завышенной в 10 раз
3 сделки с подменённым ответственным
4 сделки на неверном этапе
01

Проблема

Данные проходят путь API → raw (jsonb) → нормализация → core → метрики → отчёт. На выходе — цифры, по которым руководитель отдела продаж принимает кадровые решения: чья выручка, кто не довёл сделку, где потеряны деньги.

Перед приёмкой запускалась команда check. Она отвечала на вопрос «всё ли на месте» и возвращала нулевой код при успехе.

«Всё на месте» и «всё правильно» — разные утверждения, а команда проверяла только первое.

Цена ошибки здесь выше обычной: неверно разобранная запись не выглядит сломанной. Она остаётся в отчёте, выглядит достоверно и обнаруживается через недели — когда кто-то заметит, что цифра не сходится с ощущением.

02

Причина

Проверка сравнивала количество строк:

core_counts = {
    "leads":    "SELECT count(*) FROM core.deals",
    "contacts": "SELECT count(*) FROM core.contacts",
}
diff = raw_count - core_count
ok = diff == 0

Отсюда три независимых дефекта.

Значения полей не проверялись

Запись с неверной суммой, неверным этапом или неверным ответственным для такой проверки неотличима от корректной: она есть, и её посчитали.

Запросы к core не фильтровались по источнику

SELECT count(*) FROM core.deals считает все сделки, включая демонстрационные. Смешение источников делало сравнение бессмысленным ещё до того, как дело доходило до значений.

Изоляция демо-данных была неполной

Флаг is_demo стоял на сделках и пользователях, но не на компаниях, контактах и задачах. Активности записывались с жёстко заданным источником независимо от происхождения:

INSERT INTO core.activities (source, ...) VALUES ('amocrm', ...)

То есть демонстрационная переписка была неотличима от боевой прямо в ленте коммуникаций.

Как обнаружено

Воспроизведением, а не чтением кода. Взял корректно загруженный набор и внёс семь классов повреждений, не меняя количества записей: завысил суммы, подменил ответственных и этапы, сбросил признак выигрыша, удалил две записи, добавил одну без источника, стёр телефон контакта. Вывод — в блоке в начале страницы.

03

Решение

Трёхуровневая сверка

Модуль reconcile сравнивает core не с внешним API, а с дословно сохранённым ответом источника в слое raw. Это даёт возможность сверяться в любой момент, не создавая нагрузки на чужую систему и не завися от того, что данные там уже изменились.

УровеньЧто сравниваетЧто ловит
1количествамассовую потерю при разборе
2идентификаторыкакие записи потеряны, какие появились без источника
3значения полейневерный разбор — 25 правил по шести сущностям

Главный — третий. Первые два ловят потерю записей, третий — неверный разбор, который опаснее: запись на месте, цифра неправильная, по количествам это не видно.

self._compare(rep, raw, core, {
    "amount":             lambda i: float(i.get("price") or 0),
    "stage_amo_id":       lambda i: str(i.get("status_id")),
    "responsible_amo_id": lambda i: str(i.get("responsible_user_id")),
    "is_won":             lambda i: int(i.get("status_id") or 0) == STATUS_WON,
    "next_contact_at":    lambda i: _ts(i.get("closest_task_at")),
})

Сравнение с поправкой на типы

Наивное сравнение дало бы расхождение на каждой строке: Postgres отдаёт Decimal, источник — float; время приходит в разных часовых поясах.

def _differs(expected, actual):
    if isinstance(expected, float) or isinstance(actual, float):
        # Допуск в копейку: иначе округление даёт ложные расхождения.
        return abs(float(expected) - float(actual)) > 0.01
    if isinstance(expected, datetime) and isinstance(actual, datetime):
        # Один и тот же момент в разных поясах — не расхождение.
        return abs((expected - actual).total_seconds()) > 1
    return str(expected) != str(actual)

Старая команда удалена, а не исправлена

Две команды, отвечающие на один вопрос по-разному, гарантируют, что на приёмке однажды запустят не ту. Все ссылки в документации и в скрипте интеграционной проверки переведены на reconcile.

Изоляция источников

Миграция добавила is_demo на все сущности, нормализатор стал принимать источник параметром. Проверка результата: полный путь проходит начисто при живущих рядом демонстрационных данных — то есть ровно в той ситуации, что возникнет при подключении реальной CRM к системе, где уже есть демо.

04

Проверка

Тот же повреждённый набор:

./scripts/run.sh reconcile --source demo тот же набор
 users        source=5    core=5    missing=0  extra=0  mismatch=0   OK
 companies    source=20   core=20   missing=0  extra=0  mismatch=0   OK
 contacts     source=31   core=31   missing=0  extra=0  mismatch=1   MISMATCH
    #80001 phone_e164: источник=+7965…4317 core=None
 deals        source=45   core=44   missing=2  extra=1  mismatch=16  MISMATCH
    #90001 amount:       источник=45000.0 core=450000.00
    #90011 stage_amo_id: источник=143     core=40001
    #90013 is_won:       источник=True    core=False
    потеряно при разборе: #90021
 activities   source=163  core=157  missing=6  extra=0  mismatch=0   MISMATCH

ИТОГ: расхождений 26 — выгрузку принимать нельзя
код возврата: 1

Все семь классов повреждений обнаружены, каждый с указанием конкретной записи и поля. Отчёт выгружается в JSON для автоматической обработки.

Формулировка теста фиксирует суть

async def test_wrong_amount_is_detected(loaded):
    await loaded.execute("UPDATE core.deals SET amount = amount * 10 ...")
    deals = _by_entity(await Reconciler(loaded, SOURCE).run())["deals"]

    # Количества сходятся — проверка количеств здесь бессильна.
    assert deals.source_count == deals.core_count
    assert len([m for m in deals.field_mismatches
                if m["field"] == "amount"]) == 5

Утверждение source_count == core_count стоит в тесте намеренно: оно фиксирует, зачем нужен третий уровень.

Механизм контроля сам под контролем

make integration-check прогоняет двенадцать проверок реальными командами. Одна из них проходит, только если сверка завершилась с ошибкой на испорченных данных:

step "сверка ловит испорченные данные"
psql -c "UPDATE core.deals SET amount = amount * 10 WHERE ..."
./scripts/run.sh reconcile --source demo && fail || ok
05

Побочный результат

Усиленная сверка нашла дефект в собственном коде — и затем регрессию, внесённую его исправлением.

После расширения сверки на связи активностей она показала расхождение на всех 163 записях: contact_id в core пуст, источник при этом контакт называет. Лента клиента возвращала пустой список для любых данных из CRM — при проходящих тестах, потому что в тестах связь проставлялась вручную.

Исправление изменило INSERT ... VALUES на INSERT ... SELECT FROM core.deals. Подзапрос при отсутствии сделки возвращал NULL и строка вставлялась; SELECT при отсутствии сделки возвращает ноль строк и не вставляется ничего.

ревизия правки на фикстуре5 сценариев
примечаний на входе: 5   нормализатор отчитался: 5
фактически в core:   4

сделка + контакт + компания       → contact=8001  company=7001  
сделка без контакта, с компанией  → contact=None  company=7001  
сделка с ДВУМЯ контактами         → contact=8001  ← выбран произвольно
сделка без контакта и компании    → contact=None  company=None  
сделки не существует              → ЗАПИСЬ ПОТЕРЯНА

Счётчик отчитывался об успехе при молчаливой потере данных.

Два практических вывода

257 проходящих тестов не поймали ни пустую ленту, ни регрессию: тесты проверяли то, что я в них заложил, а не соответствие данных источнику.

Обе ошибки нашёл именно механизм сверки, ради которого всё и делалось.

06

Стек

257тестов, из них интеграционные — против настоящего Postgres
8 900строк кода, 65 модулей
3 500строк тестов
22таблицы в двухслойной схеме, 6 миграций
ЯзыкPython 3.12, async
ХранилищеPostgreSQL 16 — слой raw (jsonb) и слой core
HTTPhttpx, FastAPI
Тестыpytest, respx
Качествоruff, mypy
ИнфраструктураDocker Compose
Оркестрацияn8n — только расписание и транспорт, 9 воркфлоу
Отчётыopenpyxl
07

Другие решения

Разделение сторон разговора без машинного обучения

Задача: в записи звонка отличить реплики продавца от реплик клиента. Штатное решение — модель диаризации, требующая GPU, весов и принятия лицензионных условий.

Сначала проверил, не пишет ли телефония стороны в разные каналы. Проверки на channels == 2 недостаточно: многие записи — моно, продублированное в две дорожки. Измерение вместо предположения: разность каналов через volumedetect.

−24.1 дБнастоящее стерео — стороны разделимы точно
−91.0 дБпродублированное моно — разделить нельзя

Роли каналов при этом не назначаются автоматически: какая дорожка принадлежит продавцу, зависит от оборудования. До подтверждения реальной схемой обе помечены unknown — перепутанные роли меняют оценку продавца на противоположную.

Источник тендерных данных: исследование вместо предположений

Страница закупки — JS-приложение, данных в HTML нет. Через инспекцию сетевых запросов нашёл публичный REST. Что доступ действительно публичный, а не унаследован от сессии браузера, подтверждается соседним запросом, отвечающим 401 — авторизация работает там, где она есть.

Инкрементальность — через sitemap с lastmod: обход идёт с последнего файла и прекращается на первом полностью устаревшем.

Измерение, изменившее модель отбора: на двенадцати свежих закупках подряд стартовые цены опубликованы у одной. Фильтр по бюджету применим к 8% потока. Поэтому закупка без цены не отбрасывается, а получает отдельный класс REVIEW, а объявленный бюджет и посчитанная оценка хранятся в разных полях — чтобы оценка не выдавалась за факт.

Вторая площадка: решение не писать код

В robots.txt, блок User-agent: *:

Disallow: *f_keyword=
Disallow: *search=
Disallow: /app/next/search-tender/

Ровно то, что требовалось автоматизировать, запрещено владельцем площадки. Плюс библиотека снятия отпечатка браузера на страницах — автоматический доступ отслеживается.

Адаптер не писал. Вместо него — документ с семью вопросами для запроса официального доступа и описанием предпочтительных форматов интеграции.

Защита от записи в чужую CRM

Ошибочная запись необратима: удалить полторы тысячи примечаний из карточек задним числом нельзя. Три независимых уровня, снять нужно все три:

def check_write_allowed(mode, method, path):
    if method.upper() not in WRITE_METHODS:
        return
    if mode is AmoMode.READ:
        raise AmoWriteBlocked(...)
    if not settings.amo_write_enabled:
        raise AmoWriteBlocked(...)

Проверка стоит в единственной точке выхода наружу, а не в местах вызова: любой новый код упрётся в неё автоматически, даже если автор о запрете не знал.

08

Границы кейса

Что здесь сделано не кодом и где не хватает глубины.

КомпонентСостояние
Интеграция с CRMПолный путь пройден на respx-моках, артефакты сохранены. Доступа к реальному аккаунту нет. Это mock-verified, не production-verified
ДиаризацияВарианты сравнены по документации проектов. Замеров производительности на своём железе нет
Воркфлоу n8nСгенерированы, в production не запускались. Аудит нашёл 38 замечаний: нет retryOnFail, onError, timeout
Развёртываниеdocker-compose.prod.yml валиден, но на сервере не разворачивался

Что сделало бы кейс сильнее

09

Запуск

Система работоспособна без внешних доступов — на детерминированном демонстрационном наборе, который проходит тот же конвейер, что и боевые данные.

cp .env.example .env
make build && make up && make migrate
make demo-data
make demo-report

Проверки:

make test               # 257 тестов
make lint
make integration-check  # 12 проверок реальными командами
make security-check     # 15 проверок значений по умолчанию