DueBuddy

Как читать электронные счета: ZUGFeRD, XRechnung и DATEV без ручного ввода

Разбор инженерной задачи: гибридный PDF с XML внутри, стандарт EN 16931, несколько ставок НДС и формирование EXTF-файла для DATEV. С кодом и граблями.

Бухгалтер получает счета в шести видах: PDF-скан, фотография с телефона, гибридный PDF с XML внутри, чистый XML, Excel от поставщика и ссылка на государственную систему. Дальше начинается ручной ввод: номер, дата, суммы, НДС, позиции. На один документ уходит от четырёх до шести минут — и это в лучшем случае.

Задача выглядит как «распознай текст», но распознавание — самая простая её часть. Настоящая работа начинается там, где нужно понять, что перед тобой не картинка, а структурированный документ, и не потерять данные при выгрузке в учёт.

Ниже — как это устроено у нас, с кодом и с ошибками, на которые мы наступили.

Часть 1. Гибридный PDF ломает наивный парсер

ZUGFeRD — немецкий формат, где счёт существует одновременно как PDF для человека и как XML для программы. Снаружи обычный файл: открывается, печатается, пересылается. Внутри, среди вложений, лежит zugferd-invoice.xml или factur-x.xml.

Первая ошибка, которую делает почти каждый: берёт PDF-текст и парсит его регулярками.

import re, pdfminer.high_level

text = pdfminer.high_level.extract_text("rechnung.pdf")
total = re.search(r"Gesamtbetrag\s+([\d.,]+)", text)   # так делать не надо

Почему это тупик:

Правильно — сначала проверить, есть ли внутри структура:

from pikepdf import Pdf

def embedded_xml(path: str) -> bytes | None:
    with Pdf.open(path) as pdf:
        for name in pdf.attachments:
            data = pdf.attachments[name].get_file().read_bytes()
            if name.lower().endswith(".xml"):
                return data
    return None

Если функция вернула None — это скан или «печатный» PDF, дальше нужен OCR. Если вернула данные — всё остальное не нужно: сумма, даты, позиции и ставки уже написаны поставщиком.

Часть 2. XRechnung: то же, но без PDF

XRechnung — чистый XML для немецких госзаказчиков. Никакой картинки в файле нет, «открыть глазами» его нельзя. Технически это тот же стандарт EN 16931, отличаются национальные требования.

Для парсера разница невелика: оба формата описывают одни и те же сущности — стороны, позиции, налоговые группы, итоги. Разметка использует пространства имён CII:

from lxml import etree

NS = {
    "rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
    "ram": "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
}

root = etree.fromstring(xml_bytes)
number = root.findtext(".//rsm:ExchangedDocument/ram:ID", namespaces=NS)
issued = root.findtext(".//rsm:ExchangedDocument/ram:IssueDateTime/udt:DateTimeString",
                       namespaces={**NS, "udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"})
seller = root.findtext(".//ram:SellerTradeParty/ram:Name", namespaces=NS)

Это только верхушка. Основная часть — ram:SpecifiedTradeSettlementLineMonetarySummation и ram:IncludedNote с налоговыми группами. Именно там живёт НДС, и именно там начинаются неприятности.

Часть 3. Где реально ломается: НДС

Главная ошибка — считать, что ставка одна.

В счёте с двумя ставками (например, 19% на товар и 7% на сопутствующую услугу) налоговых групп две. Если взять «первую попавшуюся», сумма налога будет неверной, а вместе с ней и вычет.

def vat_groups(root) -> list[dict]:
    out = []
    path = ".//ram:ApplicableTradeTax"
    for tax in root.findall(path, namespaces=NS):
        out.append({
            "category": tax.findtext("ram:CategoryCode", namespaces=NS),      # S, Z, E, AE...
            "rate": tax.findtext("ram:RateApplicablePercent", namespaces=NS), # 19.00
            "base": tax.findtext("ram:BasisAmount", namespaces=NS),
            "amount": tax.findtext("ram:CalculatedAmount", namespaces=NS),
        })
    return out

Вторая ловушка — скидки на уровне позиции. В XML они лежат в ram:AppliedTradeAllowanceCharge, а итог по позиции уже учитывает скидку. Если собрать свою сумму «цена × количество» и сравнить с итогом, получится расхождение, которое легко принять за ошибку поставщика.

Практическое правило, которое нас ни разу не подвело: сверять не общий итог, а налоговые группы. Совпали ставка и сумма налога по каждой группе — почти наверняка совпало всё остальное. Не совпали — дальше можно не смотреть.

Часть 4. Выгрузка: EXTF и его сюрпризы

DATEV принимает данные файлом EXTF. Формат текстовый, но «текстовый» не значит «как получится»: колонки фиксированы, шапка содержит параметры (версия, категория, период), строки разделены точкой с запятой, кодировка и десятичный разделитель должны совпадать с ожиданиями DATEV. Категория 21 — проводки.

Упрощённая заготовка строки:

def exttf_row(inv: dict) -> str:
    return ";".join([
        "Umsatz",                              # тип записи
        inv["amount"].replace(".", ","),       # сумма с десятичной запятой
        inv["currency"],                       # EUR
        inv["account"],                        # счёт
        inv["counter_account"],                # корр. счёт
        inv["tax_key"],                        # ключ налога
        inv["date"].strftime("%Y%m%d"),        # дата формата DATEV
        inv["doc_number"],                     # номер документа
        inv["text"],                           # текст проводки
    ])

Что ломает импорт чаще всего:

  1. Кодировка. Umlaut в названии поставщика превращается в кракозябры, если записать UTF-8 там, где ожидается однобайтовая.
  2. Порядок колонок. Один лишний или пропущенный разделитель — и DATEV читает файл как мусор, не сообщая, где именно ошибка.
  3. Дата. 14.11.2026 вместо 20261114 — классика.
  4. Разделители тысяч. 1.234,56 с точкой внутри суммы ломает разбор.

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

Часть 5. Что мы делаем иначе

Мы не пытаемся распознать то, что уже структурировано. Порядок такой:

  1. Посмотреть, есть ли в файле XML (гибридный PDF, XRechnung, Factur-X, UBL, Peppol).
  2. Если есть — читать кодом, без распознавания: ошибок распознавания тут быть не может.
  3. Если нет — распознавать, но проверять не «на глаз», а по налоговым группам.
  4. Выгружать файл, который учётная система примет без правок: DATEV EXTF категория 21, 1С, UBL, CSV, Excel.

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

Часть 6. Чего этот подход не делает

Честные ограничения, чтобы не создавать ложных ожиданий:

Вывод

Задача «прочитать счёт» на практике распадается на две разные: найти структуру и не потерять её при выгрузке. Распознавание — запасной путь для тех документов, где структуры нет, а не основной.

Если у вас похожая задача — начните не с OCR, а с вопроса «а нет ли внутри XML». В половине случаев ответ экономит всю работу.

DueBuddyAll articles DueBuddy