Compare commits
20 Commits
098e099e10
...
339b31805c
| Author | SHA1 | Date | |
|---|---|---|---|
| 339b31805c | |||
| 727cb63ffc | |||
|
|
9e2e9f55df | ||
| 5e1e7d26b0 | |||
|
|
1514630075 | ||
| ed9928a46e | |||
|
|
ca759d482d | ||
|
|
735e6fb187 | ||
| b754799694 | |||
|
|
3ad910b612 | ||
| 3a91d050f2 | |||
|
|
44e0fd8229 | ||
|
|
9bc2b26c1c | ||
| 7e9a56a7a2 | |||
|
|
2c27a9ecdd | ||
| 3f3c007778 | |||
|
|
3a70f21e34 | ||
|
|
63a0649c54 | ||
|
|
dfccabcada | ||
|
|
6561caf19e |
6
Makefile
6
Makefile
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)` |
|
||||
|
||||
297
dev/api-contract-corrections.md
Normal file
297
dev/api-contract-corrections.md
Normal 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"}
|
||||
}
|
||||
```
|
||||
420
dev/api-contract-corrections.openapi.yaml
Normal file
420
dev/api-contract-corrections.openapi.yaml
Normal 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 }
|
||||
@ -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` блокировок не создаёт) |
|
||||
|
||||
@ -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`),
|
||||
|
||||
@ -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`,
|
||||
|
||||
69
scripts/regression/README.md
Normal file
69
scripts/regression/README.md
Normal 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 <тот же каталог>`.
|
||||
71
scripts/regression/expected.json
Normal file
71
scripts/regression/expected.json
Normal 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"]}
|
||||
}
|
||||
]
|
||||
}
|
||||
173
scripts/regression/parser_bank.py
Normal file
173
scripts/regression/parser_bank.py
Normal 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
307
scripts/regression/regress.sh
Executable 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
|
||||
452
scripts/regression/stand_scenario.py
Normal file
452
scripts/regression/stand_scenario.py
Normal 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())
|
||||
87
scripts/regression/summary.py
Normal file
87
scripts/regression/summary.py
Normal 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()
|
||||
95
scripts/regression/ui_smoke.py
Normal file
95
scripts/regression/ui_smoke.py
Normal 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())
|
||||
Loading…
x
Reference in New Issue
Block a user