ИНЖЕНЕРНЫЙ ПРАКТИКУМP0L4

Сквозной кейс: финансовый журнал и поздняя корректировка

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

Проверяемый учебный стенд. Реализация основана на синтетических данных и автоматических тестах. Это не продуктовая система и не проект работодателя.

Задача#

На вход приходит пакет финансовых событий. Нужно сохранить исходные значения, сформировать строки журнала, повторно безопасно принять тот же пакет, найти пропуски и изменения содержимого, рассчитать движение по счёту и показать, как позднее исправление меняет уже закрытый бизнес-период.

Все записи ниже синтетические. Стенд реализован на 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-версия будет отдельным следующим этапом с собственными интеграционными тестами.

Источники и документация

  1. Python - sqlite3 ↗
  2. SAP S/4HANA - Journal Entry ↗

Источники нужны для самостоятельной проверки. Примеры и интерпретации на сайте могут быть учебными упрощениями.