Compare commits

...

20 Commits

Author SHA1 Message Date
339b31805c Merge branch 'main' into document-statements-library-contract 2026-10-30 13:48:20 +03:00
aep
727cb63ffc Merge pull request 'Контракт корректировок: ответы Лысенко 29.10 и разделение с 1751 (#186)' (#187) from feat/186-corrections-contract-lysenko into main 2026-10-29 22:25:32 +03:00
Andrey Epifancev
9e2e9f55df Контракт корректировок: ответы Лысенко 29.10 и разделение с 1751 (#186)
- отзыв корректировки и status в PUT убраны: сохранение сразу APPLIED (ответ п. 9);
- «не учитывать в КФО» — только у нераспределённой строки, у строк агрегата excluded
  нет; EXCLUDE/INCLUDE строки агрегата — 422 ENTRY_ALLOCATED, ASSIGN исключённой —
  422 ENTRY_EXCLUDED; UNASSIGN уводит строку в нераспределённые (ответ п. 4);
- у нераспределённой строки — line_code, строка баланса владельца (ответ п. 11);
- vgo_assignment — только ручные решения (action ASSIGN | UNASSIGNED, is_exclude, side),
  автоматика — чистая функция 1751; кто что делает 1540/1751 ([allocation-split]);
- экспорт: один лист, имя Корректировки_<ГК>_<год>_<квартал>.xlsx, как в WIP #208;
- снято «ждёт п. N» там, где ответ есть; граница ровно 5 % — открытый вопрос.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 23:20:36 +04:00
aep
5e1e7d26b0 Merge pull request 'Контракт API таблицы корректировок (#183)' (#184) from feat/183-corrections-contract into main 2026-10-28 22:58:11 +03:00
Andrey Epifancev
1514630075 Контракт корректировок: сторона в AllocationIn, предел суммы (#183)
AllocationIn.side — позиция строки (D/K), vgo_assignment получает side
и уникальность по ней. MoneyIn ограничен numeric(18,2): не больше
16 цифр до запятой.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:36:09 +04:00
aep
ed9928a46e Merge pull request '05 §1.0: ОСВ других учётных систем (tp#81)' (#185) from feat/81-osv-other-systems into main 2026-10-28 20:29:05 +03:00
Andrey Epifancev
ca759d482d 05 §1.0: ОСВ других учётных систем — названия, запасной признак, группы и стороны, «ВСЕГО» (tp#81)
AURORA-1717, AURORA-1719, AURORA-1732.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 21:09:27 +04:00
Andrey Epifancev
735e6fb187 Контракт API таблицы корректировок (#183)
Markdown и OpenAPI для AURORA-1540 (бэкенд) и AURORA-1541 (фронт):
таблица агрегатов и нераспределённых строк, сохранение и отзыв
корректировки агрегата, решения по строке (vgo_assignment), экспорт.
Основа — аналитика Лысенко (DOC-272613, ответы 1–7, DOC-274230)
и дизайн 23.10.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 20:47:50 +04:00
aep
b754799694 Merge pull request 'Регрессия: web/ из git, без шага npm-ci (#181)' (#182) from feat/181-regress-web-from-git into main 2026-10-28 14:02:11 +03:00
Andrey Epifancev
3ad910b612 regression: web/ из git, без шага npm-ci (#181)
web_copy копировал рабочий каталог arm-core/web вместе с неотслеживаемым локальным package-lock.json (устаревший,
от 23.10). Lock в arm-core не коммитится (OQ-63, contour-transfer-guide §5), поэтому шаг npm-ci 28.10 был ложно
красным — ложный баг AURORA-1912 (снят). Теперь копия — git archive HEAD:web, установка без lock, как в контуре;
шаг npm-ci убран. README и dev/checks.md поправлены.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 14:58:01 +04:00
aep
3a91d050f2 Merge pull request 'scripts/regression: полная регрессия main всех репозиториев перед выкладкой (#179)' (#180) from feat/179-regression-suite into main 2026-10-28 13:30:32 +03:00
Andrey Epifancev
44e0fd8229 scripts/regression: в снимке документов locator OCR без id задания (#179)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:36:42 +04:00
Andrey Epifancev
9bc2b26c1c scripts/regression: полная регрессия main всех репозиториев перед выкладкой (#179)
regress.sh — один вход: preflight (main = origin/main, чистые, нет чужих потребителей NATS), venv, lint+unit
всех репозиториев и docs make check, фронт в чистой копии (npm ci --dry-run как проверка lock, установка без
lock), интеграционные с циклом миграций, реальные данные парсера, образы, e2e ядра, изолированный стенд с нуля
(compose-проект arm-ka-regress на своих портах) со сценарием по API, UI-смоук Playwright, парсер по doc-bank.
Не останавливается на первой ошибке; итог — summary.md с SHA и статусом шагов.

Контрольные цифры — expected.json (qa-guide §7.1–7.3, прогоны 08.10/14.10, golden шахматки); снимки свода,
шахматки и форм сравниваются с прошлым прогоном. Ссылки из dev/checks.md и чек-листа синхронизации контура.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:09:15 +04:00
aep
7e9a56a7a2 Merge pull request '05 §1.3–1.4: периоды, нетто-ребёнок, иерархия по отступам (AURORA-1823, 1863, 1858)' (#178) from feat/177-osv-false-check-rules into main 2026-10-28 10:07:49 +03:00
Andrey Epifancev
2c27a9ecdd 05 §1.3–1.4: периоды, нетто-ребёнок, иерархия по отступам (AURORA-1823, 1863, 1858; #177)
§1.4: строки разреза «Период» сверяются цепочкой (обороты — Σ, сальдо начала — первый
период, конца — последний, непрерывность между периодами); развёрнутый родитель над
свёрнутым субсчётом сверяется по нетто только при цепочке нетто-строк (узкий вариант,
решение владельца 28.10.2026), риск RR-5. §1.3: уточнение иерархии по отступам —
подуровень счёта на том же отступе, безымянная строка закрытия, вложенность по суммам
у родителя с нарушением с тремя защитами; риск RR-6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:57:07 +04:00
aep
3f3c007778 Merge pull request 'Пары участников без встречных записей (#175)' (#176) from feat/175-counter-pairs into main 2026-10-27 22:09:40 +03:00
Andrey Epifancev
3a70f21e34 10-user-journey 3.1: модалка сопоставления в режиме пар сужена до участника пары (arm-core #195, AURORA-1746)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:59:06 +04:00
Andrey Epifancev
63a0649c54 context/10: раскладка режима «Встречные записи» (R-5): пары сверху, две таблицы друг под другом
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:15:25 +04:00
Andrey Epifancev
dfccabcada docs(counter-pairs): правки финального ревью (контракт слайса 4, §3 п. 9, путь пользователя)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:33:16 +04:00
Andrey Epifancev
6561caf19e Пары участников без встречных записей: §3 п. 9, контракт Среза 4, путь пользователя (#175)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:04:52 +04:00
15 changed files with 2084 additions and 13 deletions

View File

@ -10,7 +10,7 @@ DBML ?= model/data-model-v5.23.dbml
# Checkout библиотеки arm_statements (RSHB/pylib_ai_arm_statements) рядом с docs — для statements-orders-check
STATEMENTS_DIR ?= ../pylib_ai_arm_statements
.PHONY: venv validate-contracts validate-openspec test-snapshot statements-orders-check check diagrams
.PHONY: venv validate-contracts validate-openspec test-snapshot statements-orders-check check diagrams regress
# Зависимости скриптов проверки контракта — jsonschema и PyYAML (перечни кодов форм contracts/statement-code-orders.yaml)
venv:
@ -43,3 +43,7 @@ check: validate-contracts validate-openspec test-snapshot statements-orders-chec
# Диаграммы model/diagrams/part01…09.{svg,png} из $(DBML); нужен npx (Node.js), зависимостей Python нет
diagrams:
python3 scripts/gen_model_diagrams.py $(DBML)
# Полная регрессия main всех репозиториев workspace перед выкладкой (scripts/regression/README.md); REGRESS_ARGS="--only stand,ui"
regress:
scripts/regression/regress.sh $(REGRESS_ARGS)

View File

@ -17,11 +17,26 @@
`fixtures/osv/` — это примеры двух организаций, а не спецификация формата. Парсер обязан:
1. **Найти лист(ы) с ОСВ** по содержимому: ячейка в первых 30 строках листа (`SHEET_MARKER_ROWS`: шапка ведомости всегда
над таблицей), матчащая `оборотно[- ]сальдовая ведомость` (без учёта регистра, «ё/е», переносов строк). Один файл может содержать несколько листов с разными ОСВ → несколько `document`.
над таблицей), матчащая название ведомости (без учёта регистра, «ё/е», переносов строк): `оборотно[- ]сальдовая
ведомость` в любом месте ячейки или, **с начала ячейки**, названия других учётных систем и отчётов 1С — «Оборотная
ведомость …», «ОСВ по сч. …», «Оборотный баланс», «Фин. Обороты Листок» (Navision), «Отчет по Главной книге» (SAP)
(AURORA-1717; «… см. оборотную ведомость» в примечании формы признаком не является). Отчёт 1С «Обороты счета»
(обороты в разрезе корр. счетов) — не ОСВ.
**Запасной признак** (лист без названия ведомости, tabular-parser#81): лист, который не опознан ни как ОСВ по
названию, ни как форма ББ/ОФР или её продолжение по признакам форм, — ОСВ, если в его первых 30 строках есть строка
со всеми тремя группами сумм («начало», «обороты», «конец»), у каждой группы найдены «Дебет» и «Кредит» и между ними
в строках заголовка нет других подписей; свёрнутое сальдо (одна колонка) запасного признака не даёт. Слабое
продолжение формы (лист из 1–2 кодов сразу после формы) признаком формы не считается: лист с такой таблицей после
формы — ОСВ.
Один файл может содержать несколько листов с разными ОСВ → несколько `document`.
Листы без признаков ОСВ/ББ/ОФР — пропускать с warning, не падать.
2. **Найти заголовок таблицы** по содержимому: строка(и), где встречаются одновременно «Сальдо на начало…»,
«Оборот(ы) за период», «Сальдо на конец…» (с вариантами: «Нач. сальдо», «Обороты», «Кон. сальдо», «Дебет/Кредит»,
«Дт/Кт», «Д-т/К-т»). **Обязательны группы «обороты» и «сальдо на конец»; отсутствовать может только «сальдо на
«Дт/Кт», «Д-т/К-т»; выгрузки других систем, AURORA-1719: «Остаток на начало/конец (периода)», «Остаток по дебету на
начало», «Начальное/Конечное сальдо», «Сальдо вх/исх Дт/Кт» (SAP), «НачалоДт / ОборотДт / КонецКт» (Navision)).
Сторона может стоять в самой ячейке группы после названия — через пробел («Сальдо вх Дт»), словами («Оборот по
дебету») или вплотную («Сальдо НачальноеДТВВ», «ОборотыДтВВ», SAP); тогда подзаголовков «Дебет/Кредит» нет. Сторона
перед названием группы («Дт оборот», SAP) пока не распознаётся (AURORA-1722). **Обязательны группы «обороты» и «сальдо на конец»; отсутствовать может только «сальдо на
начало»** (иначе заголовок не найден → `INVALID_FORMAT`, §1.6). Заголовок может занимать 1–4 строки с merged-ячейками; группировки могут быть как под «Счет»
в первой колонке, так и строкой «Субконто», так и перечислением через запятую (вариант 2).
3. **Определить колонки сумм по тексту**, а не по позиции: для каждой из трёх групп («начало», «оборот», «конец»)
@ -295,10 +310,38 @@ AURORA-1661; прикладной сценарий валютных строк
`CONTRACT` (§1.3, п. 3: после COUNTERPARTY дочерние продолжают классифицироваться по схеме; вид договоров здесь не
назван в шапке, поэтому тип задаёт сама раскладка), на уровень глубже контрагента. Суммы контрагента = Σ его договоров.
Иерархия листа: субсчёт → контрагент → договор (стратегия каскада `code_blocks`, `OSV_HIERARCHY_BY_CODE_BLOCKS`).
4. **«ИТОГ» — итог листа.** Строка с текстом ровно «ИТОГ» (`total_row_exact`, наравне с «Итого») — TOTAL; таблица кончается
4. **«ИТОГ» и «ВСЕГО» — итог листа.** Строка с текстом ровно «ИТОГ» или «ВСЕГО» (ведомости ГК КТЗ по счёту 009,
AURORA-1732; `total_row_exact`, наравне с «Итого») — TOTAL; таблица кончается
на ней, а не на первой «ИТОГ по …» (та не начинается с «Итого» и итогом листа не является). Сверка «Итого = Σ строк
уровня 0» (§1.4) идёт по субсчетам.
**Иерархия по отступам: подуровень счёта, строка закрытия, вложенная аналитика** (AURORA-1858, tabular-parser #78).
ОСВ 1С без outline («Субконто», вид аналитики в шапке не назван) задаёт уровни только отступом: 0 у счетов, 1 у
аналитики. Отступов не хватает в трёх случаях, стратегия `indent` их уточняет:
1. **Подуровень счёта на том же отступе.** `68.01.1` стоит на одном отступе с `68.01`. Счёт, код которого продолжает код
более раннего счёта того же отступа в том же блоке (`68.01.1` → `68.01`), становится его ребёнком, вместе со своей
аналитикой. Только если все такие пары листа на одном отступе, раскладка однозначна и сумма детей перекладываемого
родителя после перекладки сходится; иначе лист остаётся по отступам (защищает плоские списки `60`, `60.01`, `60.02`).
2. **Безымянная строка закрытия.** Строка без текста и кода сразу за строкой счёта, с оборотами, равными оборотам счёта
(1С печатает её под 90.09 и 91.09), — ребёнок счёта. Пустую ячейку отступа читатель не хранит, поэтому без правила
строка попадала на уровень 0 и удваивала сумму корней.
3. **Вложенность аналитики по суммам** (решение владельца 28.10.2026 после ревью). Если аналитика родителя на одном
отступе, а вложенность видна только по суммам (91.01: «Прочие внереализационные» = две строки ниже), то **только у
родителя с нарушением «сумма детей = родитель»** строка, равная сумме следующих соседей, жадно становится их
родителем (как стратегия `sums`). Перестройка принимается, только если после неё родитель сходится, иначе — откат.
Защиты — правило применяется, только если одновременно:
- в схеме шапки после счёта нет названных видов аналитики («Субконто» без вида), то есть вложение не меняет
`group_type` и имя группы строки (иначе контрагент мог бы стать «договором» соседа);
- все дети родителя стоят на одном отступе (если отступ различает уровни, ему верим);
- ни вкладываемая строка, ни её новый родитель не несут ИНН.
Вложение меняет дерево в результате (`level`, `parent_row_number`), а не только проверку. Родитель без нарушения не
перестраивается; равные суммы соседей (`50 + 50 = 100`) сами по себе вложенности не дают. Остаточный риск:
- RR-6 — на листе «Субконто» без вида дубль строки аналитики с той же суммой, что у соседа выше, неотличим от
«группа → единственная статья» (на 91.01: «Проценты к получению» = «по векселям») и принимается за вложенность:
ошибка «лишняя строка» в такой форме не ловится.
Правила 1 и 3 опираются на сходимость, но это не «подгонка» (§1.0 п. 7): дерево меняется только в узких случаях, где
структура подтверждается и кодом/формой выгрузки, и суммами; реальное расхождение в остальных случаях даёт `needs_check`.
Нормализация кода счёта (ядро, `domain/osv_mapping.py`): `60.1 → 60.01`, `60.01.1 → 60.01.1`, `76.7.1 → 76.07.1` —
числовой **второй** сегмент (1–3 цифры) дополняется нулём слева до двух цифр, первый и третий сегменты и буквенные
(`76.АВ`, `МЦ.04`) остаются как в файле. `account_code` строки хранится как в результате (`account_code_raw`),
@ -341,6 +384,30 @@ AURORA-1661; прикладной сценарий валютных строк
значения родителя и выше не затронуты);
- родитель (0, 0) при детях (X Дт, X Кт) принимается (нетто 0) — осознанное поведение, закреплено тестом;
- вне R-21: текст вида «12.ООО» принимается за счёт (§1.3, R-16).
**Развёрнутый родитель над свёрнутым субсчётом** (AURORA-1863, решение владельца 28.10.2026, tabular-parser #78).
1С считает развёрнутое сальдо счёта по нижнему уровню аналитики, а субсчёт может показать свёрнутым: счёт 76 —
47,1 млн Дт / 12,9 млн Кт, его субсчёт 76.07 — 8,2 млн Кт при аналитике 4,6 млн Дт / 12,8 млн Кт. По колонкам счёт
с субсчетами не сходится, по нетто сходится до копейки. Сальдо развёрнутого родителя сверяется по нетто, **только если
среди его прямых детей есть «нетто-ребёнок»** — ребёнок, который сам сошёлся со своими детьми лишь по нетто (по
колонкам — нет). Признак идёт вверх только по непрерывной цепочке таких строк: строка, сошедшаяся по колонкам, его не
передаёт, так что слепая зона не растёт до корня. Обороты — по колонкам всегда. Развёрнутый родитель без
нетто-ребёнка сверяется по колонкам, как раньше: «нетто всегда» (широкий вариант) отклонён — он прячет любую ошибку,
сохраняющую нетто. Так в банке первичных документов остаются `needs_check` 14 файлов одной организации, где счёт 68
(и 71) расходится с субсчетами на одинаковую сумму по Дт и Кт (0,05; 137,00; 5 939,76) без свёрнутого ребёнка:
отличить схлопнутую пару от потерянной по данным нельзя. Остаточный риск:
- RR-5 — у развёрнутого родителя с нетто-ребёнком колонки сальдо не информативны: ошибка, сохраняющая нетто родителя
(одинаковый сдвиг Дт и Кт, перестановка сторон), не ловится, как у свёрнутого родителя (RR-1).
**Строки разреза «Период»** (AURORA-1823, tabular-parser #78). В выгрузке 1С с группировкой «Период» в шапке под
контрагентом идут строки «Обороты за <месяц> <год>» (или «… <N> кв. <год>»), у каждой своё сальдо на начало и конец
периода; сальдо конца периода — сальдо начала следующего. Группировка распознаётся по имени в схеме шапки (словарь
`period_groups` в `osv_groups.yaml`; тип группы остаётся `OTHER`, контракт не меняется). Если **все** дети родителя —
строки-периоды, родитель сверяется цепочкой: обороты = Σ оборотов периодов (по колонкам), сальдо начала = сальдо начала
**первого** периода, сальдо конца = сальдо конца **последнего** (в порядке строк; по колонкам либо по нетто, как выше),
и цепочка непрерывна: сальдо конца каждого периода = сальдо начала следующего (пропущенные месяцы разрыва не дают —
1С не печатает месяц без движения, сальдо переходит дальше). Сумма сальдо периодов не считается. У родителя со смешанными детьми (периоды и обычные строки) суммы считаются как
прежде.
- Строка «Итого» (TOTAL) == корневая строка ACCOUNT (первая строка уровня 0); если корней уровня 0 несколько (лист без
строки корневого счёта: верхний уровень — субсчета 58.01, 58.03, OQ-133, PR #13) — «Итого» == **сумме строк уровня 0**
(показатели без суммы и сама «Итого» не входят; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя
@ -553,6 +620,21 @@ closing_* = closing_* строки Qn
Наблюдение по фикстурам: у «Счастливочной» контрагент «Русский бисквит» идёт **с ИНН 3528052718** в колонке E, а у
«Русского бисквита» ОСВ без колонки ИНН — контрагент «Пекарня Счастливочная ООО» только текстом → резолв по алиасу.
9. **Пары участников без встречных записей** (решение владельца 27.10.2026, AURORA-1746, arm-core #195). Для участников
периметра А ≠ Б (`consolidation_member` версии) пара (А, Б) входит в список, если в своде есть хотя бы одна строка
ЮЛ А с `counterparty_id = Б` (сохранённым или дорезолвленным на чтение, п. 7) и нет ни одной строки ЮЛ Б с
`counterparty_id = А`. А — `holder` (записи есть), Б — `missing` (встречных записей нет). Пара, где записей нет ни у
А, ни у Б, не входит; строки с контрагентом — самим собой не учитываются; счета, суммы и `data_type` на признак не
влияют. Это чистая функция от строк свода и периметра, не хранится (DECISION-5). Порядок — по имени Б, затем по
имени А. Поля пары: `holder_id/name`, `missing_id/name`, `holder_rows` (строки А с контрагентом Б),
`missing_unmapped_rows` (строки Б с `unmapped = true`), `missing_rows_total` (все строки Б в своде; 0 — ОСВ не
привязаны или по ним пробелы). Назначение — показать аналитику перед шахматкой места, где в строках без ИНН может
прятаться участник периметра; переход к шахматке при непустом списке **не блокируется**, несопоставленные строки
по-прежнему считаются «Внешний» (п. 5; AURORA-1388, AURORA-1865). Закрывается ручным сопоставлением (см. выше).
Строки Б, у которых ИНН в файле есть, но его нет в справочнике, сопоставления не требуют и в
`missing_unmapped_rows` не входят — отдельная задача AURORA-1866.
---
## §4. Правила элиминации — пары счетов (Прил. 3 ТЗ, DOC-223044, FNR03)

View File

@ -90,7 +90,7 @@
| Шаг | Вкладка | Действие | БД |
|---|---|---|---|
| 3.1 | **ОСВ** [30] | сводная ОСВ: Компания ГК, Счёт, Период, Контрагенты, ИНН, 6 сумм, Тип контрагента. Баннер «Требуется указать соответствия… Осталось: N» → **Показать ЮЛ без ИНН** [32-osv-no-inn] → **Указать** (только у строки без сопоставленного ЮЛ и без ИНН; у строки, сопоставленной без ИНН в файле, в колонке ИНН — ИНН ЮЛ справочника, по клику — пересопоставление) → модалка «Выбор организации для связи с …» [32-osv-map-counterparty]: ИНН, Организация, ГК, ГСК, ГСЗ, блок «Рекомендуемые», **Связать**. При уходе с вкладки с несопоставленными — «Остались неуказанные ИНН… Остаться / Перейти» [31-00]. **API:** `GET …/versions/{id}/osv` (фильтры, `limit` ≤ 2000), `…/osv/summary` (баннер «Осталось: N» и «Показать ЮЛ без ИНН» — `unmapped_total` / `?unmapped=true`: строки без сопоставленного ЮЛ и без ИНН из файла — решение владельца 08.10.2026, отмена П-1 от 07.10.2026; ИНН сопоставленного ЮЛ — `counterparty_entity_inn`; пробелы — `gaps[]`), `…/counterparty-candidates?scope=&q=&entry_id=` (блок «Рекомендуемые» — `recommended`), `POST …/osv/entries/{entry_id}/counterparty` (**Связать**), `GET …/osv/export` (**Экспорт**, xlsx) | вычисляется; `osv_entry.counterparty_id`, `legal_entity_alias`, `audit_event(OSV_COUNTERPARTY_MAP)` |
| 3.1 | **ОСВ** [30] | сводная ОСВ: Компания ГК, Счёт, Период, Контрагенты, ИНН, 6 сумм, Тип контрагента. Баннер «Есть организации без ИНН и без сопоставления… Осталось: N. Несопоставленные учитываются как внешние» (переход не блокируется; AURORA-1865) → **Показать ЮЛ без ИНН** [32-osv-no-inn] → **Указать** (только у строки без сопоставленного ЮЛ и без ИНН; у строки, сопоставленной без ИНН в файле, в колонке ИНН — ИНН ЮЛ справочника, по клику — пересопоставление) → модалка «Выбор организации для связи с …» [32-osv-map-counterparty]: ИНН, Организация, ГК, ГСК, ГСЗ, блок «Рекомендуемые», **Связать**. Над сводом переключатель «Свод | Встречные записи (N)» (`?view=pairs`): сверху пары участников без встречных записей «Б ← А» (§3 п. 9) одной строкой «таблеток» с переносом, под ними две таблицы на всю ширину одна под другой: «У А есть записи с Б» и «У Б — строки без ИНН и без сопоставления» (с поиском и «Указать»: та же модалка, но список сразу сужен до участника пары А, флажок «Показать все организации» возвращает обычный). При уходе с вкладки, если есть пары, — модалка «Проверьте встречные записи участников» («Нет встречных записей: N пар у M участников…»; **Показать пары** / **Остаться** / **Перейти**) вместо [31-00]; показывается один раз на список пар за сессию браузера, без пар не показывается (AURORA-1746). **API:** `GET …/versions/{id}/osv` (фильтры, `limit` ≤ 2000), `…/osv/summary` (`counter_pairs[]`; строки пары — `…/osv?counterparty_id=`; баннер «Осталось: N» и «Показать ЮЛ без ИНН» — `unmapped_total` / `?unmapped=true`: строки без сопоставленного ЮЛ и без ИНН из файла — решение владельца 08.10.2026, отмена П-1 от 07.10.2026; ИНН сопоставленного ЮЛ — `counterparty_entity_inn`; пробелы — `gaps[]`), `…/counterparty-candidates?scope=&q=&entry_id=` (блок «Рекомендуемые» — `recommended`), `POST …/osv/entries/{entry_id}/counterparty` (**Связать**), `GET …/osv/export` (**Экспорт**, xlsx) | вычисляется; `osv_entry.counterparty_id`, `legal_entity_alias`, `audit_event(OSV_COUNTERPARTY_MAP)` |
| 3.2 | **Шахматка** [31-01] | фильтры Год, Период, Компании, Дт-счета, Кт-счета; матрица компания × компания: сумма A / сумма B / (расхождение) красным; Итого; ниже результирующая таблица «Статья × компания» | вычисляется (§5–6 правил) |
| 3.3 | **Таблица корректировки** [31-02] [31-02-02] | фильтр «Отклонение» (Все); пары «Актив баланса ЮЛ-1 / Пассив баланса ЮЛ-2»: Наименование, Строка баланса, Счёт, Сумма; **Контроль**: % отклонения, Сумма отклонения; **Корректировка**: Строка баланса / Дт, Строка баланса / Кт, Комментарий; строка пары раскрывается в детализацию по субсчетам; Итого. В макете % отклонения считается к сумме стороны ЮЛ-1, а не к меньшей (OQ-35). **Редактировать** [31-02-01] → баннер «Режим редактирования включён…», ячейки пары и корректировки становятся полями ввода, **Отменить изменения** / **Сохранить** | `correction` (+ дочерние строки `parent_correction_id`), `audit_event(CORRECTION_*)`; ≥ 5 % → `ADJUSTMENT` |
| 3.4 | **Гудвилл** [31-03] | здесь же включается/выключается расчёт гудвилла для консолидации (`goodwill_enabled`; переключателя в снимках нет — решение OQ-27); фильтр «Гудвилл: Все значения»; по головной организации (с «Выделить все» и строкой «Всего»): Учёт в отчёте ☐, Наименование дочерней организации, Доля владения, Фондовые вложения головной организации, Доля участия головной организации в уставном капитале дочерней организации, Уставный капитал, Гудвилл (+/−), Комментарий; **Редактировать** [31-03-01] → «Доступны для редактирования столбцы „Доля владения“ и „Комментарий“» | `consolidation_version.goodwill_enabled`, `goodwill(ownership_share, include_in_kfo, comment)` |

View File

@ -0,0 +1,297 @@
# Контракт API таблицы корректировок (AURORA-1540 / AURORA-1541)
Черновик контракта для бэкенда (AURORA-1540) и фронта (AURORA-1541, arm-core #206). Машиночитаемая копия —
`dev/api-contract-corrections.openapi.yaml`; при расхождении двух файлов правится тот, что ошибся, и оба держатся одинаковыми.
Источники, в порядке приоритета:
1. Аналитика Лысенко: DOC-272613 «Корректировка данных агрегатом», ответы на вопросы 1–7 (комментарий от 27.10.2026 в
AURORA-1395), ответы на `[design-23-10-check]` (комментарий #9920570 от 29.10.2026 там же), DOC-274230 «Правила пар
счетов».
2. Дизайн таблицы корректировок от 23.10.2026 (вложение к DOC-272613).
3. ТЗ 6.8, Прил. 9 — только там, где аналитика молчит.
На `[design-23-10-check]` (12 пунктов + 3 по ТЗ) Лысенко ответил 29.10; ссылки вида «ответ п. N» — на этот комментарий.
Открыты: ОФР (AURORA-1396) и граница ровно 5 % (§2). Разделение работ с бэкендом — комментарии `[allocation-split]` в
AURORA-1540 и AURORA-1751 (29.10): разнесение строк и решения по строкам — 1751, корректировка и таблица — 1540.
## 1. Общее
Всё как в `dev/api-contract-slice-4.md` §1:
- префикс `/api/v1`, `Authorization: Bearer <JWT>`;
- чтение — право `CONSOLIDATION_VIEW`, правки — `CONSOLIDATION_EDIT`;
- версия вне видимости — 404 `NOT_FOUND`, как несуществующая;
- доменные ошибки — `{"detail": {"code", "message", ...}}`, 422 валидации тела — массив pydantic;
- **деньги — строки с двумя знаками** (`"1000000.00"`), всегда в **рублях** (суммы ОСВ); перевод в тыс. руб. и округление
для показа — на фронте;
- проценты — строки с двумя знаками (`"394.27"`), `null` — процент не определён.
**Правки.** Порядок проверок: право (403) → видимость (404) → версия редактируема (409) → операция. Какие статусы версии
разрешают корректировки, решает сервер (по §9; статус «Верифицирован» и закрытые — только чтение, подтверждено ответом по ТЗ); фронт не повторяет
это правило, а читает `editable` / `read_only_reason` из ответа таблицы. Ошибки правок версии — те же коды, что везде:
409 `VERSION_NOT_EDITABLE`, `LOCK_REQUIRED`, `LOCKED_BY_OTHER`.
## 2. Понятия
- **Строка** — неделимая строка сводной ОСВ версии (как `ConsolidatedRowOut`): `entry_ids` — её исходные `osv_entry`.
В разнесении участвуют строки уровня «счёт — контрагент», владелец и контрагент — компании периметра, тип данных — БУ
(DOC-272613 §1). Для ББ сумма строки — сальдо на конец, для ОФР — обороты по правилу (`measure`). Строка с дебетовым и
кредитовым сальдо одновременно даёт две позиции — `side = "D"` и `side = "K"`.
- **Агрегат** — правило × компания 1 × компания 2 (× тип отчётности, он следует из правила). Отдельно не хранится, считается
при каждом запросе (DOC-272613 §3). Ключ агрегата — `(rule_id, company_1_id, company_2_id)`.
- **Разнесение** (DOC-272613 «Распределение строк по правилам», ответы 1(б), 2(а)) — чистая функция от версии консолидации,
считается на каждый запрос и не хранится (DECISION-5); хранятся только ручные решения аналитика (`vgo_assignment`, §5):
1. ручное решение по строке (`vgo_assignment`) важнее автоматики;
2. строка подходит под одно правило — в это правило, даже без встречной стороны;
3. под несколько правил и у второй компании есть строки по счетам другой стороны ровно одного из них — в это правило;
4. пара есть в нескольких правилах — в правило с меньшим номером Прил. 3 (ответ 2(а));
5. иначе — нераспределённая строка.
Каждая строка учитывается не более чем в одном агрегате своего типа отчётности.
- **Сторона агрегата.** Сторона 1 — строки компании 1 по счетам стороны 1 правила, сторона 2 — строки компании 2 по счетам
стороны 2. Сумма стороны — сумма всех её строк: агрегат учитывается в КФО целиком, лишнюю строку убирают из правила, и она
становится нераспределённой (ответ п. 4).
- **«Не учитывать в КФО»** — отметка только у нераспределённой строки (ответ п. 4: «функционал нужен для нераспределённых
строк, чтобы суммы совпадали в КФО»). У строк агрегата отметки нет.
- **Отклонение** (DOC-272613 «Расчёт»): `deviation_amount = |amount_1 − amount_2|`;
`deviation_percent = deviation_amount / min(amount_1, amount_2) × 100`, если меньшая сторона > 0, иначе `null`.
**Порог: строго меньше 5 % — в допуске, от 5 % включительно — на проверку** (`exceeds_threshold`, DOC-272613).
**Открытый вопрос:** в описании AURORA-1540 и в WIP API (#208) ровно 5 % — в допуске (`> 5`); аналитик на это не отвечал.
Отклонение у отдельных строк агрегата не считается (ответ п. 3).
- **Корректировка** — решение аналитика по агрегату: какие суммы и из каких статей ББ (ОФР) исключить по каждой стороне,
комментарий. Делается **только на уровне агрегата** (DOC-272613, ответ п. 2); на строку и на нераспределённую строку
корректировка не вводится. Можно корректировать и агрегат в допуске 5 %, и с отклонением больше 5 % — с обязательным
комментарием (ответ п. 8 и по ТЗ). Сохранённая корректировка не меняет суммы сторон — они всегда считаются по строкам.
## 3. Эндпоинты
| Метод и путь | Право | Запрос | Ответ | Ошибки |
|---|---|---|---|---|
| `GET /consolidations/versions/{version_id}/corrections` | VIEW | query `statement_type` (`balance` по умолчанию / `income`) | 200 `CorrectionTableOut` | 404 `NOT_FOUND` |
| `PUT /consolidations/versions/{version_id}/corrections/aggregates/{rule_id}/{company_1_id}/{company_2_id}` | EDIT | `CorrectionIn` | 200 `CorrectionAggregateOut` | 404 `NOT_FOUND` (версия, правило или агрегата нет в версии), 409 правок версии, 409 `CORRECTION_VERSION_CONFLICT`, 422 `COMMENT_REQUIRED`, 422 `EMPTY_CORRECTION`, 422 `INVALID_LINE_CODE` |
| `POST /consolidations/versions/{version_id}/corrections/allocations` | EDIT | `AllocationIn` | 200 `CorrectionTableOut` | 404 `NOT_FOUND` (версия или правило), 409 правок версии, 422 `ENTRY_NOT_IN_VERSION`, 422 `RULE_NOT_APPLICABLE`, 422 `RULE_REQUIRED`, 422 `ENTRY_ALLOCATED`, 422 `ENTRY_EXCLUDED` |
| `GET /consolidations/versions/{version_id}/corrections/export` | VIEW | query `statement_type` | файл xlsx | 404 `NOT_FOUND` |
Импорта xlsx нет — не нужен (ответ по ТЗ). Отзыва корректировки (`REVOKED`) нет — её меняют повторным сохранением (ответ п. 9).
### 3.1 `GET …/corrections` — таблица
Вся таблица одним ответом: агрегаты со строками, нераспределённые строки, итоги. Без страниц — агрегатов в версии десятки,
строк — сотни; фильтры и сортировку делает фронт.
Порядок агрегатов: по `rule_no`, затем по имени компании 1, затем компании 2. Порядок нераспределённых: по имени владельца,
затем по коду счёта.
Пустой ответ (`aggregates = []`, `unallocated = []`) — внутригрупповых строк нет; это не ошибка.
### 3.2 `PUT …/aggregates/{rule_id}/{company_1_id}/{company_2_id}` — сохранить корректировку
Создаёт корректировку агрегата или заменяет действующую целиком (все статьи обеих сторон и комментарий приходят каждый раз).
- `debit[]` — статьи стороны 1 (компания 1), `credit[]` — стороны 2 (компания 2); первая статья стороны — основная, остальные
— дочерние (хранение — `correction.parent_correction_id`, глубина 1). Пустая сторона допустима, обе пустые — 422
`EMPTY_CORRECTION`.
- `line_code` — код строки формы ББ (ОФР) этой компании; не из формы — 422 `INVALID_LINE_CODE` с `detail.line_code`.
Сумма — строка ≥ 0, не больше 16 цифр до запятой и двух после (`numeric(18,2)`); это уменьшение статьи. Иное — 422
массивом pydantic.
- `comment` обязателен всегда, пустой после обрезки — 422 `COMMENT_REQUIRED` (DOC-272613, ответ п. 6); не длиннее
1000 символов (как комментарий смены статуса), длиннее — 422 массивом pydantic.
- Сохранение сразу применяет корректировку — `status = APPLIED`; поля `status` в теле нет (ответ п. 9). `DRAFT` есть в модели,
сервер его пока не выставляет.
- `expected_version_no` — номер редакции действующей корректировки, которую аналитик видел (`correction.version_no`).
Действующей нет — `null`. Не совпал с текущим, или `null`, а действующая есть — 409 `CORRECTION_VERSION_CONFLICT` (кто-то
сохранил раньше; фронт перечитывает таблицу).
- `line_code` — один код. У правил 1 и 2 статья стороны 2 составная (`article_2_line = "1450/1550"`, `"1310/1350"`, коды через
«/»); в корректировку пишется один из кодов.
- Корректировку можно сохранить, даже если стороны не сошлись; ответ показывает `balance` (контроль распределения). В КФО по
выбранным статьям уходит меньшая сумма (ответ п. 8). Ограничения «только при отклонении > 5 %» нет (ответ по ТЗ).
- Каждое сохранение увеличивает `version_no`; аудит `CORRECTION_CREATE` (первая) / `CORRECTION_EDIT` (следующие) с `before` /
`after`; результат КФО помечается устаревшим (AURORA-1417).
Ответ — пересчитанный агрегат.
### 3.3 `POST …/allocations` — решение по строке
Одно действие над одной позицией: строка сводной ОСВ (`entry_ids` — как в `CorrectionEntryOut`) **и сторона** (`side`).
Сторона нужна потому, что строка с дебетовым и кредитовым сальдо одновременно даёт две позиции (§2), и решение по одной не
должно задевать другую. Хранение — `vgo_assignment` (DOC-272613 «Ручное добавление и перенос строк», §5): только ручные
решения, одно действующее на `osv_entry` и сторону в версии и типе отчётности, повторное решение обновляет запись. Позиции с
такой стороной у строки нет — 422 `ENTRY_NOT_IN_VERSION`.
| `action` | Что делает | `vgo_assignment` |
|---|---|---|
| `ASSIGN` | отнести к правилу `rule_id` или перенести в него из другого агрегата; исключённую из КФО нераспределённую строку — нельзя, 422 `ENTRY_EXCLUDED` (сначала `INCLUDE`) | `action = ASSIGN`, `rule_id` |
| `UNASSIGN` | убрать из агрегата — строка становится нераспределённой (`reason = UNASSIGNED`), автоматика её обратно не вернёт (ответ п. 4) | `action = UNASSIGNED`, `rule_id = NULL` |
| `EXCLUDE` | «Не учитывать в КФО» — **только у нераспределённой строки**; строка остаётся видна с отметкой. У строки агрегата — 422 `ENTRY_ALLOCATED` (ответ п. 4) | `is_exclude = true`; записи не было — она создаётся ради отметки, `reason` строки не меняется |
| `INCLUDE` | снять «Не учитывать в КФО»; у строки агрегата — 422 `ENTRY_ALLOCATED` | `is_exclude = false`; запись, созданная только ради отметки, удаляется |
| `RESET` | снять ручное решение — строка снова разносится автоматически | запись удаляется |
Проверки `ASSIGN` (DOC-272613, шаг 2; ответ 3(а)): правило той же версии правил и того же типа отчётности; счёт и сторона
строки входят в сторону правила с учётом исключений; пара компаний агрегата — владелец и контрагент строки (её не выбирают),
обе в периметре. Не подходит — 422 `RULE_NOT_APPLICABLE` с `detail.reason`. `ASSIGN` без `rule_id` — 422 `RULE_REQUIRED`.
Строки нет среди строк версии — 422 `ENTRY_NOT_IN_VERSION`.
После решения пересчитываются затронутые агрегаты; у агрегата с применённой корректировкой ставится
`composition_changed = true` (ответ 5). Ответ — вся таблица (решение меняет и агрегаты, и нераспределённые строки).
Аудит: в `audit_action` DBML v5.23 подходящего действия нет — нужно новое (например, `VGO_ASSIGNMENT_CHANGE`) вместе с
таблицей `vgo_assignment` в DBML v5.24 (AURORA-1751).
### 3.4 `GET …/export` — xlsx
Таблица выбранного типа отчётности одним листом, как на экране: строка агрегата с колонками Дебет / Кредит / Контроль /
Корректировка, под ней его строки ОСВ (группировка, колонка «Правило» у них пустая), затем нераспределённые строки и итог;
суммы в рублях. Имя файла — `Корректировки_<название ГК>_<год>_<квартал>.xlsx` в `Content-Disposition` (`filename*`, ASCII
запасное `corrections.xlsx`), как в WIP API (#208). Выбор колонок и фильтры в первой версии не передаются. Аудит `EXPORT`.
## 4. Схемы
Поля без `?` есть всегда; `null` указан явно.
**`CorrectionTableOut`**
```
version_id uuid
statement_type "balance" | "income"
rules_version_no int версия правил элиминации версии консолидации
editable bool можно ли текущему пользователю править таблицу
read_only_reason null | "VERSION_NOT_EDITABLE" | "LOCK_REQUIRED" | "LOCKED_BY_OTHER"
rules[] CorrectionRuleOut правила типа отчётности (для пресетов и выбора правила)
aggregates[] CorrectionAggregateOut
unallocated[] UnallocatedEntryOut
totals CorrectionTotalsOut
```
**`CorrectionRuleOut`** `{id, rule_no, article_1_line, article_2_line, side_1 ("D"|"K"), measure ("closing"|"turnover"),
company_1_accounts[] (коды счетов стороны 1), company_2_accounts[], company_1_excluded[], company_2_excluded[]}`.
Подпись пресета фронт строит сам: «Правило N: <статья 1> — <статья 2>». Статья может быть составной — коды через «/»
(`"1450/1550"`), как в `elimination_rule`.
**`CorrectionAggregateOut`**
```
rule_id, rule_no
company_1 {id, name}
company_2 {id, name}
side_1 AggregateSideOut дебетовая сторона (компания 1)
side_2 AggregateSideOut кредитовая сторона (компания 2)
deviation_amount money
deviation_percent percent | null к меньшей стороне; null — меньшая сторона ≤ 0
exceeds_threshold bool deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой
status "AUTO" | "DISCREPANCY" | "ONE_SIDED" | "MANUAL" как EliminationTotalItemOut
eliminated_amount money автоматическая сумма элиминации агрегата (§5), справочно
composition_changed bool состав или суммы изменились после применённой корректировки (ответ 5; ответ п. 10 — подсвечивать)
correction CorrectionOut | null действующая корректировка (APPLIED; DRAFT — зарезервирован)
```
**`AggregateSideOut`** `{article_line (статья правила), accounts[] (коды счетов строк, вошедших в сторону; не весь перечень
правила), amount (money), entries[] (CorrectionEntryOut)}`.
**`CorrectionEntryOut`** — строка внутри агрегата:
```
entry_ids[] uuid исходные osv_entry строки свода
legal_entity_id uuid владелец строки
counterparty_id uuid
account_code string
side "D" | "K"
amount money сальдо (оборот) строки по этой стороне
decision "AUTO" | "ASSIGN" как попала в агрегат
candidate_rule_ids[] uuid другие правила, под которые строка подходит по счёту и стороне (для «Перенести в правило…»)
```
**`UnallocatedEntryOut`** — нераспределённая строка:
```
entry_ids[], owner {id, name}, counterparty {id, name}, account_code, side ("D"|"K"), amount
line_code string | null строка баланса владельца, к которой привязан счёт (ответ п. 11); null — не определена
candidate_rule_ids[] uuid правила, под которые строка подходит по счёту и стороне
reason "MULTIPLE_RULES" | "UNASSIGNED" несколько правил / убрана аналитиком
excluded bool «Не учитывать в КФО» (только у нераспределённой)
```
**`CorrectionOut`**
```
status "DRAFT" | "APPLIED" сохранение даёт APPLIED
version_no int
debit[] CorrectionLineOut статьи стороны 1, первая — основная
credit[] CorrectionLineOut статьи стороны 2, первая — основная
comment string
reason_code string | null перечень DBML (ТЗ 6.4), для ОФР
balance CorrectionBalanceOut
updated_by {id, name} | null
updated_at datetime
```
**`CorrectionLineOut`** `{line_code, amount}`.
**`CorrectionBalanceOut`** — контроль распределения (DOC-272613 «Запись значений», шаг 3):
`{debit_total, credit_total, difference (|debit_total − credit_total|), difference_percent (к меньшему, null — меньший ≤ 0),
remainder_1 (сумма стороны 1 − debit_total), remainder_2 (сумма стороны 2 − credit_total)}`.
**`CorrectionTotalsOut`** — итоги по всем агрегатам ответа (без нераспределённых): `{amount_1, amount_2, deviation_amount,
correction_debit, correction_credit}`. При фильтрах на экране фронт считает итоги видимых строк сам.
**`CorrectionIn`** `{debit[] (CorrectionLineIn), credit[] (CorrectionLineIn), comment (string), reason_code? (string|null),
expected_version_no (int|null)}`; `CorrectionLineIn {line_code, amount}`.
**`AllocationIn`** `{statement_type, entry_ids[] (≥ 1), side ("D"|"K"), action ("ASSIGN"|"UNASSIGN"|"EXCLUDE"|"INCLUDE"|"RESET"),
rule_id? (uuid, для ASSIGN)}`.
## 5. Для бэкенда (AURORA-1540)
- Модель: `correction` — как в DBML v5.23; одна запись на сторону агрегата (основная, `parent_correction_id = NULL`) и дочерние
статьи; поля `debit_*` заполняются у записей стороны 1, `credit_*` — у стороны 2; `legal_entity_id` — компания стороны,
`counterparty_id` — другая компания агрегата. `deviation_*` записываются на момент сохранения — по ним считается
`composition_changed`.
- **Кто что делает** (`[allocation-split]`, 29.10): AURORA-1751 — разнесение строк (чистая функция), ручные решения
(`POST …/allocations`, `vgo_assignment`, аудит, DBML v5.24); AURORA-1540 — таблица, корректировка, экспорт поверх функции
1751 (источник агрегатов в WIP #208 — `CorrectionAggregateSource`, его реализация вызывает функцию 1751).
- `vgo_assignment` — **только ручные решения**, автоматическое разнесение не хранится (DECISION-5): иначе ручное решение не
отличить от автоматического и при пересчёте после замены ОСВ оно затрётся. Поля (миграция AURORA-1751 + сторона):
`consolidation_version_id, statement_type, osv_entry_id, side (D | K), rule_id (NULL), action (ASSIGN | UNASSIGNED),
is_exclude (bool), created_by/at, updated_by/at`; `ASSIGN` — с `rule_id`, `UNASSIGNED` — без; уникальность
`(consolidation_version_id, statement_type, osv_entry_id, side)`. Поля `side` в DOC-272613 и в миграции нет — добавлено
контрактом, иначе у строки с дебетовым и кредитовым сальдо решения по двум позициям неразличимы. Нужны DBML v5.24, ORM-модель
в `src/models` и `audit_action` для решений по строкам.
- Разнесение — чистая функция в `api/src/domain/` (как `elimination_packing`): строки свода × правила × ручные решения →
агрегаты и нераспределённые строки (с `line_code` и `candidate_rule_ids`). Нынешняя упаковка считает каждое правило
отдельно (TODO в `intercompany_matrix.build_pairs`) — для таблицы корректировок строка должна попадать не более чем в одно
правило.
- Правила ББ 1 и 2 (DOC-274230) имеют свой расчёт и не наследуют min; что идёт в `eliminated_amount` по ним — по DOC-274230.
- ОФР: тот же ответ с `statement_type = income` **(ждёт ответа в AURORA-1396)**; до него сервер может отдавать пустые
`aggregates` и `unallocated` для `income`.
## 6. Пример
`GET /api/v1/consolidations/versions/7b…/corrections?statement_type=balance` (сокращено):
```json
{
"version_id": "7b1c…",
"statement_type": "balance",
"rules_version_no": 1,
"editable": true,
"read_only_reason": null,
"rules": [{"id": "r6…", "rule_no": 6, "article_1_line": "1230", "article_2_line": "1520", "side_1": "D",
"measure": "closing", "company_1_accounts": ["60.02", "62.01", "76"], "company_2_accounts": ["60.01", "62.02", "76"],
"company_1_excluded": ["76.07.5", "76.07.9"], "company_2_excluded": ["76.07.1"]}],
"aggregates": [{
"rule_id": "r6…", "rule_no": 6,
"company_1": {"id": "a…", "name": "ЭПТ"}, "company_2": {"id": "b…", "name": "ИЛС"},
"side_1": {"article_line": "1230", "accounts": ["62.01"], "amount": "1000000.00",
"entries": [{"entry_ids": ["e1…"], "legal_entity_id": "a…", "counterparty_id": "b…", "account_code": "62.01",
"side": "D", "amount": "1000000.00", "decision": "AUTO"}]},
"side_2": {"article_line": "1520", "accounts": ["60.02"], "amount": "202320.00", "entries": ["…"]},
"deviation_amount": "797680.00", "deviation_percent": "394.27", "exceeds_threshold": true,
"status": "DISCREPANCY", "eliminated_amount": "0.00", "composition_changed": false,
"correction": {"status": "APPLIED", "version_no": 1,
"debit": [{"line_code": "1230", "amount": "1000000.00"}],
"credit": [{"line_code": "1520", "amount": "202320.00"}],
"comment": "Требуется сверка с контрагентом", "reason_code": null,
"balance": {"debit_total": "1000000.00", "credit_total": "202320.00", "difference": "797680.00",
"difference_percent": "394.27", "remainder_1": "0.00", "remainder_2": "0.00"},
"updated_by": {"id": "u…", "name": "Иванов И. И."}, "updated_at": "2026-10-28T12:00:00+03:00"}
}],
"unallocated": [{"entry_ids": ["e9…"], "owner": {"id": "c…", "name": "ООО «БСК»"},
"counterparty": {"id": "d…", "name": "ПМП ООО"}, "account_code": "76.05", "line_code": "1520", "side": "K",
"amount": "142878.00", "candidate_rule_ids": ["r6…", "r7…"], "reason": "MULTIPLE_RULES", "excluded": false}],
"totals": {"amount_1": "1000000.00", "amount_2": "202320.00", "deviation_amount": "797680.00",
"correction_debit": "1000000.00", "correction_credit": "202320.00"}
}
```

View File

@ -0,0 +1,420 @@
openapi: 3.1.0
info:
title: АРМ КА — таблица корректировок
version: 0.2.0-draft
description: >
Черновик контракта AURORA-1540 (бэкенд) / AURORA-1541 (фронт). Текстовое описание, источники и открытые вопросы —
dev/api-contract-corrections.md; оба файла держатся одинаковыми. Деньги — строки с двумя знаками в рублях, проценты —
строки с двумя знаками. Чтение — CONSOLIDATION_VIEW, правки — CONSOLIDATION_EDIT.
servers:
- url: /api/v1
security:
- bearerAuth: []
paths:
/consolidations/versions/{version_id}/corrections:
get:
operationId: getCorrectionTable
summary: Таблица корректировок версии
x-permission: CONSOLIDATION_VIEW
parameters:
- $ref: '#/components/parameters/VersionId'
- $ref: '#/components/parameters/StatementTypeQuery'
responses:
'200':
description: Агрегаты со строками, нераспределённые строки, итоги
content:
application/json:
schema: { $ref: '#/components/schemas/CorrectionTableOut' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/DomainError' }
/consolidations/versions/{version_id}/corrections/aggregates/{rule_id}/{company_1_id}/{company_2_id}:
put:
operationId: saveCorrection
summary: Сохранить корректировку агрегата (создать или заменить целиком)
description: >
Аудит CORRECTION_CREATE (первая) / CORRECTION_EDIT; version_no++; результат КФО устаревает.
Ошибки 409 — VERSION_NOT_EDITABLE, LOCK_REQUIRED, LOCKED_BY_OTHER, CORRECTION_VERSION_CONFLICT;
422 — COMMENT_REQUIRED, EMPTY_CORRECTION, INVALID_LINE_CODE (detail.line_code).
x-permission: CONSOLIDATION_EDIT
parameters:
- $ref: '#/components/parameters/VersionId'
- $ref: '#/components/parameters/RuleId'
- $ref: '#/components/parameters/Company1Id'
- $ref: '#/components/parameters/Company2Id'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CorrectionIn' }
responses:
'200':
description: Пересчитанный агрегат
content:
application/json:
schema: { $ref: '#/components/schemas/CorrectionAggregateOut' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/DomainError' }
'409': { $ref: '#/components/responses/DomainError' }
'422': { $ref: '#/components/responses/DomainOrValidationError' }
/consolidations/versions/{version_id}/corrections/allocations:
post:
operationId: allocateEntry
summary: Решение по строке сводной ОСВ (vgo_assignment)
description: >
Только ручные решения (vgo_assignment). ASSIGN — отнести/перенести в правило (исключённую нераспределённую —
ENTRY_EXCLUDED); UNASSIGN — убрать из агрегата в нераспределённые; EXCLUDE / INCLUDE — «Не учитывать в КФО» и
снятие, только у нераспределённой строки (у строки агрегата — ENTRY_ALLOCATED); RESET — вернуть к автоматике.
409 — правки версии; 422 — ENTRY_NOT_IN_VERSION, RULE_NOT_APPLICABLE (detail.reason), RULE_REQUIRED, ENTRY_ALLOCATED,
ENTRY_EXCLUDED. Ответ — вся таблица.
x-permission: CONSOLIDATION_EDIT
parameters:
- $ref: '#/components/parameters/VersionId'
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AllocationIn' }
responses:
'200':
description: Пересчитанная таблица
content:
application/json:
schema: { $ref: '#/components/schemas/CorrectionTableOut' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/DomainError' }
'409': { $ref: '#/components/responses/DomainError' }
'422': { $ref: '#/components/responses/DomainOrValidationError' }
/consolidations/versions/{version_id}/corrections/export:
get:
operationId: exportCorrectionTable
summary: Выгрузка таблицы корректировок в xlsx
description: Аудит EXPORT.
x-permission: CONSOLIDATION_VIEW
parameters:
- $ref: '#/components/parameters/VersionId'
- $ref: '#/components/parameters/StatementTypeQuery'
responses:
'200':
description: Файл xlsx
content:
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet:
schema: { type: string, format: binary }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/DomainError' }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
parameters:
VersionId:
name: version_id
in: path
required: true
schema: { type: string, format: uuid }
RuleId:
name: rule_id
in: path
required: true
schema: { type: string, format: uuid }
Company1Id:
name: company_1_id
in: path
required: true
description: Компания стороны 1 правила (дебетовая сторона агрегата)
schema: { type: string, format: uuid }
Company2Id:
name: company_2_id
in: path
required: true
description: Компания стороны 2 правила (кредитовая сторона агрегата)
schema: { type: string, format: uuid }
StatementTypeQuery:
name: statement_type
in: query
required: false
schema: { $ref: '#/components/schemas/StatementType' }
responses:
Unauthorized:
description: Нет или плохой токен
Forbidden:
description: Нет права
content:
application/json:
schema:
type: object
properties: { detail: { type: string } }
DomainError:
description: Доменная ошибка
content:
application/json:
schema: { $ref: '#/components/schemas/DomainErrorOut' }
DomainOrValidationError:
description: Доменная ошибка или массив ошибок валидации pydantic
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/DomainErrorOut'
- type: object
properties:
detail:
type: array
items:
type: object
properties:
loc: { type: array, items: {} }
msg: { type: string }
type: { type: string }
schemas:
Money:
type: string
pattern: '^-?\d+\.\d{2}$'
description: Сумма в рублях, два знака
examples: ['1000000.00']
MoneyIn:
type: string
pattern: '^\d{1,16}(\.\d{1,2})?$'
description: Неотрицательная сумма в рублях, не больше 16 цифр до запятой и двух после (numeric(18,2))
Percent:
type: [string, 'null']
pattern: '^\d+\.\d{2}$'
description: Процент с двумя знаками; null — не определён
StatementType:
type: string
enum: [balance, income]
default: balance
Side:
type: string
enum: [D, K]
Ref:
type: object
required: [id, name]
properties:
id: { type: string, format: uuid }
name: { type: string }
DomainErrorOut:
type: object
required: [detail]
properties:
detail:
type: object
required: [code, message]
properties:
code:
type: string
enum:
- NOT_FOUND
- VERSION_NOT_EDITABLE
- LOCK_REQUIRED
- LOCKED_BY_OTHER
- CORRECTION_VERSION_CONFLICT
- COMMENT_REQUIRED
- EMPTY_CORRECTION
- INVALID_LINE_CODE
- ENTRY_NOT_IN_VERSION
- RULE_NOT_APPLICABLE
- RULE_REQUIRED
- ENTRY_ALLOCATED
- ENTRY_EXCLUDED
message: { type: string, description: Текст по-русски }
additionalProperties: true
CorrectionTableOut:
type: object
required: [version_id, statement_type, rules_version_no, editable, read_only_reason, rules, aggregates, unallocated, totals]
properties:
version_id: { type: string, format: uuid }
statement_type: { $ref: '#/components/schemas/StatementType' }
rules_version_no: { type: integer }
editable: { type: boolean, description: Может ли текущий пользователь править таблицу }
read_only_reason:
type: [string, 'null']
enum: [VERSION_NOT_EDITABLE, LOCK_REQUIRED, LOCKED_BY_OTHER, null]
rules:
type: array
items: { $ref: '#/components/schemas/CorrectionRuleOut' }
aggregates:
type: array
items: { $ref: '#/components/schemas/CorrectionAggregateOut' }
unallocated:
type: array
items: { $ref: '#/components/schemas/UnallocatedEntryOut' }
totals: { $ref: '#/components/schemas/CorrectionTotalsOut' }
CorrectionRuleOut:
type: object
required: [id, rule_no, article_1_line, article_2_line, side_1, measure, company_1_accounts, company_2_accounts, company_1_excluded, company_2_excluded]
properties:
id: { type: string, format: uuid }
rule_no: { type: integer }
article_1_line: { type: string, description: 'Код статьи; составная — коды через «/» ("1450/1550")' }
article_2_line: { type: string, description: 'Код статьи; составная — коды через «/» ("1310/1350")' }
side_1: { $ref: '#/components/schemas/Side' }
measure: { type: string, enum: [closing, turnover] }
company_1_accounts: { type: array, items: { type: string }, description: Коды счетов стороны 1 }
company_2_accounts: { type: array, items: { type: string } }
company_1_excluded: { type: array, items: { type: string } }
company_2_excluded: { type: array, items: { type: string } }
CorrectionAggregateOut:
type: object
required: [rule_id, rule_no, company_1, company_2, side_1, side_2, deviation_amount, deviation_percent, exceeds_threshold, status, eliminated_amount, composition_changed, correction]
properties:
rule_id: { type: string, format: uuid }
rule_no: { type: integer }
company_1: { $ref: '#/components/schemas/Ref' }
company_2: { $ref: '#/components/schemas/Ref' }
side_1: { $ref: '#/components/schemas/AggregateSideOut' }
side_2: { $ref: '#/components/schemas/AggregateSideOut' }
deviation_amount: { $ref: '#/components/schemas/Money' }
deviation_percent: { $ref: '#/components/schemas/Percent' }
exceeds_threshold:
type: boolean
description: deviation_percent ≥ 5, или одна сторона пуста при ненулевой другой
status: { type: string, enum: [AUTO, DISCREPANCY, ONE_SIDED, MANUAL] }
eliminated_amount: { $ref: '#/components/schemas/Money' }
composition_changed: { type: boolean, description: Состав или суммы изменились после применённой корректировки }
correction:
oneOf:
- $ref: '#/components/schemas/CorrectionOut'
- type: 'null'
AggregateSideOut:
type: object
required: [article_line, accounts, amount, entries]
properties:
article_line: { type: string }
accounts: { type: array, items: { type: string }, description: Коды счетов строк, вошедших в сторону }
amount: { $ref: '#/components/schemas/Money' }
entries:
type: array
items: { $ref: '#/components/schemas/CorrectionEntryOut' }
CorrectionEntryOut:
type: object
required: [entry_ids, legal_entity_id, counterparty_id, account_code, side, amount, decision, candidate_rule_ids]
properties:
entry_ids: { type: array, items: { type: string, format: uuid }, minItems: 1 }
legal_entity_id: { type: string, format: uuid }
counterparty_id: { type: string, format: uuid }
account_code: { type: string }
side: { $ref: '#/components/schemas/Side' }
amount: { $ref: '#/components/schemas/Money' }
decision: { type: string, enum: [AUTO, ASSIGN] }
candidate_rule_ids:
type: array
items: { type: string, format: uuid }
description: Другие правила, под которые строка подходит по счёту и стороне
UnallocatedEntryOut:
type: object
required: [entry_ids, owner, counterparty, account_code, line_code, side, amount, candidate_rule_ids, reason, excluded]
properties:
entry_ids: { type: array, items: { type: string, format: uuid }, minItems: 1 }
owner: { $ref: '#/components/schemas/Ref' }
counterparty: { $ref: '#/components/schemas/Ref' }
account_code: { type: string }
line_code:
type: [string, 'null']
description: Строка баланса владельца, к которой привязан счёт; null — не определена
side: { $ref: '#/components/schemas/Side' }
amount: { $ref: '#/components/schemas/Money' }
candidate_rule_ids: { type: array, items: { type: string, format: uuid } }
reason: { type: string, enum: [MULTIPLE_RULES, UNASSIGNED] }
excluded: { type: boolean, description: «Не учитывать в КФО» — есть только у нераспределённой строки }
CorrectionOut:
type: object
required: [status, version_no, debit, credit, comment, reason_code, balance, updated_by, updated_at]
properties:
status: { type: string, enum: [DRAFT, APPLIED], description: Сохранение даёт APPLIED; DRAFT зарезервирован }
version_no: { type: integer }
debit:
type: array
items: { $ref: '#/components/schemas/CorrectionLineOut' }
description: Статьи стороны 1, первая — основная
credit:
type: array
items: { $ref: '#/components/schemas/CorrectionLineOut' }
description: Статьи стороны 2, первая — основная
comment: { type: string }
reason_code: { type: [string, 'null'] }
balance: { $ref: '#/components/schemas/CorrectionBalanceOut' }
updated_by:
oneOf:
- $ref: '#/components/schemas/Ref'
- type: 'null'
updated_at: { type: string, format: date-time }
CorrectionLineOut:
type: object
required: [line_code, amount]
properties:
line_code: { type: string }
amount: { $ref: '#/components/schemas/Money' }
CorrectionBalanceOut:
type: object
required: [debit_total, credit_total, difference, difference_percent, remainder_1, remainder_2]
properties:
debit_total: { $ref: '#/components/schemas/Money' }
credit_total: { $ref: '#/components/schemas/Money' }
difference: { $ref: '#/components/schemas/Money' }
difference_percent: { $ref: '#/components/schemas/Percent' }
remainder_1: { $ref: '#/components/schemas/Money', description: Сумма стороны 1 минус debit_total }
remainder_2: { $ref: '#/components/schemas/Money', description: Сумма стороны 2 минус credit_total }
CorrectionTotalsOut:
type: object
required: [amount_1, amount_2, deviation_amount, correction_debit, correction_credit]
properties:
amount_1: { $ref: '#/components/schemas/Money' }
amount_2: { $ref: '#/components/schemas/Money' }
deviation_amount: { $ref: '#/components/schemas/Money' }
correction_debit: { $ref: '#/components/schemas/Money' }
correction_credit: { $ref: '#/components/schemas/Money' }
CorrectionLineIn:
type: object
required: [line_code, amount]
properties:
line_code: { type: string, minLength: 1 }
amount: { $ref: '#/components/schemas/MoneyIn' }
CorrectionIn:
type: object
required: [debit, credit, comment, expected_version_no]
properties:
debit: { type: array, items: { $ref: '#/components/schemas/CorrectionLineIn' } }
credit: { type: array, items: { $ref: '#/components/schemas/CorrectionLineIn' } }
comment: { type: string, maxLength: 1000 }
reason_code: { type: [string, 'null'] }
expected_version_no:
type: [integer, 'null']
description: >
Редакция действующей корректировки, которую видел аналитик; null — действующей нет.
Несовпадение — 409 CORRECTION_VERSION_CONFLICT. Сохранение сразу применяет корректировку (APPLIED).
AllocationIn:
type: object
required: [statement_type, entry_ids, side, action]
properties:
statement_type: { $ref: '#/components/schemas/StatementType' }
entry_ids: { type: array, items: { type: string, format: uuid }, minItems: 1 }
side:
$ref: '#/components/schemas/Side'
description: Позиция строки — дебетовая или кредитовая (строка с обоими сальдо даёт две позиции)
action: { type: string, enum: [ASSIGN, UNASSIGN, EXCLUDE, INCLUDE, RESET] }
rule_id: { type: [string, 'null'], format: uuid, description: Обязателен для ASSIGN }

View File

@ -42,8 +42,8 @@
| `POST /consolidations/versions/{version_id}/status` | EDIT | `StatusIn {to_status, comment? (≤ 1000)}` | 200 `VersionCard` | 404, 409 `VERSION_NOT_EDITABLE` / `LOCK_REQUIRED` / `LOCKED_BY_OTHER` / `TRANSITION_NOT_ALLOWED`, 422 `COMMENT_REQUIRED`, 422 (pydantic-массив) при `comment` длиннее 1000 символов | [33-01], меню «…» [34] |
| `PUT /consolidations/versions/{version_id}/members` | EDIT | `MembersIn {legal_entity_ids[] (≤ 500)}` | 200 `VersionCard` | 404, 409 `VERSION_NOT_EDITABLE` / `LOCK_REQUIRED` / `LOCKED_BY_OTHER`, 422 `ENTITY_NOT_IN_GROUP` (`legal_entity_ids`), 422 `PERIMETER_TOO_SMALL`, 422 (pydantic-массив) при более чем 500 `legal_entity_ids` | [36] |
| `GET /consolidations/versions/{version_id}/checklist` | VIEW | query `format` (`json` по умолчанию / `csv`) | `ChecklistOut {rules[], rows[]}`; с `format=csv` — файл CSV (Срез 3b) | 404 | [37] |
| `GET /consolidations/versions/{version_id}/osv` | VIEW | query: `limit`, `offset`, `legal_entity_id`, `account_root`, `counterparty_type`, `unmapped` (bool), `match_kind` (повторяемый), `q` (≤ 500) | 200 `Page[ConsolidatedRowOut]` | 404, 422 (pydantic-массив) при `q` длиннее 500 символов | [30] |
| `GET /consolidations/versions/{version_id}/osv/summary` | VIEW | — | `OsvSummaryOut {rows_total, unmapped_total, auto_matched_total, documents_total, gaps[]}` | 404 | [30], баннер «Осталось: N», [31-00] |
| `GET /consolidations/versions/{version_id}/osv` | VIEW | query: `limit`, `offset`, `legal_entity_id`, `account_root`, `counterparty_type`, `counterparty_id` (строки с этим контрагентом после дорезолва; AURORA-1746), `unmapped` (bool), `match_kind` (повторяемый), `q` (≤ 500) | 200 `Page[ConsolidatedRowOut]` | 404, 422 (pydantic-массив) при `q` длиннее 500 символов | [30] |
| `GET /consolidations/versions/{version_id}/osv/summary` | VIEW | — | `OsvSummaryOut {rows_total, unmapped_total, auto_matched_total, documents_total, gaps[], counter_pairs[]}` | 404 | [30], баннер «Осталось: N», режим «Встречные записи», модалка «Проверьте встречные записи участников» (вместо [31-00]) |
| `GET /consolidations/versions/{version_id}/osv/export` | VIEW | те же фильтры, что у `…/osv`, без `limit`/`offset` | файл xlsx (`application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`) | 404 | «Экспорт» [30] |
| `GET /consolidations/versions/{version_id}/counterparty-candidates` | VIEW | query: `q` (≤ 500), `scope` (`group` по умолчанию / `all`), `entry_id` | `CandidateOut[]` (до 100) | 404, 422 `QUERY_TOO_SHORT` (`scope=all` и `q` короче 3 символов после обрезки) | [32-osv-map-counterparty] |
| `POST /consolidations/versions/{version_id}/osv/entries/{entry_id}/counterparty` | EDIT | `MapCounterpartyIn {legal_entity_id, move_alias?=false}` | 200 `MapCounterpartyOut` | 404, 409 `VERSION_NOT_EDITABLE` / `LOCK_REQUIRED` / `LOCKED_BY_OTHER`, 409 `ALIAS_TAKEN` (`owners[] {legal_entity_id, name}`) | [32-osv-map-counterparty] «Связать» |
@ -90,15 +90,21 @@ warnings[]}` — поля с `?` могут быть `null` (ключ прису
`counterparty_name/inn` — как в файле ОСВ; `counterparty_id`/`counterparty_entity_name`/`counterparty_entity_inn` — сопоставленное ЮЛ справочника (id, имя, ИНН) или `null`;
`match_stored = true` — сопоставление хранится в БД, `false` — вычислено на лету (в БД не пишется); `match_kind` — способ
(`null`, если ЮЛ не найдено). Для склеенных строк `entry_ids` — записи по кварталам (порядок — от раннего к позднему).
**`OsvSummaryOut`** `{rows_total, unmapped_total, auto_matched_total, documents_total, gaps[]}`: `rows_total` — число строк свода (без фильтров); `unmapped_total` — число строк свода с пустым `counterparty_id` **после** дорезолва на лету **и** без `counterparty_inn` (то же, что `GET …/osv?unmapped=true` даёт в `total`; `unmapped=false` — все остальные строки); `auto_matched_total` — число строк с `match_kind` `NAME` или `ALIAS` независимо от `match_stored` (`INN`, `MANUAL`, `PROPAGATED` не входят); `documents_total` — число ОСВ-документов, привязанных к версии; **`gaps[]`** —
**`OsvSummaryOut`** `{rows_total, unmapped_total, auto_matched_total, documents_total, gaps[], counter_pairs[]}`: `rows_total` — число строк свода (без фильтров); `unmapped_total` — число строк свода с пустым `counterparty_id` **после** дорезолва на лету **и** без `counterparty_inn` (то же, что `GET …/osv?unmapped=true` даёт в `total`; `unmapped=false` — все остальные строки); `auto_matched_total` — число строк с `match_kind` `NAME` или `ALIAS` независимо от `match_stored` (`INN`, `MANUAL`, `PROPAGATED` не входят); `documents_total` — число ОСВ-документов, привязанных к версии; **`gaps[]`** —
`{legal_entity_id, legal_entity_name, account_code, missing_quarters[]}` (пара ЮЛ × счёт, у которой нет ОСВ за какие-то кварталы;
строк такой пары в своде нет). Баннер «Требуется указать соответствия… Осталось: N», кнопка «Показать ЮЛ без ИНН» [32-osv-no-inn]
и предупреждение [31-00] опираются на `unmapped_total` / `…/osv?unmapped=true`: строки свода без сопоставленного ЮЛ **и** без ИНН
строк такой пары в своде нет). Баннер «Есть организации без ИНН и без сопоставления… Осталось: N» (не блокирует переход) и кнопка «Показать ЮЛ без ИНН»
[32-osv-no-inn] опираются на `unmapped_total` / `…/osv?unmapped=true`; прежнее предупреждение [31-00] заменено модалкой
«Нет встречных записей» по `counter_pairs[]`, `unmapped_total` блокировок и модалок не создаёт. `unmapped_total` — строки свода без сопоставленного ЮЛ **и** без ИНН
из файла; строка с ИНН, которого нет в справочнике, сопоставления не требует (решение владельца 08.10.2026, arm-core #74 — отмена
П-1 от 07.10.2026). **`CandidateOut`** `{id, inn, name, company_group (Ref | null), gsk (Ref | null), gsz (Ref | null),
counterparty_type, recommended}`. **`MapCounterpartyOut`** `{row (ConsolidatedRowOut | null), propagated, alias_created,
alias_moved_from (uuid | null)}`.
**`counter_pairs[]`** (AURORA-1746, `05-domain-rules.md` §3 п. 9) — пары участников без встречных записей, вычисляются на чтение, не хранятся:
`{holder_id, holder_name, missing_id, missing_name, holder_rows, missing_unmapped_rows, missing_rows_total}`; порядок — по `missing_name`, затем `holder_name`.
Строки сторон пары берутся из `GET …/osv`: у А — `?legal_entity_id=А&counterparty_id=Б` (`total` = `holder_rows`), у Б — `?legal_entity_id=Б&unmapped=true` (`total` = `missing_unmapped_rows`).
Фильтр `counterparty_id` принимает и `GET …/osv/export`.
## 4. Нюансы, которые легко пропустить
1. **Блокировка — не «мягкая».** Любая правка версии (`PUT …/members`, `POST …/status`, ручное сопоставление) требует статуса «в
@ -181,4 +187,4 @@ alias_moved_from (uuid | null)}`.
| [37] Чек-лист | `GET …/checklist`; «Скачать CSV» — `GET …/checklist?format=csv` (Срез 3b) |
| [30] ОСВ | `GET …/osv` с фильтрами, `GET …/osv/summary`, «Экспорт» — `GET …/osv/export` |
| [32-osv-no-inn], [32-osv-map-counterparty] | `GET …/osv?unmapped=true`, `GET …/counterparty-candidates`, «Связать» — `POST …/osv/entries/{entry_id}/counterparty` |
| [31-00] Предупреждение при уходе с вкладки | `GET …/osv/summary` (`unmapped_total`) |
| [31-00] → модалка «Проверьте встречные записи участников» при уходе с вкладки «ОСВ» | `GET …/osv/summary` (`counter_pairs[]`; `unmapped_total` блокировок не создаёт) |

View File

@ -100,6 +100,13 @@ docker compose -f ../docs/dev/docker-compose.yml --project-name arm-ka run --rm
`migration-cycle`, `check-infra`, `docker-build`, `web-check`; `scan-gateway` — `help`, `docker-build`, `emulator`;
`tabular-parser` — `docker-build`.
### Перед выкладкой: полная регрессия
`make regress` в docs (`scripts/regression/regress.sh`) прогоняет всё перечисленное выше по `main` всех репозиториев
сразу, не останавливаясь на первой ошибке, плюс то, чего нет в `check-full`: фронт в чистой копии из git, e2e ядра с
настоящим парсером, стенд с нуля в отдельном compose-проекте со сценарием по API и контрольными цифрами, UI-смоук и
прогон парсера по банку первички со сравнением с прошлым прогоном. Описание этапов и итога — `scripts/regression/README.md`.
## Эталонный фрагмент `dev/Makefile.common`
Рецепты общих целей вынесены в [`Makefile.common`](Makefile.common) — это эталон (источник — `Makefile` `scan-gateway`),

View File

@ -252,8 +252,9 @@ git diff <коммит sync> HEAD -- . ':!SNAPSHOT.md' > /tmp/contour-arm-core.p
## 8. Чек-лист одной синхронизации
1. Снаружи: нужные PR слиты в `main` трёх сервисов; `make check-full` зелёный в каждом; контракт в `docs/contracts` и копии
`api/contracts` идентичны.
1. Снаружи: нужные PR слиты в `main` трёх сервисов; полная регрессия `make -C docs regress` зелёная (она включает
`check-full` каждого сервиса, e2e, стенд с нуля и UI-смоук — `scripts/regression/README.md`), а расхождения её снимков
с прошлой выкладкой объяснены задачами выкладки; контракт в `docs/contracts` и копии `api/contracts` идентичны.
2. Правки из контура (раздел 7) возвращены наружу и слиты.
3. `prepare-contour-snapshot.sh` по каждому сервису; скрипт завершился словами «снимок готов».
4. В контуре: клон, ветка, `rsync --delete`, просмотр `git status`, коммит `sync: <repo>@<hash> (<дата>)`, `make venv`,

View File

@ -0,0 +1,69 @@
# Полная регрессия перед выкладкой
Один прогон проверяет `main` всех репозиториев workspace так, как его увидит контур: проверки качества каждого
репозитория, интеграционные и сквозные тесты, образы, стенд с нуля со сценарием по API и контрольными цифрами, UI-смоук и
прогон парсера по банку первички. В отличие от `make check-full`, прогон не останавливается на первой ошибке: перед
выкладкой нужна вся картина. Задача — arm-ka-docs#179.
```bash
make regress # из корня docs, workspace — родитель docs
scripts/regression/regress.sh --only stand,ui # только часть этапов (список: --list)
scripts/regression/regress.sh --skip bank --keep-stand
ARM_KA_WORKSPACE=~/projects/awp-ca scripts/regression/regress.sh # из worktree docs: проверяются основные checkout'ы
```
Результат — `admin/regression/<дата_время>/` в workspace (`--out` — другой каталог; повторный запуск этапов в тот же
каталог заменяет только их строки): `summary.md` — итог, `logs/<этап>.<шаг>.log`, `stand/` (отчёт сценария, снимки свода,
шахматки и форм, экспорт xlsx), `ui/` (скриншоты экранов), `bank/` (результаты парсера по doc-bank). Каталог содержит
производные реальных данных — в git не класть. Код возврата: `0` — всё зелёное, `1` — есть красные шаги, `2` —
preflight не пройден.
## Этапы
| Этап | Что проверяет |
|---|---|
| `preflight` | все репозитории (`docs`, `fixtures`, `arm-statements`, `arm-core`, `tabular-parser`, `scan-gateway`) на `main`, равны `origin/main`, без правок; Docker, `uv`, `npm`, `openspec`; нет чужих потребителей общей NATS (контейнеры сервисов `arm-ka`, ядро на `:8000`) — иначе интеграционные тесты и e2e падают по таймауту. Не пройден — прогон останавливается (`--allow-dirty` — прогнать как есть, с пометкой в итоге) |
| `venv` | `make venv` во всех репозиториях (окружения по текущим зависимостям) |
| `static` | `make check` docs (контракты, OpenSpec, снимок контура, `sync-statements`), обзор свежести копий `arm_statements`, `lint` и `test` arm-statements и трёх сервисов, `dbml-check` ядра |
| `web` | фронт `arm-core/web` из git (`git archive HEAD:web`, неотслеживаемые файлы рабочего каталога не попадают) во временном каталоге: `npm install` без lock (lock в arm-core не коммитится, OQ-63 — как в контуре), biome, `tsc`, ESLint + Prettier, vitest, сборка. `main` не меняется |
| `integration` | инфраструктура общего compose-проекта `arm-ka` (`make up`), `check-infra`, `test-integration` трёх сервисов; у ядра — с циклом миграций `upgrade → downgrade base → upgrade`. Тесты создают свои БД, рабочую `armka` не трогают |
| `fixtures` | реальные данные парсера: `test-fixtures` (golden, покрытие словарей) и `golden-check` tabular-parser |
| `images` | сборка образов `arm-core:dev`, `tabular-parser:dev`, `scan-gateway:dev` из `main` (профиль `services`) |
| `e2e` | сквозной тест ядра с настоящим tabular-parser (контейнер в `arm-ka`, после теста возвращается в прежнее состояние); пропуск теста — красный |
| `stand` | **изолированный стенд с нуля**: compose-проект `arm-ka-regress` на своих портах (ядро `:18000`, парсер `:18001`, шлюз `:18002`, PostgreSQL `:15432`, NATS `:14222`, MinIO `:19000`), миграции и `seed --dev`, эмулятор ИС Сканирование; сценарий `stand_scenario.py` по `expected.json` |
| `ui` | dev-сервер фронта против стенда, Playwright (`ui_smoke.py`) по основным экранам: исключения страницы, ответы `/api` ≥ 400, пустые экраны, скриншоты |
| `bank` | CLI tabular-parser по всем табличным файлам `doc-bank` (если он есть) и сравнение с прошлым прогоном (`parser_bank.py`) |
Стенд снимается (`down -v`) после этапа `ui`; `--keep-stand` оставляет его для ручной проверки. Общий стенд `arm-ka`
(данные, тома, порты) этапы `stand` и `ui` не трогают; `integration` и `e2e` пользуются его инфраструктурой.
## Сценарий стенда и контрольные цифры
`expected.json` — данные, а не код: ГК/ГСК dev-seed, пользователи, по сценарию — файлы фикстур для загрузки, `summary`
свода, пробелы, контрольные строки с суммами, пары шахматки, ожидаемые формы ББ/ОФР. Источники цифр — `dev/qa-guide.md`
§7.1–7.3 (РБ 2024 Q3, посчитаны вручную по файлам), прогоны 08.10 и 14.10.2026 (РБ 2025 Q1) и
`test_intercompany_matrix_golden` ядра. Новая цифра — только с причиной в описании PR.
На каждый сценарий: создание консолидации (и повтор — 409), блокировка, загрузка файлов в версию, ожидание
распознавания (PDF — через эмулятор ИС), подтверждение ОСВ-документов, `summary`, пробелы, контрольные строки, свод
постранично = `rows_total`, экспорт xlsx (строки листов «Сводная ОСВ» и «Пробелы», `Content-Disposition`), чек-лист,
шахматка по всем предустановленным правилам, результирующая таблица, итоги элиминации, формы ББ/ОФР; в конце — права
(эксперт читает, но не блокирует — 403) и 404.
Помимо ручных цифр, сценарий сохраняет **снимки** (`stand/snapshots/`: весь свод построчно, шахматка, формы, документы)
и сравнивает их со снимками последнего прошлого прогона в `admin/regression/`. Расхождения не красные — их нужно
просмотреть: изменение свода или шахматки между выкладками должно объясняться задачами выкладки.
## Банк первички
`parser_bank.py` разбирает все файлы `doc-bank/index/index.csv` с маршрутом «парсер» и пишет отпечаток каждого документа
(тип, статус, счёт, период, число строк, сумма денежных колонок, коды предупреждений). База сравнения — последний
прошлый прогон (`--bank-baseline DIR` — явно), на первом прогоне — колонка `parse_status` индекса (прогон 20.10.2026).
Итог — переходы «хуже» (кандидаты в регрессию, таблица в `summary.md`), «лучше» и «тот же статус, другой разбор»
(`bank/transitions.json`). Этап не красный по переходам: регрессия это или исправление — решает человек.
## Если шаг красный
Сначала лог шага (`logs/<этап>.<шаг>.log`, путь — в `summary.md`). Частые причины не в коде: занятая NATS (preflight
`shared-consumers`), не поднятая инфраструктура, сеть для `npm install` / `uv`. Шаг можно перезапустить отдельно:
`--only <этап> --out <тот же каталог>`.

View File

@ -0,0 +1,71 @@
{
"_comment": "Контрольные цифры сценария стенда (stand_scenario.py). Источники: dev/qa-guide.md §7.1–7.3 (РБ 2024 Q3, суммы посчитаны вручную по фикстурам), прогоны 08.10 и 14.10.2026 (2025 Q1), тест test_intercompany_matrix_golden (шахматка; встречная пара 62/60: у РБ сальдо Дт 62.01 по Счастливочной 2346459.08 в файле, у Счастливочной Кт 60.01 по РБ 2346459.05 — расхождение 3 коп. в самих книгах). Меняется осознанно: новая цифра — с причиной в описании PR. Суммы — рубли, null/отсутствие = 0.",
"user": "ka_go@armka.local",
"readonly_user": "expert@armka.local",
"company_group": "Русский Бисквит",
"related_group": "ГСК Русский Бисквит",
"scenarios": [
{
"id": "rb-2024-q3",
"year": 2024,
"quarter": "Q3",
"uploads": [
{"inn": "3528052718", "files": ["osv/rb/2024-Q1/*", "osv/rb/2024-Q2/*", "osv/rb/2024-Q3/*"]},
{"inn": "3528104476", "files": ["osv/schastl/2024-Q1/*", "osv/schastl/2024-Q2/*", "osv/schastl/2024-Q3/*"]}
],
"summary": {"rows_total": 1385, "unmapped_total": 1236, "documents_total": 22},
"gaps": [
["3528052718", "01", ["Q1", "Q2"]],
["3528052718", "58.03", ["Q1", "Q2"]],
["3528052718", "66", ["Q1", "Q2"]],
["3528052718", "67", ["Q1", "Q2"]],
["3528052718", "76", ["Q1", "Q2"]],
["3528104476", "01", ["Q1", "Q2"]],
["3528104476", "02", ["Q1", "Q2"]]
],
"rows": [
{"name": "Счастливочная 60.01 ← РБ (ИНН)", "inn": "3528104476", "account_code": "60.01", "q": "бисквит",
"expect": {"opening_credit": "4454151.93", "turnover_debit": "128058143.65", "turnover_credit": "125950450.77",
"closing_credit": "2346459.05", "opening_debit": "0", "closing_debit": "0",
"counterparty_type": "GSK", "match_kind": "INN", "entry_ids": 3}},
{"name": "Счастливочная 60.02 ← РБ (нет в Q2)", "inn": "3528104476", "account_code": "60.02", "q": "бисквит",
"expect": {"opening_debit": "0", "opening_credit": "0", "turnover_debit": "9745019.62",
"turnover_credit": "9745019.62", "closing_debit": "0", "closing_credit": "0",
"counterparty_type": "GSK", "entry_ids": 2}},
{"name": "РБ 60.01 ← «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» (алиас dev-seed)", "inn": "3528052718", "account_code": "60.01", "q": "Юг-Финанс",
"expect": {"opening_credit": "38806758.00", "turnover_debit": "307986284.00", "turnover_credit": "375490642.46",
"closing_credit": "106311116.46", "opening_debit": "0", "closing_debit": "0",
"counterparty_type": "GSK"}},
{"name": "Счастливочная 62.01 ← РБ (две строки в файле)", "inn": "3528104476", "account_code": "62.01", "q": "бисквит",
"expect": {"opening_debit": "40211938.00", "turnover_debit": "398018012.46", "turnover_credit": "330517504.00",
"closing_debit": "107712446.46", "opening_credit": "0", "closing_credit": "0",
"counterparty_type": "GSK", "entry_ids": 6}},
{"name": "Счастливочная 62.02 ← РБ", "inn": "3528104476", "account_code": "62.02", "q": "бисквит",
"expect": {"opening_credit": "24100000.00", "turnover_debit": "9900000.00", "turnover_credit": "0",
"closing_credit": "14200000.00", "opening_debit": "0", "closing_debit": "0",
"counterparty_type": "GSK"}}
],
"matrix_manual": [
{"debit": "62", "credit": "60", "cells": [
["3528104476", "3528052718", "107712446.46", "106311116.46"],
["3528052718", "3528104476", "2346459.08", "2346459.05"]
]}
],
"statements": {}
},
{
"id": "rb-2025-q1",
"year": 2025,
"quarter": "Q1",
"uploads": [
{"inn": "3528052718", "files": ["osv/rb/2025-Q1/*", "statements/rb/Бухгалтерская отчетность 1 кв.25.pdf"]},
{"inn": "3528104476", "files": ["osv/schastl/2025-Q1/*", "statements/schastl/БО 1 кв 2025.PDF"]}
],
"summary": {"rows_total": 1421, "unmapped_total": 1291, "documents_total": 16},
"gaps": [],
"rows": [],
"matrix_manual": [],
"statements": {"3528052718": ["BALANCE", "INCOME"], "3528104476": ["BALANCE", "INCOME"]}
}
]
}

View File

@ -0,0 +1,173 @@
"""Прогон tabular-parser (CLI) по банку первички doc-bank и сравнение с прошлым прогоном (regress.sh, этап bank).
Берёт из doc-bank/index/index.csv файлы с route = «парсер» (xls/xlsx/xlsb), разбирает каждый CLI
`python -m src.parser.cli <файл>` в api/ сервиса (6 процессов, таймаут на файл) и пишет:
results.jsonl — строка на файл: код возврата, время, статус файла, отпечатки документов (тип, статус, счёт, период,
число строк, сумма денежных колонок, коды предупреждений);
summary.md — счётчики статусов и переходы против базы: прошлый прогон (--baseline DIR с results.jsonl) или,
если его нет, колонка parse_status индекса (прогон 20.10.2026, main 6c81199).
Переходы «лучше → хуже» (done → needs_check/ошибка, needs_check → ошибка) — кандидаты в регрессию, их надо
просмотреть; изменения отпечатков при том же статусе перечислены отдельно. Код возврата 0 всегда, кроме сбоя самого
скрипта: решение «регрессия или исправление» за человеком.
python3 -I parser_bank.py --parser-api ../tabular-parser/api --bank ../doc-bank --out DIR [--baseline DIR] [--jobs 6]
"""
import argparse
import csv
import hashlib
import json
import os
import subprocess
import time
from collections import Counter
from concurrent.futures import ThreadPoolExecutor
from decimal import Decimal, InvalidOperation
from pathlib import Path
MONEY = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
RANK = {"done": 0, "needs_check": 1, "failed": 2, "error": 2, "timeout": 3}
INDEX_STATUS = {"done": "done", "needs_check": "needs_check", "ошибка": "error", "ложный done": "done"}
def money_sum(rows: list[dict]) -> str:
total = Decimal(0)
for r in rows:
for k in MONEY:
v = r.get(k)
if v not in (None, ""):
try:
total += Decimal(str(v))
except InvalidOperation:
pass
return str(total)
def fingerprint(doc: dict) -> dict:
h = doc.get("header") or {}
rows = doc.get("rows") or []
lines = doc.get("lines") or []
fp = {
"type": doc.get("document_type"),
"status": doc.get("status"),
"locator": doc.get("locator"),
"account": h.get("account_code_raw"),
"period": [h.get("period_year"), h.get("period_quarter"), h.get("is_cumulative")],
"rows": len(rows) or len(lines),
"sum": money_sum(rows),
"warnings": sorted({w.get("code") for w in doc.get("warnings") or []}),
}
if lines:
fp["lines_hash"] = hashlib.sha1(json.dumps(lines, sort_keys=True, default=str).encode()).hexdigest()[:12]
return fp
def parse_one(api: Path, file: Path, timeout: int) -> dict:
t0 = time.time()
env = dict(os.environ, ENV_FILE=".env.test", APP_ENV="test")
try:
p = subprocess.run(
[str(api / ".venv/bin/python"), "-m", "src.parser.cli", str(file)],
cwd=api, env=env, capture_output=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
return {"rc": "timeout", "sec": timeout, "status": "timeout", "docs": []}
res = {"rc": p.returncode, "sec": round(time.time() - t0, 2)}
try:
out = json.loads(p.stdout)
except ValueError:
res |= {"status": "error", "error": (p.stderr.decode(errors="replace").strip().splitlines() or ["?"])[-1][:300], "docs": []}
return res
res["status"] = out.get("status")
if out.get("error"):
res["error"] = str(out["error"])[:300]
res["docs"] = [fingerprint(d) for d in out.get("documents") or []]
return res
def norm(status: str | None) -> str:
return {"failed": "error"}.get(status or "", status or "")
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--parser-api", required=True, type=Path)
ap.add_argument("--bank", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--baseline", type=Path)
ap.add_argument("--jobs", type=int, default=6)
ap.add_argument("--timeout", type=int, default=300)
a = ap.parse_args()
a.out.mkdir(parents=True, exist_ok=True)
index = [r for r in csv.DictReader((a.bank / "index/index.csv").open()) if r["route"] == "парсер"]
files = sorted(index, key=lambda r: -int(r["size"] or 0)) # большие первыми — короче хвост
t0 = time.time()
results: dict[str, dict] = {}
def work(r: dict) -> tuple[str, dict]:
return r["file"], parse_one(a.parser_api.resolve(), (a.bank / r["file"]).resolve(), a.timeout)
with ThreadPoolExecutor(a.jobs) as ex:
for i, (name, res) in enumerate(ex.map(work, files), 1):
results[name] = res
if i % 200 == 0:
print(f"{i}/{len(files)} за {int(time.time() - t0)} с", flush=True)
with (a.out / "results.jsonl").open("w") as f:
for r in index:
f.write(json.dumps({"file": r["file"], "name": r["name"]} | results[r["file"]], ensure_ascii=False) + "\n")
base: dict[str, dict] = {}
base_name = ""
if a.baseline and (a.baseline / "results.jsonl").exists():
base_name = f"прошлый прогон `{a.baseline}`"
for ln in (a.baseline / "results.jsonl").read_text().splitlines():
x = json.loads(ln)
base[x["file"]] = x
else:
base_name = "индекс doc-bank (parse_status, прогон 20.10.2026)"
for r in index:
if r["parse_status"] in INDEX_STATUS:
base[r["file"]] = {"status": INDEX_STATUS[r["parse_status"]], "docs": None}
names = {r["file"]: r["name"] for r in index}
cur = Counter(norm(x["status"]) for x in results.values())
worse, better, changed = [], [], []
for file, res in results.items():
b = base.get(file)
if not b:
continue
s0, s1 = norm(b["status"]), norm(res["status"])
if RANK.get(s1, 9) > RANK.get(s0, 9):
worse.append((file, s0, s1, res.get("error", "")))
elif RANK.get(s1, 9) < RANK.get(s0, 9):
better.append((file, s0, s1))
elif b.get("docs") is not None and b["docs"] != res["docs"]:
changed.append((file, s1))
md = [
"## Парсер по банку первички (doc-bank)",
"",
f"Файлов: {len(results)} за {int(time.time() - t0)} с; статусы: " + ", ".join(f"{k} {v}" for k, v in cur.most_common()),
f"База сравнения: {base_name}.",
"",
f"- хуже, чем в базе: **{len(worse)}** (кандидаты в регрессию — просмотреть)",
f"- лучше: {len(better)}",
f"- тот же статус, другой разбор (отпечаток документов): {len(changed)}",
]
if worse:
md += ["", "| Файл | Было | Стало | Ошибка |", "|---|---|---|---|"]
md += [f"| {names[f]} | {s0} | {s1} | {e.replace('|', '/')[:120]} |" for f, s0, s1, e in sorted(worse, key=lambda x: names[x[0]])[:100]]
if len(worse) > 100:
md.append(f"| … ещё {len(worse) - 100} | | | |")
tr = Counter(f"{s0} → {s1}" for _, s0, s1 in better)
if tr:
md += ["", "Переходы к лучшему: " + ", ".join(f"{k}: {v}" for k, v in tr.most_common())]
(a.out / "summary.md").write_text("\n".join(md) + "\n")
(a.out / "transitions.json").write_text(
json.dumps({"worse": worse, "better": better, "changed": changed}, ensure_ascii=False, indent=1)
)
print("\n".join(md))
return 0
if __name__ == "__main__":
raise SystemExit(main())

307
scripts/regression/regress.sh Executable file
View File

@ -0,0 +1,307 @@
#!/usr/bin/env bash
# Полная регрессия main всех репозиториев workspace перед выкладкой (arm-ka-docs#179). Описание — README.md рядом.
#
# scripts/regression/regress.sh [--out DIR] [--only st1,st2] [--skip st1,st2] [--keep-stand] [--allow-dirty]
# [--bank-baseline DIR] [--list]
#
# Этапы идут по порядку; упавший шаг не останавливает следующие (нужна вся картина), кроме preflight: без чистых
# main = origin/main прогон не имеет смысла (--allow-dirty — прогнать как есть, с пометкой в итоге).
# Итог — $OUT/summary.md (SHA репозиториев, статус и время шагов, счётчики pytest, пропуски), логи — $OUT/logs/.
# Код возврата: 0 — все шаги зелёные, 1 — есть красные, 2 — preflight не пройден или неверные аргументы.
#
# Workspace (каталог с arm-core/, tabular-parser/, … на main) — ARM_KA_WORKSPACE, по умолчанию родитель docs. Скрипты
# можно запускать из worktree docs: тогда задайте ARM_KA_WORKSPACE, проверяются всё равно основные checkout'ы.
set -uo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
WS="${ARM_KA_WORKSPACE:-$(cd "$HERE/../../.." && pwd)}"
COMPOSE_FILE="$WS/docs/dev/docker-compose.yml"
# Изолированный стенд: свой compose-проект и порты, общий стенд arm-ka (данные, тома) не трогается
REGRESS_PROJECT="${REGRESS_PROJECT:-arm-ka-regress}"
# Порты — только для команд этого проекта (dcr): глобальный export пересоздал бы контейнеры arm-ka на чужих портах
ARM_CORE_PORT=18000
REGRESS_PORTS="POSTGRES_PORT=15432 NATS_PORT=14222 NATS_MONITOR_PORT=18222 MINIO_API_PORT=19000 MINIO_CONSOLE_PORT=19001
ARM_CORE_PORT=$ARM_CORE_PORT TABULAR_PARSER_PORT=18001 SCAN_GATEWAY_PORT=18002"
ALL_STAGES="preflight venv static web integration fixtures images e2e stand ui bank"
# Репозитории под проверкой; pylib_ai_arm_statements (AURORA-1677) появится здесь после выпуска 0.1.2
REPOS="docs fixtures arm-statements arm-core tabular-parser scan-gateway"
OUT="" ONLY="" SKIP="" KEEP_STAND=0 ALLOW_DIRTY=0 BANK_BASELINE=""
while [ $# -gt 0 ]; do
case "$1" in
--out) OUT="$2"; shift 2 ;;
--only) ONLY="$2"; shift 2 ;;
--skip) SKIP="$2"; shift 2 ;;
--keep-stand) KEEP_STAND=1; shift ;;
--allow-dirty) ALLOW_DIRTY=1; shift ;;
--bank-baseline) BANK_BASELINE="$2"; shift 2 ;;
--list) echo "$ALL_STAGES"; exit 0 ;;
-h | --help) sed -n '2,13p' "$0"; exit 0 ;;
*) echo "неизвестный аргумент: $1" >&2; exit 2 ;;
esac
done
for s in ${ONLY//,/ } ${SKIP//,/ }; do
case " $ALL_STAGES " in *" $s "*) ;; *) echo "неизвестный этап: $s (есть: $ALL_STAGES)" >&2; exit 2 ;; esac
done
OUT="${OUT:-$WS/admin/regression/$(date +%Y-%m-%d_%H%M)}"
mkdir -p "$OUT/logs"
RESULTS="$OUT/results.tsv"
export ARM_KA_DOCS="$WS/docs" ARM_KA_FIXTURES="$WS/fixtures"
# ENV_FILE в окружении перебивает pytest.ini сервисов — не даём просочиться из оболочки
unset ENV_FILE
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
# step <этап> <шаг> <команда…>: вывод — в logs/<этап>.<шаг>.log, строка итога — в results.tsv
step() {
local stage=$1 name=$2 t0 rc lf
shift 2
lf="$OUT/logs/$stage.$name.log"
t0=$(date +%s)
log "$stage/$name …"
("$@") >"$lf" 2>&1
rc=$?
printf '%s\t%s\t%s\t%s\t%s\n' "$stage" "$name" "$rc" "$(($(date +%s) - t0))" "$lf" >>"$RESULTS"
if [ $rc -eq 0 ]; then log "$stage/$name ok"; else log "$stage/$name КРАСНЫЙ (rc=$rc, $lf)"; fi
return $rc
}
want() {
local s=$1
[ -n "$ONLY" ] && { case ",$ONLY," in *",$s,"*) ;; *) return 1 ;; esac; }
[ -n "$SKIP" ] && { case ",$SKIP," in *",$s,"*) return 1 ;; esac; }
return 0
}
dc() { docker compose -f "$COMPOSE_FILE" "$@"; }
# shellcheck disable=SC2086 — REGRESS_PORTS намеренно раскладывается на слова
dcr() { env $REGRESS_PORTS docker compose -f "$COMPOSE_FILE" -p "$REGRESS_PROJECT" --profile services "$@"; }
mk() { make --no-print-directory -C "$WS/$1" "${@:2}"; }
# --- preflight -------------------------------------------------------------------------------------------------------
repo_state() {
local r=$1 dir="$WS/$1" br head origin dirty
[ -d "$dir/.git" ] || [ -f "$dir/.git" ] || { echo "$r: нет репозитория в $dir"; return 1; }
git -C "$dir" fetch -q origin || { echo "$r: fetch не удался"; return 1; }
br=$(git -C "$dir" rev-parse --abbrev-ref HEAD)
head=$(git -C "$dir" rev-parse HEAD)
origin=$(git -C "$dir" rev-parse origin/main)
dirty=$(git -C "$dir" status --porcelain --untracked-files=no | wc -l | tr -d ' ')
printf '%s\t%s\t%s\t%s\n' "$r" "$br" "$head" "$(git -C "$dir" log -1 --format='%cs %s' HEAD | cut -c1-100)" >>"$OUT/shas.tsv"
echo "$r: ветка $br, HEAD ${head:0:7}, origin/main ${origin:0:7}, изменённых файлов $dirty"
[ "$br" = main ] && [ "$head" = "$origin" ] && [ "$dirty" = 0 ]
}
st_preflight() {
: >"$OUT/shas.tsv"
local bad=0 r
for r in $REPOS; do step preflight "repo-$r" repo_state "$r" || bad=1; done
step preflight docker docker info --format '{{.ServerVersion}}' || bad=1
step preflight tools sh -c 'command -v uv && command -v npm && command -v openspec' || bad=1
# Чужие потребители общей NATS (arm-ka): контейнеры сервисов и локальное ядро на :8000 перехватывают сообщения
# queue group, интеграционные тесты и e2e тогда падают по таймауту (scan-gateway — 08.10, arm-core e2e — docstring)
step preflight shared-consumers sh -c '
running=$(docker ps --filter label=com.docker.compose.project=arm-ka --format "{{.Names}}" | grep -E "arm-core-1|tabular-parser|scan-gateway|scan-emulator" || true)
local8000=$(lsof -nP -iTCP:8000 -sTCP:LISTEN 2>/dev/null | tail -n +2 || true)
[ -z "$running" ] && [ -z "$local8000" ] && { echo "сервисов на общей NATS нет"; exit 0; }
echo "на общем стенде arm-ka работают потребители NATS — остановите их до integration/e2e:"; echo "$running"; echo "$local8000"; exit 1' || bad=1
if [ $bad -ne 0 ] && [ $ALLOW_DIRTY -eq 0 ]; then
log "preflight не пройден — прогон остановлен (--allow-dirty — прогнать как есть)"
return 2
fi
[ $bad -ne 0 ] && echo "allow-dirty" >"$OUT/allow-dirty"
return 0
}
# --- окружения -------------------------------------------------------------------------------------------------------
st_venv() {
local r
for r in docs arm-statements tabular-parser scan-gateway arm-core; do step venv "$r" mk "$r" venv; done
}
# --- lint + unit (без инфраструктуры) --------------------------------------------------------------------------------
st_static() {
step static docs-check mk docs check
step static docs-statements-check mk docs statements-check
step static arm-statements-lint mk arm-statements lint
step static arm-statements-test mk arm-statements test
local r
for r in tabular-parser scan-gateway arm-core; do
step static "$r-lint" mk "$r" lint
step static "$r-test" mk "$r" test
done
step static arm-core-dbml-check mk arm-core dbml-check
}
# --- фронт arm-core/web ----------------------------------------------------------------------------------------------
# Копия web/ из git (`git archive HEAD:web` — ровно то, что в коммите, без node_modules, dist и прочих неотслеживаемых
# файлов) во временном каталоге; рабочий каталог main не меняется и не читается. web/package-lock.json в arm-core не
# коммитится (OQ-63, dev/contour-transfer-guide.md §5): установка без lock, как в контуре; локальный lock рабочего
# каталога (устаревший, собранный под другую ОС) в копию не попадает — 28.10.2026 он дал ложный красный npm ci (docs#181).
WEB_TMP="" STAND_UP=0
PLAYWRIGHT="${PLAYWRIGHT:-playwright==1.55.0}"
web_copy() {
WEB_TMP=$(mktemp -d "${TMPDIR:-/tmp}/regress-web.XXXXXX")
git -C "$WS/arm-core" archive HEAD:web | tar -x -C "$WEB_TMP"
echo "$WEB_TMP"
}
web_install() { rm -rf "$1/node_modules" "$1/package-lock.json" && npm --prefix "$1" install --no-audit --no-fund; }
st_web() {
web_copy >/dev/null
local w="$WEB_TMP"
echo "$w" >"$OUT/web-tmp"
step web npm-install web_install "$w"
step web biome sh -c "cd '$w' && npx biome check src"
step web tsc sh -c "cd '$w' && npx tsc --noEmit"
step web eslint-prettier npm --prefix "$w" run lint
step web vitest npm --prefix "$w" test
step web build npm --prefix "$w" run build
}
# --- интеграционные (общая инфраструктура arm-ka: тесты создают свои БД, рабочую armka не трогают) ----------------------
st_integration() {
step integration infra-up mk scan-gateway up
step integration arm-core-check-infra mk arm-core check-infra
step integration tabular-parser mk tabular-parser test-integration
step integration scan-gateway mk scan-gateway test-integration
# включает цикл миграций upgrade → downgrade base → upgrade и сверку с create_all (tests/test_migrations.py)
step integration arm-core mk arm-core test-integration
}
# --- реальные данные -------------------------------------------------------------------------------------------------
st_fixtures() {
step fixtures tabular-parser-fixtures mk tabular-parser test-fixtures
step fixtures tabular-parser-golden mk tabular-parser golden-check
}
# --- образы ----------------------------------------------------------------------------------------------------------
# Сборка профиля services собирает arm-core:dev, tabular-parser:dev, scan-gateway:dev из main (Dockerfile в корне репо);
# эти же образы потом поднимают e2e и стенд. Эквивалент make docker-build трёх сервисов.
st_images() {
step images build dcr build
}
# --- e2e ядра с настоящим tabular-parser (общая NATS/MinIO из .env.test) ---------------------------------------------
e2e_run() {
local was rc
was=$(docker ps -q --filter name=arm-ka-tabular-parser-1)
dc -p arm-ka --profile services up -d --no-build --wait tabular-parser || return 1
mk arm-core e2e
rc=$?
[ -z "$was" ] && dc -p arm-ka --profile services stop tabular-parser
return $rc
}
st_e2e() {
step e2e arm-core-osv-ingest e2e_run
# пропуск («профиль services не поднят») даёт код 0 — его считаем красным
if grep -q "SKIPPED" "$OUT/logs/e2e.arm-core-osv-ingest.log"; then
printf 'e2e\tnot-skipped\t1\t0\t%s\n' "$OUT/logs/e2e.arm-core-osv-ingest.log" >>"$RESULTS"
log "e2e/not-skipped КРАСНЫЙ: e2e пропущен"
fi
}
# prev_run <подкаталог/файл>: каталог <подкаталог> последнего прошлого прогона рядом с $OUT, где файл есть (база сравнения)
prev_run() {
local f
f=$(find "$(dirname "$OUT")" -mindepth 3 -maxdepth 3 -path "*/$1" ! -path "$OUT/*" 2>/dev/null | sort | tail -1)
[ -n "$f" ] && dirname "$f"
}
# --- изолированный стенд с нуля: миграции, seed --dev, сценарий по API -----------------------------------------------
stand_down() { dcr down -v --remove-orphans; }
st_stand() {
local stand_base
stand_base=$(prev_run stand/report.json)
step stand down-before stand_down
step stand up dcr up -d --no-build --wait \
postgres nats minio arm-core tabular-parser scan-gateway scan-emulator
step stand scenario python3 -I "$HERE/stand_scenario.py" --api "http://127.0.0.1:$ARM_CORE_PORT" \
--fixtures "$WS/fixtures" --expected "$HERE/expected.json" --out "$OUT/stand" \
${stand_base:+--baseline "$stand_base"}
STAND_UP=1
}
# Стенд снимается после ui (экраны смотрят на его данные); --keep-stand — оставить для ручной проверки
stand_teardown() {
[ "$STAND_UP" = 1 ] || return 0
dcr logs --no-color >"$OUT/logs/stand.compose.log" 2>&1
if [ $KEEP_STAND -eq 1 ]; then
log "стенд оставлен: проект $REGRESS_PROJECT, ядро http://127.0.0.1:$ARM_CORE_PORT (снять: docker compose -p $REGRESS_PROJECT --profile services down -v)"
else
step stand down-after stand_down
fi
}
# --- UI-смоук: dev-сервер фронта (копия web/ из этапа web) против стенда, Playwright по основным экранам --------------
WEB_PORT=13000
st_ui() {
[ "$STAND_UP" = 1 ] && [ -f "$OUT/stand/report.json" ] || {
printf 'ui\tstand-required\t1\t0\t-\n' >>"$RESULTS"
log "ui: стенд не поднят в этом запуске (этап stand) — КРАСНЫЙ"
return 1
}
if [ -z "$WEB_TMP" ]; then
web_copy >/dev/null
step ui npm-install web_install "$WEB_TMP"
fi
step ui browser uv run --with "$PLAYWRIGHT" playwright install chromium
(cd "$WEB_TMP" && REACT_APP_ROOT_PATH=/ ARM_CORE_URL="http://127.0.0.1:$ARM_CORE_PORT" \
exec npx rsbuild dev --port $WEB_PORT) >"$OUT/logs/ui.web-server.log" 2>&1 &
step ui web-server sh -c "for i in \$(seq 1 60); do curl -sf -o /dev/null http://127.0.0.1:$WEB_PORT/ && exit 0; sleep 2; done;
echo 'dev-сервер фронта не поднялся за 120 с'; tail -20 '$OUT/logs/ui.web-server.log'; exit 1" || return 1
step ui smoke uv run --with "$PLAYWRIGHT" python "$HERE/ui_smoke.py" --web "http://127.0.0.1:$WEB_PORT" \
--api "http://127.0.0.1:$ARM_CORE_PORT" --stand-report "$OUT/stand/report.json" --out "$OUT/ui"
lsof -ti "tcp:$WEB_PORT" -sTCP:LISTEN | xargs kill 2>/dev/null
return 0
}
# --- парсер по банку первички (doc-bank) со сравнением с прошлым прогоном --------------------------------------------
st_bank() {
[ -d "$WS/doc-bank/files" ] || { log "bank: нет $WS/doc-bank — этап пропущен"; return 0; }
local base="${BANK_BASELINE:-$(prev_run bank/results.jsonl)}"
step bank parse "$WS/tabular-parser/api/.venv/bin/python" -I "$HERE/parser_bank.py" \
--parser-api "$WS/tabular-parser/api" --bank "$WS/doc-bank" --out "$OUT/bank" ${base:+--baseline "$base"}
}
# --- итог ------------------------------------------------------------------------------------------------------------
summary() { python3 -I "$HERE/summary.py" "$OUT" "$ALL_STAGES"; }
touch "$RESULTS"
# Повторный запуск этапов в тот же --out: строки этих этапов из прошлого запуска убираем, остальные остаются в итоге
for s in $ALL_STAGES; do
want "$s" || continue
awk -F'\t' -v s="$s" '$1 != s' "$RESULTS" >"$RESULTS.tmp" && mv "$RESULTS.tmp" "$RESULTS"
done
log "регрессия: workspace $WS, результат $OUT"
rc=0
for s in $ALL_STAGES; do
want "$s" || continue
"st_$s"
src=$?
if [ "$s" = preflight ] && [ $src -eq 2 ]; then rc=2; break; fi
done
stand_teardown
summary
[ -n "$WEB_TMP" ] && rm -rf "$WEB_TMP"
# awk завершается кодом 0, если есть красный шаг
if [ $rc -eq 0 ] && awk -F'\t' '$3 != 0 { bad = 1 } END { exit !bad }' "$RESULTS"; then rc=1; fi
log "итог: $OUT/summary.md (код $rc)"
exit $rc

View File

@ -0,0 +1,452 @@
"""Сценарий регрессии на стенде с нуля (regress.sh, этап stand): только stdlib, запуск `python3 -I`.
Для каждого сценария expected.json: консолидация ГК/ГСК за период → блокировка → загрузка файлов фикстур в версию →
ожидание распознавания (tabular-parser, scan-gateway + эмулятор ИС) → подтверждение ОСВ-документов → проверки свода
(summary, пробелы, контрольные строки), экспорта xlsx, шахматки, форм ББ/ОФР; плюс негатив (повтор создания — 409,
блокировка без права EDIT — 403). Итог — report.json (проверки) и snapshots/<сценарий>.*.json (весь свод, шахматка,
формы); с --baseline снимки сравниваются с прошлым прогоном (расхождения — в report.json, раздел diffs).
python3 -I stand_scenario.py --api http://127.0.0.1:18000 --fixtures ../fixtures --expected expected.json --out DIR
[--baseline DIR_ПРОШЛОГО_stand] [--timeout 600]
Код возврата: 0 — все проверки зелёные, 1 — есть красные (расхождения со снимком прошлого прогона красными не считаются,
их надо просмотреть).
"""
import argparse
import io
import json
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import zipfile
from decimal import Decimal
from pathlib import Path
MONEY = ("opening_debit", "opening_credit", "turnover_debit", "turnover_credit", "closing_debit", "closing_credit")
PENDING_FILE = {"UPLOADED", "PROCESSING"}
# locator документов из OCR содержит id задания (file:<uuid>.json;table:0) — в снимке заменяется
UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
TO_CONFIRM = {"RECOGNIZED", "NEEDS_CHECK"}
class Api:
def __init__(self, base: str) -> None:
self.base = base.rstrip("/") + "/api/v1"
self.token = ""
def call(self, method: str, path: str, body=None, *, raw=False, ctype=None, params=None):
url = self.base + path + ("?" + urllib.parse.urlencode(params, doseq=True) if params else "")
data, headers = None, {}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
if body is not None:
if ctype:
data, headers["Content-Type"] = body, ctype
else:
data, headers["Content-Type"] = json.dumps(body).encode(), "application/json"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=120) as r:
payload = r.read()
return r.status, (payload if raw else (json.loads(payload) if payload else None)), dict(r.headers)
except urllib.error.HTTPError as e:
payload = e.read()
try:
return e.code, json.loads(payload), dict(e.headers)
except ValueError:
return e.code, payload.decode(errors="replace"), dict(e.headers)
def ok(self, method: str, path: str, body=None, **kw):
status, data, _ = self.call(method, path, body, **kw)
if status >= 300:
raise RuntimeError(f"{method} {path}: {status} {str(data)[:300]}")
return data
def login(self, email: str) -> str:
self.token = ""
self.token = self.ok("POST", "/dev/token", {"email": email})["access_token"]
return self.token
def upload(self, path: str, file: Path):
boundary = uuid.uuid4().hex
name = file.name.replace('"', "%22")
body = (
f'--{boundary}\r\nContent-Disposition: form-data; name="files"; filename="{name}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode() + file.read_bytes() + f"\r\n--{boundary}--\r\n".encode()
return self.ok("POST", path, body, ctype=f"multipart/form-data; boundary={boundary}")
def pages(self, path: str, params=None, limit=200):
offset, items = 0, []
while True:
page = self.ok("GET", path, params={**(params or {}), "limit": limit, "offset": offset})
items += page["items"]
offset += limit
if offset >= page["total"]:
return items
class Report:
def __init__(self) -> None:
self.checks: list[dict] = []
self.diffs: list[dict] = []
def check(self, name: str, ok: bool, detail: str = "") -> bool:
self.checks.append({"name": name, "ok": bool(ok), "detail": detail})
print(("ok " if ok else "FAIL ") + name + (f": {detail}" if detail else ""), flush=True)
return ok
def dec(v) -> Decimal:
return Decimal(str(v)) if v not in (None, "") else Decimal(0)
def scrub(obj, inn_by_le: dict):
"""Снимок без идентификаторов стенда: id ЮЛ → ИНН, прочие id и списки id убираются (меняются на каждом стенде)."""
if isinstance(obj, list):
return [scrub(x, inn_by_le) for x in obj]
if not isinstance(obj, dict):
return obj
out = {}
for k, v in obj.items():
if isinstance(v, str) and v in inn_by_le:
out[k] = inn_by_le[v]
elif k == "id" or k.endswith(("_id", "_ids")):
continue
else:
out[k] = scrub(v, inn_by_le)
return out
def wait_health(base: str, timeout: float) -> dict:
deadline, last = time.time() + timeout, None
while time.time() < deadline:
try:
with urllib.request.urlopen(base.rstrip("/") + "/healthcheck", timeout=5) as r:
last = json.loads(r.read())
if last.get("nats") == "ok":
return last
except (OSError, ValueError) as e:
last = {"error": str(e)}
time.sleep(2)
raise RuntimeError(f"ядро не готово за {timeout} с: {last}")
def xlsx_rows(content: bytes) -> dict[str, int]:
"""Число строк <row> на листе по имени листа (без openpyxl: только stdlib)."""
z = zipfile.ZipFile(io.BytesIO(content))
wb = z.read("xl/workbook.xml").decode()
rels = z.read("xl/_rels/workbook.xml.rels").decode()
targets = dict(re.findall(r'<Relationship[^>]*Id="([^"]+)"[^>]*Target="([^"]+)"', rels))
targets |= {k: v for v, k in re.findall(r'<Relationship[^>]*Target="([^"]+)"[^>]*Id="([^"]+)"', rels)}
out = {}
for name, rid in re.findall(r'<sheet[^>]*name="([^"]+)"[^>]*r:id="([^"]+)"', wb):
target = targets[rid].lstrip("/")
target = target if target.startswith("xl/") else "xl/" + target
out[name] = len(re.findall(r"<row[ >]", z.read(target).decode()))
return out
def run_scenario(api: Api, sc: dict, ctx: dict, fixtures: Path, rep: Report, out: Path, timeout: float) -> None:
sid = sc["id"]
by_inn = ctx["by_inn"]
status, card, _ = api.call(
"POST",
"/consolidations",
{"company_group_id": ctx["group_id"], "related_group_id": ctx["related_id"], "year": sc["year"], "quarter": sc["quarter"]},
)
if not rep.check(f"{sid}: создание консолидации", status == 201, f"{status}"):
return
vid, cid = card["id"], card["consolidation_id"]
rep.check(f"{sid}: карточка версии", card["status"] == "INITIATED" and card["version_no"] == 1, f"status={card['status']}")
st2, body2, _ = api.call(
"POST",
"/consolidations",
{"company_group_id": ctx["group_id"], "related_group_id": ctx["related_id"], "year": sc["year"], "quarter": sc["quarter"]},
)
rep.check(f"{sid}: повтор создания — 409", st2 == 409, f"{st2} {str(body2)[:120]}")
api.ok("POST", f"/consolidations/versions/{vid}/lock")
# загрузка
sent = 0
for up in sc["uploads"]:
le = by_inn[up["inn"]]
files = sorted(p for pat in up["files"] for p in fixtures.glob(pat) if p.is_file())
for f in files:
res = api.upload(f"/consolidations/{cid}/legal-entities/{le}/files", f)
if res["rejected"]:
rep.check(f"{sid}: загрузка {f.name}", False, str(res["rejected"])[:200])
sent += len(res["accepted"])
expected_files = sum(len([p for pat in up["files"] for p in fixtures.glob(pat) if p.is_file()]) for up in sc["uploads"])
rep.check(f"{sid}: файлы приняты", sent == expected_files, f"{sent} из {expected_files}")
# распознавание
t0, files = time.time(), []
while time.time() - t0 < timeout:
files = [f for up in sc["uploads"] for f in api.pages(f"/consolidations/{cid}/legal-entities/{by_inn[up['inn']]}/files")]
if files and not any(f["status"] in PENDING_FILE for f in files):
break
time.sleep(3)
by_status: dict[str, int] = {}
for f in files:
by_status[f["status"]] = by_status.get(f["status"], 0) + 1
rep.check(
f"{sid}: распознавание завершено за {int(time.time() - t0)} с",
by_status.get("PROCESSED", 0) == expected_files,
json.dumps(by_status, ensure_ascii=False),
)
for f in files:
if f["status"] != "PROCESSED":
job = f.get("job") or {}
rep.check(f"{sid}: файл {f['original_name']}", False, f"{f['status']} {job.get('error_code')} {job.get('error_message')}")
# подтверждение ОСВ-документов (чистые уже CONFIRMED — автоподтверждение OQ-157)
confirmed = 0
for f in files:
for d in f["documents"]:
if d["document_type"] == "OSV" and d["status"] in TO_CONFIRM:
st, body, _ = api.call("POST", f"/documents/{d['id']}/confirm")
ok = st in (200, 201) or (st == 409 and "ALREADY_CONFIRMED" in str(body))
rep.check(f"{sid}: подтверждение {f['original_name']} / {d['locator']}", ok, f"{st}") if not ok else None
confirmed += 1
docs = [(f["original_name"], d) for f in files for d in f["documents"]]
(out / "snapshots" / f"{sid}.documents.json").write_text(
json.dumps(
sorted(
[
{k: d.get(k) for k in ("document_type", "osv_account_code", "period_year", "period_quarter", "is_cumulative", "period_check", "counterparty_check", "locator", "issue_counts")}
| {"file": n, "status_after_recognition": d["status"], "locator": UUID_RE.sub("<uuid>", str(d["locator"]))}
for n, d in docs
],
key=lambda x: (x["file"], str(x["locator"])),
),
ensure_ascii=False,
indent=1,
)
)
print(f"{sid}: подтверждено вручную {confirmed}", flush=True)
for kind in ("OSV", "BALANCE", "INCOME"):
doc = next((d for _, d in docs if d["document_type"] == kind), None)
if doc:
ctx["documents"][f"{sid}-{kind.lower()}"] = doc["id"]
# свод
summary = api.ok("GET", f"/consolidations/versions/{vid}/osv/summary")
for k, v in sc["summary"].items():
rep.check(f"{sid}: summary.{k}", summary[k] == v, f"{summary[k]} (ожидалось {v})")
inn_by_le = {v: k for k, v in by_inn.items()}
gaps = sorted([inn_by_le.get(g["legal_entity_id"], g["legal_entity_name"]), g["account_code"], sorted(g["missing_quarters"])] for g in summary["gaps"])
rep.check(f"{sid}: пробелы", gaps == sorted(sc["gaps"]), f"{len(gaps)}: {gaps}" if gaps != sorted(sc["gaps"]) else f"{len(gaps)}")
card = api.ok("GET", f"/consolidations/versions/{vid}")
rep.check(f"{sid}: статус версии после загрузки", card["status"] == "DATA_CORRECTION", card["status"])
for row in sc["rows"]:
items = [
r
for r in api.pages(f"/consolidations/versions/{vid}/osv", {"legal_entity_id": by_inn[row["inn"]], "q": row["q"]})
if r["account_code"] == row["account_code"]
]
if not rep.check(f"{sid}: строка «{row['name']}» найдена", len(items) == 1, f"строк: {len(items)}"):
continue
r, bad = items[0], []
for k, v in row["expect"].items():
if k in MONEY:
if dec(r[k]) != dec(v):
bad.append(f"{k}={r[k]} (ожидалось {v})")
elif k == "entry_ids":
if len(r["entry_ids"]) != v:
bad.append(f"entry_ids={len(r['entry_ids'])} (ожидалось {v})")
elif r.get(k) != v:
bad.append(f"{k}={r.get(k)} (ожидалось {v})")
rep.check(f"{sid}: строка «{row['name']}» суммы", not bad, "; ".join(bad))
rows = api.pages(f"/consolidations/versions/{vid}/osv", limit=2000)
rep.check(f"{sid}: свод постранично = rows_total", len(rows) == summary["rows_total"], f"{len(rows)}")
snap_rows = sorted(
(
{
"le": inn_by_le.get(r["legal_entity_id"], r["legal_entity_name"]),
"account": r["account_code"],
"row": r["row_number"],
"cp": r["counterparty_name"],
"cp_inn": r["counterparty_inn"],
"type": r["counterparty_type"],
"match": r["match_kind"],
}
| {k: str(dec(r[k])) for k in MONEY}
for r in rows
),
key=lambda x: (x["le"], str(x["account"]), str(x["cp"]), x["row"] or 0),
)
(out / "snapshots" / f"{sid}.osv.json").write_text(json.dumps(snap_rows, ensure_ascii=False, indent=0))
types: dict[str, int] = {}
for r in rows:
types[r["counterparty_type"]] = types.get(r["counterparty_type"], 0) + 1
print(f"{sid}: типы контрагентов {types}", flush=True)
# экспорт
st, content, headers = api.call("GET", f"/consolidations/versions/{vid}/osv/export", raw=True)
if rep.check(f"{sid}: экспорт свода 200", st == 200, f"{st}"):
sheets = xlsx_rows(content)
(out / f"{sid}.svod.xlsx").write_bytes(content)
main_rows = sheets.get("Сводная ОСВ", -1) - 1
gaps_rows = sheets.get("Пробелы", -1) - 1
rep.check(f"{sid}: экспорт — строк = rows_total", main_rows == summary["rows_total"], f"{sheets}")
rep.check(f"{sid}: экспорт — пробелов = summary.gaps", gaps_rows == len(summary["gaps"]), f"{gaps_rows}")
cd = headers.get("Content-Disposition") or headers.get("content-disposition") or ""
rep.check(f"{sid}: экспорт — Content-Disposition", "attachment" in cd and ".xlsx" in cd, cd[:120])
# чек-лист
st, checklist, _ = api.call("GET", f"/consolidations/versions/{vid}/checklist")
rep.check(f"{sid}: чек-лист 200", st == 200, f"{st}")
# шахматка: все предустановленные правила + результирующая таблица
rules = api.ok("GET", f"/consolidations/versions/{vid}/elimination-rules")["rules"]
matrix = {}
for rule in rules:
m = api.ok("GET", f"/consolidations/versions/{vid}/intercompany-matrix/preset/{rule['id']}")
names = {c["id"]: inn_by_le.get(c["id"], c["name"]) for c in m["companies"]}
matrix[f"{rule['rule_no']} {rule['statement_type']} {rule['side_1']} {rule['measure']}"] = sorted(
[names.get(c["company_a_id"]), names.get(c["company_b_id"]), str(dec(c["amount_a"])), str(dec(c["amount_b"])), str(dec(c["variance_amount"])), c["one_sided"]]
for c in m["cells"]
)
st, result_table, _ = api.call("GET", f"/consolidations/versions/{vid}/intercompany-matrix/result-table")
rep.check(f"{sid}: результирующая таблица 200", st == 200, f"{st}")
st, totals, _ = api.call("GET", f"/consolidations/versions/{vid}/elimination-totals")
rep.check(f"{sid}: итоги элиминации 200", st == 200, f"{st}")
rep.check(f"{sid}: правил элиминации", len(rules) > 0, f"{len(rules)}")
# ручная шахматка «Дт <счёт> / Кт <счёт>» — как в test_intercompany_matrix_golden ядра; предустановленные правила
# собирают стороны шире одного счёта (например, правило 6 — ещё 76.06 и 76.АВ), их суммы — только в снимке
accounts = {acc["code"]: acc["id"] for acc in ctx["accounts"]}
for m in sc["matrix_manual"]:
body = {"debit_account_ids": [accounts[m["debit"]]], "credit_account_ids": [accounts[m["credit"]]]}
st, res, _ = api.call("POST", f"/consolidations/versions/{vid}/intercompany-matrix/manual", body)
if not rep.check(f"{sid}: шахматка Дт {m['debit']} / Кт {m['credit']} — 200", st == 200, f"{st} {str(res)[:200]}"):
continue
got = {
(inn_by_le.get(c["company_a_id"]), inn_by_le.get(c["company_b_id"])): [str(dec(c["amount_a"])), str(dec(c["amount_b"]))]
for c in res["cells"]
}
matrix[f"manual Дт {m['debit']} / Кт {m['credit']}"] = sorted([*k, *v] for k, v in got.items())
for a_inn, b_inn, amount_a, amount_b in m["cells"]:
cell = got.get((a_inn, b_inn))
rep.check(
f"{sid}: шахматка Дт {m['debit']} / Кт {m['credit']}, {a_inn} → {b_inn}",
cell is not None and dec(cell[0]) == dec(amount_a) and dec(cell[1]) == dec(amount_b),
f"{cell} (ожидалось {[amount_a, amount_b]})",
)
(out / "snapshots" / f"{sid}.matrix.json").write_text(
json.dumps(
{"rules": len(rules), "matrix": matrix, "result_table": scrub(result_table, inn_by_le), "totals": scrub(totals, inn_by_le)},
ensure_ascii=False, indent=1, default=str,
)
)
# формы ББ/ОФР из PDF (эмулятор ИС): типы документов, строки, проверки
stmts = {}
for inn, want in sc["statements"].items():
got = sorted(d["document_type"] for n, d in docs if d["document_type"] in ("BALANCE", "INCOME") and by_inn[inn] in {f["legal_entity_id"] for f in files if f["original_name"] == n})
rep.check(f"{sid}: формы {inn}", got == sorted(want), f"{got}")
for n, d in docs:
if d["document_type"] in ("BALANCE", "INCOME"):
s = api.ok("GET", f"/documents/{d['id']}/statement")
stmts[f"{n} / {d['document_type']}"] = {
"status": s["document"]["status"],
"form_variant": s["document"]["form_variant"],
"lines": len(s["lines"]),
"codes": sorted(str(ln["line_code"]) for ln in s["lines"] if ln["line_code"]),
"failed": sorted(c.get("code", str(c)) if isinstance(c, dict) else str(c) for c in s["checks"]["failed"]),
"issues": sorted(i["code"] for i in s["issues"]),
}
rep.check(f"{sid}: форма {n} / {d['document_type']} — строки есть", len(s["lines"]) > 0, f"строк {len(s['lines'])}, статус {s['document']['status']}")
(out / "snapshots" / f"{sid}.statements.json").write_text(json.dumps(stmts, ensure_ascii=False, indent=1))
ctx["versions"][sid] = vid
def negative(api: Api, exp: dict, ctx: dict, rep: Report) -> None:
vid = next(iter(ctx["versions"].values()), None)
if not vid:
return
api.login(exp["readonly_user"])
st, _, _ = api.call("GET", f"/consolidations/versions/{vid}/osv/summary")
rep.check("права: эксперт читает свод", st == 200, f"{st}")
st, body, _ = api.call("POST", f"/consolidations/versions/{vid}/lock")
rep.check("права: эксперт не берёт блокировку — 403", st == 403, f"{st} {str(body)[:120]}")
api.login(exp["user"])
st, _, _ = api.call("GET", "/consolidations/versions/" + str(uuid.uuid4()))
rep.check("несуществующая версия — 404", st == 404, f"{st}")
def compare(out: Path, baseline: Path, rep: Report) -> None:
for cur in sorted((out / "snapshots").glob("*.json")):
old = baseline / "snapshots" / cur.name
if not old.exists():
rep.diffs.append({"file": cur.name, "detail": "нет в прошлом прогоне"})
continue
a, b = json.loads(old.read_text()), json.loads(cur.read_text())
if a == b:
continue
if isinstance(a, list) and isinstance(b, list):
sa, sb = {json.dumps(x, sort_keys=True, ensure_ascii=False) for x in a}, {json.dumps(x, sort_keys=True, ensure_ascii=False) for x in b}
detail = f"было {len(a)}, стало {len(b)}; ушло {len(sa - sb)}, появилось {len(sb - sa)}"
examples = sorted(sb - sa)[:5]
else:
ka = set(a) if isinstance(a, dict) else set()
changed = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k)) if isinstance(a, dict) else []
detail = f"изменились ключи: {changed[:10]}" + (f" (+{len(changed) - 10})" if len(changed) > 10 else "")
examples = [] if ka else [str(b)[:200]]
rep.diffs.append({"file": cur.name, "detail": detail, "examples": examples})
print(f"сравнение с {baseline}: расхождений {len(rep.diffs)}", flush=True)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--api", required=True)
ap.add_argument("--fixtures", required=True, type=Path)
ap.add_argument("--expected", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--baseline", type=Path)
ap.add_argument("--timeout", type=float, default=600)
a = ap.parse_args()
exp = json.loads(a.expected.read_text())
(a.out / "snapshots").mkdir(parents=True, exist_ok=True)
rep = Report()
api = Api(a.api)
ctx: dict = {}
try:
health = wait_health(a.api, 180)
rep.check("ядро готово", True, json.dumps(health, ensure_ascii=False)[:200])
api.login(exp["user"])
groups = api.ok("GET", "/directories/company-groups", params={"q": exp["company_group"]})["items"]
group = next(g for g in groups if g["name"] == exp["company_group"])
related = next(r for r in api.ok("GET", f"/directories/company-groups/{group['id']}/related-groups") if r["name"] == exp["related_group"])
les = api.pages("/directories/legal-entities", {"company_group_id": group["id"]}, limit=200)
ctx = {"group_id": group["id"], "related_id": related["id"], "by_inn": {le["inn"]: le["id"] for le in les}, "versions": {}, "documents": {}}
ctx["accounts"] = api.ok("GET", "/directories/accounts")
rep.check("dev-seed: ГК, ГСК, ЮЛ", len(les) > 0 and all(i in ctx["by_inn"] for sc in exp["scenarios"] for i in (u["inn"] for u in sc["uploads"])), f"ЮЛ {len(les)}")
for sc in exp["scenarios"]:
try:
run_scenario(api, sc, ctx, a.fixtures, rep, a.out, a.timeout)
except Exception as e: # noqa: BLE001 — сценарий падает целиком, следующий идёт
rep.check(f"{sc['id']}: сценарий без исключений", False, f"{type(e).__name__}: {e}")
negative(api, exp, ctx, rep)
except Exception as e: # noqa: BLE001
rep.check("сценарий без исключений", False, f"{type(e).__name__}: {e}")
if a.baseline and (a.baseline / "snapshots").is_dir():
compare(a.out, a.baseline, rep)
ids = {k: ctx[k] for k in ("group_id", "versions", "documents")} if ctx else {}
report = {"checks": rep.checks, "diffs": rep.diffs, "baseline": str(a.baseline or ""), "ids": ids}
(a.out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=1))
red = [c for c in rep.checks if not c["ok"]]
print(f"проверок {len(rep.checks)}, красных {len(red)}, расхождений со снимком {len(rep.diffs)}")
return 1 if red else 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,87 @@
"""Итог регрессии: summary.md из results.tsv, shas.tsv и логов шагов (вызывает regress.sh).
Аргументы: каталог прогона, список этапов через пробел (порядок в таблице). Для логов pytest — последняя строка-итог
(«N passed, M skipped …») и число пропусков из-за отсутствия docs/fixtures: зелёный шаг с такими пропусками не доказывает
сверку контракта и прохождение на реальных данных (dev/checks.md, «Внешние ресурсы»).
"""
import json
import re
import sys
from pathlib import Path
PYTEST_TAIL = re.compile(r"=+ (.*\b(passed|failed|error|skipped|deselected)\b.*) in [\d.]+s")
# причины пропуска «нет соседнего репозитория» в сервисах (dev/checks.md)
MISSING_RES = re.compile(r"SKIPPED.*(ARM_KA_FIXTURES|ARM_KA_DOCS|fixtures недоступ|docs недоступ|нет \.\./(docs|fixtures))")
def pytest_info(log: Path) -> str:
try:
text = log.read_text(errors="replace")
except OSError:
return ""
tails = [m.group(1) for m in PYTEST_TAIL.finditer(text)]
missing = len(MISSING_RES.findall(text))
parts = []
if tails:
parts.append(tails[-1] if len(tails) == 1 else " | ".join(tails))
if missing:
parts.append(f"⚠ пропусков без docs/fixtures: {missing}")
return "; ".join(parts)
def first_error(log: Path) -> str:
"""Короткая подсказка для красного шага: первая строка FAILED/ERROR/КРАСНЫЙ/Error из лога."""
try:
lines = log.read_text(errors="replace").splitlines()
except OSError:
return ""
for pat in (r"^FAILED |^ERROR ", r"FAIL|КРАСН|✗", r"[Ee]rror|ошибк"):
for ln in lines:
if re.search(pat, ln):
return ln.strip()[:160]
return lines[-1].strip()[:160] if lines else ""
def main() -> None:
out, stages = Path(sys.argv[1]), sys.argv[2].split()
rows = [ln.split("\t") for ln in (out / "results.tsv").read_text().splitlines() if ln.strip()]
shas = [ln.split("\t") for ln in (out / "shas.tsv").read_text().splitlines()] if (out / "shas.tsv").exists() else []
red = [r for r in rows if r[2] != "0"]
md = [f"# Регрессия {out.name}", ""]
md.append(f"**Итог: {'ЗЕЛЁНАЯ' if not red else f'КРАСНАЯ — {len(red)} из {len(rows)} шагов'}**")
if (out / "allow-dirty").exists():
md.append("")
md.append("⚠ Прогон с `--allow-dirty`: preflight не пройден (не main, не origin/main или есть правки) — см. этап preflight.")
md += ["", "## Репозитории", "", "| Репозиторий | Ветка | SHA | Последний коммит |", "|---|---|---|---|"]
md += [f"| {r} | {b} | `{h[:10]}` | {s} |" for r, b, h, s in shas]
md += ["", "## Шаги", "", "| Этап | Шаг | Статус | Время, с | Детали |", "|---|---|---|---|---|"]
order = {s: i for i, s in enumerate(stages)}
for stage, name, rc, sec, log in sorted(rows, key=lambda r: order.get(r[0], 99)):
logp = Path(log)
detail = pytest_info(logp)
if rc != "0":
detail = "; ".join(x for x in (detail, first_error(logp)) if x)
status = "ok" if rc == "0" else f"**КРАСНЫЙ** ({rc})"
md.append(f"| {stage} | {name} | {status} | {sec} | {detail.replace('|', '/')} |")
stand = out / "stand" / "report.json"
if stand.exists():
rep = json.loads(stand.read_text())
md += ["", "## Стенд: сценарий по API", ""]
red = [c for c in rep["checks"] if not c["ok"]]
md.append(f"Проверок {len(rep['checks'])}, красных {len(red)}.")
md.append("")
md += [f"- {'✓' if c['ok'] else '✗'} {c['name']}" + (f": {c['detail']}" if c["detail"] else "") for c in rep["checks"]]
if rep.get("baseline"):
md += ["", f"Сравнение снимков со стендом прошлого прогона `{rep['baseline']}`: расхождений {len(rep['diffs'])}."]
md += [f"- `{d['file']}`: {d['detail']}" for d in rep["diffs"]]
bank = out / "bank" / "summary.md"
if bank.exists():
md += ["", bank.read_text().strip()]
md += ["", f"Логи: `{out / 'logs'}`"]
(out / "summary.md").write_text("\n".join(md) + "\n")
print("\n".join(md))
if __name__ == "__main__":
main()

View File

@ -0,0 +1,95 @@
"""UI-смоук фронта arm-core/web против стенда регрессии (regress.sh, этап ui; после сценария stand).
Обходит основные экраны под ka_go (токен — в sessionStorage, как после входа), на каждом ждёт затишья сети и
собирает: исключения страницы, ошибки консоли, ответы /api со статусом >= 400. Красный экран — исключение страницы,
ответ /api >= 500 или 4xx (кроме перечисленных в ALLOWED_4XX), пустая страница. Скриншоты — <out>/<экран>.png,
итог — <out>/report.json. Идентификаторы версий и документов берёт из report.json сценария стенда.
uv run --with playwright==1.55.0 python ui_smoke.py --web http://127.0.0.1:13000 --api http://127.0.0.1:18000 \\
--stand-report DIR/stand/report.json --out DIR/ui
"""
import argparse
import json
import sys
import urllib.request
from pathlib import Path
from playwright.sync_api import sync_playwright
# 4xx, которые фронт получает штатно (пусто — любой 4xx красный; дополнять с причиной)
ALLOWED_4XX: set[tuple[str, int]] = set()
def token(api: str, email: str) -> str:
req = urllib.request.Request(
api.rstrip("/") + "/api/v1/dev/token",
data=json.dumps({"email": email}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())["access_token"]
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--web", required=True)
ap.add_argument("--api", required=True)
ap.add_argument("--stand-report", required=True, type=Path)
ap.add_argument("--out", required=True, type=Path)
ap.add_argument("--user", default="ka_go@armka.local")
a = ap.parse_args()
a.out.mkdir(parents=True, exist_ok=True)
ids = json.loads(a.stand_report.read_text()).get("ids", {})
g = ids.get("group_id")
pages = [("groups", "/groups"), ("catalogue-groups", "/catalogue/groups"), ("catalogue-le", "/catalogue/jur_l")]
if g:
pages.append(("group", f"/groups/{g}"))
for sid, vid in (ids.get("versions") or {}).items():
pages += [(f"{sid}-osv", f"/groups/{g}/versions/{vid}/osv"), (f"{sid}-chess", f"/groups/{g}/versions/{vid}/chess")]
for name, doc in (ids.get("documents") or {}).items():
pages.append((f"doc-{name}", f"/documents/{doc}"))
tok = token(a.api, a.user)
results = []
with sync_playwright() as p:
browser = p.chromium.launch()
ctx = browser.new_context(viewport={"width": 1600, "height": 1000}, locale="ru-RU")
ctx.add_init_script(f"try {{ sessionStorage.setItem('armka.token', {json.dumps(tok)}); }} catch (e) {{}}")
for name, path in pages:
page = ctx.new_page()
errors, console, bad_api = [], [], []
page.on("pageerror", lambda e, errors=errors: errors.append(str(e)[:300]))
page.on("console", lambda m, console=console: console.append(m.text[:300]) if m.type == "error" else None)
def on_response(r, bad_api=bad_api):
if "/api/" in r.url and r.status >= 400:
bad_api.append(f"{r.status} {r.request.method} {r.url.split('/api/', 1)[1][:150]}")
page.on("response", on_response)
try:
page.goto(a.web.rstrip("/") + path, wait_until="networkidle", timeout=60000)
page.wait_for_timeout(1500)
text = page.inner_text("body")
except Exception as e: # noqa: BLE001 — экран красный, обход продолжается
errors.append(f"{type(e).__name__}: {e}"[:300])
text = ""
page.screenshot(path=str(a.out / f"{name}.png"), full_page=False)
red_api = [
x for x in bad_api
if int(x.split()[0]) >= 500 or (x.split()[1], int(x.split()[0])) not in ALLOWED_4XX
]
ok = not errors and not red_api and len(text.strip()) > 20
results.append({"name": name, "path": path, "ok": ok, "page_errors": errors, "api_errors": bad_api, "console_errors": console[:10]})
print(("ok " if ok else "FAIL ") + f"{name} {path}" + ("" if ok else f": {errors or red_api or 'пустая страница'}"), flush=True)
page.close()
browser.close()
(a.out / "report.json").write_text(json.dumps(results, ensure_ascii=False, indent=1))
red = [r for r in results if not r["ok"]]
print(f"экранов {len(results)}, красных {len(red)}")
return 1 if red else 0
if __name__ == "__main__":
sys.exit(main())