diff --git a/openspec/changes/intercompany-matrix-ui/design.md b/openspec/changes/intercompany-matrix-ui/design.md
new file mode 100644
index 0000000..52b451c
--- /dev/null
+++ b/openspec/changes/intercompany-matrix-ui/design.md
@@ -0,0 +1,97 @@
+## Context
+
+Экран «Шахматка» [31-01] добавлен в `arm-core/web` PR #158 (ветка `feat/chessTable`, AURORA-1538) как вторая вкладка страницы
+отчёта по версии. Страница `layouts/ConsolidatedOsvPage.tsx` разделена: общую шапку и вкладки держит
+`layouts/ConsolidationReportPage.tsx`, содержимое вкладок — `widgets/ConsolidatedOsvContent` (прежняя сводная ОСВ без изменений
+поведения) и `widgets/ConsolidatedChessContent` (матрица `ConsolidationChessTable` и результирующая таблица
+`ConsolidationChessResTable`). Бэкенд — change `2026-10-16-intercompany-matrix` (матрица, результирующая таблица) и
+`2026-10-22-intercompany-result-table-export` (выгрузка результирующей таблицы); расчёт — чистая функция версии, ничего не
+хранится.
+
+## Decisions
+
+- **D-1. Маршрут `:tab`.** `OsvTabs` берёт активную вкладку из `useParams().tab` (по умолчанию `osv`) и по выбору заменяет
+ последний сегмент адреса. Неизвестный `tab` заменяется на `osv` (`navigate(…, {replace: true})`).
+- **D-2. Ключ версии.** Страница отчёта разделена на обёртку с `key={version_id}` и содержимое: при смене версии карточка,
+ фильтры и данные создаются заново (как было в `ConsolidatedOsvPage`).
+- **D-3. Режим расчёта из выбора счетов:** `manual` — при непустых Дт и Кт, `all` — при пустых обоих; при одной выбранной
+ стороне запроса нет (бэкенд ответил бы 422 на `manual`, а `all` показал бы не то, что выбрано). Тот же признак выбирает
+ маршрут экспорта.
+- **D-4. Единицы, знаки и фильтр компаний — на клиенте.** Запрос повторяется только при смене выбора счетов или версии.
+ Единицы — сдвиг запятой в десятичной строке (`utils/chessTableCellFormatValue.ts`), `Intl.NumberFormat` получает строку и
+ форматирует её как точное десятичное (без float).
+- **D-5. План счетов** — `GET /directories/accounts` целиком, деревья Дт и Кт строятся на клиенте по `parent_id`.
+- **D-6. `apiFetchBlob(path, init)`** принимает `RequestInit`, чтобы выгружать POST-ом с телом; без `init` поведение прежнее.
+- **D-7. Полноэкранный режим** — Fullscreen API на контейнере блока, состояние следует за `fullscreenchange`; выпадающие окна
+ antd рендерятся в `document.fullscreenElement` (`ConfigProvider getPopupContainer`).
+- **D-8. Итоги — задача бэкенда.** Решение владельца 22.10.2026: агрегаты (строки «Итого») должен отдавать API. Перенос —
+ технический долг: AURORA-1756 (итоги и правило суммирования в API, ждёт аналитику AURORA-1373) и AURORA-1757 (вывод итогов из
+ API). До этого строки «Итого» остаются клиентскими (решение владельца: «особо не мешает»): точное сложение строк через
+ `sumAmounts`, под матрицей — `amount_a` по колонке, под результирующей таблицей — все статьи по колонке; колонка «Итого»
+ результирующей таблицы — `group_total` из ответа.
+- **D-9. `useNotificationApi` мемоизирован** (`useMemo` по `notification` из `App.useApp()`, который antd сам мемоизирует):
+ объект уведомлений стабилен между рендерами, поэтому `loadData`/`loadResData` — `useCallback([notification])`, а эффекты
+ загрузки зависят от них без лишних перезапросов.
+
+## Known issues
+
+Открытая проблема на момент слияния (решение владельца 23.10.2026 — сливать, остаток в техдолг AURORA-1799):
+
+1. Выбрана одна сторона Дт/Кт: «Экспорт» недоступен, но таблица показывает прежние данные, подсказка — всплывающее уведомление
+ и постоянный текст внутри выпадающего окна, текста у таблицы и у фильтров нет. Целевое поведение — решение (ж).
+
+Пункты раунда 3 про поиск в дереве счетов и стили закрыты ревьюером (решение (п)).
+
+## Decisions made without discussion
+
+- **2026-10-21 (а). Спеку пишет ревьюер в ветке автора** — по решению владельца (разрешено явно); альтернатива — оставить
+ автору. Цена ошибки: автор может не согласиться с формулировками; правится одним коммитом в этом же change.
+- **2026-10-21 (б). Спека описывает фактическое поведение, дефекты вынесены в «Known issues».** Отменено 22.10.2026 (е). Обоснование: спека — единственный
+ источник истины при рулингах, а закреплять баги требованиями нельзя; иначе их закрытие превратится в «нарушение спеки». Цена
+ ошибки: отдельные требования (например, режим `all` при выборе одной стороны, «Итого» из расхождений) описаны как есть
+ и после исправления потребуют MODIFIED.
+- **2026-10-21 (в). При восстановлении затёртого фронта** (коммит 175343b) из `origin/main` взяты целиком все файлы без
+ шахматочных изменений, а в `App.tsx`, `layouts/index.ts`, `api/client.ts` (параметр `init`), `OsvTabs.tsx`,
+ `ConsolidatedOsvContent.tsx` оставлены изменения шахматки. Возвращён `key={version_id}` (разделением страницы на обёртку и
+ содержимое — поведение как в main). Обоснование: изменения PR в этих файлах относятся к шахматке; цена ошибки — незамеченная
+ откатившаяся правка main в этих пяти файлах (построчно сверено с `origin/main`, расхождений кроме шахматочных нет).
+- **2026-10-21 (г). Механические правки biome без смены поведения** (коммит e19291c): тип `IntercompanyMatrixResponse` у `let res`,
+ `Number.isNaN`, `key` у `span` в `OsvTabs`, `?? null` вместо пустого фрагмента. Предупреждения eslint (8 новых в коде шахматки:
+ `any`, deps хуков, `console`) не исправлялись — они входят в Known issues. Цена ошибки: `key` на `span` внутри `Tooltip`
+ безвреден, но избыточен.
+- **2026-10-21 (д). Дельта к `consolidated-osv-ui` не делается:** вкладки описаны в новой спеке, текст
+ `consolidated-osv-ui` не правился, чтобы не смешивать UI-половины. Цена ошибки: при архивации две спеки частично описывают
+ один компонент (`OsvTabs`).
+- **2026-10-22 (е). Спека переписана на целевое поведение** (раунд 3 ревью): владелец решил, что спеки и тесты чужих PR ведём
+ мы, а автор правит продуктовый код; целевая спека даёт автору однозначный ориентир, расхождения кода — в «Known issues».
+ Цена ошибки: до исправлений спека и код ветки расходятся; PR не сливается, пока «Known issues» не закрыты.
+- **2026-10-22 (ж). Выбрана одна сторона Дт/Кт — пустая таблица с текстом** «Для расчёта по счетам выберите счета и в Дт,
+ и в Кт» у таблицы и у фильтров, «Экспорт» матрицы недоступен. Альтернатива — оставить прежние данные с подсказкой; отвергнута:
+ на экране были бы данные, не соответствующие фильтру. Цена ошибки: правка текста и одного условия.
+- **2026-10-22 (з). Правило отметок дерева по полному плану счетов** (родитель отмечен ⇔ отмечены все потомки), в том числе при
+ поиске. Обоснование: бэкенд разворачивает выбранный счёт в потомков, лишняя отметка родителя меняет расчёт. Цена ошибки: если
+ аналитик захочет «родитель + выбранные субсчета» как отдельный режим — MODIFIED требования.
+- **2026-10-22 (и). Запасные имена файлов** `Шахматка_<ГК>_<год>_<квартал>.xlsx` и
+ `Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx`. Цена ошибки: низкая, имя из `Content-Disposition` приоритетнее.
+- **2026-10-22 (к). Тесты форматтера** `utils/chessTableCellFormatValue.test.ts` написаны ревьюером: сдвиг единиц, точное
+ округление, большие суммы, «-0,00», пустые и нечисловые значения при любых единицах.
+- **2026-10-22 (л). В ветку влит `main`** (PR #159), чтобы выгрузка результирующей таблицы работала на стенде из ветки.
+- **2026-10-22 (м). Желательные пункты ревью закрыты ревьюером** (решение владельца): форматтер (проверка числа до сдвига
+ запятой, `Intl.StringNumericLiteral` через `ES2023.Intl` в `lib` tsconfig вместо `as any`, без мёртвого разбора скобок),
+ запасные имена (`utils/download.xlsxFilename`), отдельные флаги выгрузки, зависимости эффектов (D-9), «—» вместо выдуманного
+ нуля, ячейки по макету, «Год»/«Период» версии только для чтения, сервис `services/api/intercompanyMatrix.ts` с `withJsonBody`,
+ типы шахматки в `types/api.ts`, `Account.kind: 'A' | 'P' | 'AP' | null`. Цена ошибки: конфликты слияния у автора в
+ `ConsolidatedChessContent.tsx` и `ConsolidationChessTableFilter.tsx` — он правит те же файлы по «Known issues»; автору сказано
+ начать с `git pull`.
+- **2026-10-22 (н). «-0,00» убирается проверкой готовой строки, а не опцией `signDisplay: 'negative'`.** Обоснование: на движке
+ без Intl.NumberFormat v3 неизвестное значение `signDisplay` бросает `RangeError` и ломает весь экран, а проверка строки
+ безопасна везде. Цена ошибки: нет.
+- **2026-10-22 (о). Расхождение в скобках всегда**, в том числе нулевое — `(0,00)`: в API `variance_amount` — модуль разности
+ (неотрицательный), скобки в макете 31-01 — способ показа третьей строки, а не знак. Значок пропущенных строк ОСВ — оранжевый
+ `WarningOutlined` у первого числа. Подпись «Период» (не «Квартал») — как в макете. Цена ошибки: правка вида ячейки.
+- **2026-10-23 (п). Остаток раунда 3 закрыт ревьюером, пункт про одну сторону Дт/Кт — в техдолг** (решение владельца: «доправим
+ сами, потом сольём»). Отметки дерева — чистая функция `utils/treeChecks.toggleTreeCheck` (правило (з) снизу вверх по полному
+ дереву на всех уровнях; скрытые поиском отметки берутся из состояния выпадашки, а не из применённого фильтра — иначе снятые
+ отметки возвращались), тесты `utils/treeChecks.test.ts`. Правило тегов из `App.css` перенесено в
+ `ConsolidationChessTableFilter.css`, класс с формы ЮЛ снят. Пустая таблица с подсказкой при одной стороне — AURORA-1799.
+ Цена ошибки: при одной стороне до AURORA-1799 на экране прежние данные, выгрузка недоступна.
diff --git a/openspec/changes/intercompany-matrix-ui/proposal.md b/openspec/changes/intercompany-matrix-ui/proposal.md
new file mode 100644
index 0000000..4bb57d4
--- /dev/null
+++ b/openspec/changes/intercompany-matrix-ui/proposal.md
@@ -0,0 +1,49 @@
+## Why
+
+В ветке `feat/chessTable` (PR RSHB/arm-core#158, автор — Голов) во фронте появился экран «Шахматка» — вкладка страницы
+отчёта по версии консолидации рядом со «Сводной ОСВ». Бэкенд шахматки (`GET/POST …/intercompany-matrix/*`) уже в main
+(change `2026-10-16-intercompany-matrix`), у экрана спеки нет. Спеку и тесты ведёт ревьюер в ветке автора по решению
+владельца (21–22.10.2026): спека задаёт целевое поведение экрана, расхождения кода ветки с ней — `design.md`, «Known issues».
+
+## What Changes
+
+Репозиторий — только arm-core (`web/`). Экран «Шахматка» [31-01], эндпоинты arm-core:
+`GET …/intercompany-matrix/all`, `POST …/intercompany-matrix/manual`, `GET …/intercompany-matrix/all/export`,
+`POST …/intercompany-matrix/manual/export`, `GET …/intercompany-matrix/result-table`,
+`GET …/intercompany-matrix/result-table/export`, `GET /directories/accounts`.
+
+- Маршрут `/groups/:group_id/versions/:version_id/:tab`: страница отчёта (`ConsolidationReportPage`) с общей шапкой и
+ вкладками (`OsvTabs`); `tab = osv` — сводная ОСВ, `tab = chess` — шахматка. Прежний адрес `…/osv` продолжает работать.
+- Таблица ЮЛ × ЮЛ: в ячейке три числа (сумма A, сумма Б, расхождение); итоги считает бэкенд (пока API их не отдаёт —
+ строки «Итого» нет).
+- Результирующая таблица ВГО по статьям с колонкой «Итого» (`group_total`) и выгрузкой в XLSX.
+- Фильтры: компании, счета Дт, счета Кт (деревом), единицы, знаков после запятой; режимы расчёта `all` / `manual`.
+- Экспорт в XLSX (`all` / `manual`, результирующая таблица), полноэкранный режим.
+- Новая спека `intercompany-matrix-ui` (ADDED Requirements).
+
+## Non-goals
+
+- Исправление продуктового кода по «Known issues» (`design.md`) — за автором PR; спеку и тесты ведёт ревьюер.
+- Пресеты счетов — во фронте не реализованы.
+- Итоги «Итого» в API и их вывод — AURORA-1756, AURORA-1757.
+- Выбор года и квартала на экране: селекторы заглушены (`disabled`), версия задаётся адресом.
+- Изменения бэкенда и DBML: шахматка — чистая функция от версии, ничего не хранится (DECISION-5).
+- Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» — недоступны.
+
+## Capabilities
+
+### New Capabilities
+- `intercompany-matrix-ui`: экран «Шахматка» версии консолидации.
+
+### Modified Capabilities
+
+Нет: `consolidated-osv-ui` не правится (вкладки описаны в новой спеке, см. design.md).
+
+## Impact
+
+- `web/src/App.tsx`, `web/src/layouts/{ConsolidationReportPage,index}.ts(x)`, `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx`,
+ `web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedChessContent/`,
+ `web/src/widgets/ConsolidationChessTable/`, `web/src/widgets/ConsolidationChessResTable/`,
+ `web/src/widgets/ConsolidationChessFormatTableFilter/`, `web/src/components/TreeFilterDropdown/`,
+ `web/src/utils/chessTableCellFormatValue.ts`, `web/src/services/consodilation/intercompanyMatrix.ts`, `web/src/types/chess/chess.ts`, `web/src/api/client.ts` (`apiFetchBlob` принимает `init`).
+- Права: экран читает данные под `CONSOLIDATION_VIEW`; меню по правам не скрывается.
diff --git a/openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md b/openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md
new file mode 100644
index 0000000..cb2b870
--- /dev/null
+++ b/openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md
@@ -0,0 +1,179 @@
+## ADDED Requirements
+
+### Requirement: Вкладки страницы отчёта по версии
+Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз
+и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`:
+`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка». Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки
+(навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL
+заменяться на `osv` без новой записи в истории браузера. Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий»,
+«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё
+состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново.
+
+#### Scenario: Переход на шахматку
+- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка»
+- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка
+
+#### Scenario: Прежний адрес сводной ОСВ
+- **WHEN** открыт адрес `…/versions/{id}/osv`
+- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна
+
+#### Scenario: Неизвестная вкладка
+- **WHEN** открыт адрес `…/versions/{id}/abc`
+- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc`
+
+### Requirement: Таблица шахматки ЮЛ × ЮЛ
+Экран SHALL загружать матрицу при открытии вкладки (`GET /consolidations/versions/{id}/intercompany-matrix/all`) и показывать
+таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`companies[]`), остальные колонки —
+те же компании. В ячейке пары (строка A, колонка Б) SHALL быть три числа друг под другом: сумма `amount_a`, сумма `amount_b`
+и расхождение `variance_amount` в скобках; расхождение выделяется красным, если `exceeds_threshold = true` или
+`one_sided = true` (пара без встречной стороны; правило подсветки — как в API и xlsx, AURORA-1651). Если у пары есть
+строки ОСВ без сопоставленного счёта (`skipped_osv_entry_count > 0`), рядом с первым числом SHALL показываться значок
+предупреждения с подсказкой «Строк ОСВ без сопоставленного счёта: N; в суммы не вошли». Если ячейки пары в ответе нет
+(в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Ниже таблицы закреплена строка «Итого»: до
+появления итогов в API (AURORA-1756, AURORA-1757) она временно считается на клиенте точным сложением десятичных строк — сумма
+`amount_a` по колонке видимых ячеек; итог, который не удалось сложить, показывается прочерком «—», а не нулём. Пока идёт
+загрузка, таблица показывает индикатор загрузки; ошибка API показывается уведомлением с текстом сервера. Панель фильтров
+показывает год и квартал версии неактивными полями «Год» и «Период» (например, `2024` и `К1`): версия задаётся адресом.
+
+#### Scenario: Ячейка с расхождением выше порога
+- **WHEN** для пары `(A, Б)` сервер вернул `exceeds_threshold = true`
+- **THEN** третье число ячейки (расхождение) показано красным, первые два — обычным цветом
+
+#### Scenario: Пара без встречной стороны
+- **WHEN** для пары `(A, Б)` сервер вернул `amount_b = 0`, `one_sided = true`, `exceeds_threshold = false`
+- **THEN** третье число ячейки (расхождение) показано красным
+
+#### Scenario: Пары нет в ответе
+- **WHEN** в `cells[]` нет ячейки для пары компаний
+- **THEN** на их пересечении показан прочерк «—»
+
+#### Scenario: Пропущенные строки ОСВ
+- **WHEN** у ячейки `skipped_osv_entry_count = 2`
+- **THEN** у первого числа ячейки виден значок предупреждения, подсказка — «Строк ОСВ без сопоставленного счёта: 2; в суммы не вошли»
+
+### Requirement: Результирующая таблица ВГО
+Под матрицей экран SHALL показывать результирующую таблицу ВГО, загруженную при открытии вкладки
+(`GET /consolidations/versions/{id}/intercompany-matrix/result-table`): первая колонка «Статья» — статьи ответа (`articles[]`,
+`name`) в порядке ответа, далее колонки компаний (`companies[]`) с суммой статьи по компании (`amounts[]` по `company_id`) и
+колонка «Итого» со значением `group_total` из ответа; пустая сумма — прочерк «—». Ниже таблицы закреплена строка «Итого»: до
+появления итогов в API (AURORA-1756, AURORA-1757) она временно считается на клиенте точным сложением сумм всех статей по
+колонке (итог, который не удалось сложить, — «—»). У таблицы свои выбор единиц и знаков (по правилам требования «Единицы и знаки», независимо от
+матрицы), кнопка «Экспорт» и кнопка «На весь экран». Пока идёт загрузка, таблица показывает индикатор; ошибка API (в том числе
+422 `RESULT_ACCOUNTS_MISSING`) показывается уведомлением с текстом сервера.
+
+#### Scenario: Колонка «Итого» из ответа
+- **WHEN** сервер вернул статью «Дебиторская задолженность» с `group_total = "1296510.00"`, выбраны «тыс. руб.» и 2 знака
+- **THEN** в колонке «Итого» этой статьи показано `1 296,51`
+
+#### Scenario: Нет обязательного счёта
+- **WHEN** сервер ответил 422 `RESULT_ACCOUNTS_MISSING`
+- **THEN** показано уведомление с текстом сервера, матрица шахматки при этом показывается как обычно
+
+### Requirement: Единицы и знаки
+Экран SHALL позволять выбрать единицы суммы — «руб.», «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков
+после запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает):
+запятая сдвигается в десятичной строке на 3 / 6 / 9 разрядов, значение округляется до выбранного числа знаков (половина —
+от нуля) без перевода в число с плавающей точкой и показывается с разделителем разрядов и десятичной запятой. Значение,
+округлённое до нуля, показывается без минуса. Пустое значение — пустая ячейка. Значение, которое не удаётся прочитать как
+число, SHALL показываться как пришло — при любых единицах.
+
+#### Scenario: Смена единиц
+- **WHEN** выбраны «млн. руб.» и 1 знак, а сервер вернул `amount_a = "2346459.05"`
+- **THEN** в ячейке показано `2,3`
+
+#### Scenario: Точное округление
+- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"1005"`
+- **THEN** показано `1,01` (не `1,00`, как дало бы округление числа с плавающей точкой)
+
+#### Scenario: Маленькая отрицательная сумма
+- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"-4.00"`
+- **THEN** показано `0,00`, а не `-0,00`
+
+### Requirement: Фильтр компаний
+Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать матрицу на клиенте: остаются строки и
+колонки выбранных компаний и ячейки, у которых обе компании выбраны. Запрос на сервер при этом не отправляется. Список
+вариантов — компании последнего загруженного ответа. Выпадающее окно выбора — дерево с поиском по названию, кнопками
+применения и сброса.
+
+#### Scenario: Две компании из периметра
+- **WHEN** выбраны две компании из пяти
+- **THEN** матрица содержит две строки и две колонки, «Итого» считается по оставшимся ячейкам
+
+### Requirement: Фильтр счетов Дт и Кт и режимы расчёта
+Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии
+`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название», в том числе у выбранных
+дочерних счетов в поле выбора. Выбор счетов Дт и Кт — множественный, пустой выбор — «Все счета».
+
+Отметки в дереве SHALL считаться по полному плану счетов: родительский счёт отмечен тогда и только тогда, когда отмечены все
+его потомки в полном дереве. Поиск по дереву скрывает не совпавшие узлы, но не меняет этого правила: отметка единственного
+видимого при поиске субсчёта не отмечает его родителя, а снятие отметки с субсчёта снимает её и с родителя. Скрытые поиском
+отметки при отметке видимых узлов сохраняются.
+
+Режим расчёта определяется выбором:
+- ничего не выбрано ни в Дт, ни в Кт — SHALL выполняться `GET …/intercompany-matrix/all` (режим `all`);
+- выбраны счета и в Дт, и в Кт — SHALL выполняться `POST …/intercompany-matrix/manual` с `{debit_account_ids,
+ credit_account_ids}` (режим `manual`);
+- выбрана только одна сторона — запрос SHALL NOT отправляться, таблица матрицы SHALL быть пустой и вместо данных показывать
+ текст «Для расчёта по счетам выберите счета и в Дт, и в Кт»; тот же текст SHALL показываться у фильтров Дт/Кт (не
+ всплывающим уведомлением) и только в этом состоянии; кнопка «Экспорт» матрицы SHALL быть недоступна.
+
+Каждое изменение выбора счетов, приводящее к режиму `all` или `manual`, перезапрашивает матрицу; на экране остаётся ответ на
+последний запрос, индикатор загрузки снимается и при переходе в состояние «выбрана одна сторона». Кнопка «Сбросить фильтры»
+появляется, когда выбрано что-либо в компаниях, Дт или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не
+сбрасывается. Ошибка загрузки плана счетов показывается уведомлением.
+
+#### Scenario: Обе стороны выбраны
+- **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт
+- **THEN** отправляется `POST …/intercompany-matrix/manual` с идентификаторами этих счетов и таблица показывает ответ
+
+#### Scenario: Выбрана одна сторона
+- **WHEN** выбран счёт только в Дт
+- **THEN** запрос матрицы не отправляется, таблица пуста и показывает «Для расчёта по счетам выберите счета и в Дт, и в Кт»,
+ тот же текст виден у фильтров, «Экспорт» матрицы недоступен, выбор Дт остаётся в фильтре
+
+#### Scenario: Выбрана одна сторона во время загрузки
+- **WHEN** выбраны Дт и Кт, запрос `manual` ещё выполняется, и пользователь очищает Кт
+- **THEN** индикатор загрузки снят, ответ прежнего запроса на экран не выводится, таблица в состоянии «выбрана одна сторона»
+
+#### Scenario: Поиск и отметка субсчёта
+- **WHEN** в дереве Дт введён поиск, видна ветка 60 с единственным субсчётом 60.02, и отмечен 60.02
+- **THEN** выбранным остаётся только 60.02; после сброса поиска счёт 60 и его прочие субсчета не отмечены
+
+#### Scenario: Снятие субсчёта отмеченного родителя
+- **WHEN** отмечен счёт 60 (отмечены 60, 60.01, 60.02) и снята отметка с 60.01
+- **THEN** выбранным остаётся 60.02, счёт 60 не отмечен
+
+#### Scenario: Сброс
+- **WHEN** выбраны компании и счета, нажато «Сбросить фильтры»
+- **THEN** все три выбора пусты, матрица перезапрошена в режиме `all`, кнопка исчезла
+
+### Requirement: Экспорт в XLSX
+Кнопка «Экспорт» матрицы SHALL скачивать XLSX в том же режиме, что и таблица: в режиме `manual` —
+`POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, в режиме `all` —
+`GET …/intercompany-matrix/all/export`; при выбранной одной стороне кнопка недоступна. Кнопка «Экспорт» результирующей таблицы
+SHALL скачивать `GET …/intercompany-matrix/result-table/export`. Файл сохраняется под именем из `Content-Disposition`, при его
+отсутствии — `Шахматка_<ГК>_<год>_<квартал>.xlsx` и `Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx` соответственно.
+Пока идёт выгрузка, в состоянии загрузки только нажатая кнопка; ошибка API показывается уведомлением. Фильтр компаний, единицы и
+знаки в запрос экспорта не передаются.
+
+#### Scenario: Экспорт по всем счетам
+- **WHEN** счета не выбраны и нажато «Экспорт» матрицы
+- **THEN** отправлен `GET …/intercompany-matrix/all/export`, браузер сохраняет файл `intercompany-matrix_….xlsx`
+
+#### Scenario: Экспорт результирующей таблицы
+- **WHEN** нажато «Экспорт» результирующей таблицы
+- **THEN** отправлен `GET …/intercompany-matrix/result-table/export`, кнопка «Экспорт» матрицы не переходит в состояние загрузки
+
+### Requirement: Полноэкранный режим
+Кнопка «На весь экран» матрицы и результирующей таблицы SHALL переводить свой блок (панель фильтров и таблицу) в
+полноэкранный режим средствами Fullscreen API с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть».
+Выпадающие списки фильтров и подсказки SHALL оставаться видимыми в полноэкранном режиме. Выход клавишей Esc SHALL возвращать
+обычный вид и подпись кнопки. Отказ браузера войти в полноэкранный режим показывается уведомлением.
+
+#### Scenario: Вход и выход
+- **WHEN** нажато «На весь экран», затем Esc
+- **THEN** блок разворачивается на весь экран, затем возвращается в страницу, кнопка снова «На весь экран»
+
+#### Scenario: Фильтр в полноэкранном режиме
+- **WHEN** матрица в полноэкранном режиме и открыт выбор «Компании»
+- **THEN** выпадающее окно видно поверх полноэкранного блока
diff --git a/openspec/changes/intercompany-matrix-ui/tasks.md b/openspec/changes/intercompany-matrix-ui/tasks.md
new file mode 100644
index 0000000..bc82b5f
--- /dev/null
+++ b/openspec/changes/intercompany-matrix-ui/tasks.md
@@ -0,0 +1,13 @@
+## 1. Спека и ревью (RSHB/arm-core#158, AURORA-1538)
+
+- [x] 1.1 Спека `intercompany-matrix-ui` (ревьюер; с раунда 3 — целевое поведение).
+- [x] 1.2 Тесты форматтера сумм `utils/chessTableCellFormatValue.test.ts` (ревьюер).
+- [x] 1.3 Желательные пункты ревью раунда 3 (ревьюер, решение (м) в `design.md`).
+- [x] 1.4 «Known issues» раунда 3: автор (6b956f7, 79d4008) и ревьюер (решение (п)); пункт про одну сторону Дт/Кт — 2.2.
+- [x] 1.5 Перед слиянием: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR.
+- [x] 1.6 Сверка экрана с макетом 31-01 / 31-01-01 в браузере (`docs/mockup/INDEX.md`), в том числе полноэкранный режим.
+
+## 2. После слияния
+
+- [ ] 2.1 Итоги «Итого» из API вместо клиентских — AURORA-1756 (бэк), AURORA-1757 (фронт).
+- [ ] 2.2 Одна сторона Дт/Кт — пустая таблица с подсказкой, без уведомления — AURORA-1799.
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 24faf7a..871c730 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -10,7 +10,6 @@ import {
AuditLogging,
CatalogueGroup,
CatalogueJur,
- ConsolidatedOsvPage,
ConsolidationPage,
DocStorage,
DocumentPage,
@@ -18,6 +17,7 @@ import {
Main,
Mono,
} from './layouts/';
+import { ConsolidationReportPage } from './layouts/ConsolidationReportPage';
import { store } from './store/store';
import { Sidebar } from './widgets/Sidebar/Sidebar';
@@ -37,7 +37,7 @@ function App() {
} />
} />
- } />
+ } />
} />
} />
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index e6522d2..2aa72d4 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -127,13 +127,19 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise
}
/** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */
-export async function apiFetchBlob(path: string): Promise<{ blob: Blob; filename: string | null }> {
- const response = await send(path, {});
+export async function apiFetchBlob(
+ path: string,
+ init: RequestInit = {}, // Добавили init
+): Promise<{ blob: Blob; filename: string | null }> {
+ const response = await send(path, init);
const blob = await response.blob();
- return { blob, filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')) };
+ return {
+ blob,
+ filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')),
+ };
}
-function withJsonBody(method: string, body: unknown): RequestInit {
+export function withJsonBody(method: string, body: unknown): RequestInit {
return body === undefined ? { method } : { method, body: JSON.stringify(body) };
}
diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css
new file mode 100644
index 0000000..adf8ee1
--- /dev/null
+++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css
@@ -0,0 +1,3 @@
+.filterTree .ant-tree-switcher-noop {
+ display: none !important;
+}
diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx
new file mode 100644
index 0000000..5e5f680
--- /dev/null
+++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx
@@ -0,0 +1,152 @@
+import { SearchOutlined } from '@ant-design/icons';
+import { Button, Input, Space, Tree } from 'antd';
+import { type ReactNode, useEffect, useMemo, useState } from 'react';
+import { toggleTreeCheck } from '../../utils/treeChecks';
+import './TreeFilterDropdown.css';
+
+export type OptionType = {
+ title: string;
+ key: string;
+ code?: string;
+ children?: OptionType[];
+};
+
+interface TreeFilterDropdownProps {
+ title: string;
+ placeholder?: string;
+ treeData: OptionType[];
+ selectedKeys: string[];
+ onChange: (keys: string[]) => void;
+ onClose: () => void;
+ warningBlock?: ReactNode;
+}
+
+export const TreeFilterDropdown: React.FC = ({
+ title,
+ placeholder = 'Введите значение',
+ treeData,
+ selectedKeys,
+ onChange,
+ onClose,
+ warningBlock,
+}) => {
+ const [query, setQuery] = useState('');
+
+ const [checkedKeys, setCheckedKeys] = useState(selectedKeys);
+ const [expandedKeys, setExpandedKeys] = useState([]);
+
+ const filteredTreeData = useMemo(() => {
+ if (!query) return treeData;
+
+ const filterNode = (node: OptionType): OptionType | null => {
+ const titleStr = String(node.title).toLowerCase();
+ const isMatch = titleStr.includes(query.toLowerCase());
+
+ let filteredChildren: OptionType[] = [];
+ if (node.children) {
+ filteredChildren = node.children.map(filterNode).filter((child): child is OptionType => child !== null);
+ }
+
+ if (isMatch || filteredChildren.length > 0) {
+ return {
+ ...node,
+ children: filteredChildren.length > 0 ? filteredChildren : undefined,
+ };
+ }
+ return null;
+ };
+
+ return treeData.map(filterNode).filter((node): node is OptionType => node !== null);
+ }, [treeData, query]);
+
+ useEffect(() => {
+ if (query) {
+ const getAllKeys = (nodes: OptionType[]): string[] => {
+ let keys: string[] = [];
+ nodes.forEach((node) => {
+ keys.push(node.key);
+ if (node.children) keys = [...keys, ...getAllKeys(node.children)];
+ });
+ return keys;
+ };
+ setExpandedKeys(getAllKeys(filteredTreeData));
+ }
+ }, [query, filteredTreeData]);
+
+ const getAllNodeKeys = (nodes: OptionType[]): string[] => {
+ let keys: string[] = [];
+ nodes.forEach((node) => {
+ keys.push(node.key);
+ if (node.children) keys = [...keys, ...getAllNodeKeys(node.children)];
+ });
+ return keys;
+ };
+
+ const allFilteredKeys = getAllNodeKeys(filteredTreeData);
+ const visibleCheckedKeys = checkedKeys.filter((key) => allFilteredKeys.includes(key));
+
+ const handleSelectAll = () => {
+ setCheckedKeys(getAllNodeKeys(treeData));
+ };
+
+ const handleClear = () => {
+ setCheckedKeys([]);
+ };
+
+ const handleCancel = () => {
+ setCheckedKeys(selectedKeys);
+ onClose();
+ };
+
+ function checkHandler(keys: string[], info: { node: OptionType; checked: boolean }) {
+ setCheckedKeys(toggleTreeCheck(treeData, checkedKeys, keys, info.node.key, info.checked));
+ }
+ useEffect(() => {
+ setCheckedKeys(selectedKeys);
+ }, [selectedKeys]);
+
+ return (
+