diff --git a/openspec/changes/intercompany-matrix-ui/design.md b/openspec/changes/intercompany-matrix-ui/design.md new file mode 100644 index 0000000..52b451c --- /dev/null +++ b/openspec/changes/intercompany-matrix-ui/design.md @@ -0,0 +1,97 @@ +## Context + +Экран «Шахматка» [31-01] добавлен в `arm-core/web` PR #158 (ветка `feat/chessTable`, AURORA-1538) как вторая вкладка страницы +отчёта по версии. Страница `layouts/ConsolidatedOsvPage.tsx` разделена: общую шапку и вкладки держит +`layouts/ConsolidationReportPage.tsx`, содержимое вкладок — `widgets/ConsolidatedOsvContent` (прежняя сводная ОСВ без изменений +поведения) и `widgets/ConsolidatedChessContent` (матрица `ConsolidationChessTable` и результирующая таблица +`ConsolidationChessResTable`). Бэкенд — change `2026-10-16-intercompany-matrix` (матрица, результирующая таблица) и +`2026-10-22-intercompany-result-table-export` (выгрузка результирующей таблицы); расчёт — чистая функция версии, ничего не +хранится. + +## Decisions + +- **D-1. Маршрут `:tab`.** `OsvTabs` берёт активную вкладку из `useParams().tab` (по умолчанию `osv`) и по выбору заменяет + последний сегмент адреса. Неизвестный `tab` заменяется на `osv` (`navigate(…, {replace: true})`). +- **D-2. Ключ версии.** Страница отчёта разделена на обёртку с `key={version_id}` и содержимое: при смене версии карточка, + фильтры и данные создаются заново (как было в `ConsolidatedOsvPage`). +- **D-3. Режим расчёта из выбора счетов:** `manual` — при непустых Дт и Кт, `all` — при пустых обоих; при одной выбранной + стороне запроса нет (бэкенд ответил бы 422 на `manual`, а `all` показал бы не то, что выбрано). Тот же признак выбирает + маршрут экспорта. +- **D-4. Единицы, знаки и фильтр компаний — на клиенте.** Запрос повторяется только при смене выбора счетов или версии. + Единицы — сдвиг запятой в десятичной строке (`utils/chessTableCellFormatValue.ts`), `Intl.NumberFormat` получает строку и + форматирует её как точное десятичное (без float). +- **D-5. План счетов** — `GET /directories/accounts` целиком, деревья Дт и Кт строятся на клиенте по `parent_id`. +- **D-6. `apiFetchBlob(path, init)`** принимает `RequestInit`, чтобы выгружать POST-ом с телом; без `init` поведение прежнее. +- **D-7. Полноэкранный режим** — Fullscreen API на контейнере блока, состояние следует за `fullscreenchange`; выпадающие окна + antd рендерятся в `document.fullscreenElement` (`ConfigProvider getPopupContainer`). +- **D-8. Итоги — задача бэкенда.** Решение владельца 22.10.2026: агрегаты (строки «Итого») должен отдавать API. Перенос — + технический долг: AURORA-1756 (итоги и правило суммирования в API, ждёт аналитику AURORA-1373) и AURORA-1757 (вывод итогов из + API). До этого строки «Итого» остаются клиентскими (решение владельца: «особо не мешает»): точное сложение строк через + `sumAmounts`, под матрицей — `amount_a` по колонке, под результирующей таблицей — все статьи по колонке; колонка «Итого» + результирующей таблицы — `group_total` из ответа. +- **D-9. `useNotificationApi` мемоизирован** (`useMemo` по `notification` из `App.useApp()`, который antd сам мемоизирует): + объект уведомлений стабилен между рендерами, поэтому `loadData`/`loadResData` — `useCallback([notification])`, а эффекты + загрузки зависят от них без лишних перезапросов. + +## Known issues + +Открытая проблема на момент слияния (решение владельца 23.10.2026 — сливать, остаток в техдолг AURORA-1799): + +1. Выбрана одна сторона Дт/Кт: «Экспорт» недоступен, но таблица показывает прежние данные, подсказка — всплывающее уведомление + и постоянный текст внутри выпадающего окна, текста у таблицы и у фильтров нет. Целевое поведение — решение (ж). + +Пункты раунда 3 про поиск в дереве счетов и стили закрыты ревьюером (решение (п)). + +## Decisions made without discussion + +- **2026-10-21 (а). Спеку пишет ревьюер в ветке автора** — по решению владельца (разрешено явно); альтернатива — оставить + автору. Цена ошибки: автор может не согласиться с формулировками; правится одним коммитом в этом же change. +- **2026-10-21 (б). Спека описывает фактическое поведение, дефекты вынесены в «Known issues».** Отменено 22.10.2026 (е). Обоснование: спека — единственный + источник истины при рулингах, а закреплять баги требованиями нельзя; иначе их закрытие превратится в «нарушение спеки». Цена + ошибки: отдельные требования (например, режим `all` при выборе одной стороны, «Итого» из расхождений) описаны как есть + и после исправления потребуют MODIFIED. +- **2026-10-21 (в). При восстановлении затёртого фронта** (коммит 175343b) из `origin/main` взяты целиком все файлы без + шахматочных изменений, а в `App.tsx`, `layouts/index.ts`, `api/client.ts` (параметр `init`), `OsvTabs.tsx`, + `ConsolidatedOsvContent.tsx` оставлены изменения шахматки. Возвращён `key={version_id}` (разделением страницы на обёртку и + содержимое — поведение как в main). Обоснование: изменения PR в этих файлах относятся к шахматке; цена ошибки — незамеченная + откатившаяся правка main в этих пяти файлах (построчно сверено с `origin/main`, расхождений кроме шахматочных нет). +- **2026-10-21 (г). Механические правки biome без смены поведения** (коммит e19291c): тип `IntercompanyMatrixResponse` у `let res`, + `Number.isNaN`, `key` у `span` в `OsvTabs`, `?? null` вместо пустого фрагмента. Предупреждения eslint (8 новых в коде шахматки: + `any`, deps хуков, `console`) не исправлялись — они входят в Known issues. Цена ошибки: `key` на `span` внутри `Tooltip` + безвреден, но избыточен. +- **2026-10-21 (д). Дельта к `consolidated-osv-ui` не делается:** вкладки описаны в новой спеке, текст + `consolidated-osv-ui` не правился, чтобы не смешивать UI-половины. Цена ошибки: при архивации две спеки частично описывают + один компонент (`OsvTabs`). +- **2026-10-22 (е). Спека переписана на целевое поведение** (раунд 3 ревью): владелец решил, что спеки и тесты чужих PR ведём + мы, а автор правит продуктовый код; целевая спека даёт автору однозначный ориентир, расхождения кода — в «Known issues». + Цена ошибки: до исправлений спека и код ветки расходятся; PR не сливается, пока «Known issues» не закрыты. +- **2026-10-22 (ж). Выбрана одна сторона Дт/Кт — пустая таблица с текстом** «Для расчёта по счетам выберите счета и в Дт, + и в Кт» у таблицы и у фильтров, «Экспорт» матрицы недоступен. Альтернатива — оставить прежние данные с подсказкой; отвергнута: + на экране были бы данные, не соответствующие фильтру. Цена ошибки: правка текста и одного условия. +- **2026-10-22 (з). Правило отметок дерева по полному плану счетов** (родитель отмечен ⇔ отмечены все потомки), в том числе при + поиске. Обоснование: бэкенд разворачивает выбранный счёт в потомков, лишняя отметка родителя меняет расчёт. Цена ошибки: если + аналитик захочет «родитель + выбранные субсчета» как отдельный режим — MODIFIED требования. +- **2026-10-22 (и). Запасные имена файлов** `Шахматка_<ГК>_<год>_<квартал>.xlsx` и + `Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx`. Цена ошибки: низкая, имя из `Content-Disposition` приоритетнее. +- **2026-10-22 (к). Тесты форматтера** `utils/chessTableCellFormatValue.test.ts` написаны ревьюером: сдвиг единиц, точное + округление, большие суммы, «-0,00», пустые и нечисловые значения при любых единицах. +- **2026-10-22 (л). В ветку влит `main`** (PR #159), чтобы выгрузка результирующей таблицы работала на стенде из ветки. +- **2026-10-22 (м). Желательные пункты ревью закрыты ревьюером** (решение владельца): форматтер (проверка числа до сдвига + запятой, `Intl.StringNumericLiteral` через `ES2023.Intl` в `lib` tsconfig вместо `as any`, без мёртвого разбора скобок), + запасные имена (`utils/download.xlsxFilename`), отдельные флаги выгрузки, зависимости эффектов (D-9), «—» вместо выдуманного + нуля, ячейки по макету, «Год»/«Период» версии только для чтения, сервис `services/api/intercompanyMatrix.ts` с `withJsonBody`, + типы шахматки в `types/api.ts`, `Account.kind: 'A' | 'P' | 'AP' | null`. Цена ошибки: конфликты слияния у автора в + `ConsolidatedChessContent.tsx` и `ConsolidationChessTableFilter.tsx` — он правит те же файлы по «Known issues»; автору сказано + начать с `git pull`. +- **2026-10-22 (н). «-0,00» убирается проверкой готовой строки, а не опцией `signDisplay: 'negative'`.** Обоснование: на движке + без Intl.NumberFormat v3 неизвестное значение `signDisplay` бросает `RangeError` и ломает весь экран, а проверка строки + безопасна везде. Цена ошибки: нет. +- **2026-10-22 (о). Расхождение в скобках всегда**, в том числе нулевое — `(0,00)`: в API `variance_amount` — модуль разности + (неотрицательный), скобки в макете 31-01 — способ показа третьей строки, а не знак. Значок пропущенных строк ОСВ — оранжевый + `WarningOutlined` у первого числа. Подпись «Период» (не «Квартал») — как в макете. Цена ошибки: правка вида ячейки. +- **2026-10-23 (п). Остаток раунда 3 закрыт ревьюером, пункт про одну сторону Дт/Кт — в техдолг** (решение владельца: «доправим + сами, потом сольём»). Отметки дерева — чистая функция `utils/treeChecks.toggleTreeCheck` (правило (з) снизу вверх по полному + дереву на всех уровнях; скрытые поиском отметки берутся из состояния выпадашки, а не из применённого фильтра — иначе снятые + отметки возвращались), тесты `utils/treeChecks.test.ts`. Правило тегов из `App.css` перенесено в + `ConsolidationChessTableFilter.css`, класс с формы ЮЛ снят. Пустая таблица с подсказкой при одной стороне — AURORA-1799. + Цена ошибки: при одной стороне до AURORA-1799 на экране прежние данные, выгрузка недоступна. diff --git a/openspec/changes/intercompany-matrix-ui/proposal.md b/openspec/changes/intercompany-matrix-ui/proposal.md new file mode 100644 index 0000000..4bb57d4 --- /dev/null +++ b/openspec/changes/intercompany-matrix-ui/proposal.md @@ -0,0 +1,49 @@ +## Why + +В ветке `feat/chessTable` (PR RSHB/arm-core#158, автор — Голов) во фронте появился экран «Шахматка» — вкладка страницы +отчёта по версии консолидации рядом со «Сводной ОСВ». Бэкенд шахматки (`GET/POST …/intercompany-matrix/*`) уже в main +(change `2026-10-16-intercompany-matrix`), у экрана спеки нет. Спеку и тесты ведёт ревьюер в ветке автора по решению +владельца (21–22.10.2026): спека задаёт целевое поведение экрана, расхождения кода ветки с ней — `design.md`, «Known issues». + +## What Changes + +Репозиторий — только arm-core (`web/`). Экран «Шахматка» [31-01], эндпоинты arm-core: +`GET …/intercompany-matrix/all`, `POST …/intercompany-matrix/manual`, `GET …/intercompany-matrix/all/export`, +`POST …/intercompany-matrix/manual/export`, `GET …/intercompany-matrix/result-table`, +`GET …/intercompany-matrix/result-table/export`, `GET /directories/accounts`. + +- Маршрут `/groups/:group_id/versions/:version_id/:tab`: страница отчёта (`ConsolidationReportPage`) с общей шапкой и + вкладками (`OsvTabs`); `tab = osv` — сводная ОСВ, `tab = chess` — шахматка. Прежний адрес `…/osv` продолжает работать. +- Таблица ЮЛ × ЮЛ: в ячейке три числа (сумма A, сумма Б, расхождение); итоги считает бэкенд (пока API их не отдаёт — + строки «Итого» нет). +- Результирующая таблица ВГО по статьям с колонкой «Итого» (`group_total`) и выгрузкой в XLSX. +- Фильтры: компании, счета Дт, счета Кт (деревом), единицы, знаков после запятой; режимы расчёта `all` / `manual`. +- Экспорт в XLSX (`all` / `manual`, результирующая таблица), полноэкранный режим. +- Новая спека `intercompany-matrix-ui` (ADDED Requirements). + +## Non-goals + +- Исправление продуктового кода по «Known issues» (`design.md`) — за автором PR; спеку и тесты ведёт ревьюер. +- Пресеты счетов — во фронте не реализованы. +- Итоги «Итого» в API и их вывод — AURORA-1756, AURORA-1757. +- Выбор года и квартала на экране: селекторы заглушены (`disabled`), версия задаётся адресом. +- Изменения бэкенда и DBML: шахматка — чистая функция от версии, ничего не хранится (DECISION-5). +- Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий», «Подтверждение КФО клиента» — недоступны. + +## Capabilities + +### New Capabilities +- `intercompany-matrix-ui`: экран «Шахматка» версии консолидации. + +### Modified Capabilities + +Нет: `consolidated-osv-ui` не правится (вкладки описаны в новой спеке, см. design.md). + +## Impact + +- `web/src/App.tsx`, `web/src/layouts/{ConsolidationReportPage,index}.ts(x)`, `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx`, + `web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedChessContent/`, + `web/src/widgets/ConsolidationChessTable/`, `web/src/widgets/ConsolidationChessResTable/`, + `web/src/widgets/ConsolidationChessFormatTableFilter/`, `web/src/components/TreeFilterDropdown/`, + `web/src/utils/chessTableCellFormatValue.ts`, `web/src/services/consodilation/intercompanyMatrix.ts`, `web/src/types/chess/chess.ts`, `web/src/api/client.ts` (`apiFetchBlob` принимает `init`). +- Права: экран читает данные под `CONSOLIDATION_VIEW`; меню по правам не скрывается. diff --git a/openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md b/openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md new file mode 100644 index 0000000..cb2b870 --- /dev/null +++ b/openspec/changes/intercompany-matrix-ui/specs/intercompany-matrix-ui/spec.md @@ -0,0 +1,179 @@ +## ADDED Requirements + +### Requirement: Вкладки страницы отчёта по версии +Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз +и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`: +`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка». Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки +(навигация, не локальное состояние), активная вкладка подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL +заменяться на `osv` без новой записи в истории браузера. Вкладки «Таблица корректировки», «Гудвилл», «КФО», «КФО Общий», +«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё +состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново. + +#### Scenario: Переход на шахматку +- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка» +- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка + +#### Scenario: Прежний адрес сводной ОСВ +- **WHEN** открыт адрес `…/versions/{id}/osv` +- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна + +#### Scenario: Неизвестная вкладка +- **WHEN** открыт адрес `…/versions/{id}/abc` +- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc` + +### Requirement: Таблица шахматки ЮЛ × ЮЛ +Экран SHALL загружать матрицу при открытии вкладки (`GET /consolidations/versions/{id}/intercompany-matrix/all`) и показывать +таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`companies[]`), остальные колонки — +те же компании. В ячейке пары (строка A, колонка Б) SHALL быть три числа друг под другом: сумма `amount_a`, сумма `amount_b` +и расхождение `variance_amount` в скобках; расхождение выделяется красным, если `exceeds_threshold = true` или +`one_sided = true` (пара без встречной стороны; правило подсветки — как в API и xlsx, AURORA-1651). Если у пары есть +строки ОСВ без сопоставленного счёта (`skipped_osv_entry_count > 0`), рядом с первым числом SHALL показываться значок +предупреждения с подсказкой «Строк ОСВ без сопоставленного счёта: N; в суммы не вошли». Если ячейки пары в ответе нет +(в том числе на диагонали), SHALL показываться прочерк «—» на приглушённом фоне. Ниже таблицы закреплена строка «Итого»: до +появления итогов в API (AURORA-1756, AURORA-1757) она временно считается на клиенте точным сложением десятичных строк — сумма +`amount_a` по колонке видимых ячеек; итог, который не удалось сложить, показывается прочерком «—», а не нулём. Пока идёт +загрузка, таблица показывает индикатор загрузки; ошибка API показывается уведомлением с текстом сервера. Панель фильтров +показывает год и квартал версии неактивными полями «Год» и «Период» (например, `2024` и `К1`): версия задаётся адресом. + +#### Scenario: Ячейка с расхождением выше порога +- **WHEN** для пары `(A, Б)` сервер вернул `exceeds_threshold = true` +- **THEN** третье число ячейки (расхождение) показано красным, первые два — обычным цветом + +#### Scenario: Пара без встречной стороны +- **WHEN** для пары `(A, Б)` сервер вернул `amount_b = 0`, `one_sided = true`, `exceeds_threshold = false` +- **THEN** третье число ячейки (расхождение) показано красным + +#### Scenario: Пары нет в ответе +- **WHEN** в `cells[]` нет ячейки для пары компаний +- **THEN** на их пересечении показан прочерк «—» + +#### Scenario: Пропущенные строки ОСВ +- **WHEN** у ячейки `skipped_osv_entry_count = 2` +- **THEN** у первого числа ячейки виден значок предупреждения, подсказка — «Строк ОСВ без сопоставленного счёта: 2; в суммы не вошли» + +### Requirement: Результирующая таблица ВГО +Под матрицей экран SHALL показывать результирующую таблицу ВГО, загруженную при открытии вкладки +(`GET /consolidations/versions/{id}/intercompany-matrix/result-table`): первая колонка «Статья» — статьи ответа (`articles[]`, +`name`) в порядке ответа, далее колонки компаний (`companies[]`) с суммой статьи по компании (`amounts[]` по `company_id`) и +колонка «Итого» со значением `group_total` из ответа; пустая сумма — прочерк «—». Ниже таблицы закреплена строка «Итого»: до +появления итогов в API (AURORA-1756, AURORA-1757) она временно считается на клиенте точным сложением сумм всех статей по +колонке (итог, который не удалось сложить, — «—»). У таблицы свои выбор единиц и знаков (по правилам требования «Единицы и знаки», независимо от +матрицы), кнопка «Экспорт» и кнопка «На весь экран». Пока идёт загрузка, таблица показывает индикатор; ошибка API (в том числе +422 `RESULT_ACCOUNTS_MISSING`) показывается уведомлением с текстом сервера. + +#### Scenario: Колонка «Итого» из ответа +- **WHEN** сервер вернул статью «Дебиторская задолженность» с `group_total = "1296510.00"`, выбраны «тыс. руб.» и 2 знака +- **THEN** в колонке «Итого» этой статьи показано `1 296,51` + +#### Scenario: Нет обязательного счёта +- **WHEN** сервер ответил 422 `RESULT_ACCOUNTS_MISSING` +- **THEN** показано уведомление с текстом сервера, матрица шахматки при этом показывается как обычно + +### Requirement: Единицы и знаки +Экран SHALL позволять выбрать единицы суммы — «руб.», «тыс. руб.» (по умолчанию), «млн. руб.», «млрд руб.» — и число знаков +после запятой 0–3 (по умолчанию 2). Выбор применяется на клиенте к уже загруженным значениям (повторного запроса не делает): +запятая сдвигается в десятичной строке на 3 / 6 / 9 разрядов, значение округляется до выбранного числа знаков (половина — +от нуля) без перевода в число с плавающей точкой и показывается с разделителем разрядов и десятичной запятой. Значение, +округлённое до нуля, показывается без минуса. Пустое значение — пустая ячейка. Значение, которое не удаётся прочитать как +число, SHALL показываться как пришло — при любых единицах. + +#### Scenario: Смена единиц +- **WHEN** выбраны «млн. руб.» и 1 знак, а сервер вернул `amount_a = "2346459.05"` +- **THEN** в ячейке показано `2,3` + +#### Scenario: Точное округление +- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"1005"` +- **THEN** показано `1,01` (не `1,00`, как дало бы округление числа с плавающей точкой) + +#### Scenario: Маленькая отрицательная сумма +- **WHEN** выбраны «тыс. руб.» и 2 знака, а сервер вернул `"-4.00"` +- **THEN** показано `0,00`, а не `-0,00` + +### Requirement: Фильтр компаний +Выбор «Компании» (множественный, пустой выбор — «Все компании») SHALL ограничивать матрицу на клиенте: остаются строки и +колонки выбранных компаний и ячейки, у которых обе компании выбраны. Запрос на сервер при этом не отправляется. Список +вариантов — компании последнего загруженного ответа. Выпадающее окно выбора — дерево с поиском по названию, кнопками +применения и сброса. + +#### Scenario: Две компании из периметра +- **WHEN** выбраны две компании из пяти +- **THEN** матрица содержит две строки и две колонки, «Итого» считается по оставшимся ячейкам + +### Requirement: Фильтр счетов Дт и Кт и режимы расчёта +Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии +`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название», в том числе у выбранных +дочерних счетов в поле выбора. Выбор счетов Дт и Кт — множественный, пустой выбор — «Все счета». + +Отметки в дереве SHALL считаться по полному плану счетов: родительский счёт отмечен тогда и только тогда, когда отмечены все +его потомки в полном дереве. Поиск по дереву скрывает не совпавшие узлы, но не меняет этого правила: отметка единственного +видимого при поиске субсчёта не отмечает его родителя, а снятие отметки с субсчёта снимает её и с родителя. Скрытые поиском +отметки при отметке видимых узлов сохраняются. + +Режим расчёта определяется выбором: +- ничего не выбрано ни в Дт, ни в Кт — SHALL выполняться `GET …/intercompany-matrix/all` (режим `all`); +- выбраны счета и в Дт, и в Кт — SHALL выполняться `POST …/intercompany-matrix/manual` с `{debit_account_ids, + credit_account_ids}` (режим `manual`); +- выбрана только одна сторона — запрос SHALL NOT отправляться, таблица матрицы SHALL быть пустой и вместо данных показывать + текст «Для расчёта по счетам выберите счета и в Дт, и в Кт»; тот же текст SHALL показываться у фильтров Дт/Кт (не + всплывающим уведомлением) и только в этом состоянии; кнопка «Экспорт» матрицы SHALL быть недоступна. + +Каждое изменение выбора счетов, приводящее к режиму `all` или `manual`, перезапрашивает матрицу; на экране остаётся ответ на +последний запрос, индикатор загрузки снимается и при переходе в состояние «выбрана одна сторона». Кнопка «Сбросить фильтры» +появляется, когда выбрано что-либо в компаниях, Дт или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не +сбрасывается. Ошибка загрузки плана счетов показывается уведомлением. + +#### Scenario: Обе стороны выбраны +- **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт +- **THEN** отправляется `POST …/intercompany-matrix/manual` с идентификаторами этих счетов и таблица показывает ответ + +#### Scenario: Выбрана одна сторона +- **WHEN** выбран счёт только в Дт +- **THEN** запрос матрицы не отправляется, таблица пуста и показывает «Для расчёта по счетам выберите счета и в Дт, и в Кт», + тот же текст виден у фильтров, «Экспорт» матрицы недоступен, выбор Дт остаётся в фильтре + +#### Scenario: Выбрана одна сторона во время загрузки +- **WHEN** выбраны Дт и Кт, запрос `manual` ещё выполняется, и пользователь очищает Кт +- **THEN** индикатор загрузки снят, ответ прежнего запроса на экран не выводится, таблица в состоянии «выбрана одна сторона» + +#### Scenario: Поиск и отметка субсчёта +- **WHEN** в дереве Дт введён поиск, видна ветка 60 с единственным субсчётом 60.02, и отмечен 60.02 +- **THEN** выбранным остаётся только 60.02; после сброса поиска счёт 60 и его прочие субсчета не отмечены + +#### Scenario: Снятие субсчёта отмеченного родителя +- **WHEN** отмечен счёт 60 (отмечены 60, 60.01, 60.02) и снята отметка с 60.01 +- **THEN** выбранным остаётся 60.02, счёт 60 не отмечен + +#### Scenario: Сброс +- **WHEN** выбраны компании и счета, нажато «Сбросить фильтры» +- **THEN** все три выбора пусты, матрица перезапрошена в режиме `all`, кнопка исчезла + +### Requirement: Экспорт в XLSX +Кнопка «Экспорт» матрицы SHALL скачивать XLSX в том же режиме, что и таблица: в режиме `manual` — +`POST …/intercompany-matrix/manual/export` с телом `{debit_account_ids, credit_account_ids}`, в режиме `all` — +`GET …/intercompany-matrix/all/export`; при выбранной одной стороне кнопка недоступна. Кнопка «Экспорт» результирующей таблицы +SHALL скачивать `GET …/intercompany-matrix/result-table/export`. Файл сохраняется под именем из `Content-Disposition`, при его +отсутствии — `Шахматка_<ГК>_<год>_<квартал>.xlsx` и `Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx` соответственно. +Пока идёт выгрузка, в состоянии загрузки только нажатая кнопка; ошибка API показывается уведомлением. Фильтр компаний, единицы и +знаки в запрос экспорта не передаются. + +#### Scenario: Экспорт по всем счетам +- **WHEN** счета не выбраны и нажато «Экспорт» матрицы +- **THEN** отправлен `GET …/intercompany-matrix/all/export`, браузер сохраняет файл `intercompany-matrix_….xlsx` + +#### Scenario: Экспорт результирующей таблицы +- **WHEN** нажато «Экспорт» результирующей таблицы +- **THEN** отправлен `GET …/intercompany-matrix/result-table/export`, кнопка «Экспорт» матрицы не переходит в состояние загрузки + +### Requirement: Полноэкранный режим +Кнопка «На весь экран» матрицы и результирующей таблицы SHALL переводить свой блок (панель фильтров и таблицу) в +полноэкранный режим средствами Fullscreen API с белым фоном и таблицей на высоту окна; в нём кнопка меняется на «Свернуть». +Выпадающие списки фильтров и подсказки SHALL оставаться видимыми в полноэкранном режиме. Выход клавишей Esc SHALL возвращать +обычный вид и подпись кнопки. Отказ браузера войти в полноэкранный режим показывается уведомлением. + +#### Scenario: Вход и выход +- **WHEN** нажато «На весь экран», затем Esc +- **THEN** блок разворачивается на весь экран, затем возвращается в страницу, кнопка снова «На весь экран» + +#### Scenario: Фильтр в полноэкранном режиме +- **WHEN** матрица в полноэкранном режиме и открыт выбор «Компании» +- **THEN** выпадающее окно видно поверх полноэкранного блока diff --git a/openspec/changes/intercompany-matrix-ui/tasks.md b/openspec/changes/intercompany-matrix-ui/tasks.md new file mode 100644 index 0000000..bc82b5f --- /dev/null +++ b/openspec/changes/intercompany-matrix-ui/tasks.md @@ -0,0 +1,13 @@ +## 1. Спека и ревью (RSHB/arm-core#158, AURORA-1538) + +- [x] 1.1 Спека `intercompany-matrix-ui` (ревьюер; с раунда 3 — целевое поведение). +- [x] 1.2 Тесты форматтера сумм `utils/chessTableCellFormatValue.test.ts` (ревьюер). +- [x] 1.3 Желательные пункты ревью раунда 3 (ревьюер, решение (м) в `design.md`). +- [x] 1.4 «Known issues» раунда 3: автор (6b956f7, 79d4008) и ревьюер (решение (п)); пункт про одну сторону Дт/Кт — 2.2. +- [x] 1.5 Перед слиянием: `make web-check`, `npm test`, `npm run lint` — зелёные, вывод в описание PR. +- [x] 1.6 Сверка экрана с макетом 31-01 / 31-01-01 в браузере (`docs/mockup/INDEX.md`), в том числе полноэкранный режим. + +## 2. После слияния + +- [ ] 2.1 Итоги «Итого» из API вместо клиентских — AURORA-1756 (бэк), AURORA-1757 (фронт). +- [ ] 2.2 Одна сторона Дт/Кт — пустая таблица с подсказкой, без уведомления — AURORA-1799. diff --git a/web/src/App.tsx b/web/src/App.tsx index 24faf7a..871c730 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,7 +10,6 @@ import { AuditLogging, CatalogueGroup, CatalogueJur, - ConsolidatedOsvPage, ConsolidationPage, DocStorage, DocumentPage, @@ -18,6 +17,7 @@ import { Main, Mono, } from './layouts/'; +import { ConsolidationReportPage } from './layouts/ConsolidationReportPage'; import { store } from './store/store'; import { Sidebar } from './widgets/Sidebar/Sidebar'; @@ -37,7 +37,7 @@ function App() { } /> } /> - } /> + } /> } /> } /> diff --git a/web/src/api/client.ts b/web/src/api/client.ts index e6522d2..2aa72d4 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -127,13 +127,19 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise } /** Бинарный ответ (скан, xlsx) с токеном и тем же разбором ошибок; имя — из Content-Disposition, если доступно. */ -export async function apiFetchBlob(path: string): Promise<{ blob: Blob; filename: string | null }> { - const response = await send(path, {}); +export async function apiFetchBlob( + path: string, + init: RequestInit = {}, // Добавили init +): Promise<{ blob: Blob; filename: string | null }> { + const response = await send(path, init); const blob = await response.blob(); - return { blob, filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')) }; + return { + blob, + filename: filenameFromContentDisposition(response.headers.get('Content-Disposition')), + }; } -function withJsonBody(method: string, body: unknown): RequestInit { +export function withJsonBody(method: string, body: unknown): RequestInit { return body === undefined ? { method } : { method, body: JSON.stringify(body) }; } diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css new file mode 100644 index 0000000..adf8ee1 --- /dev/null +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.css @@ -0,0 +1,3 @@ +.filterTree .ant-tree-switcher-noop { + display: none !important; +} diff --git a/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx new file mode 100644 index 0000000..5e5f680 --- /dev/null +++ b/web/src/components/TreeFilterDropdown/TreeFilterDropdown.tsx @@ -0,0 +1,152 @@ +import { SearchOutlined } from '@ant-design/icons'; +import { Button, Input, Space, Tree } from 'antd'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { toggleTreeCheck } from '../../utils/treeChecks'; +import './TreeFilterDropdown.css'; + +export type OptionType = { + title: string; + key: string; + code?: string; + children?: OptionType[]; +}; + +interface TreeFilterDropdownProps { + title: string; + placeholder?: string; + treeData: OptionType[]; + selectedKeys: string[]; + onChange: (keys: string[]) => void; + onClose: () => void; + warningBlock?: ReactNode; +} + +export const TreeFilterDropdown: React.FC = ({ + title, + placeholder = 'Введите значение', + treeData, + selectedKeys, + onChange, + onClose, + warningBlock, +}) => { + const [query, setQuery] = useState(''); + + const [checkedKeys, setCheckedKeys] = useState(selectedKeys); + const [expandedKeys, setExpandedKeys] = useState([]); + + const filteredTreeData = useMemo(() => { + if (!query) return treeData; + + const filterNode = (node: OptionType): OptionType | null => { + const titleStr = String(node.title).toLowerCase(); + const isMatch = titleStr.includes(query.toLowerCase()); + + let filteredChildren: OptionType[] = []; + if (node.children) { + filteredChildren = node.children.map(filterNode).filter((child): child is OptionType => child !== null); + } + + if (isMatch || filteredChildren.length > 0) { + return { + ...node, + children: filteredChildren.length > 0 ? filteredChildren : undefined, + }; + } + return null; + }; + + return treeData.map(filterNode).filter((node): node is OptionType => node !== null); + }, [treeData, query]); + + useEffect(() => { + if (query) { + const getAllKeys = (nodes: OptionType[]): string[] => { + let keys: string[] = []; + nodes.forEach((node) => { + keys.push(node.key); + if (node.children) keys = [...keys, ...getAllKeys(node.children)]; + }); + return keys; + }; + setExpandedKeys(getAllKeys(filteredTreeData)); + } + }, [query, filteredTreeData]); + + const getAllNodeKeys = (nodes: OptionType[]): string[] => { + let keys: string[] = []; + nodes.forEach((node) => { + keys.push(node.key); + if (node.children) keys = [...keys, ...getAllNodeKeys(node.children)]; + }); + return keys; + }; + + const allFilteredKeys = getAllNodeKeys(filteredTreeData); + const visibleCheckedKeys = checkedKeys.filter((key) => allFilteredKeys.includes(key)); + + const handleSelectAll = () => { + setCheckedKeys(getAllNodeKeys(treeData)); + }; + + const handleClear = () => { + setCheckedKeys([]); + }; + + const handleCancel = () => { + setCheckedKeys(selectedKeys); + onClose(); + }; + + function checkHandler(keys: string[], info: { node: OptionType; checked: boolean }) { + setCheckedKeys(toggleTreeCheck(treeData, checkedKeys, keys, info.node.key, info.checked)); + } + useEffect(() => { + setCheckedKeys(selectedKeys); + }, [selectedKeys]); + + return ( +
+
{title}
+ {warningBlock} +
Поиск
+ } + placeholder={placeholder} + value={query} + onChange={(e) => setQuery(e.target.value)} + style={{ marginBottom: 12 }} + /> + + + + + + +
+ checkHandler(keys as string[], test)} + expandedKeys={expandedKeys} + onExpand={(keys) => setExpandedKeys(keys as string[])} + /> +
+ +
+ + +
+
+ ); +}; diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx new file mode 100644 index 0000000..02d72b4 --- /dev/null +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -0,0 +1,89 @@ +import { Space, message } from 'antd'; +import { type ReactNode, useEffect, useState } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { ApiError } from '../api/client'; +import PageTitle from '../components/PageTitle'; +import { TableTag } from '../components/TableTag/TableTag'; +import { TAG_PRESETS } from '../components/TableTag/presets'; +import { getVersionCard } from '../services/api/consolidations'; +import type { ConsRussianStatus } from '../types/consolidations/common_types'; +import type { VersionCard } from '../types/consolidations/version_members'; +import { quarterShortLabel } from '../utils/labels'; +import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; +import { ChessContent } from '../widgets/ConsolidatedChessContent/ConsolidatedChessContent'; +import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; +import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent'; + +function ReportContent() { + const [card, setCard] = useState(null); + const { version_id, tab } = useParams<{ tab?: string; version_id: string }>(); + const navigation = useNavigate(); + const { pathname } = useLocation(); + const TAB_COMPONENTS: Record = { + osv: , + + chess: , + }; + + const ActiveTab = TAB_COMPONENTS[tab as string] ?? null; + + const russianStatus = card ? (statusMapping[card.status] ?? card.status) : ''; + const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default; + const groupName = card?.company_group.name ?? ''; + const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : ''; + + useEffect(() => { + if (version_id) { + let actual = true; + getVersionCard(version_id) + .then((c) => { + if (actual) setCard(c); + }) + .catch((e: unknown) => { + if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию'); + }); + return () => { + actual = false; + }; + } + }, [version_id]); + + useEffect(() => { + const isValidTab = !tab || tab === 'osv' || tab === 'chess'; + + if (!isValidTab) { + const pathParts = pathname.split('/'); + pathParts[pathParts.length - 1] = 'osv'; + const newPath = pathParts.join('/'); + navigation(newPath, { replace: true }); + } + }, [tab, navigation, pathname]); + return ( +
+
+ + Статус отчёта + + + ) + } + /> + +
+ {ActiveTab} +
+ ); +} + +export function ConsolidationReportPage() { + const { version_id = '' } = useParams<{ version_id: string }>(); + // key: при смене версии всё состояние (карточка, фильтры, страница, данные) создаётся заново + return ; +} diff --git a/web/src/layouts/index.ts b/web/src/layouts/index.ts index b006199..33cee41 100644 --- a/web/src/layouts/index.ts +++ b/web/src/layouts/index.ts @@ -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'; diff --git a/web/src/services/api/intercompanyMatrix.ts b/web/src/services/api/intercompanyMatrix.ts new file mode 100644 index 0000000..db626df --- /dev/null +++ b/web/src/services/api/intercompanyMatrix.ts @@ -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(`${base(versionId)}/all`); + +export const fetchChessConsolidationsManual = (versionId: string, data: ChessManualRequest) => + api.post(`${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(`${base(versionId)}/result-table`); + +export const exportChessConsolidationsResult = (versionId: string) => apiFetchBlob(`${base(versionId)}/result-table/export`); diff --git a/web/src/types/api.ts b/web/src/types/api.ts index c6e0850..232064d 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -394,3 +394,62 @@ export interface LegalEntityBrief { name: string; inn: string; } + +export interface Account { + id: string; + code: string; + parent_id: string | null; + name: string; + kind: 'A' | 'P' | 'AP' | null; + row_type: string; + is_off_balance: boolean; + is_debit: boolean; + is_credit: boolean; +} + +/** Шахматка ВГО (`IntercompanyMatrixOut`): компании периметра и направленные ячейки пар. */ +export interface MatrixCompany { + id: string; + name: string; +} + +export interface MatrixCell { + company_a_id: string; + company_b_id: string; + amount_a: string; + amount_b: string; + variance_amount: string; + exceeds_threshold: boolean; + one_sided: boolean; + variance_percent: string | null; + /** Строки ОСВ пары без сопоставленного счёта; в суммы не вошли. */ + skipped_osv_entry_count: number; +} + +export interface IntercompanyMatrixResponse { + companies: MatrixCompany[]; + cells: MatrixCell[]; +} + +export type ChessManualRequest = { + debit_account_ids: string[]; + credit_account_ids: string[]; +}; + +/** Результирующая таблица ВГО (`IntercompanyResultTableOut`): статьи §6 по компаниям. */ +export interface MatrixAmount { + company_id: string; + amount: string; +} + +export interface MatrixResCell { + code: string; + name: string; + group_total: string; + amounts: MatrixAmount[]; +} + +export interface IntercompanyMatrixResultResponse { + companies: MatrixCompany[]; + articles: MatrixResCell[]; +} diff --git a/web/src/utils/chessTableCellFormatValue.test.ts b/web/src/utils/chessTableCellFormatValue.test.ts new file mode 100644 index 0000000..0430ed8 --- /dev/null +++ b/web/src/utils/chessTableCellFormatValue.test.ts @@ -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)'); + }); +}); diff --git a/web/src/utils/chessTableCellFormatValue.ts b/web/src/utils/chessTableCellFormatValue.ts new file mode 100644 index 0000000..65d3bbf --- /dev/null +++ b/web/src/utils/chessTableCellFormatValue.ts @@ -0,0 +1,34 @@ +const UNIT_SHIFT: Record = { 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; +}; diff --git a/web/src/utils/download.test.ts b/web/src/utils/download.test.ts index 685e2fc..0eedc5d 100644 --- a/web/src/utils/download.test.ts +++ b/web/src/utils/download.test.ts @@ -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'); + }); +}); diff --git a/web/src/utils/download.ts b/web/src/utils/download.ts index e63f8d4..0dfb575 100644 --- a/web/src/utils/download.ts +++ b/web/src/utils/download.ts @@ -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 файлом через временную ссылку. */ diff --git a/web/src/utils/treeChecks.test.ts b/web/src/utils/treeChecks.test.ts new file mode 100644 index 0000000..3c1ec2f --- /dev/null +++ b/web/src/utils/treeChecks.test.ts @@ -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']); + }); +}); diff --git a/web/src/utils/treeChecks.ts b/web/src/utils/treeChecks.ts new file mode 100644 index 0000000..406695f --- /dev/null +++ b/web/src/utils/treeChecks.ts @@ -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) => { + 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); +}; diff --git a/web/src/utils/useNotification.tsx b/web/src/utils/useNotification.tsx index c2444e3..600077c 100644 --- a/web/src/utils/useNotification.tsx +++ b/web/src/utils/useNotification.tsx @@ -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], + ); } diff --git a/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx new file mode 100644 index 0000000..8e38d37 --- /dev/null +++ b/web/src/widgets/ConsolidatedChessContent/ConsolidatedChessContent.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [matrixExportLoading, setMatrixExportLoading] = useState(false); + const [resExportLoading, setResExportLoading] = useState(false); + const [companies, setCompanies] = useState([]); + const [decimals, setDecimals] = useState(2); + const [units, setUnits] = useState('thousand'); + const [debit, setDebit] = useState([]); + const [credit, setCredit] = useState([]); + + const [chessResData, setChessResData] = useState(null); + const [chessResUnits, setChessResUnits] = useState('thousand'); + const [chessResDecimals, setChessResDecimals] = useState(2); + const [chessResLoading, setChessResLoading] = useState(false); + + const chessTableRef = useRef(null); + const resTableRef = useRef(null); + const [isChessFullscreen, setIsChessFullscreen] = useState(false); + const [isResFullscreen, setIsResFullscreen] = useState(false); + + const toggleFullscreen = async (ref: React.RefObject, 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 ( + (document.fullscreenElement as HTMLElement) || document.body}> +
+
+ +
+ +
+ +
+
+ + +
+
+ } + /> +
+ +
+ +
+ +
+
+ + +
+
+ } + /> +
+ +
+ ); +}; diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 9597458..dc03aa3 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -1,26 +1,80 @@ import { Segmented, Tooltip } from 'antd'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; -const SOON = 'Будет в следующих срезах'; -const TABS = ['Шахматка', 'Таблица корректировки', 'Гудвилл', 'КФО', 'КФО Общий', 'Подтверждение КФО клиента']; +const TAB_CONFIG = [ + { key: 'osv', label: 'ОСВ' }, + { key: 'chess', label: 'Шахматка' }, + { + key: 'correction', + label: 'Таблица корректировки', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, + { + key: 'goodwill', + label: 'Гудвилл', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, + { + key: 'kfo', + label: 'КФО', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, + { + key: 'kfo_common', + label: 'КФО Общий', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, + { + key: 'kfo_confirm', + label: 'Подтверждение КФО клиента', + disabled: true, + tooltip: 'Будет в следующих срезах', + }, +]; -/** Ряд вкладок шагов консолидации (макет [30]): активна только «ОСВ». */ export function OsvTabs() { + const navigate = useNavigate(); + const location = useLocation(); + const { tab } = useParams<{ tab?: string }>(); + + const activeKey = tab || 'osv'; + + const handleTabChange = (newKey: string | number) => { + const newPath = location.pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`); + navigate(newPath); + }; + + const options = TAB_CONFIG.map((tabItem) => { + const isActive = tabItem.key === activeKey; + + const label = ( + + {tabItem.label} + + ); + + return { + value: tabItem.key, + disabled: tabItem.disabled, + label: tabItem.tooltip ? {label} : label, + }; + }); + return ( ОСВ }, - ...TABS.map((t) => ({ - value: t, - disabled: true, - label: ( - - {t} - - ), - })), - ]} + options={options} /> ); } diff --git a/web/src/layouts/ConsolidatedOsvPage.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx similarity index 50% rename from web/src/layouts/ConsolidatedOsvPage.tsx rename to web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index f9dcd0e..dca5fec 100644 --- a/web/src/layouts/ConsolidatedOsvPage.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -1,51 +1,33 @@ import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { Alert, Button, Space, message } from 'antd'; -import { useEffect, useState } from 'react'; +import { Alert, Button, message } from 'antd'; +import { useState } from 'react'; import { useParams } from 'react-router-dom'; -import { ApiError } from '../api/client'; -import PageTitle from '../components/PageTitle'; -import { TableTag } from '../components/TableTag/TableTag'; -import { TAG_PRESETS } from '../components/TableTag/presets'; -import { getVersionCard } from '../services/api/consolidations'; -import { exportOsv } from '../services/api/osv'; -import type { ConsolidatedRowOut } from '../types/api'; -import type { ConsRussianStatus } from '../types/consolidations/common_types'; -import type { VersionCard } from '../types/consolidations/version_members'; -import { exportFilename, saveBlob } from '../utils/download'; -import { quarterShortLabel } from '../utils/labels'; -import { statusColorMapping, statusMapping } from '../widgets/ConsoleTable/consts'; -import { CounterpartyMapModal } from '../widgets/ConsolidatedOsv/CounterpartyMapModal'; -import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps'; -import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable'; -import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs'; -import { mapEntryId } from '../widgets/ConsolidatedOsv/counterpartyMapping'; -import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv'; +import { ApiError } from '../../api/client'; +import { exportOsv } from '../../services/api/osv'; +import type { ConsolidatedRowOut } from '../../types/api'; +import type { VersionCard } from '../../types/consolidations/version_members'; +import { exportFilename, saveBlob } from '../../utils/download'; +import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal'; +import { OsvGaps } from '../ConsolidatedOsv/OsvGaps'; +import { OsvTable } from '../ConsolidatedOsv/OsvTable'; +import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping'; +import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv'; -function OsvContent({ versionId }: { versionId: string }) { - const [card, setCard] = useState(null); +export function OsvContent({ card }: { card: VersionCard | null }) { const [exporting, setExporting] = useState(false); + const { version_id } = useParams<{ version_id: string }>(); + // строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ) const [mapping, setMapping] = useState(null); - const osv = useConsolidatedOsv(versionId); - - useEffect(() => { - let actual = true; - getVersionCard(versionId) - .then((c) => { - if (actual) setCard(c); - }) - .catch((e: unknown) => { - if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию'); - }); - return () => { - actual = false; - }; - }, [versionId]); + const osv = useConsolidatedOsv(version_id ?? ''); const download = async () => { + if (!version_id) { + return; + } setExporting(true); try { - const { blob, filename } = await exportOsv(versionId, osv.filters); + const { blob, filename } = await exportOsv(version_id, osv.filters); saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? '')); } catch (e: unknown) { message.error(e instanceof ApiError ? e.message : 'Не удалось выгрузить свод'); @@ -57,27 +39,9 @@ function OsvContent({ versionId }: { versionId: string }) { const s = osv.summary; const unmapped = s?.unmapped_total ?? 0; const onlyUnmapped = osv.filters.unmapped === true; - const russianStatus = card ? (statusMapping[card.status] ?? card.status) : ''; - const preset = statusColorMapping[russianStatus as ConsRussianStatus] ?? TAG_PRESETS.default; - const groupName = card?.company_group.name ?? ''; - const period = card ? `${card.year} · ${quarterShortLabel(card.quarter)}` : ''; + return ( -
- - Статус отчёта - - - ) - } - /> - + <>
- {mapping && ( + {mapping && version_id && ( setMapping(null)} onMapped={osv.refresh} /> )} -
+ ); } - -export function ConsolidatedOsvPage() { - const { version_id = '' } = useParams(); - // key: при смене версии всё состояние (фильтры, страница, данные) создаётся заново - return ; -} diff --git a/web/src/widgets/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx b/web/src/widgets/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx new file mode 100644 index 0000000..5c2b5d9 --- /dev/null +++ b/web/src/widgets/ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter.tsx @@ -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 = ({ + 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 ( +
+ + + Единицы + onDecimalsChange(v || 0)} + options={DECIMALS_OPTIONS} + variant='underlined' + /> + + +
+ ); +}; diff --git a/web/src/widgets/ConsolidationChessResTable/ConsolidatedChessResTable.css b/web/src/widgets/ConsolidationChessResTable/ConsolidatedChessResTable.css new file mode 100644 index 0000000..eb37592 --- /dev/null +++ b/web/src/widgets/ConsolidationChessResTable/ConsolidatedChessResTable.css @@ -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 ; +} diff --git a/web/src/widgets/ConsolidationChessResTable/ConsolidationChessResTable.tsx b/web/src/widgets/ConsolidationChessResTable/ConsolidationChessResTable.tsx new file mode 100644 index 0000000..a8440de --- /dev/null +++ b/web/src/widgets/ConsolidationChessResTable/ConsolidationChessResTable.tsx @@ -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 = —; + +interface ConsolidationChessResTableI { + data: IntercompanyMatrixResultResponse | null; + loading: boolean; + decimals: number; + units: string; + toolbar?: React.ReactNode; + isFullscreen?: boolean; +} + +export const ConsolidationChessResTable: React.FC = ({ + data, + loading, + decimals, + units, + toolbar, + isFullscreen = false, +}) => { + const matrixData = useMemo(() => { + if (!data?.articles) return {}; + const result: Record> = {}; + + 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 = useMemo(() => { + if (!data?.companies) return []; + + const firstColumn: ColumnsType[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[number] = { + title: 'Итого', + dataIndex: 'group_total', + key: 'group_total', + width: 150, + align: 'right', + render: (val: string) => {chessTableCellFormatValue(val, units, decimals) || DASH}, + }; + + return [firstColumn, ...companyColumns, totalColumn]; + }, [data?.companies, matrixData, decimals, units]); + + const scrollY = isFullscreen ? 'calc(100vh - 180px)' : 436; + + return ( + + 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 ( + + + +
+ Итого +
+
+ + {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 ( + + {chessTableCellFormatValue(colTotal, units, decimals) || DASH} + + ); + })} + + + + {chessTableCellFormatValue(sumAmounts(data.articles.map((a) => a.group_total)) ?? '', units, decimals) || DASH} + + +
+
+ ); + }} + /> + ); +}; diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidatedChessTable.css b/web/src/widgets/ConsolidationChessTable/ConsolidatedChessTable.css new file mode 100644 index 0000000..0bfdefa --- /dev/null +++ b/web/src/widgets/ConsolidationChessTable/ConsolidatedChessTable.css @@ -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 ; +} diff --git a/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx new file mode 100644 index 0000000..a9d1cc4 --- /dev/null +++ b/web/src/widgets/ConsolidationChessTable/ConsolidationChessTable.tsx @@ -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 = ({ + data, + loading, + decimals, + units, + toolbar, + isFullscreen = false, +}) => { + const matrixData = useMemo(() => { + if (!data?.cells) return {}; + const result: Record> = {}; + 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 = useMemo(() => { + if (!data?.companies) return []; + + const firstColumn: ColumnsType[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 —; + return ( +
+ + {cell.skipped_osv_entry_count > 0 && ( + + + + )} + {chessTableCellFormatValue(cell.amount_a, units, decimals)} + + {chessTableCellFormatValue(cell.amount_b, units, decimals)} + {/* подсветка как в API и xlsx: порог > 5 % или пара без встречной стороны (AURORA-1651) */} + + ({chessTableCellFormatValue(cell.variance_amount, units, decimals)}) + +
+ ); + }, + })); + + 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 ( + + 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 ( + + + +
+ Итого +
+
+ + {data?.companies.map((company, index) => ( + +
+ {chessTableCellFormatValue((totalRowData as TotalRow)[company.id], units, decimals) || '—'} +
+
+ ))} +
+
+ ); + }} + /> + ); +}; diff --git a/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.css b/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.css new file mode 100644 index 0000000..8dacfcf --- /dev/null +++ b/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.css @@ -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; +} diff --git a/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx b/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx new file mode 100644 index 0000000..cf58c0c --- /dev/null +++ b/web/src/widgets/ConsolidationChessTableFilter/ConsolidationChessTableFilter.tsx @@ -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 = {}; + 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 = ({ + 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([]); + const [creditTreeData, setCreditTreeData] = useState([]); + + 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('/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 = Выберите счета по Дт и Кт для фильтрации; + + return ( + + + Год + + + + Компании + ( + setDebitOpen(false)} + warningBlock={warningBlock} + /> + )} + loading={optionsLoading} + /> + + + + Кт +