Техническая статья

Валидация счетов EN 16931 в Delphi без Java

PDFlibPas, Delphi PDF Library от losLab, проверяет бизнес-правила EN 16931, XRechnung 3.0.2 и Peppol BIS Billing 3.0 нативно, на Pascal. TPDFlib.ValidateInvoiceBusinessRulesBuiltIn читает XML, встроенный в PDF со счётом Factur-X или ZUGFeRD, и возвращает JSON-отчёт с официальными идентификаторами правил — без Java-рантайма, Saxon или Schematron-инструментария. Отчёт сам называет себя подмножеством, и эта пометка — важнейшее поле во всём отчёте

Конвейер встроенной проверки счетов EN 16931 в PDF-библиотеке PDFlibPas для Delphi: обнаружение Factur-X, стадии схемы и schematron, JSON-отчёт о покрытом подмножестве
встроенный валидатор выполняет стадии схемы и schematron в одном процессе и сообщает точно, какие правила подмножество покрывает, а какие нет

Давление за этим в основном немецкое. С января 2025 каждая компания в Германии обязана уметь принимать структурированные B2B электронные счета, а обязанность отправлять подступает поэтапно. Принимающая сторона Delphi-приложения для бухгалтерии или ERP внезапно должна ответить на вопрос, которого раньше не задавала: действительно ли XML внутри этого PDF — валидный счёт EN 16931? Официальный ответ дают Schematron-артефакты, скомпилированные в XSLT 2.0, или KoSIT-валидатор для XRechnung, и оба живут на JVM. Таскать и патчить JRE рядом с каждой десктопной установкой только чтобы отвергнуть плохой счёт — расход, который многие команды предпочли бы не нести

Что именно проверяет встроенный валидатор?

Нативный валидатор отображает XML счёта на бизнес-термины EN 16931 и прогоняет документированное подмножество правил из артефактов валидации EN 16931 1.3.16, сообщая каждую находку под её официальным идентификатором. Поддерживаются оба синтаксиса: UN/CEFACT CII D16B, который встраивают Factur-X и ZUGFeRD, и OASIS UBL 2.1 Invoice и CreditNote. Покрываемые области:

  • Обязательные термины: идентификатор спецификации, номер счёта, дата выставления, код типа, валюта, имена продавца и покупателя, адреса, итоги документа, разбивка НДС и строки (BR-01 to BR-16, BR-21 to BR-28, BR-CO-04, BR-CO-18, BR-CO-26, BR-45 to BR-48)
  • Вычисления: итоги строки, скидки, надбавки, налога и к оплате, плюс налоговая база и сумма налога каждой категории НДС (BR-CO-10 to BR-CO-17, BR-xx-08, BR-xx-09)
  • Правила категорий НДС для S, Z, E, AE, K, G, O, L и M: ставки по строкам, причины освобождения и идентификаторы НДС, которых требует каждая категория
  • Все справочники кодов BR-CL-01 to BR-CL-26, включая 2100 с лишним кодов единиц UN/ECE Rec 20/21 за BR-CL-23, и правила двух десятичных знаков BR-DEC-01, -05, -09 to -20 и -23
  • Правила профилей: XRechnung 3.0.2 (BR-DE-1 to BR-DE-31, BR-DEX-01 to BR-DEX-14) на CII и UBL, а также ядро, общие, справочниковые и национальные правила Peppol BIS Billing 3.0.20 на UBL

Охват сообщается, а не подразумевается. Без файла схемы стадия схемы подтверждает лишь корректность XML и корневой элемент. Каждая стадия перечисляет правила вне подмножества в поле notCovered, а GetBuiltInInvoiceRuleCatalog возвращает всю карту правил как JSON, чтобы ваш UI мог показать пользователю, что именно покрывает зелёный результат

Ещё одну границу легко пропустить. Документы Factur-X MINIMUM и BASIC WL — не счета EN 16931, поэтому валидатор не применяет к ним правила EN 16931. Вместо этого он добавляет информационную находку BuiltIn.Coverage.Profile, которая так и говорит, — честный ответ и заодно подсказка: по немецким правилам эти два профиля электронными счетами и не считаются

