Merge pull request 'AURORA-1757: итоги шахматки из API, фильтр компаний на сервере' (#225) from feat/224-chess-totals-ui into main
This commit is contained in:
commit
4ab792ee39
17
openspec/changes/intercompany-matrix-totals-ui/design.md
Normal file
17
openspec/changes/intercompany-matrix-totals-ui/design.md
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Решения
|
||||||
|
|
||||||
|
- **Варианты фильтра «Компании».** После перехода фильтра на сервер ответ с выбором содержит только выбранные ЮЛ, поэтому
|
||||||
|
варианты берутся из последнего ответа без выбора (`isWholePerimeter`). Первый запрос вкладки всегда без выбора.
|
||||||
|
- **Итоги не пересчитываются на клиенте.** Только форматирование `chessTableCellFormatValue`; нет итога — «—».
|
||||||
|
- **Одна сторона Дт/Кт.** Бэк теперь умеет «пустая сторона = все балансовые счета», но поведение экрана (подсказка, запрос
|
||||||
|
не шлём) не меняем — это AURORA-1799.
|
||||||
|
|
||||||
|
## Decisions made without discussion
|
||||||
|
|
||||||
|
| Дата | Решение | Обоснование | Цена ошибки |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2026-11-06 | Задача ведётся как bounded, но с OpenSpec change для спеки экрана | спека — источник истины, правка `specs/` напрямую — то, что сами отметили в ревью #218 | нет |
|
||||||
|
| 2026-11-06 | Колонка результирующей таблицы переименована «Итого» → «Итого по группе» | так в DOC-221277 и в xlsx; рядом исчезла строка «Итого», два разных «Итого» больше не путаются | косметика |
|
||||||
|
| 2026-11-06 | Каждая отметка в фильтре «Компании» перезапрашивает матрицу без задержки | так уже работают фильтры Дт/Кт; гонки гасит `isActual` | лишние запросы при быстром выборе — можно добавить задержку |
|
||||||
26
openspec/changes/intercompany-matrix-totals-ui/proposal.md
Normal file
26
openspec/changes/intercompany-matrix-totals-ui/proposal.md
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Итоги шахматки на экране считались на клиенте (PR #158), фильтр «Компании» скрывал строки без запроса. Бэк (AURORA-1756,
|
||||||
|
PR #218, change `intercompany-matrix-totals`) теперь отдаёт `total_debit` / `total_credit`, принимает `legal_entity_ids` и
|
||||||
|
заменил `GET …/all` и `POST …/manual` единым `POST …/intercompany-matrix`. AURORA-1757, issue arm-core #224.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Клиент — единый `POST …/intercompany-matrix` и `…/export`.
|
||||||
|
- Матрица: колонка «Итого Дт» справа и строка «Итого Кт» снизу из ответа API; клиентское сложение убрано.
|
||||||
|
- Фильтр «Компании» уходит в запрос; варианты — весь периметр из ответа без выбора компаний.
|
||||||
|
- Выгрузка матрицы — с тем же выбором компаний.
|
||||||
|
- Результирующая таблица: строки «Итого» снизу нет, колонка — «Итого по группе».
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `intercompany-matrix-ui`
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
`web/src`: `services/api/intercompanyMatrix.ts`, `types/api.ts`, `utils/chessMatrixRequest.ts`, `widgets/ConsolidationChess/*`.
|
||||||
|
Сливается вместе с #218. Состояние «выбрана одна сторона» не меняется (AURORA-1799).
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Таблица шахматки ЮЛ × ЮЛ
|
||||||
|
Экран SHALL загружать матрицу при открытии вкладки (`POST /consolidations/versions/{id}/intercompany-matrix`, тело — по требованию «Фильтр счетов Дт и Кт и режимы расчёта») и показывать
|
||||||
|
таблицу: первая колонка «ДБ / КТ» (закреплена слева) — компании периметра в порядке ответа (`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 показываться прочерк «—» на приглушённом фоне. Справа закреплена колонка «Итого Дт» —
|
||||||
|
`total_debit` ответа для компании строки; ниже таблицы закреплена строка «Итого Кт» — `total_credit` для компании колонки;
|
||||||
|
ячейка на их пересечении пустая. Итоги считает сервер (DOC-221277 «Итоги шахматки», AURORA-1756), клиент их только форматирует
|
||||||
|
(«Единицы и знаки»); отсутствующий в ответе итог — прочерк «—». Пока идёт
|
||||||
|
загрузка, таблица показывает индикатор загрузки; ошибка 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; в суммы не вошли»
|
||||||
|
|
||||||
|
#### Scenario: Итоги из ответа
|
||||||
|
- **WHEN** сервер вернул `total_debit[A] = "120000.00"` и `total_credit[Б] = "75000.00"`, выбраны «тыс. руб.» и 2 знака
|
||||||
|
- **THEN** в колонке «Итого Дт» строки A показано `120,00`, в строке «Итого Кт» под колонкой Б — `75,00`, пересечение итогов пустое
|
||||||
|
|
||||||
|
### Requirement: Результирующая таблица ВГО
|
||||||
|
Под матрицей экран SHALL показывать результирующую таблицу ВГО, загруженную при открытии вкладки
|
||||||
|
(`GET /consolidations/versions/{id}/intercompany-matrix/result-table`): первая колонка «Статья» — статьи ответа (`articles[]`,
|
||||||
|
`name`) в порядке ответа, далее колонки компаний (`companies[]`) с суммой статьи по компании (`amounts[]` по `company_id`) и
|
||||||
|
колонка «Итого по группе» со значением `group_total` из ответа; пустая сумма — прочерк «—». Строки итога по всем статьям под
|
||||||
|
таблицей нет: суммы разных статей не складываются (аналитик, 03.11.2026). У таблицы свои выбор единиц и знаков (по правилам требования «Единицы и знаки», независимо от
|
||||||
|
матрицы), кнопка «Экспорт» и кнопка «На весь экран». Пока идёт загрузка, таблица показывает индикатор; ошибка 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 передаваться в запрос матрицы
|
||||||
|
`legal_entity_ids`: сервер возвращает только выбранные компании, ячейки между ними и итоги «Итого Дт» / «Итого Кт» по ним.
|
||||||
|
Каждое изменение выбора перезапрашивает матрицу (в состоянии «выбрана одна сторона» — нет). Список вариантов — компании
|
||||||
|
последнего ответа, полученного без выбора компаний (весь периметр версии); ответы с выбором его не меняют. Выпадающее окно выбора — дерево с поиском по названию, кнопками
|
||||||
|
применения и сброса.
|
||||||
|
|
||||||
|
#### Scenario: Две компании из периметра
|
||||||
|
- **WHEN** выбраны две компании из пяти
|
||||||
|
- **THEN** запрос содержит `legal_entity_ids` этих двух компаний, матрица содержит две строки и две колонки, итоги — из ответа, в списке вариантов по-прежнему пять компаний
|
||||||
|
|
||||||
|
### Requirement: Фильтр счетов Дт и Кт и режимы расчёта
|
||||||
|
Экран SHALL загружать план счетов `GET /directories/accounts` один раз при открытии и строить из него два дерева по иерархии
|
||||||
|
`parent_id`: дебетовые счета (`is_debit`) и кредитовые (`is_credit`); подпись узла — «код - название», в том числе у выбранных
|
||||||
|
дочерних счетов в поле выбора. Выбор счетов Дт и Кт — множественный, пустой выбор — «Все счета».
|
||||||
|
|
||||||
|
Отметки в дереве SHALL считаться по полному плану счетов: родительский счёт отмечен тогда и только тогда, когда отмечены все
|
||||||
|
его потомки в полном дереве. Поиск по дереву скрывает не совпавшие узлы, но не меняет этого правила: отметка единственного
|
||||||
|
видимого при поиске субсчёта не отмечает его родителя, а снятие отметки с субсчёта снимает её и с родителя. Скрытые поиском
|
||||||
|
отметки при отметке видимых узлов сохраняются.
|
||||||
|
|
||||||
|
Режим расчёта определяется выбором:
|
||||||
|
- ничего не выбрано ни в Дт, ни в Кт — SHALL выполняться `POST …/intercompany-matrix` с пустыми `debit_account_ids` и
|
||||||
|
`credit_account_ids` (режим `all`);
|
||||||
|
- выбраны счета и в Дт, и в Кт — SHALL выполняться `POST …/intercompany-matrix` с выбранными `debit_account_ids` и
|
||||||
|
`credit_account_ids` (режим `manual`);
|
||||||
|
- выбрана только одна сторона — запрос SHALL NOT отправляться, таблица матрицы SHALL быть пустой и вместо данных показывать
|
||||||
|
текст «Для расчёта по счетам выберите счета и в Дт, и в Кт»; тот же текст SHALL показываться у фильтров Дт/Кт (не
|
||||||
|
всплывающим уведомлением) и только в этом состоянии; кнопка «Экспорт» матрицы SHALL быть недоступна.
|
||||||
|
|
||||||
|
В тело всегда входит `legal_entity_ids` (требование «Фильтр компаний»). Каждое изменение выбора счетов, приводящее к режиму `all` или `manual`, перезапрашивает матрицу; на экране остаётся ответ на
|
||||||
|
последний запрос, индикатор загрузки снимается и при переходе в состояние «выбрана одна сторона». Кнопка «Сбросить фильтры»
|
||||||
|
появляется, когда выбрано что-либо в компаниях, Дт или Кт, и очищает все три выбора. Выбор единиц и знаков кнопкой не
|
||||||
|
сбрасывается. Ошибка загрузки плана счетов показывается уведомлением.
|
||||||
|
|
||||||
|
#### Scenario: Обе стороны выбраны
|
||||||
|
- **WHEN** выбран счёт 60.01 в Дт и счёт 62.01 в Кт
|
||||||
|
- **THEN** отправляется `POST …/intercompany-matrix` с идентификаторами этих счетов и таблица показывает ответ
|
||||||
|
|
||||||
|
#### 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 в том же режиме, что и таблица: `POST …/intercompany-matrix/export` с тем же
|
||||||
|
телом, что запрос таблицы (`debit_account_ids`, `credit_account_ids`, `legal_entity_ids`), — итоги в файле совпадают с экраном; при выбранной одной стороне кнопка недоступна. Кнопка «Экспорт» результирующей таблицы
|
||||||
|
SHALL скачивать `GET …/intercompany-matrix/result-table/export`. Файл сохраняется под именем из `Content-Disposition`, при его
|
||||||
|
отсутствии — `Шахматка_<ГК>_<год>_<квартал>.xlsx` и `Результирующая_таблица_ВГО_<ГК>_<год>_<квартал>.xlsx` соответственно.
|
||||||
|
Пока идёт выгрузка, в состоянии загрузки только нажатая кнопка; ошибка API показывается уведомлением. Единицы и знаки в запрос экспорта
|
||||||
|
не передаются.
|
||||||
|
|
||||||
|
#### Scenario: Экспорт по всем счетам
|
||||||
|
- **WHEN** счета не выбраны и нажато «Экспорт» матрицы
|
||||||
|
- **THEN** отправлен `POST …/intercompany-matrix/export` с пустыми списками, браузер сохраняет файл `intercompany-matrix_….xlsx`
|
||||||
|
|
||||||
|
#### Scenario: Экспорт результирующей таблицы
|
||||||
|
- **WHEN** нажато «Экспорт» результирующей таблицы
|
||||||
|
- **THEN** отправлен `GET …/intercompany-matrix/result-table/export`, кнопка «Экспорт» матрицы не переходит в состояние загрузки
|
||||||
8
openspec/changes/intercompany-matrix-totals-ui/tasks.md
Normal file
8
openspec/changes/intercompany-matrix-totals-ui/tasks.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
- [x] 1.1 Клиент `fetchIntercompanyMatrix` / `exportIntercompanyMatrix`, тип `IntercompanyMatrixRequest`
|
||||||
|
- [x] 1.2 `chessMatrixRequest` / `isWholePerimeter` + тесты
|
||||||
|
- [x] 1.3 Матрица: «Итого Дт» / «Итого Кт» из ответа
|
||||||
|
- [x] 1.4 Фильтр «Компании» в запросе, варианты из периметра; выгрузка с выбором компаний
|
||||||
|
- [x] 1.5 Результирующая таблица без строки «Итого»
|
||||||
|
- [ ] 1.6 Сценарий на стенде (Playwright) и сверка с макетом 31-01 / 31-01-01
|
||||||
@ -1,17 +1,13 @@
|
|||||||
import { api, apiFetchBlob, withJsonBody } from '../../api/client';
|
import { api, apiFetchBlob, withJsonBody } from '../../api/client';
|
||||||
import type { ChessManualRequest, IntercompanyMatrixResponse, IntercompanyMatrixResultResponse } from '../../types/api';
|
import type { IntercompanyMatrixRequest, IntercompanyMatrixResponse, IntercompanyMatrixResultResponse } from '../../types/api';
|
||||||
|
|
||||||
const base = (versionId: string) => `/consolidations/versions/${versionId}/intercompany-matrix`;
|
const base = (versionId: string) => `/consolidations/versions/${versionId}/intercompany-matrix`;
|
||||||
|
|
||||||
export const fetchChessConsolidations = (versionId: string) => api.get<IntercompanyMatrixResponse>(`${base(versionId)}/all`);
|
export const fetchIntercompanyMatrix = (versionId: string, data: IntercompanyMatrixRequest) =>
|
||||||
|
api.post<IntercompanyMatrixResponse>(base(versionId), data);
|
||||||
|
|
||||||
export const fetchChessConsolidationsManual = (versionId: string, data: ChessManualRequest) =>
|
export const exportIntercompanyMatrix = (versionId: string, data: IntercompanyMatrixRequest) =>
|
||||||
api.post<IntercompanyMatrixResponse>(`${base(versionId)}/manual`, data);
|
apiFetchBlob(`${base(versionId)}/export`, withJsonBody('POST', 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) =>
|
export const fetchChessConsolidationsResult = (versionId: string) =>
|
||||||
api.get<IntercompanyMatrixResultResponse>(`${base(versionId)}/result-table`);
|
api.get<IntercompanyMatrixResultResponse>(`${base(versionId)}/result-table`);
|
||||||
|
|||||||
@ -455,11 +455,17 @@ export interface MatrixCell {
|
|||||||
export interface IntercompanyMatrixResponse {
|
export interface IntercompanyMatrixResponse {
|
||||||
companies: MatrixCompany[];
|
companies: MatrixCompany[];
|
||||||
cells: MatrixCell[];
|
cells: MatrixCell[];
|
||||||
|
/** «Итого Дт»: сумма `amount_a` строки компании, ключ — ID ЮЛ. */
|
||||||
|
total_debit: Record<string, string>;
|
||||||
|
/** «Итого Кт»: сумма `amount_b` столбца компании, ключ — ID ЮЛ. */
|
||||||
|
total_credit: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChessManualRequest = {
|
/** Тело `POST …/intercompany-matrix`: пустой список — все балансовые счета стороны / весь периметр. */
|
||||||
|
export type IntercompanyMatrixRequest = {
|
||||||
debit_account_ids: string[];
|
debit_account_ids: string[];
|
||||||
credit_account_ids: string[];
|
credit_account_ids: string[];
|
||||||
|
legal_entity_ids: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Результирующая таблица ВГО (`IntercompanyResultTableOut`): статьи §6 по компаниям. */
|
/** Результирующая таблица ВГО (`IntercompanyResultTableOut`): статьи §6 по компаниям. */
|
||||||
|
|||||||
30
web/src/utils/chessMatrixRequest.test.ts
Normal file
30
web/src/utils/chessMatrixRequest.test.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { chessMatrixRequest, isWholePerimeter } from './chessMatrixRequest';
|
||||||
|
|
||||||
|
describe('chessMatrixRequest', () => {
|
||||||
|
it('пустые фильтры — пустые списки: все балансовые счета и весь периметр', () => {
|
||||||
|
expect(chessMatrixRequest([], [], [])).toEqual({ debit_account_ids: [], credit_account_ids: [], legal_entity_ids: [] });
|
||||||
|
});
|
||||||
|
it('выбранные счета и компании уходят в запрос как есть', () => {
|
||||||
|
expect(chessMatrixRequest(['d1'], ['c1', 'c2'], ['le1'])).toEqual({
|
||||||
|
debit_account_ids: ['d1'],
|
||||||
|
credit_account_ids: ['c1', 'c2'],
|
||||||
|
legal_entity_ids: ['le1'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('списки копируются: правка фильтра после отправки не меняет тело', () => {
|
||||||
|
const companies = ['le1'];
|
||||||
|
const body = chessMatrixRequest([], [], companies);
|
||||||
|
companies.push('le2');
|
||||||
|
expect(body.legal_entity_ids).toEqual(['le1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isWholePerimeter', () => {
|
||||||
|
it('без выбора компаний ответ содержит весь периметр — из него строятся варианты фильтра', () => {
|
||||||
|
expect(isWholePerimeter(chessMatrixRequest(['d1'], ['c1'], []))).toBe(true);
|
||||||
|
});
|
||||||
|
it('с выбором компаний ответ сужен — варианты фильтра не трогаем', () => {
|
||||||
|
expect(isWholePerimeter(chessMatrixRequest([], [], ['le1']))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
19
web/src/utils/chessMatrixRequest.ts
Normal file
19
web/src/utils/chessMatrixRequest.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import type { IntercompanyMatrixRequest } from '../types/api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тело `POST …/intercompany-matrix` и `…/export` по фильтрам экрана (спека consolidated-osv, AURORA-1756/1757).
|
||||||
|
* Пустой список стороны — все балансовые счета этой стороны, пустой `legal_entity_ids` — весь периметр версии.
|
||||||
|
* Компании отбирает сервер, чтобы «Итого Дт» и «Итого Кт» считались по выбранным ЮЛ.
|
||||||
|
*/
|
||||||
|
export const chessMatrixRequest = (
|
||||||
|
debit: readonly string[],
|
||||||
|
credit: readonly string[],
|
||||||
|
companies: readonly string[],
|
||||||
|
): IntercompanyMatrixRequest => ({
|
||||||
|
debit_account_ids: [...debit],
|
||||||
|
credit_account_ids: [...credit],
|
||||||
|
legal_entity_ids: [...companies],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Ответ на запрос без выбора компаний содержит весь периметр — из него строятся варианты фильтра «Компании». */
|
||||||
|
export const isWholePerimeter = (request: IntercompanyMatrixRequest): boolean => request.legal_entity_ids.length === 0;
|
||||||
@ -1,19 +1,23 @@
|
|||||||
import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
|
||||||
import { Button, ConfigProvider } from 'antd';
|
import { Button, ConfigProvider } from 'antd';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { ApiError } from '../../../api/client';
|
import { ApiError } from '../../../api/client';
|
||||||
import {
|
import {
|
||||||
exportChessConsolidationsResult,
|
exportChessConsolidationsResult,
|
||||||
exportChessTableAll,
|
exportIntercompanyMatrix,
|
||||||
exportChessTableManual,
|
|
||||||
fetchChessConsolidations,
|
|
||||||
fetchChessConsolidationsManual,
|
|
||||||
fetchChessConsolidationsResult,
|
fetchChessConsolidationsResult,
|
||||||
|
fetchIntercompanyMatrix,
|
||||||
} from '../../../services/api/intercompanyMatrix';
|
} from '../../../services/api/intercompanyMatrix';
|
||||||
import type { IntercompanyMatrixResponse, IntercompanyMatrixResultResponse } from '../../../types/api';
|
import type {
|
||||||
|
IntercompanyMatrixRequest,
|
||||||
|
IntercompanyMatrixResponse,
|
||||||
|
IntercompanyMatrixResultResponse,
|
||||||
|
MatrixCompany,
|
||||||
|
} from '../../../types/api';
|
||||||
import type { VersionCard } from '../../../types/consolidations/version_members';
|
import type { VersionCard } from '../../../types/consolidations/version_members';
|
||||||
import { chessMatrixMode } from '../../../utils/chessMatrixMode';
|
import { chessMatrixMode } from '../../../utils/chessMatrixMode';
|
||||||
|
import { chessMatrixRequest, isWholePerimeter } from '../../../utils/chessMatrixRequest';
|
||||||
import { saveBlob, xlsxFilename } from '../../../utils/download';
|
import { saveBlob, xlsxFilename } from '../../../utils/download';
|
||||||
import { useNotificationApi } from '../../../utils/useNotification';
|
import { useNotificationApi } from '../../../utils/useNotification';
|
||||||
import { ConsolidationChessFormatTableFilter } from '../ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter';
|
import { ConsolidationChessFormatTableFilter } from '../ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter';
|
||||||
@ -30,6 +34,8 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
const [matrixExportLoading, setMatrixExportLoading] = useState(false);
|
const [matrixExportLoading, setMatrixExportLoading] = useState(false);
|
||||||
const [resExportLoading, setResExportLoading] = useState(false);
|
const [resExportLoading, setResExportLoading] = useState(false);
|
||||||
const [companies, setCompanies] = useState<string[]>([]);
|
const [companies, setCompanies] = useState<string[]>([]);
|
||||||
|
// варианты фильтра «Компании» — весь периметр версии; при выборе компаний ответ сужен и их не меняет
|
||||||
|
const [perimeter, setPerimeter] = useState<MatrixCompany[]>([]);
|
||||||
const [decimals, setDecimals] = useState(2);
|
const [decimals, setDecimals] = useState(2);
|
||||||
const [units, setUnits] = useState('thousand');
|
const [units, setUnits] = useState('thousand');
|
||||||
const [debit, setDebit] = useState<string[]>([]);
|
const [debit, setDebit] = useState<string[]>([]);
|
||||||
@ -77,17 +83,15 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
const isHalfFiltered = matrixMode === 'half';
|
const isHalfFiltered = matrixMode === 'half';
|
||||||
|
|
||||||
const loadData = useCallback(
|
const loadData = useCallback(
|
||||||
async (vId: string, dIds: string[], cIds: string[], isActual: () => boolean) => {
|
async (vId: string, request: IntercompanyMatrixRequest, isActual: () => boolean) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let res: IntercompanyMatrixResponse;
|
const res = await fetchIntercompanyMatrix(vId, request);
|
||||||
if (chessMatrixMode(dIds, cIds) === 'manual') {
|
|
||||||
res = await fetchChessConsolidationsManual(vId, { debit_account_ids: dIds, credit_account_ids: cIds });
|
|
||||||
} else {
|
|
||||||
res = await fetchChessConsolidations(vId);
|
|
||||||
}
|
|
||||||
if (isActual()) {
|
if (isActual()) {
|
||||||
setData(res);
|
setData(res);
|
||||||
|
if (isWholePerimeter(request)) {
|
||||||
|
setPerimeter(res.companies);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isActual() && e instanceof ApiError) {
|
if (isActual() && e instanceof ApiError) {
|
||||||
@ -126,13 +130,9 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
}
|
}
|
||||||
setMatrixExportLoading(true);
|
setMatrixExportLoading(true);
|
||||||
try {
|
try {
|
||||||
if (matrixMode === 'manual') {
|
// выгрузка по тем же счетам и компаниям, что на экране: итоги в xlsx совпадают с таблицей
|
||||||
const { blob, filename } = await exportChessTableManual(version_id, { debit_account_ids: debit, credit_account_ids: credit });
|
const { blob, filename } = await exportIntercompanyMatrix(version_id, chessMatrixRequest(debit, credit, companies));
|
||||||
saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo));
|
saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo));
|
||||||
} else {
|
|
||||||
const { blob, filename } = await exportChessTableAll(version_id);
|
|
||||||
saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
notification.error({ description: e.message });
|
notification.error({ description: e.message });
|
||||||
@ -158,16 +158,6 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
// выбрана только одна сторона Дт/Кт — запрос не шлём, таблица показывает подсказку
|
// выбрана только одна сторона Дт/Кт — запрос не шлём, таблица показывает подсказку
|
||||||
if (!version_id || isHalfFiltered) return;
|
if (!version_id || isHalfFiltered) return;
|
||||||
@ -175,13 +165,13 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
const isActual = () => isMounted;
|
const isActual = () => isMounted;
|
||||||
|
|
||||||
loadData(version_id, debit, credit, isActual);
|
loadData(version_id, chessMatrixRequest(debit, credit, companies), isActual);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
}, [version_id, debit, credit, isHalfFiltered, loadData]);
|
}, [version_id, debit, credit, companies, isHalfFiltered, loadData]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!version_id) return;
|
if (!version_id) return;
|
||||||
@ -211,7 +201,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}>
|
}}>
|
||||||
<ConsolidationChessTable
|
<ConsolidationChessTable
|
||||||
data={filteredData}
|
data={data}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
decimals={decimals}
|
decimals={decimals}
|
||||||
units={units}
|
units={units}
|
||||||
@ -235,7 +225,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
|||||||
onDebitChange={setDebit}
|
onDebitChange={setDebit}
|
||||||
credit={credit}
|
credit={credit}
|
||||||
onCreditChange={setCredit}
|
onCreditChange={setCredit}
|
||||||
allCompanies={data?.companies || []}
|
allCompanies={perimeter}
|
||||||
year={card?.year}
|
year={card?.year}
|
||||||
quarter={card?.quarter}
|
quarter={card?.quarter}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { useMemo } from 'react';
|
|||||||
import type { IntercompanyMatrixResultResponse, MatrixResCell } from '../../../types/api';
|
import type { IntercompanyMatrixResultResponse, MatrixResCell } from '../../../types/api';
|
||||||
import './ConsolidatedChessResTable.css';
|
import './ConsolidatedChessResTable.css';
|
||||||
import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue';
|
import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue';
|
||||||
import { sumAmounts } from '../../../utils/money';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -66,7 +65,7 @@ export const ConsolidationChessResTable: React.FC<ConsolidationChessResTableI> =
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const totalColumn: ColumnsType<MatrixResCell>[number] = {
|
const totalColumn: ColumnsType<MatrixResCell>[number] = {
|
||||||
title: 'Итого',
|
title: 'Итого по группе',
|
||||||
dataIndex: 'group_total',
|
dataIndex: 'group_total',
|
||||||
key: 'group_total',
|
key: 'group_total',
|
||||||
width: 150,
|
width: 150,
|
||||||
@ -91,44 +90,6 @@ export const ConsolidationChessResTable: React.FC<ConsolidationChessResTableI> =
|
|||||||
size='medium'
|
size='medium'
|
||||||
bordered
|
bordered
|
||||||
title={() => toolbar}
|
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>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../.
|
|||||||
import './ConsolidatedChessTable.css';
|
import './ConsolidatedChessTable.css';
|
||||||
import { CHESS_HALF_FILTER_HINT } from '../../../utils/chessMatrixMode';
|
import { CHESS_HALF_FILTER_HINT } from '../../../utils/chessMatrixMode';
|
||||||
import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue';
|
import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue';
|
||||||
import { sumAmounts } from '../../../utils/money';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -21,11 +20,7 @@ interface ConsolidationChessTableI {
|
|||||||
isHalfFiltered?: boolean;
|
isHalfFiltered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TotalRow = {
|
const DASH = <span style={{ color: '#bfbfbf' }}>—</span>;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
[key: string]: string; // Здесь будут храниться суммы по ID компаний
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
|
export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
|
||||||
data,
|
data,
|
||||||
@ -91,33 +86,20 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [firstColumn, ...companyColumns];
|
// «Итого Дт» — сумма дебетовых значений строки компании; считает сервер (DOC-221277, AURORA-1756)
|
||||||
}, [data?.companies, matrixData, decimals, units]);
|
const debitTotalColumn: ColumnsType<MatrixCompany>[number] = {
|
||||||
|
title: 'Итого Дт',
|
||||||
const totalRowData = useMemo(() => {
|
key: 'total_debit',
|
||||||
if (!data?.companies) return null;
|
fixed: 'right',
|
||||||
|
width: 150,
|
||||||
const totalRow: TotalRow = {
|
align: 'right',
|
||||||
id: 'total-row',
|
render: (_: unknown, row: MatrixCompany) => (
|
||||||
name: 'Итого',
|
<Text strong>{chessTableCellFormatValue(data.total_debit?.[row.id], units, decimals) || DASH}</Text>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
data.companies.forEach((colCompany) => {
|
return [firstColumn, ...companyColumns, debitTotalColumn];
|
||||||
const amountsToSum: string[] = [];
|
}, [data?.companies, data?.total_debit, matrixData, decimals, units]);
|
||||||
|
|
||||||
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 - 200px)' : 436;
|
const scrollY = isFullscreen ? 'calc(100vh - 200px)' : 436;
|
||||||
|
|
||||||
@ -141,27 +123,26 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
|
|||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
summary={() => {
|
summary={() => {
|
||||||
if (!totalRowData) return null;
|
if (!data?.companies.length || isHalfFiltered) return null;
|
||||||
|
|
||||||
|
// «Итого Кт» — сумма кредитовых значений столбца компании; пересечение с «Итого Дт» пустое
|
||||||
return (
|
return (
|
||||||
<Table.Summary fixed>
|
<Table.Summary fixed>
|
||||||
{!isHalfFiltered && (
|
|
||||||
<Table.Summary.Row className='resRow'>
|
<Table.Summary.Row className='resRow'>
|
||||||
<Table.Summary.Cell className='resRow' index={0} align='left'>
|
<Table.Summary.Cell className='resRow' index={0} align='left'>
|
||||||
<div style={{ backgroundColor: '#e6f4ea', width: '100%', height: '100%' }}>
|
<div style={{ backgroundColor: '#e6f4ea', width: '100%', height: '100%' }}>
|
||||||
<Text strong>Итого</Text>
|
<Text strong>Итого Кт</Text>
|
||||||
</div>
|
</div>
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
|
|
||||||
{data?.companies.map((company, index) => (
|
{data.companies.map((company, index) => (
|
||||||
<Table.Summary.Cell className='resRow' key={company.id} index={index + 1} align='right'>
|
<Table.Summary.Cell className='resRow' key={company.id} index={index + 1} align='right'>
|
||||||
<div>
|
<Text strong>{chessTableCellFormatValue(data.total_credit?.[company.id], units, decimals) || DASH}</Text>
|
||||||
<Text strong>{chessTableCellFormatValue((totalRowData as TotalRow)[company.id], units, decimals) || '—'}</Text>
|
|
||||||
</div>
|
|
||||||
</Table.Summary.Cell>
|
</Table.Summary.Cell>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<Table.Summary.Cell className='resRow' index={data.companies.length + 1} />
|
||||||
</Table.Summary.Row>
|
</Table.Summary.Row>
|
||||||
)}
|
|
||||||
</Table.Summary>
|
</Table.Summary>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user