# УПД, счёт-фактура и ДОП в XML для ЭДО (формат ФНС 5.03)

> **Область:** сборка и проверка файлов обмена `ON_NSCHFDOPPR` — титул продавца УПД (`СЧФДОП`), счёта-фактуры (`СЧФ`) и документа об отгрузке (`ДОП`) для загрузки к любому оператору ЭДО (Диадок, СБИС, Такском, ЭДО Лайт, 1С-ЭДО).
> **Не путать** с `commerceml_ru` (обмен 1С↔сайт), `fns_xml_ru` (2-НДФЛ/3-НДФЛ/ЕГРЮЛ) и `accounting_docs_ru` (та же первичка, но в Excel).

Нормативная база: приказ ФНС от 19.12.2023 № ЕД-7-26/970@ в редакции приказа от 15.11.2024 № ЕД-7-26/1032@. **Версия формата — только 5.03.** Форматы 5.01 и 5.02 недействительны с 01.04.2025: оператор ЭДО отклоняет такой файл, а пользователь видит отказ уже после загрузки.

## Главное правило

XML **никогда не пишется руками и не сочиняется по памяти** — структура собирается примитивами скилла, а затем обязательно прогоняется через `validate_upd_xml`, где оракул — встроенная официальная XSD ФНС (`ON_NSCHFDOPPR_1_997_01_05_03_05.xsd`, 195 КБ, вшита в `upd_schema.py`). Файл, не прошедший валидацию, клиенту не отдаётся.

Собранный файл **не патчится** — ни регуляркой, ни побайтово, ни правкой текста: ошибка XSD означает неверный `doc`, его и чинить, а потом пересобирать `save_upd_xml`. Патч мимо сборщика ломает `ИдФайл`, кодировку `windows-1251` и повторяется при каждом следующем запуске.

```python
from fns_upd import rows_to_positions, save_upd_xml, validate_upd_xml, reconcile_with_source, format_report

positions = rows_to_positions(df)
path = save_upd_xml(doc, output_dir="/home/user/output")
report = validate_upd_xml(path)
reconcile_with_source(report, positions=positions)
print(format_report(report))
if not report["valid"]:
    ...
```

Табличная часть передаётся целиком — со всеми денежными колонками, какие есть в исходнике. `rows_to_positions` сам находит суммы, НДС и итог по строке; они попадают в XML как напечатаны, а не пересчитываются. Отсюда и `reconcile_with_source(report, positions=positions)` без `expected_total`: ожидаемый итог берётся из колонки исходника, а сверка расчёта с самим собой ничего не доказывает.

## Конвертация, а не пересборка

Исходный документ — единственный источник содержания. Задача звучит как «переделай в XML», но по сути это **перенос**: то, что напечатано в присланном файле, обязано оказаться в XML без изменений и без потерь.

- Табличная часть входит в документ **только через `rows_to_positions`**. Разбирать таблицу самостоятельно — по координатам ячеек (`df.iloc[7, 3]`), regex'ом по тексту, «на глаз» из превью — запрещено: именно так теряются строки с объединёнными ячейками и переносами в наименовании.
- Строка без наименования больше не пропускается молча: `rows_to_positions` падает и называет отброшенные строки. Служебные строки (итог, разделитель, повтор шапки) пропускаются только явным `allow_partial=True` — и это решение надо озвучить клиенту.
- Реквизиты берутся из исходника дословно: наименования, ИНН/КПП, адреса, номер и дата документа, основание передачи. Переформулировать, сокращать, «причёсывать» — нельзя.
- Чего в исходнике нет — спросить. Заглушки, «типовые» значения и данные из прошлых документов не подставлять.
- Суммы не пересчитываются, если они напечатаны: колонки суммы без налога, НДС и стоимости с налогом переносятся как есть, расчёт остаётся только запасным вариантом для колонок, которых в таблице нет. Расхождение до копейки — округление исходника, и побеждает исходник; расхождение больше копейки — ошибка сборки с обоими числами, и чинится оно в исходнике или в разметке колонок, а не подгонкой.
- XSD и контрольные соотношения проверяют файл сам по себе: собранный из половины таблицы документ проходит их полностью. Единственное, что связывает результат с исходником, — `reconcile_with_source`.

## Ловушки формата — источник почти всех отказов операторов