Валидация встроенного XML гибридного PDF

ValidateInvoiceBusinessRulesBuiltIn принимает один аргумент — имя встроенного файла, и пустая строка — нормальный выбор. Тогда PDFlibPas берёт вложение, которое определило обнаружение Factur-X; для PDF без метаданных FacturX он откатывается к единственному вложению .xml. Несколько кандидатов .xml или два вложения с запрошенным именем дают ошибку Invoice.AttachmentAmbiguous вместо угадывания, декодирование ограничено 64 МБ, а отсутствующий счёт ставит LastErrorCode в 401

uses
  System.SysUtils, System.JSON, PDFlibrary;

procedure CheckInboundInvoice(const FileName: string);
var
  PDF: TPDFlib;
  Report: TJSONObject;
begin
  PDF := TPDFlib.Create;
  try
    if PDF.LoadFromFile(FileName, '') <> 1 then
      raise Exception.CreateFmt('Cannot open %s (error %d)',
        [FileName, PDF.LastErrorCode]);
    // Пустое имя: проверяем обнаруженное вложение Factur-X / ZUGFeRD
    Report := TJSONObject.ParseJSONValue(
      PDF.ValidateInvoiceBusinessRulesBuiltIn('')) as TJSONObject;
    try
      Writeln('profile:  ', Report.GetValue<string>('profile'));
      Writeln('valid:    ', Report.GetValue<Boolean>('valid'));
      Writeln('coverage: ', Report.GetValue<string>('coverage'));        // "subset"
      Writeln('full:     ', Report.GetValue<Boolean>('fullConformance')); // здесь никогда не True
    finally
      Report.Free;
    end;
  finally
    PDF.Free;
  end;
end;

Верхний уровень отчёта несёт standard, version, profile, complete, valid, fullConformance, coverage, стадии schema и schematron и объект package, который во встроенном режиме равен null. Каждая стадия сообщает статус, движок, число выполненных правил, свой охват и массив issues. У каждой находки есть ruleID, fieldPath, severity, category, line и message

Читать находки по категории, а не по тексту

Поле category — то, что делает отчёт пригодным для реальной почты входящих, потому что оно подсказывает, кто должен чинить проблему. Значения: syntax, schema, cardinality, calculation, vat, codelist, decimal, profile, coverage, processing и integrity. Находка calculation обычно указывает на округление у отправителя, codelist — на мастер-данные, а profile — на поле XRechnung вроде buyer reference

procedure ListIssues(Report: TJSONObject);
var
  Item: TJSONValue;
  Issue: TJSONObject;
begin
  for Item in Report.GetValue<TJSONArray>('schematron.issues') do
  begin
    Issue := Item as TJSONObject;
    if Issue.GetValue<string>('severity') = 'info' then
      Continue;  // пометки покрытия вроде BuiltIn.Coverage.Profile
    Writeln(Format('[%s] %s at %s: %s', [
      Issue.GetValue<string>('category'),
      Issue.GetValue<string>('ruleID'),
      Issue.GetValue<string>('fieldPath'),
      Issue.GetValue<string>('message')]));
  end;
end;

Тот же цикл стоит прогнать и по schema.issues: ошибки корректности XML и корневого элемента попадают туда, и стадия бизнес-правил никогда не видит документ, который не смогла разобрать

Почему один и тот же счёт проходит в CII и падает в UBL?

Официальные артефакты не применяют одинаковый числовой допуск в обоих синтаксисах, и PDFlibPas копирует каждое правило из XSLT своего синтаксиса, а не гармонизирует их. BR-CO-17 — самый наглядный случай. Он проверяет, что сумма налога категории НДС равна налоговой базе, умноженной на ставку, с округлением до двух знаков, и оба синтаксиса допускают расхождение в одну денежную единицу. Артефакт CII записывает это как <= и >=; артефакт UBL использует abs со строгим <

