Инженерный кейс · Python · PostgreSQL
Конвейер переносил данные CRM в аналитическое хранилище. Перед приёмкой выгрузки запускалась команда проверки. На наборе, где у пяти сделок сумма была завышена в десять раз, она возвращала код 0.
источник сущность raw core расхождение demo contacts 31 31 ок demo leads 45 45 ок demo users 5 5 ок код возврата: 0 фактически в наборе: 5 сделок с суммой, завышенной в 10 раз 3 сделки с подменённым ответственным 4 сделки на неверном этапе
Данные проходят путь API → raw (jsonb) → нормализация → core →
метрики → отчёт. На выходе — цифры, по которым руководитель отдела
продаж принимает кадровые решения: чья выручка, кто не довёл сделку, где
потеряны деньги.
Перед приёмкой запускалась команда check. Она отвечала на
вопрос «всё ли на месте» и возвращала нулевой код при успехе.
«Всё на месте» и «всё правильно» — разные утверждения, а команда проверяла только первое.
Цена ошибки здесь выше обычной: неверно разобранная запись не выглядит сломанной. Она остаётся в отчёте, выглядит достоверно и обнаруживается через недели — когда кто-то заметит, что цифра не сходится с ощущением.
Проверка сравнивала количество строк:
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', ...)
То есть демонстрационная переписка была неотличима от боевой прямо в ленте коммуникаций.
Воспроизведением, а не чтением кода. Взял корректно загруженный набор и внёс семь классов повреждений, не меняя количества записей: завысил суммы, подменил ответственных и этапы, сбросил признак выигрыша, удалил две записи, добавил одну без источника, стёр телефон контакта. Вывод — в блоке в начале страницы.
Модуль 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 к
системе, где уже есть демо.
Тот же повреждённый набор:
✓ 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
Усиленная сверка нашла дефект в собственном коде — и затем регрессию, внесённую его исправлением.
После расширения сверки на связи активностей она показала расхождение на
всех 163 записях: contact_id в core пуст,
источник при этом контакт называет. Лента клиента возвращала пустой список
для любых данных из CRM — при проходящих тестах, потому
что в тестах связь проставлялась вручную.
Исправление изменило INSERT ... VALUES на
INSERT ... SELECT FROM core.deals. Подзапрос при отсутствии
сделки возвращал NULL и строка вставлялась; SELECT
при отсутствии сделки возвращает ноль строк и не вставляется ничего.
примечаний на входе: 5 нормализатор отчитался: 5 фактически в core: 4 сделка + контакт + компания → contact=8001 company=7001 ✓ сделка без контакта, с компанией → contact=None company=7001 ✓ сделка с ДВУМЯ контактами → contact=8001 ← выбран произвольно сделка без контакта и компании → contact=None company=None ✓ сделки не существует → ЗАПИСЬ ПОТЕРЯНА
Счётчик отчитывался об успехе при молчаливой потере данных.
257 проходящих тестов не поймали ни пустую ленту, ни регрессию: тесты проверяли то, что я в них заложил, а не соответствие данных источнику.
Обе ошибки нашёл именно механизм сверки, ради которого всё и делалось.
| Язык | Python 3.12, async |
| Хранилище | PostgreSQL 16 — слой raw (jsonb) и слой core |
| HTTP | httpx, FastAPI |
| Тесты | pytest, respx |
| Качество | ruff, mypy |
| Инфраструктура | Docker Compose |
| Оркестрация | n8n — только расписание и транспорт, 9 воркфлоу |
| Отчёты | openpyxl |
Задача: в записи звонка отличить реплики продавца от реплик клиента. Штатное решение — модель диаризации, требующая GPU, весов и принятия лицензионных условий.
Сначала проверил, не пишет ли телефония стороны в разные каналы.
Проверки на channels == 2 недостаточно: многие записи —
моно, продублированное в две дорожки. Измерение вместо предположения:
разность каналов через volumedetect.
Роли каналов при этом не назначаются автоматически:
какая дорожка принадлежит продавцу, зависит от оборудования. До
подтверждения реальной схемой обе помечены unknown —
перепутанные роли меняют оценку продавца на противоположную.
Страница закупки — JS-приложение, данных в HTML нет. Через инспекцию
сетевых запросов нашёл публичный REST. Что доступ действительно
публичный, а не унаследован от сессии браузера, подтверждается
соседним запросом, отвечающим 401 — авторизация работает
там, где она есть.
Инкрементальность — через sitemap с lastmod:
обход идёт с последнего файла и прекращается на первом полностью
устаревшем.
Измерение, изменившее модель отбора: на двенадцати свежих закупках
подряд стартовые цены опубликованы у одной. Фильтр по
бюджету применим к 8% потока. Поэтому закупка без цены не
отбрасывается, а получает отдельный класс REVIEW, а
объявленный бюджет и посчитанная оценка хранятся в разных полях —
чтобы оценка не выдавалась за факт.
В robots.txt, блок User-agent: *:
Disallow: *f_keyword= Disallow: *search= Disallow: /app/next/search-tender/
Ровно то, что требовалось автоматизировать, запрещено владельцем площадки. Плюс библиотека снятия отпечатка браузера на страницах — автоматический доступ отслеживается.
Адаптер не писал. Вместо него — документ с семью вопросами для запроса официального доступа и описанием предпочтительных форматов интеграции.
Ошибочная запись необратима: удалить полторы тысячи примечаний из карточек задним числом нельзя. Три независимых уровня, снять нужно все три:
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(...)
Проверка стоит в единственной точке выхода наружу, а не в местах вызова: любой новый код упрётся в неё автоматически, даже если автор о запрете не знал.
Что здесь сделано не кодом и где не хватает глубины.
| Компонент | Состояние |
|---|---|
| Интеграция с CRM | Полный путь пройден на respx-моках, артефакты сохранены. Доступа к реальному аккаунту нет. Это mock-verified, не production-verified |
| Диаризация | Варианты сравнены по документации проектов. Замеров производительности на своём железе нет |
| Воркфлоу n8n | Сгенерированы, в production не запускались. Аудит нашёл 38 замечаний: нет retryOnFail, onError, timeout |
| Развёртывание | docker-compose.prod.yml валиден, но на сервере не разворачивался |
reconcile и убедиться, что тесты падают. Сейчас проверено,
что сверка ловит порчу данных, но не проверено, что тесты поймают порчу
сверки.
Система работоспособна без внешних доступов — на детерминированном демонстрационном наборе, который проходит тот же конвейер, что и боевые данные.
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 проверок значений по умолчанию