Задача#
На вход приходит пакет финансовых событий. Нужно сохранить исходные значения, сформировать строки журнала, повторно безопасно принять тот же пакет, найти пропуски и изменения содержимого, рассчитать движение по счёту и показать, как позднее исправление меняет уже закрытый бизнес-период.
Все записи ниже синтетические. Стенд реализован на Python и SQLite, работает только с RUB в копейках и намеренно моделирует одно событие двумя строками: дебет и кредит. Это демонстрация контракта и проверок, а не бухгалтерская система или промышленный ETL.
Поток обработки#
JSON пакет
│ проверка полей, дат, валюты и суммы
▼
source_events ── идемпотентность по event_id + SHA-256
│
├── debit (+amount_minor)
└── credit (−amount_minor)
▼
journal_lines
│ │
│ └── сравнение ожидаемых и загруженных ID/payload
└── обороты по счёту, business_date и observed_at
Контракт и зерно#
| Набор | Зерно строки | Ключ и важные поля |
|---|---|---|
source_events |
Одно неизменяемое входное событие | event_id; документ, дебетовый и кредитовый счета, amount_minor, валюта, деловая дата, время известности и хеш payload |
journal_lines |
Одна сторона упрощённой двухстрочной записи события | (event_id, side); счёт, сумма в копейках, валюта и временные поля события |
| Расчёт движения | Счёт за бизнес-период на заданный момент знания | Счёт + валюта + полуинтервал business_date + отсечка observed_at |
business_date отвечает, к какому периоду относится событие. observed_at отвечает, когда система узнала о нём. Это разные оси времени; поздняя запись может относиться к январю и стать известной в феврале.
Синтетический пример#
Событие EVT-200 отражает расход 200,00 RUB 12 января. 3 февраля поступают сторно на 200,00 RUB и исправленная запись на 180,00 RUB. Исходное событие не обновляется и не удаляется.
| Срез | Включённые события по расходному счёту | Чистое движение |
|---|---|---|
| Известно на 31 января | первоначальное событие +20 000 копеек |
200,00 RUB |
| Известно на 4 февраля | +20 000 − 20 000 + 18 000 копеек |
180,00 RUB |
Это не личные расходы автора. Значения нужны только для воспроизводимой проверки временной логики.
SQL: движение с отсечкой знаний#
Запрос считает знаковое дебетовое движение по одной валюте. Полуинтервал дат включает начало и исключает конец; время сравнивается в каноническом UTC формате с Z.
SELECT account_code,
currency,
SUM(CASE WHEN side = 'debit' THEN amount_minor ELSE -amount_minor END) AS net_minor
FROM journal_lines
WHERE business_date >= '2026-01-01'
AND business_date < '2026-02-01'
AND observed_at <= '2026-01-31T23:59:59Z'
GROUP BY account_code, currency
ORDER BY account_code, currency;
Чтобы получить пересчитанный январь, меняется только отсечка observed_at на 2026-02-04T23:59:59Z. В результате позднее сторно и замена попадут в тот же январский бизнес-срез.
Что именно сверяем#
Нулевая сумма дебета и кредита проверяет баланс уже загруженных строк, но не доказывает, что всё ожидаемое пришло. Поэтому кейс отдельно сравнивает ожидаемый пакет с source_events по множеству идентификаторов и каноническому хешу содержимого.
reconcile_source_events() возвращает missing_ids, unexpected_ids и conflicting_payload_ids. Функция check_invariants() отдельно проверяет, что для каждого загруженного события есть ожидаемые строки журнала и совпадают счета, сумма и временные атрибуты. Эти проверки отвечают на разные вопросы и не подменяют друг друга.
Ошибки и ожидаемое поведение#
| Сценарий | Ожидаемая реакция |
|---|---|
| Повтор того же события с прежним payload | Нет дубля; повторная загрузка идемпотентна |
| Тот же ID с изменённой суммой | Конфликт payload; весь пакет откатывается |
| Событие есть в эталонном пакете, но отсутствует в журнале источника | ID отражается как пропущенный |
| Загружен ID, которого нет в ожидаемом пакете | ID отражается как лишний |
| Исправленный payload под уже загруженным ID | ID отражается как конфликт содержимого |
| Сторно не равно исходной сумме или меняет счета | Контракт отвергается |
| Замена известна раньше сторно | Контракт отвергается; пакет откатывается |
| Удалена одна сторона проводки | check_invariants() находит нарушение числа строк, даже если другие суммы случайно взаимно компенсируются |
Запуск и границы результата#
Из каталога sites/financial-dwh/lab/financial-edwh/ выполните:
python demo.py
python -m unittest discover -s tests -v
Тесты проверяют позднее исправление, идемпотентность, контроль полноты и payload, точность копеек, баланс строк, атомарный откат и негативные сценарии. Они выполняются на SQLite и не подтверждают совместимость или производительность PostgreSQL/Greenplum.
Стенд не моделирует многолинейные документы, несколько валют, курсовую переоценку, полноценную главную книгу, изоляцию параллельных загрузок или промышленную обработку CDC. PostgreSQL-версия будет отдельным следующим этапом с собственными интеграционными тестами.