Возьмите налоговую базу 100.00 при 19 процентах, то есть ожидаемый налог 19.00, и счёт, который декларирует 20.00. В CII 20.00 минус 1 равно 19.00, что удовлетворяет <=, и BR-CO-17 проходит. В UBL то же сравнение строгое: 19.00 не меньше 19.00, и BR-CO-17 падает. Такой счёт никто не отправляет нарочно, но генераторы с багом округления сидят ровно на таких границах, а тестовый набор, покрывающий только один синтаксис, этого не заметит

Сравнение допуска BR-CO-17 при валидации счетов в PDFlibPas для Delphi: один и тот же счёт с расхождением округления в одну единицу проходит в CII и падает в UBL
единица округления сидит ровно на границе BR-CO-17, поэтому один и тот же счёт проходит в CII и падает в UBL

Различия идут дальше BR-CO-17. BR-xx-08 в CII требует точного равенства после округления до двух знаков, тогда как UBL допускает ±1 и срабатывает, только когда у категории реально есть строки, скидки или надбавки. BR-O-08 точен в обоих. Артефакт CII определяет BR-AF-09 и BR-AG-09 как true(), так что там они не падают никогда, а UBL проверяет их с ±1. И когда в разбивке НДС CII вообще нет ставки, правила не-O категорий проходят формально, потому что XPath every $rate по пустой последовательности истинен. Нативному коду приходится пропускать их так же, иначе он сыплет ошибками на официальных примерах

Вторая ловушка касается идентификаторов правил. Префикс категории правила — не код категории НДС: K отображается в BR-IC, L в BR-AF, M в BR-AG. Выводить идентификатор из кода категории — получать BR-K-09 и BR-K-10, идентификаторы, которых в EN 16931 нет. Если ваше приложение сопоставляет идентификаторы правил с текстами подсказок, берите идентификатор из отчёта и никогда не собирайте его из кода налоговой категории

Как включаются правила XRechnung и Peppol?

Встроенный валидатор выбирает правила профиля по идентификатору спецификации (BT-24). Идентификатор, содержащий xrechnung, включает правила CIUS XRechnung 3.0.2 на CII или UBL; идентификатор расширения urn:xeinkauf.de:kosit:extension:xrechnung_3.0 дополнительно включает правила BR-DEX. Проверки сумм строк субсчёта и НДС (BR-DEX-02, -03 и -09 to -14) выполняются только на UBL; на CII BR-DEX-15 сообщает, что строки субсчёта недоступны. Идентификатор, содержащий peppol, включает правила Peppol BIS Billing 3.0.20, которые Peppol определяет только для UBL. CII-документ, заявляющий кастомизацию Peppol, получает информационную пометку BuiltIn.Coverage.Profile о том, что эти правила не проверялись, вместо тихого прохода. Для прочих профилей CIUS используйте официальные артефакты

Одна деталь UBL стоит знания: BR-06 и BR-07 принимают в качестве имени продавца или покупателя только PartyLegalEntity/RegistrationName. Откат к PartyName/Name выглядит снисходительно, но прячет реальные провалы BR-06, поэтому проверка сделана строгой

Когда всё ещё нужен официальный Schematron?

Официальные артефакты — EN 16931 Schematron через Saxon или KoSIT-валидатор для XRechnung — нужны везде, где результат имеет юридическую или договорную силу: отказ поставщику по счёту, сертификация собственного генератора, разбор спора. Встроенный отчёт никогда не ставит fullConformance в true. Этот флаг требует верифицированного пакета правил, прохождения обеих стадий, обеих стадий, выполненных движком, исполняющим собственные правила пакета, и пустого notCovered

ValidateInvoiceBusinessRulesWithPackage выполняет тот путь. Пакет правил — каталог с манифестом rulepackage.xml, созданный CreateInvoiceRulePackage, который называет стандарт, версию, схему и Schematron-стилевой лист и записывает SHA-256 каждого файла. PDFlibPas сверяет каждый хеш при загрузке пакета и снова прямо перед тем, как движок прочитает файл, сужая окно для подмены после проверки. Пути не могут выйти за каталог пакета, а нативный XSD-загрузчик отказывает импортам, которых нет в манифесте. При настроенном xmllint импортируемые схемы читает сам xmllint, так что от подмены их защищает только проверка при загрузке

