Перейти к содержимому

Сейчас: внедряю системы ИИ-мониторинга в Steady Control

Daniil Dushin

E-Invoice

Превращает PDF-счёт в электронный счёт XRechnung и по правилам объясняет, что в нём не так.

Роль
Личный проект — проектирование, код, тесты
Год
2026
Категория
ИИ · Веб
Стек
Python 3.12, FastAPI, Jinja2, Anthropic API
Обложка E-Invoice — итоговые суммы счёта рядом с XML, в который они превращаются

Проблема

С 1 января 2025 года счета между компаниями в Германии должны быть электронными: в структурированном формате по европейскому стандарту EN 16931, а не в PDF. Бумага и PDF ещё разрешены до 31 декабря 2026 года, а для компаний с оборотом за прошлый год не более 800 000 евро — до 31 декабря 2027 года. Для приёма переходного периода нет: с 1 января 2025 года каждая компания должна уметь принимать электронные счета.

Многие небольшие компании до сих пор пишут счета в Word и отправляют PDF. PDF может выглядеть правильно для человека и всё равно быть неверным для машины: суммы не сходятся, нет ссылки покупателя, в IBAN опечатка. Я хочу работать в Европе, а в Германии через это изменение прямо сейчас проходит каждая компания, поэтому я сделал конвертер, которым сам хотел бы пользоваться на месте бухгалтера.

Решение

  • Текстовый слой PDF собирается обратно в строки и ячейки таблицы по координатам слов, поэтому строка таблицы остаётся строкой.
  • Claude читает счёт в заранее заданную JSON-схему (structured output). Ему сказано копировать то, что напечатано, и не исправлять числа: «исправленная» сумма спрятала бы ту самую ошибку, ради которой нужен валидатор.
  • Без API-ключа экстрактор на правилах читает встроенные немецкие и английские макеты офлайн (демо-режим).
  • Значения, которых нет в PDF, но которые нужны стандарту, вычисляются и помечаются как derived, чтобы «вычислено» никогда не путалось с «проверено».
  • 67 бизнес-правил с официальными ID: 51 из EN 16931, 15 из XRechnung и 1 моё собственное. Каждое нарушенное правило показывает свой ID и числа: «BR-CO-10: the lines add up to 882.30, the invoice says 882.80».
  • На выходе — файл XRechnung 3.0 UBL 2.1 для скачивания, из веб-приложения или из командной строки. История в SQLite хранит каждый счёт, его статус и его XML.

Скриншоты

Страница результата для немецкого счёта с неверной промежуточной суммой и опечаткой в IBAN — два красных правила, одно предупреждение, неверные суммы отмечены красным
Пример счёта с двумя намеренными опечатками. Каждое нарушенное правило показывает свой официальный ID и числа, на которых оно споткнулось.
Страница результата для австрийского счёта с обратным начислением НДС — все 63 применимых правила пройдены, XML XRechnung рядом с извлечёнными полями
Счёт из Австрии с обратным начислением НДС (reverse charge). Значения, которые вычислены, а не прочитаны, помечены как derived.
Стартовая страница с формой загрузки, пятью примерами счетов и историей обработанных счетов со статусом и нарушенными правилами
Загрузка, встроенные примеры и история в SQLite.

Технологии

  • Python 3.12
  • FastAPI
  • Jinja2
  • Anthropic API
  • pdfplumber
  • lxml
  • SQLite
  • pytest

Деньги везде хранятся в Decimal и округляются половиной вверх, как XPath-функция round() в официальных правилах; встроенный round() в Python округляет 0,125 до 0,12. JSON-схема для LLM возвращает суммы строками, поэтому они никогда не проходят через float. Эталон — официальный валидатор KoSIT (на Java); чтобы понять, насколько близко моё подмножество правил, скрипт прогоняет на XML каждого примера официальную схему UBL, схематронику CEN EN 16931 и схематронику KoSIT XRechnung.

Фрагмент кода

Каждое правило — маленькая функция, зарегистрированная со своим официальным ID и текстом. Она возвращает None, если счёт проходит проверку, или фразу с настоящими числами, если не проходит. Тест намеренно ломает каждое правило, а ещё один тест падает, если у нового правила такого случая нет:

python
@rule(
    "BR-CO-15",
    "Invoice total amount with VAT (BT-112) = Invoice total amount without VAT (BT-109) + "
    "Invoice total VAT amount (BT-110).",
)
def br_co_15(inv: Invoice) -> str | None:
    t = inv.totals
    if t.tax_inclusive is None or t.tax_exclusive is None or t.vat_total is None:
        raise NotApplicable
    expected = _xpath_round(t.tax_exclusive + t.vat_total)
    if t.tax_inclusive == expected:
        return None
    return (
        f"{t.tax_exclusive:f} + {t.vat_total:f} = {expected:f}, "
        f"but the total with VAT is {t.tax_inclusive:f}"
    )

Баг, который я нашёл и исправил

Моё правило BR-CO-13 находило ошибку, которой нет в официальных правилах

Что сломалось

Для примера с неверной промежуточной суммой (строки дают 882,30, в PDF написано 882,80) мой валидатор показывал три ошибки. Официальная схематроника CEN на том же XML показала две: BR-CO-10 и BR-CO-15, но не BR-CO-13.

Причина

Я реализовал BR-CO-13 по тексту правила: «сумма без НДС = Σ нетто-сумм строк − скидки + надбавки». Официальная проверка строки не суммирует. Без скидок и надбавок она сравнивает сумму без НДС (BT-109) с напечатанной в счёте суммой по строкам (BT-106). Сверять строки с BT-106 — задача BR-CO-10, поэтому одна опечатка попадала в отчёт дважды, и мой отчёт не совпадал с тем, что покажет валидатор у покупателя.

Исправление

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

До
expected = _xpath_round(_sum(line.net_amount for line in inv.lines))
if inv.totals.tax_exclusive == expected:
    return None
После
# The official schematron compares BT-109 with BT-106 (LineExtensionAmount), not with the
# sum of the lines: that sum is BR-CO-10's job.
if t.tax_exclusive == t.line_net_total:
    return None

Результат

  • 192 автоматических теста: извлечение на каждом макете-примере, каждое из 67 правил — и прохождение, и нарушение, округление Decimal, XML, разобранный обратно поэлементно, веб-приложение и командная строка.
  • На всех пяти примерах официальная схематроника CEN и KoSIT выдаёт ровно те же ID правил, что и мой валидатор, а каждый XML-файл проходит проверку схемой UBL 2.1.
  • Офлайн-конвертация одного примера (чтение, извлечение, 67 правил, XML) занимает 16–29 мс.