Merge origin/main в AURORA-1589: шапка, «Единицы» и [31-00] — в страницу отчёта с вкладками #158 (ревью #166)
Конфликты после #158 (страница отчёта с вкладками по адресу): - layouts/ConsolidationReportPage: пояснение статуса, тег, «Изменить статус» и модалка [33-01] из ConsolidatedOsvPage; - widgets/ConsolidatedOsvContent: «Единицы / Знаков» и onUnmappedChange (unmapped_total наверх); - OsvTabs: вкладки по адресу из main + модалка [31-00] при уходе с «ОСВ»; - types/api.ts: обе части. Спека consolidations-ui: решение (ц), Known issues 11 и 15 (lucide-react в зеркале есть), tasks 2.5, 2.8; ARCHITECTURE.md и web/CLAUDE.md — новая страница отчёта. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
36224dd81c
@ -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 на экране прежние данные, выгрузка недоступна.
|
||||
@ -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`; меню по правам не скрывается.
|
||||
@ -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** выпадающее окно видно поверх полноэкранного блока
|
||||
@ -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.
|
||||
@ -4,7 +4,7 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
|
||||
список консолидаций ГК [34] (`widgets/ConsoleTable/`), модалку «Взять в работу» и перехват [35]
|
||||
(`widgets/ConsoleTable/LockModal.tsx`), панель «Организации» (`widgets/ConsoleLegalEntities/`), модалку «Новая
|
||||
консолидация» [42] (`widgets/NewConsoleModal/`, строка ЮЛ — `CompanyOptionRow.tsx`), кнопку «Изменить статус» в шапке
|
||||
карточки версии [30] (`layouts/ConsolidatedOsvPage.tsx`) и модалку [33-01] (`widgets/StatusChangeModal/ChangeStatusModal.tsx`).
|
||||
карточки версии [30] (`layouts/ConsolidationReportPage.tsx` — общая страница отчёта с вкладками после #158) и модалку [33-01] (`widgets/StatusChangeModal/ChangeStatusModal.tsx`).
|
||||
Бэкенд — спека `consolidations-api` (список с `in_work_only`, «Взять в работу, перехват, снятие», «Статусы Среза 4»); API не
|
||||
меняется. В ветку добавлена вторая библиотека иконок `lucide-react` (иконки макета: меню «⋯», «Взять в работу», «К
|
||||
отчётности», боковое меню).
|
||||
@ -70,16 +70,15 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
|
||||
обрезки), «К3» через `quarterShortLabel`, опечатки исправлены, ФИО с инициалами без второй точки (`endSentence`).
|
||||
10. ~~Пояснение под заголовком~~ — закрыто: `CONS_STATUS_DESCRIPTION` / `consStatusDescription` (тексты спеки; нет текста —
|
||||
пусто, название статуса не повторяется).
|
||||
11. ~~Модалка [31-00]~~ — закрыто: `UnmappedInnModal` и проверка в `OsvTabs` (`needsUnmappedWarning`). Вкладки шагов
|
||||
пока неактивны, поэтому на стенде модалка проверена на временной копии с активной «Шахматкой» (решение (м)).
|
||||
11. ~~Модалка [31-00]~~ — закрыто: `UnmappedInnModal` и проверка в `OsvTabs` (`needsUnmappedWarning`); после влива
|
||||
main (#158) срабатывает при уходе с «ОСВ» на «Шахматку» (решение (ц)).
|
||||
12. ~~«+ Создать» при одном ЮЛ~~ — закрыто: `MIN_PERIMETER = 2`, кнопка неактивна, у поля — «Выберите не меньше двух ЮЛ».
|
||||
13. ~~Модалка «Изменить статус»: загрузка без обработки ошибок, `comment.trim()` на `undefined`~~ — закрыто: модалка
|
||||
карточку не грузит, получает её со страницы (`card`), после 200 страница берёт карточку из ответа (D-4);
|
||||
`canSubmitStatusChange` принимает `undefined`; ошибки — текст сервера, модалка открыта.
|
||||
14. ~~Тестов нет~~ — закрыто: `web/src/utils/consolidations.test.ts`, `formatScaledAmount` в `web/src/utils/money.test.ts`.
|
||||
15. **`lucide-react@0.263.1` в зеркале npm банка не проверен** (открыто). Пакет в `dependencies`, снимок контура его
|
||||
оставляет (`docs/dev/contour-transfer-guide.md` §3.2); если версии нет в Nexus, сборка в контуре упадёт. Вопрос автору
|
||||
PR / платформе.
|
||||
15. ~~`lucide-react@0.263.1` в зеркале npm банка не проверен~~ — закрыто 23.10.2026: автор PR подтвердил владельцу, что
|
||||
пакет в зеркале есть. Пакет в `dependencies`, снимок контура его оставляет (`docs/dev/contour-transfer-guide.md` §3.2).
|
||||
16. **Предупреждение браузера о несохранённых правках** — частично: сделано для трёх модалок экранов консолидаций
|
||||
(требование «Предупреждение о несохранённых правках в модалках консолидаций», решение (л)). Открыто: нужно ли оно в
|
||||
остальных формах приложения (справочники, проверка документов) и при переходе по меню вне модалок (у `BrowserRouter`
|
||||
@ -129,7 +128,7 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
|
||||
модалке»; модалки этого PR — понятное место, маска модалки и так закрывает меню; формы других экранов и переход по
|
||||
меню вне модалок требуют решения владельца (Known issues 16). Цена ошибки: если предупреждение нужно и в других формах —
|
||||
подключить тот же хук; если не нужно вовсе — убрать три вызова.
|
||||
- **2026-10-23 (м). Модалка [31-00] — в `OsvTabs` с параметрами `onNavigate` / `enabledTabs`**; пока шагов нет, вкладки
|
||||
- **2026-10-23 (м). Модалка [31-00] — в `OsvTabs` с параметрами `onNavigate` / `enabledTabs`** (заменено решением (ц)); пока шагов нет, вкладки
|
||||
неактивны и модалка не вызывается, на стенде она проверена на временной копии с активной «Шахматкой». Обоснование: PR
|
||||
шахматки (#158) только передаёт переход, модалку не пишет. Цена ошибки: подключение вкладки — две строки.
|
||||
- **2026-10-23 (н). Ошибки в модалках — текст сервера** (`ApiError.message`, по-русски) вместо карты фронтовых текстов;
|
||||
@ -156,3 +155,10 @@ PR RSHB/arm-core#166 (ветка `AURORA-1589`, автор — Степанов,
|
||||
из `widgets/ConsoleTable/consts.ts` (там остались цвета бейджей). Цена ошибки: низкая.
|
||||
- **2026-10-23 (х). Отступ секций бокового меню — `padding: 12px 0`** (у автора закомментирован `12px`): по вертикали —
|
||||
как в макете [34], по горизонтали пункты остаются на одной линии с кнопкой «Новая консолидация». Цена ошибки: CSS.
|
||||
- **2026-10-23 (ц). Влив main после #158 — перенос в страницу отчёта с вкладками** (решение владельца: «вливаем main и
|
||||
переносим сами»). #158 заменил `layouts/ConsolidatedOsvPage.tsx` на `layouts/ConsolidationReportPage.tsx` (шапка + вкладки
|
||||
по адресу `/versions/:version_id/:tab`) и `widgets/ConsolidatedOsvContent/` (содержимое вкладки «ОСВ»). Шапка этого PR
|
||||
(пояснение статуса, тег, «Изменить статус» и модалка [33-01]) перенесена в `ConsolidationReportPage`, «Единицы / Знаков»
|
||||
— в `OsvContent`. Модалка [31-00] встроена в вкладки по адресу: `OsvTabs({ unmappedTotal })` спрашивает только при уходе
|
||||
с «ОСВ», а `OsvContent` сообщает странице `unmapped_total` сводки через `onUnmappedChange` (при уходе с вкладки — 0);
|
||||
параметры `onNavigate` / `enabledTabs` из (м) не нужны. Цена ошибки: если [31-00] нужна и на «Назад» — это решение (д).
|
||||
|
||||
@ -63,7 +63,8 @@
|
||||
- `web/src/widgets/ConsoleTable/` (`ConsoleTable.tsx`, `LockModal.tsx`, `config.tsx`, `consts.ts`, `flatten.ts`),
|
||||
`web/src/widgets/StatusChangeModal/ChangeStatusModal.tsx`, `web/src/widgets/NewConsoleModal/`
|
||||
(`NewConsoleModal.tsx`, `CompanyOptionRow.tsx`), `web/src/widgets/ConsoleLegalEntities/`,
|
||||
`web/src/layouts/ConsolidatedOsvPage.tsx`, `web/src/layouts/ConsolidationsList.tsx`,
|
||||
`web/src/layouts/ConsolidationReportPage.tsx` (шапка, после влива #158), `web/src/widgets/ConsolidatedOsvContent/`
|
||||
(«Единицы»), `web/src/layouts/ConsolidationsList.tsx`,
|
||||
`web/src/services/api/consolidations.ts`, `web/src/store/slices/consolidations/thunks.ts`, `web/src/types/`,
|
||||
`web/src/widgets/Sidebar/` (иконки), `web/package.json` (`lucide-react`).
|
||||
- Права: действия требуют `CONSOLIDATION_EDIT` на сервере; меню по правам не скрывается.
|
||||
|
||||
@ -19,8 +19,9 @@
|
||||
`parseInWorkOnly`, `canTakeInWork`, `needsUnmappedWarning`, `endSentence`).
|
||||
- [x] 2.4 Предупреждение браузера о несохранённых правках — в модалках экранов консолидаций (решение (л)); остальные
|
||||
формы — вопрос владельцу (Known issues 16).
|
||||
- [ ] 2.5 Проверить наличие `lucide-react@0.263.1` в зеркале npm банка (Known issues 15) или заменить иконки на
|
||||
`@ant-design/icons` — вопрос автору PR.
|
||||
- [x] 2.5 Проверить наличие `lucide-react@0.263.1` в зеркале npm банка (Known issues 15) — есть, подтвердил автор PR.
|
||||
- [x] 2.8 Влив main после #158: шапка, «Единицы» и [31-00] перенесены в страницу отчёта с вкладками (решение (ц));
|
||||
`tsc`, `biome`, `npm run lint`, `vitest`, `make web-check` — зелёные; сверка на стенде.
|
||||
- [x] 2.6 Проверки в копии ветки с `lucide-react`: `tsc --noEmit`, `biome check`, `prettier --check`, `eslint` (0 ошибок,
|
||||
предупреждений меньше, чем на main), `vitest run`, `npm run build` — зелёные.
|
||||
- [x] 2.7 Сверка на стенде: список [34] (флажок, «Версия», «⋯»), [35] (свободная версия, перехват и гонка — с подменой
|
||||
|
||||
184
openspec/specs/intercompany-matrix-ui/spec.md
Normal file
184
openspec/specs/intercompany-matrix-ui/spec.md
Normal file
@ -0,0 +1,184 @@
|
||||
# intercompany-matrix-ui Specification
|
||||
|
||||
## Purpose
|
||||
Аналитик видит шахматку ВГО версии [31-01] (матрица Дт/Кт по компаниям с фильтрами компаний и счетов, единицами и полноэкранным режимом) и результирующую таблицу ВГО [31-01-01], выгружает обе в xlsx; суммы и итоги приходят с бэка, фронт их только форматирует.
|
||||
|
||||
## 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** выпадающее окно видно поверх полноэкранного блока
|
||||
@ -35,7 +35,7 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне
|
||||
| `../hooks/useBeforeUnload.ts` | — | предупреждение браузера о несохранённых правках в открытой модалке |
|
||||
| `ConsoleLegalEntities/` | [34] | панель «Организации» выбранной версии и чек-лист |
|
||||
| `NewConsoleModal/` (`CompanyOptionRow.tsx`) | [42] | «Новая консолидация»; строка ЮЛ с ИНН отдельной колонкой |
|
||||
| `StatusChangeModal/ChangeStatusModal.tsx` | [33-01] | «Изменить статус» (`POST …/status`); открывается из шапки `layouts/ConsolidatedOsvPage.tsx` |
|
||||
| `StatusChangeModal/ChangeStatusModal.tsx` | [33-01] | «Изменить статус» (`POST …/status`); открывается из шапки `layouts/ConsolidationReportPage.tsx` |
|
||||
|
||||
## Границы
|
||||
- Сетевые запросы — только через `src/api/client.ts`; `fetch` напрямую не вызывается.
|
||||
|
||||
@ -49,7 +49,7 @@ arm-web перенесены в arm-core #150–#157 (arm-web#7 → #151), сп
|
||||
- `src/auth/` — `AuthGate` (профиль `/me`), `DevLogin` (`POST /dev/token`, только не в production), `token.ts`.
|
||||
- `src/store/` — Redux Toolkit: `store.ts`, слайсы `companyGroupsSlice` и `consolidations` (+ `thunks.ts`).
|
||||
- `src/layouts/` — страницы (маршруты в `src/App.tsx`, в т.ч. `/documents/:document_id` и
|
||||
`/groups/:group_id/versions/:version_id/osv`), `src/widgets/` — блоки экранов (`DocumentReview` — проверка ББ/ОФР/ОСВ
|
||||
`/groups/:group_id/versions/:version_id/:tab` — страница отчёта с вкладками `osv`, `chess`), `src/widgets/` — блоки экранов (`DocumentReview` — проверка ББ/ОФР/ОСВ
|
||||
со сканом, `ConsolidatedOsv` — сводная ОСВ, `FilesDrawer` — файлы ЮЛ, `ConsoleTable` — список консолидаций ГК и
|
||||
«Взять в работу», `StatusChangeModal` — «Изменить статус» версии), `src/components/` — примитивы
|
||||
(`GenericTable`, `TableTag`, `PageTitle`), `src/types/` — DTO API.
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
@ -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) };
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
.filterTree .ant-tree-switcher-noop {
|
||||
display: none !important;
|
||||
}
|
||||
152
web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx
Normal file
152
web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -1,206 +0,0 @@
|
||||
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Select, Space, Tooltip, message } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { ApiError } from '../api/client';
|
||||
import { useCurrentUser } from '../auth/MeContext';
|
||||
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 { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations';
|
||||
import { exportFilename, saveBlob } from '../utils/download';
|
||||
import { quarterShortLabel } from '../utils/labels';
|
||||
import { statusColorMapping } 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 { PRECISIONS, UNITS, useNumberDisplay } from '../widgets/ConsolidatedOsv/useNumberDisplay';
|
||||
import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
|
||||
|
||||
function OsvContent({ versionId }: { versionId: string }) {
|
||||
const [card, setCard] = useState<VersionCard | null>(null);
|
||||
const [statusModalOpen, setModalOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
|
||||
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
|
||||
const osv = useConsolidatedOsv(versionId);
|
||||
const currentUser = useCurrentUser();
|
||||
const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
|
||||
|
||||
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 download = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const { blob, filename } = await exportOsv(versionId, 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 : 'Не удалось выгрузить свод');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const s = osv.summary;
|
||||
const unmapped = s?.unmapped_total ?? 0;
|
||||
const onlyUnmapped = osv.filters.unmapped === true;
|
||||
const russianStatus = card ? (CONS_STATUS_LABEL[card.status] ?? card.status) : '';
|
||||
// под заголовком — пояснение статуса, не его название
|
||||
const description = card ? consStatusDescription(card.status) : '';
|
||||
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
|
||||
const statusChangeAllowed = card ? canChangeStatus(card.status, card.lock, currentUser.id) : false;
|
||||
const ownLock = card ? isLockedBy(card.lock, currentUser.id) : false;
|
||||
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={description}
|
||||
extra={
|
||||
card && (
|
||||
<Space size={8} style={{ display: 'flex', gap: 4 }}>
|
||||
<span style={{ color: '#667085', fontSize: 12 }}>Статус отчёта</span>
|
||||
<TableTag
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
width: 158,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
}}
|
||||
text={russianStatus}
|
||||
{...preset}
|
||||
/>
|
||||
{/* активна при своей блокировке и наличии ручных переходов из текущего статуса */}
|
||||
<Tooltip title={ownLock ? undefined : 'Возьмите консолидацию в работу'}>
|
||||
<Button
|
||||
size='middle'
|
||||
style={{ fontSize: 12 }}
|
||||
type='default'
|
||||
disabled={!statusChangeAllowed}
|
||||
onClick={() => setModalOpen(true)}>
|
||||
Изменить статус
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<OsvTabs unmappedTotal={unmapped} />
|
||||
<main
|
||||
style={{
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
background: 'white',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#667085' }}>Единицы</div>
|
||||
<Select variant='underlined' value={unit} onChange={setUnit} options={UNITS} style={{ width: 140 }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#667085' }}>Знаков после запятой</div>
|
||||
<Select
|
||||
variant='underlined'
|
||||
options={PRECISIONS.map((i) => ({ label: i, value: i }))}
|
||||
value={precision}
|
||||
onChange={(v) => setPrecision(v ?? 2)}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={download}>
|
||||
Экспорт
|
||||
</Button>
|
||||
</div>
|
||||
{osv.error && <Alert type='error' showIcon title={osv.error} />}
|
||||
{unmapped > 0 && (
|
||||
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
flexWrap: 'wrap',
|
||||
background: '#fffaeb',
|
||||
border: '1px solid #fedf89',
|
||||
borderRadius: 12,
|
||||
padding: '10px 12px',
|
||||
color: '#93370d',
|
||||
fontSize: 13,
|
||||
}}>
|
||||
<strong style={{ color: '#7a2e0e' }}>Требуется указать соответствия</strong>
|
||||
<span>Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}.</span>
|
||||
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
|
||||
<Button
|
||||
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => osv.patchFilters({ unmapped: !onlyUnmapped })}>
|
||||
{onlyUnmapped ? 'Показать все строки' : 'Показать ЮЛ без ИНН'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<OsvGaps gaps={s?.gaps ?? []} />
|
||||
<OsvTable
|
||||
rows={osv.rows}
|
||||
total={osv.total}
|
||||
page={osv.page}
|
||||
pageSize={OSV_PAGE_SIZE}
|
||||
loading={osv.loading}
|
||||
filters={osv.filters}
|
||||
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
|
||||
onPage={osv.setPage}
|
||||
numFormat={format}
|
||||
onFilter={osv.patchFilters}
|
||||
onMap={setMapping}
|
||||
/>
|
||||
</main>
|
||||
{mapping && (
|
||||
<CounterpartyMapModal
|
||||
key={mapEntryId(mapping)}
|
||||
versionId={versionId}
|
||||
row={mapping}
|
||||
onClose={() => setMapping(null)}
|
||||
onMapped={osv.refresh}
|
||||
/>
|
||||
)}
|
||||
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConsolidatedOsvPage() {
|
||||
const { version_id = '' } = useParams();
|
||||
// key: при смене версии всё состояние (фильтры, страница, данные) создаётся заново
|
||||
return <OsvContent key={version_id} versionId={version_id} />;
|
||||
}
|
||||
123
web/src/layouts/ConsolidationReportPage.tsx
Normal file
123
web/src/layouts/ConsolidationReportPage.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { Button, Space, Tooltip, message } from 'antd';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { ApiError } from '../api/client';
|
||||
import { useCurrentUser } from '../auth/MeContext';
|
||||
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 { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations';
|
||||
import { quarterShortLabel } from '../utils/labels';
|
||||
import { statusColorMapping } from '../widgets/ConsoleTable/consts';
|
||||
import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent';
|
||||
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
|
||||
import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent';
|
||||
import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
|
||||
|
||||
function ReportContent() {
|
||||
const [card, setCard] = useState<VersionCard | null>(null);
|
||||
const [statusModalOpen, setModalOpen] = useState(false);
|
||||
// unmapped_total сводки ОСВ — для [31-00] при уходе с вкладки «ОСВ»
|
||||
const [unmappedTotal, setUnmappedTotal] = useState(0);
|
||||
const currentUser = useCurrentUser();
|
||||
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} onUnmappedChange={setUnmappedTotal} />,
|
||||
|
||||
chess: <ChessContent card={card} />,
|
||||
};
|
||||
|
||||
const ActiveTab = TAB_COMPONENTS[tab as string] ?? null;
|
||||
|
||||
const russianStatus = card ? (CONS_STATUS_LABEL[card.status] ?? card.status) : '';
|
||||
// под заголовком — пояснение статуса, не его название
|
||||
const description = card ? consStatusDescription(card.status) : '';
|
||||
const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default;
|
||||
const statusChangeAllowed = card ? canChangeStatus(card.status, card.lock, currentUser.id) : false;
|
||||
const ownLock = card ? isLockedBy(card.lock, currentUser.id) : false;
|
||||
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={description}
|
||||
extra={
|
||||
card && (
|
||||
<Space size={8} style={{ display: 'flex', gap: 4 }}>
|
||||
<span style={{ color: '#667085', fontSize: 12 }}>Статус отчёта</span>
|
||||
<TableTag
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
width: 158,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 11,
|
||||
}}
|
||||
text={russianStatus}
|
||||
{...preset}
|
||||
/>
|
||||
{/* активна при своей блокировке и наличии ручных переходов из текущего статуса */}
|
||||
<Tooltip title={ownLock ? undefined : 'Возьмите консолидацию в работу'}>
|
||||
<Button
|
||||
size='middle'
|
||||
style={{ fontSize: 12 }}
|
||||
type='default'
|
||||
disabled={!statusChangeAllowed}
|
||||
onClick={() => setModalOpen(true)}>
|
||||
Изменить статус
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<OsvTabs unmappedTotal={unmappedTotal} />
|
||||
</div>
|
||||
{ActiveTab}
|
||||
{card && <ChangeStatusModal open={statusModalOpen} card={card} onSuccess={setCard} onClose={() => setModalOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConsolidationReportPage() {
|
||||
const { version_id = '' } = useParams<{ version_id: string }>();
|
||||
// key: при смене версии всё состояние (карточка, фильтры, страница, данные) создаётся заново
|
||||
return <ReportContent key={version_id} />;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
19
web/src/services/api/intercompanyMatrix.ts
Normal file
19
web/src/services/api/intercompanyMatrix.ts
Normal 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`);
|
||||
@ -401,3 +401,62 @@ export interface VersionStatusUpdate {
|
||||
to_status: ConsStatus;
|
||||
comment: 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[];
|
||||
}
|
||||
|
||||
52
web/src/utils/chessTableCellFormatValue.test.ts
Normal file
52
web/src/utils/chessTableCellFormatValue.test.ts
Normal 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)');
|
||||
});
|
||||
});
|
||||
34
web/src/utils/chessTableCellFormatValue.ts
Normal file
34
web/src/utils/chessTableCellFormatValue.ts
Normal 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;
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 файлом через временную ссылку. */
|
||||
|
||||
45
web/src/utils/treeChecks.test.ts
Normal file
45
web/src/utils/treeChecks.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
44
web/src/utils/treeChecks.ts
Normal file
44
web/src/utils/treeChecks.ts
Normal 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);
|
||||
};
|
||||
@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,66 +1,110 @@
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Button, Modal, Segmented, Tooltip } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { needsUnmappedWarning } from '../../utils/consolidations';
|
||||
|
||||
const SOON = 'Будет в следующих срезах';
|
||||
const OSV = 'ОСВ';
|
||||
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: 'Будет в следующих срезах',
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
/** `unmapped_total` сводки: больше нуля — при уходе с «ОСВ» показывается модалка [31-00]. */
|
||||
unmappedTotal: number;
|
||||
/** Переход на вкладку шага. Пока шагов нет — вкладки неактивны, переход не задан (шахматка — PR #158). */
|
||||
onNavigate?: (tab: string) => void;
|
||||
/** Вкладки, по которым уже можно перейти (остальные — «Будет в следующих срезах»). */
|
||||
enabledTabs?: readonly string[];
|
||||
/** `unmapped_total` сводки ОСВ: больше нуля — уход с «ОСВ» через модалку [31-00]. */
|
||||
unmappedTotal?: number;
|
||||
};
|
||||
|
||||
/** Ряд вкладок шагов консолидации (макет [30]): «ОСВ» — текущая; уход с неё при несопоставленных строках — через [31-00]. */
|
||||
export function OsvTabs({ unmappedTotal, onNavigate, enabledTabs = [] }: Props) {
|
||||
/** Ряд вкладок шагов консолидации (макет [30]); вкладка — сегмент адреса `/versions/:version_id/:tab`. */
|
||||
export function OsvTabs({ unmappedTotal = 0 }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { tab } = useParams<{ tab?: string }>();
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
|
||||
const select = (tab: string) => {
|
||||
if (tab === OSV || !onNavigate) return;
|
||||
if (needsUnmappedWarning(unmappedTotal)) {
|
||||
setPending(tab);
|
||||
const activeKey = tab || 'osv';
|
||||
|
||||
const go = (newKey: string) => {
|
||||
const newPath = location.pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`);
|
||||
navigate(newPath);
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string | number) => {
|
||||
const newKey = String(value);
|
||||
if (newKey === activeKey) return;
|
||||
if (activeKey === 'osv' && needsUnmappedWarning(unmappedTotal)) {
|
||||
setPending(newKey);
|
||||
} else {
|
||||
onNavigate(tab);
|
||||
go(newKey);
|
||||
}
|
||||
};
|
||||
|
||||
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={OSV}
|
||||
onChange={(v) => select(String(v))}
|
||||
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto', overflowY: 'hidden' }}
|
||||
options={[
|
||||
{ value: OSV, label: <span style={{ color: '#2a8c3a', fontWeight: 600 }}>{OSV}</span> },
|
||||
...TABS.map((t) => {
|
||||
const enabled = enabledTabs.includes(t) && !!onNavigate;
|
||||
return {
|
||||
value: t,
|
||||
disabled: !enabled,
|
||||
label: enabled ? (
|
||||
t
|
||||
) : (
|
||||
<Tooltip title={SOON}>
|
||||
<span>{t}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
};
|
||||
}),
|
||||
]}
|
||||
value={activeKey}
|
||||
onChange={handleTabChange}
|
||||
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
|
||||
options={options}
|
||||
/>
|
||||
<UnmappedInnModal
|
||||
open={pending !== null}
|
||||
unmappedTotal={unmappedTotal}
|
||||
onStay={() => setPending(null)}
|
||||
onGo={() => {
|
||||
const tab = pending;
|
||||
const next = pending;
|
||||
setPending(null);
|
||||
if (tab && onNavigate) onNavigate(tab);
|
||||
if (next) go(next);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -0,0 +1,144 @@
|
||||
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, Select, message } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
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';
|
||||
import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
|
||||
|
||||
type Props = {
|
||||
card: VersionCard | null;
|
||||
/** Сообщает странице отчёта `unmapped_total` сводки — для модалки [31-00] при уходе с вкладки «ОСВ». */
|
||||
onUnmappedChange?: (total: number) => void;
|
||||
};
|
||||
|
||||
export function OsvContent({ card, onUnmappedChange }: Props) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const { version_id } = useParams<{ version_id: string }>();
|
||||
|
||||
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
|
||||
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
|
||||
const osv = useConsolidatedOsv(version_id ?? '');
|
||||
const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
|
||||
|
||||
const download = async () => {
|
||||
if (!version_id) {
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
try {
|
||||
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 : 'Не удалось выгрузить свод');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const s = osv.summary;
|
||||
const unmapped = s?.unmapped_total ?? 0;
|
||||
const onlyUnmapped = osv.filters.unmapped === true;
|
||||
|
||||
useEffect(() => {
|
||||
onUnmappedChange?.(unmapped);
|
||||
}, [unmapped, onUnmappedChange]);
|
||||
// вне вкладки «ОСВ» предупреждать не о чем
|
||||
useEffect(() => () => onUnmappedChange?.(0), [onUnmappedChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<main
|
||||
style={{
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
background: 'white',
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#667085' }}>Единицы</div>
|
||||
<Select variant='underlined' value={unit} onChange={setUnit} options={UNITS} style={{ width: 140 }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#667085' }}>Знаков после запятой</div>
|
||||
<Select
|
||||
variant='underlined'
|
||||
options={PRECISIONS.map((i) => ({ label: i, value: i }))}
|
||||
value={precision}
|
||||
onChange={(v) => setPrecision(v ?? 2)}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={download}>
|
||||
Экспорт
|
||||
</Button>
|
||||
</div>
|
||||
{osv.error && <Alert type='error' showIcon title={osv.error} />}
|
||||
{unmapped > 0 && (
|
||||
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
flexWrap: 'wrap',
|
||||
background: '#fffaeb',
|
||||
border: '1px solid #fedf89',
|
||||
borderRadius: 12,
|
||||
padding: '10px 12px',
|
||||
color: '#93370d',
|
||||
fontSize: 13,
|
||||
}}>
|
||||
<strong style={{ color: '#7a2e0e' }}>Требуется указать соответствия</strong>
|
||||
<span>Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}.</span>
|
||||
{/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */}
|
||||
<Button
|
||||
style={{ marginLeft: 'auto', minHeight: 30, borderColor: '#f4b740', background: '#fff', color: '#93370d' }}
|
||||
icon={<FilterOutlined />}
|
||||
onClick={() => osv.patchFilters({ unmapped: !onlyUnmapped })}>
|
||||
{onlyUnmapped ? 'Показать все строки' : 'Показать ЮЛ без ИНН'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<OsvGaps gaps={s?.gaps ?? []} />
|
||||
<OsvTable
|
||||
rows={osv.rows}
|
||||
total={osv.total}
|
||||
page={osv.page}
|
||||
pageSize={OSV_PAGE_SIZE}
|
||||
loading={osv.loading}
|
||||
filters={osv.filters}
|
||||
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
|
||||
onPage={osv.setPage}
|
||||
numFormat={format}
|
||||
onFilter={osv.patchFilters}
|
||||
onMap={setMapping}
|
||||
/>
|
||||
</main>
|
||||
{mapping && version_id && (
|
||||
<CounterpartyMapModal
|
||||
key={mapEntryId(mapping)}
|
||||
versionId={version_id}
|
||||
row={mapping}
|
||||
onClose={() => setMapping(null)}
|
||||
onMapped={osv.refresh}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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 ;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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 ;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "ES2020"],
|
||||
"lib": ["DOM", "ES2020", "ES2023.Intl"],
|
||||
"jsx": "react-jsx",
|
||||
"target": "ES2020",
|
||||
"noEmit": true,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user