const
  // SHA-256 проверенного и одобренного вами rulepackage.xml
  ApprovedManifestSHA256 = '<64 hex digits from your release review>';
var
  Report, Pkg: TJSONObject;
begin
  // PDF: TPDFlib со счётом, уже загруженным как в первом примере
  Report := TJSONObject.ParseJSONValue(
    PDF.ValidateInvoiceBusinessRulesWithPackage('',
      'C:\Rules\EN16931-CII-1.3.16', ApprovedManifestSHA256,
      '',                          // пусто: нативная стадия XSD
      'C:\Java\bin\java.exe', 'C:\Saxon\saxon-he.jar',
      0)) as TJSONObject;          // 0: 30000 ms на инструмент
  try
    if not (Report.FindValue('package') is TJSONObject) then
      raise Exception.Create('No rule package in the report');
    Pkg := Report.GetValue<TJSONObject>('package');
    if not (Pkg.GetValue<Boolean>('pinned') and Pkg.GetValue<Boolean>('verified')) then
      raise Exception.Create('Rule package failed its integrity check');
    if Report.GetValue<Boolean>('fullConformance') then
      Writeln('Conforms to the complete rule package');
  finally
    Report.Free;
  end;
end;

Зафиксированный хеш — не необязательное украшение. Хеши файлов доказывают лишь, что файлы совпадают с манифестом, но ничто внутри пакета не доказывает подлинность самого манифеста: атакующий, способный переписать правила, перепишет и манифест вместе с ними. Зазор закрывает только хеш, зафиксированный вызывающим — в коде или в подписанной конфигурации, — а отчёт держит pinned и verified раздельно, чтобы вы видели, какая именно гарантия у вас есть

Схема целостности пакета правил счетов PDFlibPas в Delphi: хеши SHA-256 манифеста перепроверяются при загрузке и перед каждым чтением, вызов фиксирует хеш
хеши файлов защищают содержимое пакета, и только хеш, зафиксированный вызывающим, защищает сам манифест

Если передать пустой JavaExecutable, стадия схемы всё равно валидирует нативно по XSD пакета, включая фасеты и identity constraints с v3.672.0, но бизнес-правила откатываются ко встроенному подмножеству, а fullConformance остаётся false. Конструкции схемы, которые нативный загрузчик не поддерживает, — скажем, substitution groups — делают загрузку неудачной с XSD.Unsupported, а не пропускаются

Куда встраиваются veraPDF и Mustang

Ни одна из этих функций не смотрит на PDF-контейнер. Соответствие PDF/A-3 по ISO 19005-3, XMP-схема расширения Factur-X и связь associated file — отдельные вопросы, и для них veraPDF и Mustang остаются референсными инструментами, как описано в статье о валидации электронных счетов с veraPDF и Mustang. Производящая сторона — от вложения XML до объявления профиля — раскрыта в статьях о сборке гибридных счетов Factur-X и ZUGFeRD на Delphi и о схемах расширения PDF/A-3 для метаданных Factur-X

Разумное разделение гоняет встроенную проверку в процессе на каждом входящем счёте, где она стоит миллисекунды: UBL-счёт размером около 1 МБ с base64-вложениями валидируется за 15 мс, или 79 мс с XSD UBL 2.1. Запакованный запуск Saxon уходит в CI и в путь отказа, а контейнер покрывает veraPDF. Каким бы ни был встроенный результат, он описывает покрытое подмножество, а не стандарт — ровно об этом напоминает поле coverage

Функции валидации счетов, каталог правил и инструменты пакетов правил поставляются с Delphi PDF Library для Delphi и C++Builder — вместе с API Factur-X, PDF/A-3 и встроенных файлов, использованными выше