Compare commits

...

25 Commits

Author SHA1 Message Date
aep
664c9487d7 Merge pull request 'Добавление шахматки' (#158) from feat/chessTable into main 2026-10-23 09:41:46 +03:00
Andrey Epifancev
fd3bb79a9b fix(web): шахматка — отметки дерева счетов по полному дереву, стили тегов в css фильтра (AURORA-1538, ревью #158)
- toggleTreeCheck (utils/treeChecks): родитель отмечен, только если отмечены все потомки, на всех уровнях; скрытые
  поиском отметки берутся из состояния выпадашки — снятые подряд отметки больше не возвращаются; тесты.
- Правило тегов из App.css → ConsolidationChessTableFilter.css (App.css пустой, как в main); класс с формы ЮЛ снят.
- Спека: Known issues — остаток (одна сторона Дт/Кт) в техдолг AURORA-1799, решение (п), tasks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:41:28 +04:00
79d4008fcd форматирование 2026-10-23 09:30:17 +03:00
6b956f7664 обновленна логика кнокпи экспорта шахматки и обновленна логика древовидноого фильтра 2026-10-23 09:28:41 +03:00
Andrey Epifancev
478d0acd98 Merge origin/main в feat/chessTable (one_sided по суммам, пресеты #141, #165)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:36:38 +04:00
Andrey Epifancev
814929819a fix(web): шахматка красит и пару без встречной стороны (one_sided), как API и xlsx (AURORA-1651, ревью #158)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:47:07 +04:00
2812897858 исправлена функция выбора дт и кт 2026-10-22 17:43:45 +03:00
Andrey Epifancev
22d5733d16 docs(openspec): intercompany-matrix-ui — временные клиентские «Итого», вид ячеек, решения раунда 3 (ревью #158)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:42:00 +04:00
Andrey Epifancev
c3d82bd293 fix(web): желательные пункты ревью шахматки, раунд 3 (ревью #158, AURORA-1538)
- форматтер сумм: проверка числа до сдвига запятой, Intl.StringNumericLiteral (ES2023.Intl) вместо any, без разбора скобок;
- запасные имена выгрузок xlsxFilename, отдельные флаги загрузки у двух «Экспорт»;
- useNotificationApi мемоизирован, загрузки — useCallback, зависимости эффектов полные;
- ячейки: расхождение в скобках, значок пропущенных строк ОСВ, «—» вместо выдуманного нуля;
- «Год»/«Период» версии только для чтения;
- сервис services/api/intercompanyMatrix.ts с withJsonBody, типы шахматки в types/api.ts, Account.kind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:42:00 +04:00
Andrey Epifancev
b8f0672029 docs(openspec): intercompany-matrix-ui — целевое поведение экрана (ревью #158, раунд 3)
Итоги отдаёт бэкенд (AURORA-1756/1757), результирующая таблица и её выгрузка,
«руб.», точное округление, одна сторона Дт/Кт без запроса, правило отметок
дерева по полному плану счетов, отдельные флаги выгрузки, полноэкранный режим.
Known issues — по состоянию 601a125.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:30:01 +04:00
Andrey Epifancev
718d83d6d1 test(web): тесты форматтера сумм шахматки (ревью #158, AURORA-1538)
Сдвиг единиц, точное округление без float, большие суммы, «-0,00»,
пустые значения.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:30:01 +04:00
Andrey Epifancev
0134120905 Merge origin/main в feat/chessTable (ревью #158, PR #159)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:24:11 +04:00
601a125349 форматирование 2026-10-22 14:36:18 +03:00
ea9cb4a084 исправленны баги пулл реквеста (обновленны стили, добалвенно уведомление о фильтрах ДТ и Кт, добавленна возможность выбирать рубли в фильтре единиц исчисления, ) 2026-10-22 14:28:47 +03:00
473492f6f0 added export res chess table 2026-10-22 10:37:27 +03:00
512cbf6cea fixed style bug 2026-10-22 10:26:31 +03:00
a0ef90159e fixed padding in full screen table 2026-10-22 10:14:01 +03:00
d1c0f93360 добавленна результирующая таблицв ВГО 2026-10-22 10:07:33 +03:00
cbeb9daa38 fixed bugs 2026-10-21 16:48:11 +03:00
Andrey Epifancev
91fdc6c0f2 docs(openspec): change intercompany-matrix-ui — экран шахматки (ревью #158)
proposal, design (Known issues — открытые дефекты ветки за автором; Decisions made without discussion), tasks и спека
intercompany-matrix-ui: вкладки по маршруту :tab, таблица ЮЛ x ЮЛ, фильтры компаний и счетов Дт/Кт, режимы all/manual,
единицы и знаки, экспорт XLSX, полноэкранный режим. Описано фактическое поведение ветки; дефекты в требования не вошли.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:06:40 +04:00
Andrey Epifancev
e19291c886 chore(web): format и biome для шахматки (ревью #158)
- npm run format (eslint --fix, biome check --write, prettier --write) по файлам шахматки;
- biome: import type и сортировка импортов; явный тип IntercompanyMatrixResponse у `let res`
  (ConsolidatedChessContent); Number.isNaN вместо isNaN (ConsolidationChessTable); key у span в OsvTabs;
  `?? null` вместо пустого фрагмента (ConsolidatioReportPage). Поведение не менялось.

Остаются предупреждения eslint в коде шахматки (any, deps хуков, console) — за автором.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:05:23 +04:00
Andrey Epifancev
175343b843 fix(web): вернуть синхронизацию фронта #139/#140, затёртую в ветке шахматки (ревью #158)
Коммит 7891430 откатил синхронизацию фронта (5bec434, #139/#140) и форматирование Prettier (#137).
Из origin/main восстановлены файлы, не относящиеся к шахматке:
- index.tsx (тема antd: Layout.fontSize, Form.labelFontSize/labelColor);
- layouts/DocumentPage.tsx (переключатель документа, комментарий к документу);
- services/api: consolidations.ts (getChecklist), documents.ts (postComment), files.ts (removeFile),
  directories/catalogue_jur.ts; types/consolidations/version_members.ts (ChecklistBackendShape);
- widgets: ConsoleLegalEntities (ChecklistModal, checklist_utils), FilesDrawer (DeleteModal, ReplaceDocModal,
  documentRows), DocumentReview (CommentModal, StatementReview, reviewCounters, reviewView),
  CatalogueJurForm, CatalogueJurTable, CompanyGroupsList;
- hooks/useVirtualList.tsx и components/TableTag/presets.tsx (форматирование Prettier).

В файлах шахматки оставлены её изменения: App.tsx, layouts/index.ts, api/client.ts (init в apiFetchBlob), OsvTabs.tsx,
ConsolidatedOsvContent.tsx. Из main возвращён key={version_id}: страница отчёта разделена на обёртку с key и
содержимое, чтобы при смене версии состояние (карточка, фильтры, страница) создавалось заново, как в ConsolidatedOsvPage.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:04:19 +04:00
1413feda44 Merge branch 'main' of https://git.aepif.ru/RSHB/arm-core into feat/chessTable 2026-10-21 10:51:53 +03:00
9bd8a2a015 added chess table 2026-10-21 10:51:13 +03:00
7891430fce added chess table consolidation 2026-10-19 17:39:34 +03:00
30 changed files with 1913 additions and 105 deletions

View File

@ -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 на экране прежние данные, выгрузка недоступна.

View File

@ -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`; меню по правам не скрывается.

View File

@ -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** выпадающее окно видно поверх полноэкранного блока

View File

@ -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.

View File

@ -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() {
<Route path='/groups'>
<Route index element={<GroupsLayout />} />
<Route path=':group_id' element={<ConsolidationPage />} />
<Route path=':group_id/versions/:version_id/osv' element={<ConsolidatedOsvPage />} />
<Route path=':group_id/versions/:version_id/:tab' element={<ConsolidationReportPage />} />
<Route path='monozaemchiki' element={<Mono />} />
</Route>
<Route path='/catalogue/groups' element={<CatalogueGroup />} />

View File

@ -127,13 +127,19 @@ export async function apiFetch<T>(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) };
}

View File

@ -0,0 +1,3 @@
.filterTree .ant-tree-switcher-noop {
display: none !important;
}

View File

@ -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<TreeFilterDropdownProps> = ({
title,
placeholder = 'Введите значение',
treeData,
selectedKeys,
onChange,
onClose,
warningBlock,
}) => {
const [query, setQuery] = useState('');
const [checkedKeys, setCheckedKeys] = useState<string[]>(selectedKeys);
const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
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 (
<div className='filterTree' style={{ padding: 12, width: 380 }}>
<div style={{ fontWeight: 600, marginBottom: 12, fontSize: 16 }}>{title}</div>
{warningBlock}
<div style={{ fontSize: 12, marginBottom: 4 }}>Поиск</div>
<Input
prefix={<SearchOutlined />}
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ marginBottom: 12 }}
/>
<Space style={{ marginBottom: 12, justifyContent: 'space-between', width: '100%' }}>
<Button onClick={handleSelectAll}>Выбрать все</Button>
<Button onClick={handleClear}>Очистить</Button>
</Space>
<div style={{ maxHeight: 280, overflowY: 'auto', border: '1px solid #f0f0f0', borderRadius: 8, padding: 8 }}>
<Tree
checkable
selectable={false}
treeData={filteredTreeData}
checkedKeys={visibleCheckedKeys}
onCheck={(keys, test) => checkHandler(keys as string[], test)}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys as string[])}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 10 }}>
<Button onClick={handleCancel}>Отмена</Button>
<Button
type='primary'
style={{ background: '#2a8c3a' }}
onClick={() => {
onChange(checkedKeys);
onClose();
}}>
Применить
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,89 @@
import { Space, message } from 'antd';
import { type ReactNode, useEffect, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { getVersionCard } from '../services/api/consolidations';
import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members';
import { quarterShortLabel } from '../utils/labels';
import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts';
import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent';
function ReportContent() {
const [card, setCard] = useState<VersionCard | null>(null);
const { version_id, tab } = useParams<{ tab?: string; version_id: string }>();
const navigation = useNavigate();
const { pathname } = useLocation();
const TAB_COMPONENTS: Record<string, ReactNode> = {
osv: <OsvContent card={card} />,
chess: <ChessContent card={card} />,
};
const ActiveTab = TAB_COMPONENTS[tab as string] ?? null;
const russianStatus = card ? (statusMapping[card.status] ?? card.status) : '';
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
const groupName = card?.company_group.name ?? '';
const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : '';
useEffect(() => {
if (version_id) {
let actual = true;
getVersionCard(version_id)
.then((c) => {
if (actual) setCard(c);
})
.catch((e: unknown) => {
if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию');
});
return () => {
actual = false;
};
}
}, [version_id]);
useEffect(() => {
const isValidTab = !tab || tab === 'osv' || tab === 'chess';
if (!isValidTab) {
const pathParts = pathname.split('/');
pathParts[pathParts.length - 1] = 'osv';
const newPath = pathParts.join('/');
navigation(newPath, { replace: true });
}
}, [tab, navigation, pathname]);
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100vh', gap: 12 }}>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 12 }}>
<PageTitle
backable
location={['Отчётность', groupName, period]}
header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'}
description={russianStatus}
extra={
card && (
<Space size={8}>
<span style={{ color: '#667085', fontSize: 12 }}>Статус отчёта</span>
<TableTag text={russianStatus} {...preset} />
</Space>
)
}
/>
<OsvTabs />
</div>
{ActiveTab}
</div>
);
}
export function ConsolidationReportPage() {
const { version_id = '' } = useParams<{ version_id: string }>();
// key: при смене версии всё состояние (карточка, фильтры, страница, данные) создаётся заново
return <ReportContent key={version_id} />;
}

View File

@ -9,4 +9,4 @@ export { GroupsLayout } from './Groups';
export { AuditLogging } from './AuditLogging';
export { Admin } from './Admin';
export { DocumentPage } from './DocumentPage';
export { ConsolidatedOsvPage } from './ConsolidatedOsvPage';
export { ConsolidationReportPage } from './ConsolidationReportPage';

View File

@ -0,0 +1,19 @@
import { api, apiFetchBlob, withJsonBody } from '../../api/client';
import type { ChessManualRequest, IntercompanyMatrixResponse, IntercompanyMatrixResultResponse } from '../../types/api';
const base = (versionId: string) => `/consolidations/versions/${versionId}/intercompany-matrix`;
export const fetchChessConsolidations = (versionId: string) => api.get<IntercompanyMatrixResponse>(`${base(versionId)}/all`);
export const fetchChessConsolidationsManual = (versionId: string, data: ChessManualRequest) =>
api.post<IntercompanyMatrixResponse>(`${base(versionId)}/manual`, data);
export const exportChessTableAll = (versionId: string) => apiFetchBlob(`${base(versionId)}/all/export`);
export const exportChessTableManual = (versionId: string, data: ChessManualRequest) =>
apiFetchBlob(`${base(versionId)}/manual/export`, withJsonBody('POST', data));
export const fetchChessConsolidationsResult = (versionId: string) =>
api.get<IntercompanyMatrixResultResponse>(`${base(versionId)}/result-table`);
export const exportChessConsolidationsResult = (versionId: string) => apiFetchBlob(`${base(versionId)}/result-table/export`);

View File

@ -394,3 +394,62 @@ export interface LegalEntityBrief {
name: string;
inn: string;
}
export interface Account {
id: string;
code: string;
parent_id: string | null;
name: string;
kind: 'A' | 'P' | 'AP' | null;
row_type: string;
is_off_balance: boolean;
is_debit: boolean;
is_credit: boolean;
}
/** Шахматка ВГО (`IntercompanyMatrixOut`): компании периметра и направленные ячейки пар. */
export interface MatrixCompany {
id: string;
name: string;
}
export interface MatrixCell {
company_a_id: string;
company_b_id: string;
amount_a: string;
amount_b: string;
variance_amount: string;
exceeds_threshold: boolean;
one_sided: boolean;
variance_percent: string | null;
/** Строки ОСВ пары без сопоставленного счёта; в суммы не вошли. */
skipped_osv_entry_count: number;
}
export interface IntercompanyMatrixResponse {
companies: MatrixCompany[];
cells: MatrixCell[];
}
export type ChessManualRequest = {
debit_account_ids: string[];
credit_account_ids: string[];
};
/** Результирующая таблица ВГО (`IntercompanyResultTableOut`): статьи §6 по компаниям. */
export interface MatrixAmount {
company_id: string;
amount: string;
}
export interface MatrixResCell {
code: string;
name: string;
group_total: string;
amounts: MatrixAmount[];
}
export interface IntercompanyMatrixResultResponse {
companies: MatrixCompany[];
articles: MatrixResCell[];
}

View File

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { chessTableCellFormatValue as fmt } from './chessTableCellFormatValue';
const NBSP = ' ';
describe('chessTableCellFormatValue', () => {
it('руб.: разряды, запятая, заданное число знаков', () => {
expect(fmt('4454151.93', 'rub', 2)).toBe(`4${NBSP}454${NBSP}151,93`);
expect(fmt('100', 'rub', 2)).toBe('100,00');
expect(fmt('123.456', 'rub', 0)).toBe('123');
expect(fmt('123.4', 'rub', 3)).toBe('123,400');
});
it('тыс./млн/млрд — сдвиг запятой в строке', () => {
expect(fmt('2346459.05', 'thousand', 2)).toBe(`2${NBSP}346,46`);
expect(fmt('2346459.05', 'million', 1)).toBe('2,3');
expect(fmt('2346459.05', 'billion', 3)).toBe('0,002');
expect(fmt('12', 'thousand', 3)).toBe('0,012');
});
it('округление точное, без двоичной погрешности float', () => {
// 1005 руб. = 1,005 тыс.: через Number(...).toFixed(2) вышло бы «1,00»
expect(fmt('1005', 'thousand', 2)).toBe('1,01');
expect(fmt('0.005', 'rub', 2)).toBe('0,01');
});
it('большие суммы не теряют копейки', () => {
expect(fmt('12345678901234567.89', 'rub', 2)).toBe(`12${NBSP}345${NBSP}678${NBSP}901${NBSP}234${NBSP}567,89`);
});
it('отрицательные', () => {
expect(fmt('-1234567.1', 'rub', 2)).toBe(`-1${NBSP}234${NBSP}567,10`);
expect(fmt('-2346459.05', 'thousand', 2)).toBe(`-2${NBSP}346,46`);
});
it('отрицательная сумма, округлённая до нуля, — без минуса', () => {
expect(fmt('-4.00', 'thousand', 2)).toBe('0,00');
expect(fmt('-0.001', 'rub', 0)).toBe('0');
});
it('пустое значение — пустая строка', () => {
expect(fmt(null, 'rub', 2)).toBe('');
expect(fmt(undefined, 'thousand', 2)).toBe('');
expect(fmt('', 'million', 2)).toBe('');
});
it('нечисловое значение — как пришло, при любых единицах', () => {
expect(fmt('abc', 'rub', 2)).toBe('abc');
expect(fmt('abc', 'thousand', 2)).toBe('abc');
expect(fmt('(123.45)', 'million', 2)).toBe('(123.45)');
});
});

View File

@ -0,0 +1,34 @@
const UNIT_SHIFT: Record<string, number> = { rub: 0, thousand: 3, million: 6, billion: 9 };
const DECIMAL = /^-?\d+(\.\d+)?$/;
/** Сдвиг запятой влево на `shift` разрядов в десятичной строке, без перевода в число. */
function shiftDecimal(value: string, shift: number): string {
if (shift === 0) {
return value;
}
const negative = value.startsWith('-');
const [intPart, fracPart = ''] = (negative ? value.slice(1) : value).split('.');
const padded = intPart.padStart(shift + 1, '0');
return `${negative ? '-' : ''}${padded.slice(0, -shift)}.${padded.slice(-shift)}${fracPart}`;
}
/**
* Сумма из API (десятичная строка) в выбранных единицах и знаках для экрана шахматки
* (спека intercompany-matrix-ui, «Единицы и знаки»): точное округление без float, ноль без минуса,
* пустое — пустая строка, нечисловое — как пришло.
*/
export const chessTableCellFormatValue = (val: string | number | null | undefined, units: string, decimals: number): string => {
if (val === undefined || val === null || val === '') return '';
const str = String(val).trim();
if (!DECIMAL.test(str)) return String(val);
// строка форматируется Intl как точное десятичное (Intl.NumberFormat v3)
const formatted = new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(shiftDecimal(str, UNIT_SHIFT[units] ?? 0) as Intl.StringNumericLiteral);
// «-0,00» после округления малой отрицательной суммы показываем как ноль
return /^-[0,]+$/.test(formatted) ? formatted.slice(1) : formatted;
};

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { exportFilename, filenameFromContentDisposition } from './download';
import { exportFilename, filenameFromContentDisposition, xlsxFilename } from './download';
describe('filenameFromContentDisposition', () => {
it('RFC 5987 filename*', () => {
@ -22,3 +22,14 @@ describe('exportFilename', () => {
expect(exportFilename('ГК «Русский бисквит»/Север', 2024, 'Q3')).toBe('Сводная_ОСВ_ГК_«Русский_бисквит»_Север_2024_Q3.xlsx');
});
});
describe('xlsxFilename', () => {
it('префикс, ГК, год и квартал', () => {
expect(xlsxFilename('Шахматка', { groupName: 'ГК «Русский бисквит»', year: 2024, quarter: 'Q1' })).toBe(
'Шахматка_ГК_«Русский_бисквит»_2024_Q1.xlsx',
);
});
it('без карточки версии — только префикс', () => {
expect(xlsxFilename('Результирующая_таблица_ВГО', null)).toBe('Результирующая_таблица_ВГО.xlsx');
});
});

View File

@ -15,10 +15,19 @@ export function filenameFromContentDisposition(header: string | null): string |
return plain ? plain[1].trim() : null;
}
const safeName = (name: string) => name.replace(/[\\/:*?"<>|\s]+/g, '_');
/** Запасное имя экспорта свода, если Content-Disposition недоступен. */
export function exportFilename(groupName: string, year: number, quarter: string): string {
const safe = groupName.replace(/[\\/:*?"<>|\s]+/g, '_');
return `Сводная_ОСВ_${safe}_${year}_${quarter}.xlsx`;
return `Сводная_ОСВ_${safeName(groupName)}_${year}_${quarter}.xlsx`;
}
/** Запасное имя xlsx-выгрузки версии: `<префикс>_<ГК>_<год>_<квартал>.xlsx`, без карточки версии — `<префикс>.xlsx`. */
export function xlsxFilename(prefix: string, version: { groupName: string; year: number; quarter: string } | null): string {
if (!version) {
return `${prefix}.xlsx`;
}
return `${prefix}_${safeName(version.groupName)}_${version.year}_${version.quarter}.xlsx`;
}
/** Сохранить blob файлом через временную ссылку. */

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { type TreeCheckNode, toggleTreeCheck } from './treeChecks';
// 60 → 60.01, 60.02; 60.02 → 60.02.1, 60.02.2; 62 без детей
const tree: TreeCheckNode[] = [
{
key: '60',
children: [{ key: '60.01' }, { key: '60.02', children: [{ key: '60.02.1' }, { key: '60.02.2' }] }],
},
{ key: '62' },
];
const sorted = (keys: string[]) => [...keys].sort();
describe('toggleTreeCheck', () => {
it('отметка родителя отмечает всех потомков', () => {
expect(sorted(toggleTreeCheck(tree, [], [], '60', true))).toEqual(['60', '60.01', '60.02', '60.02.1', '60.02.2']);
});
it('единственный найденный субсчёт не отмечает родителя, даже если дерево поиска прислало родителя', () => {
// при поиске у 60 виден только 60.01, и Tree сам дописывает 60 в отмеченные
expect(sorted(toggleTreeCheck(tree, [], ['60', '60.01'], '60.01', true))).toEqual(['60.01']);
});
it('родитель отмечается, когда отмечены все его субсчета, на всех уровнях', () => {
const keys = toggleTreeCheck(tree, ['60.01', '60.02.1'], [], '60.02.2', true);
expect(sorted(keys)).toEqual(['60', '60.01', '60.02', '60.02.1', '60.02.2']);
});
it('снятие субсчёта снимает родителя и предков, остальные отметки остаются', () => {
const all = ['60', '60.01', '60.02', '60.02.1', '60.02.2', '62'];
expect(sorted(toggleTreeCheck(tree, all, [], '60.02.1', false))).toEqual(['60.01', '60.02.2', '62']);
});
it('снятые подряд отметки не возвращаются', () => {
const first = toggleTreeCheck(tree, ['60.01', '62'], ['62'], '60.01', false);
expect(first).toEqual(['62']);
expect(toggleTreeCheck(tree, first, [], '62', false)).toEqual([]);
});
it('отметки, скрытые поиском, сохраняются', () => {
// виден только 62, а 60.01 отмечен раньше и скрыт фильтром
expect(sorted(toggleTreeCheck(tree, ['60.01'], ['62'], '62', true))).toEqual(['60.01', '62']);
});
});

View File

@ -0,0 +1,44 @@
export type TreeCheckNode = {
key: string;
children?: TreeCheckNode[];
};
const descendantKeys = (node: TreeCheckNode): string[] => (node.children ?? []).flatMap((child) => [child.key, ...descendantKeys(child)]);
const findNode = (nodes: TreeCheckNode[], key: string): TreeCheckNode | undefined => {
for (const node of nodes) {
if (node.key === key) return node;
const found = node.children && findNode(node.children, key);
if (found) return found;
}
return undefined;
};
// Снизу вверх: родитель отмечен, только если отмечены все его дети (а значит, и все потомки)
const syncParents = (nodes: TreeCheckNode[], keys: Set<string>) => {
nodes.forEach((node) => {
if (!node.children?.length) return;
syncParents(node.children, keys);
if (node.children.every((child) => keys.has(child.key))) keys.add(node.key);
else keys.delete(node.key);
});
};
/**
* Новые отметки дерева после клика по узлу `key`.
* Отметки считаются по полному дереву, а не по найденному поиском: `treeKeys` (что прислал Tree по видимым узлам)
* может содержать родителя, у которого виден один субсчёт, — он снимается, если отмечены не все субсчета.
* Отметки, скрытые поиском, берутся из `prevKeys` (текущее состояние выпадашки, не применённый фильтр).
*/
export const toggleTreeCheck = (tree: TreeCheckNode[], prevKeys: string[], treeKeys: string[], key: string, checked: boolean): string[] => {
const keys = new Set([...prevKeys, ...treeKeys]);
const node = findNode(tree, key);
if (node) {
for (const k of [node.key, ...descendantKeys(node)]) {
if (checked) keys.add(k);
else keys.delete(k);
}
}
syncParents(tree, keys);
return Array.from(keys);
};

View File

@ -1,5 +1,5 @@
import { App } from 'antd';
import type { ReactNode } from 'react';
import { type ReactNode, useMemo } from 'react';
type NotifyArgs = {
title?: ReactNode;
@ -11,14 +11,18 @@ type NotifyArgs = {
export function useNotificationApi() {
const { notification } = App.useApp();
return {
error: ({ title = 'Ошибка', description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.error({ title: title, description, placement, duration }),
success: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.success({ title: title, description, placement, duration }),
info: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.info({ title: title, description, placement, duration }),
warning: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.warning({ title: title, description, placement, duration }),
};
// один объект на всё время жизни App: можно класть в зависимости эффектов и useCallback
return useMemo(
() => ({
error: ({ title = 'Ошибка', description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.error({ title: title, description, placement, duration }),
success: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.success({ title: title, description, placement, duration }),
info: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.info({ title: title, description, placement, duration }),
warning: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.warning({ title: title, description, placement, duration }),
}),
[notification],
);
}

View File

@ -0,0 +1,322 @@
import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Button, ConfigProvider } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../../api/client';
import {
exportChessConsolidationsResult,
exportChessTableAll,
exportChessTableManual,
fetchChessConsolidations,
fetchChessConsolidationsManual,
fetchChessConsolidationsResult,
} from '../../services/api/intercompanyMatrix';
import type { IntercompanyMatrixResponse, IntercompanyMatrixResultResponse } from '../../types/api';
import type { VersionCard } from '../../types/consolidations/version_members';
import { saveBlob, xlsxFilename } from '../../utils/download';
import { useNotificationApi } from '../../utils/useNotification';
import { ConsolidationChessFormatTableFilter } from '../ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter';
import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable';
import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable';
import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter';
export const ChessContent = ({ card }: { card: VersionCard | null }) => {
const { version_id } = useParams<{ version_id: string }>();
const notification = useNotificationApi();
const [data, setData] = useState<IntercompanyMatrixResponse | null>(null);
const [loading, setLoading] = useState(false);
const [matrixExportLoading, setMatrixExportLoading] = useState(false);
const [resExportLoading, setResExportLoading] = useState(false);
const [companies, setCompanies] = useState<string[]>([]);
const [decimals, setDecimals] = useState(2);
const [units, setUnits] = useState('thousand');
const [debit, setDebit] = useState<string[]>([]);
const [credit, setCredit] = useState<string[]>([]);
const [chessResData, setChessResData] = useState<IntercompanyMatrixResultResponse | null>(null);
const [chessResUnits, setChessResUnits] = useState('thousand');
const [chessResDecimals, setChessResDecimals] = useState(2);
const [chessResLoading, setChessResLoading] = useState(false);
const chessTableRef = useRef<HTMLDivElement>(null);
const resTableRef = useRef<HTMLDivElement>(null);
const [isChessFullscreen, setIsChessFullscreen] = useState(false);
const [isResFullscreen, setIsResFullscreen] = useState(false);
const toggleFullscreen = async (ref: React.RefObject<HTMLDivElement>, setFullscreen: (val: boolean) => void) => {
if (!ref.current) return;
try {
if (!document.fullscreenElement) {
await ref.current.requestFullscreen();
setFullscreen(true);
} else {
await document.exitFullscreen();
setFullscreen(false);
}
} catch (e) {
notification.error({
description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим',
});
}
};
useEffect(() => {
const handleFullscreenChange = () => {
if (!document.fullscreenElement) {
setIsChessFullscreen(false);
setIsResFullscreen(false);
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
const isHalfFiltered = (debit.length > 0 && credit.length === 0) || (debit.length === 0 && credit.length > 0);
const loadData = useCallback(
async (vId: string, dIds: string[], cIds: string[], isActual: () => boolean) => {
if (isHalfFiltered) {
notification.info({ description: 'Для сравнений по Дт и Кт нужно заполнить оба фильтра' });
return;
}
setLoading(true);
try {
let res: IntercompanyMatrixResponse;
if (dIds?.length && cIds?.length) {
res = await fetchChessConsolidationsManual(vId, { debit_account_ids: dIds, credit_account_ids: cIds });
} else {
res = await fetchChessConsolidations(vId);
}
if (isActual()) {
setData(res);
}
} catch (e) {
if (isActual() && e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
if (isActual()) {
setLoading(false);
}
}
},
[notification, isHalfFiltered],
);
const loadResData = useCallback(
async (vid: string) => {
setChessResLoading(true);
try {
const res = await fetchChessConsolidationsResult(vid);
setChessResData(res);
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setChessResLoading(false);
}
},
[notification],
);
const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null;
const download = async () => {
if (!version_id) {
return;
}
setMatrixExportLoading(true);
try {
if (debit?.length && credit?.length) {
const { blob, filename } = await exportChessTableManual(version_id, { debit_account_ids: debit, credit_account_ids: credit });
saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo));
} else {
const { blob, filename } = await exportChessTableAll(version_id);
saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo));
}
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setMatrixExportLoading(false);
}
};
const downloadResTable = async () => {
if (!version_id) {
return;
}
setResExportLoading(true);
try {
const { blob, filename } = await exportChessConsolidationsResult(version_id);
saveBlob(blob, filename ?? xlsxFilename('Результирующая_таблица_ВГО', versionInfo));
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setResExportLoading(false);
}
};
const filteredData = useMemo(() => {
if (!data) return null;
if (companies.length === 0) return data;
const filteredCompanies = data.companies.filter((c) => companies.includes(c.id));
const filteredCells = data.cells.filter((c) => companies.includes(c.company_a_id) && companies.includes(c.company_b_id));
return { companies: filteredCompanies, cells: filteredCells };
}, [data, companies]);
useEffect(() => {
if (!version_id) return;
let isMounted = true;
const isActual = () => isMounted;
loadData(version_id, debit, credit, isActual);
return () => {
isMounted = false;
setLoading(false);
};
}, [version_id, debit, credit, loadData]);
useEffect(() => {
if (!version_id) return;
loadResData(version_id);
}, [version_id, loadResData]);
return (
<ConfigProvider getPopupContainer={() => (document.fullscreenElement as HTMLElement) || document.body}>
<div
style={{
display: 'flex',
flexDirection: 'column',
overflowY: 'auto',
overflowX: 'hidden',
gap: 24,
paddingBottom: 32,
width: '100%',
}}>
<div
ref={chessTableRef}
style={{
backgroundColor: isChessFullscreen ? '#fff' : 'transparent',
padding: isChessFullscreen ? 6 : 0,
height: isChessFullscreen ? '100vh' : 'auto',
overflow: isChessFullscreen ? 'hidden' : 'visible',
display: 'flex',
flexDirection: 'column',
}}>
<ConsolidationChessTable
data={filteredData}
loading={loading}
decimals={decimals}
units={units}
isFullscreen={isChessFullscreen}
toolbar={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
flexWrap: 'wrap',
gap: 16,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
<ConsolidationChessTableFilter
companies={companies}
onCompaniesChange={setCompanies}
debit={debit}
onDebitChange={setDebit}
credit={credit}
onCreditChange={setCredit}
allCompanies={data?.companies || []}
year={card?.year}
quarter={card?.quarter}
/>
<div
style={{
width: 1,
height: 50,
backgroundColor: '#e5e7eb',
}}
/>
<ConsolidationChessFormatTableFilter
decimals={decimals}
units={units}
onDecimalsChange={setDecimals}
onUnitsChange={setUnits}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button disabled={isHalfFiltered} loading={matrixExportLoading} onClick={() => download()} icon={<DownloadOutlined />}>
Экспорт
</Button>
<Button
onClick={() => toggleFullscreen(chessTableRef, setIsChessFullscreen)}
icon={isChessFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
{isChessFullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
</div>
</div>
}
/>
</div>
<div
ref={resTableRef}
style={{
backgroundColor: isResFullscreen ? '#fff' : 'transparent',
padding: isResFullscreen ? 6 : 0,
height: isResFullscreen ? '100vh' : 'auto',
overflow: isResFullscreen ? 'hidden' : 'visible',
display: 'flex',
flexDirection: 'column',
}}>
<ConsolidationChessResTable
data={chessResData}
loading={chessResLoading}
decimals={chessResDecimals}
units={chessResUnits}
isFullscreen={isResFullscreen}
toolbar={
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
flexWrap: 'wrap',
gap: 16,
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
<ConsolidationChessFormatTableFilter
decimals={chessResDecimals}
units={chessResUnits}
onDecimalsChange={setChessResDecimals}
onUnitsChange={setChessResUnits}
/>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button loading={resExportLoading} onClick={() => downloadResTable()} icon={<DownloadOutlined />}>
Экспорт
</Button>
<Button
onClick={() => toggleFullscreen(resTableRef, setIsResFullscreen)}
icon={isResFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
{isResFullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
</div>
</div>
}
/>
</div>
</div>
</ConfigProvider>
);
};

View File

@ -1,26 +1,80 @@
import { Segmented, Tooltip } from 'antd';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
const SOON = 'Будет в следующих срезах';
const TABS = ['Шахматка', 'Таблица корректировки', 'Гудвилл', 'КФО', 'КФО Общий', 'Подтверждение КФО клиента'];
const TAB_CONFIG = [
{ key: 'osv', label: 'ОСВ' },
{ key: 'chess', label: 'Шахматка' },
{
key: 'correction',
label: 'Таблица корректировки',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'goodwill',
label: 'Гудвилл',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo',
label: 'КФО',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo_common',
label: 'КФО Общий',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
{
key: 'kfo_confirm',
label: 'Подтверждение КФО клиента',
disabled: true,
tooltip: 'Будет в следующих срезах',
},
];
/** Ряд вкладок шагов консолидации (макет [30]): активна только «ОСВ». */
export function OsvTabs() {
const navigate = useNavigate();
const location = useLocation();
const { tab } = useParams<{ tab?: string }>();
const activeKey = tab || 'osv';
const handleTabChange = (newKey: string | number) => {
const newPath = location.pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`);
navigate(newPath);
};
const options = TAB_CONFIG.map((tabItem) => {
const isActive = tabItem.key === activeKey;
const label = (
<span
key={tabItem.key}
style={{
color: isActive ? '#2a8c3a' : undefined,
fontWeight: isActive ? 600 : 400,
}}>
{tabItem.label}
</span>
);
return {
value: tabItem.key,
disabled: tabItem.disabled,
label: tabItem.tooltip ? <Tooltip title={tabItem.tooltip}>{label}</Tooltip> : label,
};
});
return (
<Segmented
value='ОСВ'
value={activeKey}
onChange={handleTabChange}
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
options={[
{ value: 'ОСВ', label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>ОСВ</span> },
...TABS.map((t) => ({
value: t,
disabled: true,
label: (
<Tooltip title={SOON}>
<span>{t}</span>
</Tooltip>
),
})),
]}
options={options}
/>
);
}

View File

@ -1,51 +1,33 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, Space, message } from 'antd';
import { useEffect, useState } from 'react';
import { Alert, Button, message } from 'antd';
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { TableTag } from '../components/TableTag/TableTag';
import { TAG_PRESETS } from '../components/TableTag/presets';
import { getVersionCard } from '../services/api/consolidations';
import { exportOsv } from '../services/api/osv';
import type { ConsolidatedRowOut } from '../types/api';
import type { ConsRussianStatus } from '../types/consolidations/common_types';
import type { VersionCard } from '../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../utils/download';
import { quarterShortLabel } from '../utils/labels';
import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts';
import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable';
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv';
import { ApiError } from '../../api/client';
import { exportOsv } from '../../services/api/osv';
import type { ConsolidatedRowOut } from '../../types/api';
import type { VersionCard } from '../../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../../utils/download';
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../ConsolidatedOsv/OsvTable';
import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv';
function OsvContent({ versionId }: { versionId: string }) {
const [card, setCard] = useState<VersionCard | null>(null);
export function OsvContent({ card }: { card: VersionCard | null }) {
const [exporting, setExporting] = useState(false);
const { version_id } = useParams<{ version_id: string }>();
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const osv = useConsolidatedOsv(versionId);
useEffect(() => {
let actual = true;
getVersionCard(versionId)
.then((c) => {
if (actual) setCard(c);
})
.catch((e: unknown) => {
if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию');
});
return () => {
actual = false;
};
}, [versionId]);
const osv = useConsolidatedOsv(version_id ?? '');
const download = async () => {
if (!version_id) {
return;
}
setExporting(true);
try {
const { blob, filename } = await exportOsv(versionId, osv.filters);
const { blob, filename } = await exportOsv(version_id, osv.filters);
saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? ''));
} catch (e: unknown) {
message.error(e instanceof ApiError ? e.message : 'Не удалось выгрузить свод');
@ -57,27 +39,9 @@ function OsvContent({ versionId }: { versionId: string }) {
const s = osv.summary;
const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true;
const russianStatus = card ? (statusMapping[card.status] ?? card.status) : '';
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
const groupName = card?.company_group.name ?? '';
const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : '';
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100vh', gap: 12 }}>
<PageTitle
backable
location={['Отчётность', groupName, period]}
header={card ? `${groupName} — ${card.year} ${quarterShortLabel(card.quarter)}` : 'Сводная ОСВ'}
description={russianStatus}
extra={
card && (
<Space size={8}>
<span style={{ color: '#667085', fontSize: 12 }}>Статус отчёта</span>
<TableTag text={russianStatus} {...preset} />
</Space>
)
}
/>
<OsvTabs />
<>
<main
style={{
flex: '1 1 auto',
@ -135,21 +99,15 @@ function OsvContent({ versionId }: { versionId: string }) {
onMap={setMapping}
/>
</main>
{mapping && (
{mapping && version_id && (
<CounterpartyMapModal
key={mapEntryId(mapping)}
versionId={versionId}
versionId={version_id}
row={mapping}
onClose={() => setMapping(null)}
onMapped={osv.refresh}
/>
)}
</div>
</>
);
}
export function ConsolidatedOsvPage() {
const { version_id = '' } = useParams();
// key: при смене версии всё состояние (фильтры, страница, данные) создаётся заново
return <OsvContent key={version_id} versionId={version_id} />;
}

View File

@ -0,0 +1,51 @@
import { Select, Space } from 'antd';
import type React from 'react';
interface ConsolidationChessFormatTableFilterI {
decimals: number;
onDecimalsChange: (val: number) => void;
units: string;
onUnitsChange: (val: string) => void;
}
export const ConsolidationChessFormatTableFilter: React.FC<ConsolidationChessFormatTableFilterI> = ({
decimals,
onDecimalsChange,
units,
onUnitsChange,
}) => {
const UNIT_OPTIONS = [
{ value: 'rub', label: 'руб.' },
{ value: 'thousand', label: 'тыс. руб.' },
{ value: 'million', label: 'млн. руб.' },
{ value: 'billion', label: 'млрд руб.' },
];
const DECIMALS_OPTIONS = [
{ value: 0, label: '0' },
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
];
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', flexWrap: 'wrap', gap: 16 }}>
<Space size='middle' wrap>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Единицы</span>
<Select style={{ minWidth: 120 }} options={UNIT_OPTIONS} value={units} onChange={onUnitsChange} variant='underlined' />
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Знаков после запятой</span>
<Select
style={{ minWidth: 150 }}
value={decimals}
onChange={(v) => onDecimalsChange(v || 0)}
options={DECIMALS_OPTIONS}
variant='underlined'
/>
</Space>
</Space>
</div>
);
};

View File

@ -0,0 +1,11 @@
.chessTable .ant-table-summary {
border-end-end-radius: 8px !important;
border-end-start-radius: 8px !important;
}
.chessTable .ant-table-cell-fix {
background-color: #f8fafc !important;
}
.chessTable .resRow {
background-color: #e6f4ea !important ;
}

View File

@ -0,0 +1,134 @@
import { Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type React from 'react';
import { useMemo } from 'react';
import type { IntercompanyMatrixResultResponse, MatrixResCell } from '../../types/api';
import './ConsolidatedChessResTable.css';
import { chessTableCellFormatValue } from '../../utils/chessTableCellFormatValue';
import { sumAmounts } from '../../utils/money';
const { Text } = Typography;
const DASH = <span style={{ color: '#bfbfbf' }}>—</span>;
interface ConsolidationChessResTableI {
data: IntercompanyMatrixResultResponse | null;
loading: boolean;
decimals: number;
units: string;
toolbar?: React.ReactNode;
isFullscreen?: boolean;
}
export const ConsolidationChessResTable: React.FC<ConsolidationChessResTableI> = ({
data,
loading,
decimals,
units,
toolbar,
isFullscreen = false,
}) => {
const matrixData = useMemo(() => {
if (!data?.articles) return {};
const result: Record<string, Record<string, string>> = {};
data.articles.forEach((article) => {
result[article.code] = {};
article.amounts.forEach((amount) => {
result[article.code][amount.company_id] = amount.amount;
});
});
return result;
}, [data?.articles]);
const columns: ColumnsType<MatrixResCell> = useMemo(() => {
if (!data?.companies) return [];
const firstColumn: ColumnsType<MatrixResCell>[number] = {
title: 'Статья',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 250,
};
const companyColumns = data.companies.map((company) => ({
title: company.name,
dataIndex: company.id,
key: company.id,
width: 150,
align: 'right' as const,
render: (_: unknown, row: MatrixResCell) => {
const formatted = chessTableCellFormatValue(matrixData[row.code]?.[company.id], units, decimals);
return formatted || DASH;
},
}));
const totalColumn: ColumnsType<MatrixResCell>[number] = {
title: 'Итого',
dataIndex: 'group_total',
key: 'group_total',
width: 150,
align: 'right',
render: (val: string) => <Text strong>{chessTableCellFormatValue(val, units, decimals) || DASH}</Text>,
};
return [firstColumn, ...companyColumns, totalColumn];
}, [data?.companies, matrixData, decimals, units]);
const scrollY = isFullscreen ? 'calc(100vh - 180px)' : 436;
return (
<Table<MatrixResCell>
className='chessTable'
dataSource={data?.articles || []}
columns={columns}
rowKey='code'
pagination={false}
loading={loading}
scroll={{ x: 'max-content', y: scrollY }}
size='medium'
bordered
title={() => toolbar}
summary={() => {
if (!data?.articles) return null;
return (
<Table.Summary fixed>
<Table.Summary.Row className='resRow'>
<Table.Summary.Cell className='resRow' index={0} align='left'>
<div>
<Text strong>Итого</Text>
</div>
</Table.Summary.Cell>
{data.companies.map((company, index) => {
const amountsToSum: string[] = [];
data.articles.forEach((article) => {
const amount = matrixData[article.code]?.[company.id];
if (amount) amountsToSum.push(amount);
});
// сумму, которую не удалось сложить, не выдумываем нулём — ячейка покажет «—»
const colTotal = sumAmounts(amountsToSum) ?? '';
return (
<Table.Summary.Cell key={company.id} index={index + 1} align='right'>
<Text strong>{chessTableCellFormatValue(colTotal, units, decimals) || DASH}</Text>
</Table.Summary.Cell>
);
})}
<Table.Summary.Cell index={data.companies.length + 1} align='right'>
<Text strong>
{chessTableCellFormatValue(sumAmounts(data.articles.map((a) => a.group_total)) ?? '', units, decimals) || DASH}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
);
}}
/>
);
};

View File

@ -0,0 +1,18 @@
.chessTable .empty-cell {
background-color: #f2f4f7;
}
.chessTable .ant-table-tbody > tr:hover > td.empty-cell {
background-color: #f2f4f7 !important;
}
.chessTable .ant-table-summary {
border-end-end-radius: 8px !important;
border-end-start-radius: 8px !important;
}
.chessTable .ant-table-cell-fix {
background-color: #f8fafc !important;
}
.chessTable .resRow {
background-color: #e6f4ea !important ;
}

View File

@ -0,0 +1,158 @@
import { WarningOutlined } from '@ant-design/icons';
import { Table, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type React from 'react';
import { useMemo } from 'react';
import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../../types/api';
import './ConsolidatedChessTable.css';
import { chessTableCellFormatValue } from '../../utils/chessTableCellFormatValue';
import { sumAmounts } from '../../utils/money';
const { Text } = Typography;
interface ConsolidationChessTableI {
data: IntercompanyMatrixResponse | null;
loading: boolean;
decimals: number;
units: string;
toolbar?: React.ReactNode;
isFullscreen?: boolean;
}
type TotalRow = {
id: string;
name: string;
[key: string]: string; // Здесь будут храниться суммы по ID компаний
};
export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
data,
loading,
decimals,
units,
toolbar,
isFullscreen = false,
}) => {
const matrixData = useMemo(() => {
if (!data?.cells) return {};
const result: Record<string, Record<string, MatrixCell>> = {};
data.cells.forEach((cell) => {
if (!result[cell.company_a_id]) result[cell.company_a_id] = {};
result[cell.company_a_id][cell.company_b_id] = cell;
});
return result;
}, [data?.cells]);
const columns: ColumnsType<MatrixCompany> = useMemo(() => {
if (!data?.companies) return [];
const firstColumn: ColumnsType<MatrixCompany>[number] = {
title: 'ДБ / КТ',
dataIndex: 'name',
key: 'name',
fixed: 'left',
width: 200,
};
const companyColumns = data.companies.map((company) => ({
title: company.name,
dataIndex: company.id,
key: company.id,
width: 150,
align: 'right' as const,
onCell: (row: MatrixCompany) => {
const cell = matrixData[row.id]?.[company.id];
if (!cell) return { className: 'empty-cell' };
return {};
},
render: (_: unknown, row: MatrixCompany) => {
const cell = matrixData[row.id]?.[company.id];
if (!cell) return <span style={{ color: '#bfbfbf' }}>—</span>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 13 }}>
<span>
{cell.skipped_osv_entry_count > 0 && (
<Tooltip title={`Строк ОСВ без сопоставленного счёта: ${cell.skipped_osv_entry_count}; в суммы не вошли`}>
<WarningOutlined style={{ color: '#d46b08', marginRight: 4 }} />
</Tooltip>
)}
{chessTableCellFormatValue(cell.amount_a, units, decimals)}
</span>
<span>{chessTableCellFormatValue(cell.amount_b, units, decimals)}</span>
{/* подсветка как в API и xlsx: порог > 5 % или пара без встречной стороны (AURORA-1651) */}
<span style={{ color: cell.exceeds_threshold || cell.one_sided ? 'red' : 'inherit' }}>
({chessTableCellFormatValue(cell.variance_amount, units, decimals)})
</span>
</div>
);
},
}));
return [firstColumn, ...companyColumns];
}, [data?.companies, matrixData, decimals, units]);
const totalRowData = useMemo(() => {
if (!data?.companies) return null;
const totalRow: TotalRow = {
id: 'total-row',
name: 'Итого',
};
data.companies.forEach((colCompany) => {
const amountsToSum: string[] = [];
data.companies.forEach((rowCompany) => {
const cell = matrixData[rowCompany.id]?.[colCompany.id];
if (cell?.amount_a) {
amountsToSum.push(cell.amount_a);
}
});
// сумму, которую не удалось сложить, не выдумываем нулём — ячейка покажет «—»
totalRow[colCompany.id] = sumAmounts(amountsToSum) ?? '';
});
return totalRow as MatrixCompany;
}, [data?.companies, matrixData]);
const scrollY = isFullscreen ? 'calc(100vh - 150px)' : 436;
return (
<Table<MatrixCompany>
className='chessTable'
dataSource={data?.companies || []}
columns={columns}
rowKey='id'
pagination={false}
loading={loading}
scroll={{ x: 'max-content', y: scrollY }}
size='small'
bordered
title={() => toolbar}
summary={() => {
if (!totalRowData) return null;
return (
<Table.Summary fixed>
<Table.Summary.Row className='resRow'>
<Table.Summary.Cell className='resRow' index={0} align='left'>
<div style={{ backgroundColor: '#e6f4ea', width: '100%', height: '100%' }}>
<Text strong>Итого</Text>
</div>
</Table.Summary.Cell>
{data?.companies.map((company, index) => (
<Table.Summary.Cell className='resRow' key={company.id} index={index + 1} align='right'>
<div>
<Text strong>{chessTableCellFormatValue((totalRowData as TotalRow)[company.id], units, decimals) || '—'}</Text>
</div>
</Table.Summary.Cell>
))}
</Table.Summary.Row>
</Table.Summary>
);
}}
/>
);
};

View File

@ -0,0 +1,7 @@
/* Теги выбранных значений в селектах фильтра шахматки */
.custom-select-selection-item .ant-select-selection-item {
background-color: #ecfdf3 !important;
color: #1f5f35 !important;
box-shadow: inset 0 0 0 1px #2ea64b38 !important;
border-radius: 999px !important;
}

View File

@ -0,0 +1,230 @@
import { Button, Select, Space } from 'antd';
import type React from 'react';
import { useEffect, useState } from 'react';
import { ApiError, api } from '../../api/client';
import { type OptionType, TreeFilterDropdown } from '../../components/TreeFilterDropdown/TreeFilterDropdown';
import type { Account, MatrixCompany, Quarter } from '../../types/api';
import { quarterShortLabel } from '../../utils/labels';
import { useNotificationApi } from '../../utils/useNotification';
import './ConsolidationChessTableFilter.css';
interface ConsolidationChessTableFilterI {
companies: string[];
onCompaniesChange: (val: string[]) => void;
debit: string[];
onDebitChange: (val: string[]) => void;
credit: string[];
onCreditChange: (val: string[]) => void;
allCompanies: MatrixCompany[];
/** Год и квартал версии — только для показа: версия задаётся адресом. */
year?: number;
quarter?: Quarter;
}
const buildTree = (items: Account[]): OptionType[] => {
const map: Record<string, OptionType> = {};
const roots: OptionType[] = [];
items.forEach((item) => {
map[item.id] = {
title: `${item.code} - ${item.name}`,
key: item.id,
code: item.code,
children: [],
};
});
items.forEach((item) => {
if (item.parent_id && map[item.parent_id]) {
map[item.parent_id].children?.push(map[item.id]);
} else {
roots.push(map[item.id]);
}
});
const cleanUp = (nodes: OptionType[]): OptionType[] => {
return nodes.map((node) => {
if (node.children && node.children.length > 0) {
return { ...node, children: cleanUp(node.children) };
}
return { title: node.title, key: node.key, code: node.code };
});
};
return cleanUp(roots);
};
export const ConsolidationChessTableFilter: React.FC<ConsolidationChessTableFilterI> = ({
companies,
onCompaniesChange,
debit,
onDebitChange,
credit,
onCreditChange,
allCompanies,
year,
quarter,
}) => {
const [companyOpen, setCompanyOpen] = useState(false);
const [debitOpen, setDebitOpen] = useState(false);
const [creditOpen, setCreditOpen] = useState(false);
const [optionsLoading, setOptionsLoading] = useState(false);
const [debitTreeData, setDebitTreeData] = useState<OptionType[]>([]);
const [creditTreeData, setCreditTreeData] = useState<OptionType[]>([]);
const notification = useNotificationApi();
const companyOptions = allCompanies.map((c) => ({ label: c.name, value: c.id }));
const companyTreeData = allCompanies.map((c) => ({ title: c.name, key: c.id }));
const flattenTree = (nodes: OptionType[]): { label: string; value: string }[] => {
let result: { label: string; value: string }[] = [];
nodes.forEach((node) => {
result.push({ label: String(node.code), value: String(node.key) });
if (node.children) {
result = [...result, ...flattenTree(node.children)];
}
});
return result;
};
function dropFiltersHandler() {
onCompaniesChange([]);
onDebitChange([]);
onCreditChange([]);
}
useEffect(() => {
const loadAccounts = async () => {
setOptionsLoading(true);
try {
const res = await api.get<Account[]>('/directories/accounts');
setDebitTreeData(buildTree(res.filter((acc) => acc.is_debit)));
setCreditTreeData(buildTree(res.filter((acc) => acc.is_credit)));
} catch (e) {
notification.error({ description: e instanceof ApiError ? e.message : 'Не удалось загрузить счета' });
} finally {
setOptionsLoading(false);
}
};
loadAccounts();
}, [notification]);
const warningBlock = <span style={{ color: '#d46b08', fontSize: 13 }}>Выберите счета по Дт и Кт для фильтрации</span>;
return (
<Space size='middle' wrap>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Год</span>
<Select
disabled
style={{ minWidth: 100 }}
value={year ?? null}
options={year ? [{ value: year, label: String(year) }] : []}
variant='underlined'
/>
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Период</span>
<Select
disabled
style={{ minWidth: 100 }}
value={quarter ?? null}
options={quarter ? [{ value: quarter, label: quarterShortLabel(quarter) }] : []}
variant='underlined'
/>
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Компании</span>
<Select
mode='multiple'
style={{ minWidth: 200 }}
placeholder='Все компании'
options={companyOptions}
value={companies}
onChange={onCompaniesChange}
maxTagCount='responsive'
open={companyOpen}
onOpenChange={setCompanyOpen}
variant='underlined'
showSearch={false}
popupMatchSelectWidth={false}
className='custom-select-selection-item'
popupRender={() => (
<TreeFilterDropdown
title='Компании'
placeholder='Введите название компании'
treeData={companyTreeData}
selectedKeys={companies}
onChange={onCompaniesChange}
onClose={() => setCompanyOpen(false)}
/>
)}
/>
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Дт</span>
<Select
mode='multiple'
style={{ minWidth: 150 }}
placeholder='Все счета'
value={debit}
onChange={onDebitChange}
maxTagCount='responsive'
variant='underlined'
open={debitOpen}
onOpenChange={setDebitOpen}
showSearch={false}
popupMatchSelectWidth={false}
options={flattenTree(debitTreeData)}
className='custom-select-selection-item'
popupRender={() => (
<TreeFilterDropdown
title='Дебетовые счета'
placeholder='Введите номер или название счета'
treeData={debitTreeData}
selectedKeys={debit}
onChange={onDebitChange}
onClose={() => setDebitOpen(false)}
warningBlock={warningBlock}
/>
)}
loading={optionsLoading}
/>
</Space>
<Space orientation='vertical' size={0}>
<span style={{ fontSize: 12, color: '#667085' }}>Кт</span>
<Select
mode='multiple'
style={{ minWidth: 150 }}
placeholder='Все счета'
value={credit}
onChange={onCreditChange}
maxTagCount='responsive'
variant='underlined'
open={creditOpen}
onOpenChange={setCreditOpen}
showSearch={false}
popupMatchSelectWidth={false}
options={flattenTree(creditTreeData)}
className='custom-select-selection-item'
popupRender={() => (
<TreeFilterDropdown
title='Кредитовые счета'
placeholder='Введите номер или название счета'
treeData={creditTreeData}
selectedKeys={credit}
onChange={onCreditChange}
onClose={() => setCreditOpen(false)}
warningBlock={warningBlock}
/>
)}
loading={optionsLoading}
/>
</Space>
{companies?.length || debit?.length || credit?.length ? <Button onClick={() => dropFiltersHandler()}>Сбросить фильтры</Button> : null}
</Space>
);
};

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"lib": ["DOM", "ES2020"],
"lib": ["DOM", "ES2020", "ES2023.Intl"],
"jsx": "react-jsx",
"target": "ES2020",
"noEmit": true,