| Реквизит | Неверно | Верно |
|---|---|---|
| Кодировка | UTF-8, тем более с BOM | `windows-1251`, без BOM |
| Даты | `2026-03-12` | `12.03.2026` (`ДатаТип`) |
| Время | `14:03:22` | `14.03.22` (`ВремяТип`, разделитель — точка) |
| Суммы | копейки целым числом (`6550000`) | рубли с двумя знаками (`65500.00`), разделитель — точка |
| Номер СФ | `НомерСчФ` / `ДатаСчФ` | `НомерДок` / `ДатаДок` |
| Организация | `СвЮЛ` | `СвЮЛУч` (для ИП — `СвИП` + `ФИО`) |
| Табличная часть | `<СвТов>` | `<ТаблСчФакт>` → `<СведТов>` |
| Сумма НДС | атрибут `СумНал="0"` | вложенный `<СумНал><СумНал>360.00</СумНал></СумНал>` |
| Освобождение | `НалСт="0%"` | `НалСт="без НДС"` + `<СумНал><БезНДС>без НДС</БезНДС></СумНал>` |
| Валюта | атрибут `КодОКВ` на `СвСчФакт` | отдельный `<ДенИзм КодОКВ="643" НаимОКВ="Российский рубль"/>` |
| Адрес | `<АдрРФ><АдрТекст>…` | `АдрРФТип` не имеет дочерних узлов; свободную строку класть в `<АдрИнф КодСтр="643" НаимСтран="РОССИЯ" АдрТекст="…"/>` |
| Передача без договора | `<БезДокОснПер>БЕЗ ДОКУМЕНТА-ОСНОВАНИЯ</БезДокОснПер>` | `<БезДокОснПер>1</БезДокОснПер>` — по XSD это перечисление из одного значения `1` |

**`0%` ≠ `без НДС`.** `0%` — экспортная ставка со своим пакетом подтверждающих документов; `без НДС` — освобождение (УСН, ст. 145/149 НК). Подставлять одно вместо другого нельзя: у клиента разъедется декларация. Если в исходной таблице ставка не указана или написана словами — `normalize_rate` приводит её к перечню ФНС и **отказывает**, если значение неоднозначно. Спроси клиента, а не угадывай.

## Обязательные блоки

- `Файл` — `ИдФайл`, `ВерсФорм="5.03"`, `ВерсПрог` (все три required).
- `Документ` — `КНД="1115131"`, `Функция`, `ДатаИнфПр`, `ВремИнфПр`.
- `СвСчФакт` — `НомерДок`, `ДатаДок`, `СвПрод`, `СвПокуп`, `ДенИзм`.
- `ТаблСчФакт` — минимум одна `СведТов` (обязателен `НалСт` и блок `СумНал`) плюс `ВсегоОпл` с `СумНалВсего`.
- `СвПродПер` — для `СЧФДОП` и `ДОП`: `СодОпер` и либо `ОснПер` (договор/спецификация), либо `БезДокОснПер`. У `ОснПер` обязательны все три реквизита — наименование, номер и дата. Если в исходнике напечатано только «Основной договор» без номера и даты, дату **не выдумывать**: спросить клиента либо убрать `основание` из `передача` — тогда соберётся `БезДокОснПер`, и это надо назвать в ответе.
- `Подписант` — `СпосПодтПолном` и `ФИО`; блок обязателен, документ без него не проходит XSD.

`ИдФайл` строится по шаблону `ON_NSCHFDOPPR_{ИНН+КПП продавца}_{ИНН+КПП покупателя}_{ГГГГММДД}_{UUID}_0_0_0_0_0_00` и **обязан совпадать с именем файла** — за это отвечает `make_id_fajl`, имя возвращает `save_upd_xml`. Переименовывать файл после сборки нельзя.

## Примитивы

`fns_upd` — фасад, из него доступно всё:

- `save_upd_xml(doc, output_dir="/home/user/output")` → путь; `build_upd_xml(doc)` → `(имя, байты)`.
- `validate_upd_xml(path|bytes)` → `{"valid", "errors", "warnings", "summary", "reconciled"}`; `format_report(report)` — печатный вид.
- `reconcile_with_source(report, expected_rows=None, expected_total=None, positions=None)` — сверка готового XML с исходником; расхождение по числу позиций или итогу переводит отчёт в `valid=False`. Ожидаемый итог берётся из колонки исходника; переданный вручную `expected_total`, который с ней расходится, сам становится ошибкой. Без ожидаемых значений отказывает: сверять не с чем.
- `rows_to_positions(rows, mapping=None, default_rate=None, allow_partial=False)` — DataFrame / `list[dict]` / `list[list]` → позиции; распознаёт русские заголовки, подставляет ОКЕИ по единице измерения. Возвращает `PositionList` с `source_rows`, `dropped`, `totals` (уйдёт в XML) и `source_totals` (напечатано в исходнике).
- `line_amounts(line)` — суммы одной позиции той же арифметикой, что и сборка; `normalize_rate(value)`, `guess_okei(unit)`, `totals_of(positions)`, `nds_amount(base, rate)`, `money`, `fns_date`, `fns_time`, `make_id_fajl`.
- Имена колонок в `mapping` и ключи позиций — одни и те же: `наим`, `кол`, `ед`, `океи`, `цена`, `сумма_без_ндс`, `ставка`, `ндс`, `сумма_с_ндс`, `артикул`, `признак`. Таблица, названная этими именами, разбирается без `mapping`.

Структура `doc`:

```python
doc = {
    "функция": "СЧФДОП",
    "номер": "19389",
    "дата": "12.03.2026",
    "продавец": {"наим": 'ООО "ЭКСПЕРТНЫЕ РЕШЕНИЯ"', "инн": "5032349058", "кпп": "503201001",
                 "адрес": "143025, Московская обл, ..."},
    "покупатель": {"наим": 'ООО "ПАКТЕХ"', "инн": "6151018385", "кпп": "616301001",
                   "адрес": "344000, Ростовская обл, ..."},
    "позиции": [{"наим": "...", "кол": 1, "цена": 65500, "ставка": "20%", "ед": "шт"}],
    "передача": {"содержание": "Товары переданы",
                 "основание": {"наим": "Договор", "номер": "12", "дата": "01.03.2026"}},
    "подписант": {"фамилия": "Макаров", "имя": "Владимир", "отчество": "Олегович",
                  "должность": "Генеральный директор", "способ_подтв": "1"},
}
```

Контрагент с ИНН из 12 цифр автоматически собирается как ИП (нужны `фамилия`/`имя`), из 10 — как юрлицо. `сумма_без_ндс` в позиции имеет приоритет над `цена × кол` — так переносятся строки со скидкой; расхождение уходит в `warnings`, не в ошибку.

## Что делать по шагам

1. Прочитать исходник (`.xlsx`/`.csv`/PDF) целиком и зафиксировать, с чем потом сверяться: число строк табличной части и напечатанный в документе итог. Показать клиенту, что распозналось: продавец, покупатель, число позиций, итог.
2. Перенести табличную часть через `rows_to_positions`. Проверить реквизиты, которых нет в таблице и которые нельзя выдумать: ИНН/КПП обеих сторон, адреса, ФИО и должность подписанта, основание передачи. **Отсутствующее — спросить, не подставлять заглушки.**
3. Собрать (`save_upd_xml`) и провалидировать (`validate_upd_xml`). При `valid=False` — чинить и повторять, не отдавая промежуточный файл.
4. Сверить с исходником: `reconcile_with_source(report, positions=positions)`. `expected_rows` и `expected_total` передаются руками, только когда итог напечатан вне табличной части и в позиции не попал. Пока сверка не пройдена, файл валиден только сам по себе — клиенту его не отдавать и «ВАЛИДЕН» не писать.
5. Отдать артефакт карточкой скачивания. XML **не печатать в чат** через `cat` — это результат инструмента, клиент его не видит, а строк там сотни.
6. В ответе указать: версию формата (5.03), функцию, номер и дату документа, число позиций, итог, НДС, результат сверки с исходником — и напомнить, что подпись ставится уже в сервисе ЭДО сертификатом.

## Ограничения

- Скилл собирает **титул продавца**. Ответный титул покупателя (`ON_NSCHFDOPPK`) и приём входящих УПД сюда не входят.
- Электронная подпись не формируется: файл загружается в ЭДО неподписанным, подпись накладывает оператор.
- `<СвОЭДОтпр>` (идентификаторы участников ЭДО) не заполняется — оператор проставляет их при отправке.
- ТОРГ-12 и акт выполненных работ — другие приказы и другие XSD, этим скиллом не покрываются.
