Merge pull request 'AURORA-1757: итоги шахматки из API, фильтр компаний на сервере' (#225) from feat/224-chess-totals-ui into main

This commit is contained in:
aep 2026-11-06 16:21:36 +03:00
commit 4ab792ee39
11 changed files with 295 additions and 132 deletions

View 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` | лишние запросы при быстром выборе — можно добавить задержку |

View 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).

View File

@ -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`, кнопка «Экспорт» матрицы не переходит в состояние загрузки

View 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

View File

@ -1,17 +1,13 @@
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`;
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) =>
api.post<IntercompanyMatrixResponse>(`${base(versionId)}/manual`, data);
export const exportChessTableAll = (versionId: string) => apiFetchBlob(`${base(versionId)}/all/export`);
export const exportChessTableManual = (versionId: string, data: ChessManualRequest) =>
apiFetchBlob(`${base(versionId)}/manual/export`, withJsonBody('POST', data));
export const exportIntercompanyMatrix = (versionId: string, data: IntercompanyMatrixRequest) =>
apiFetchBlob(`${base(versionId)}/export`, withJsonBody('POST', data));
export const fetchChessConsolidationsResult = (versionId: string) =>
api.get<IntercompanyMatrixResultResponse>(`${base(versionId)}/result-table`);

View File

@ -455,11 +455,17 @@ export interface MatrixCell {
export interface IntercompanyMatrixResponse {
companies: MatrixCompany[];
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[];
credit_account_ids: string[];
legal_entity_ids: string[];
};
/** Результирующая таблица ВГО (`IntercompanyResultTableOut`): статьи §6 по компаниям. */

View 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);
});
});

View 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;

View File

@ -1,19 +1,23 @@
import { DownloadOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
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 { ApiError } from '../../../api/client';
import {
exportChessConsolidationsResult,
exportChessTableAll,
exportChessTableManual,
fetchChessConsolidations,
fetchChessConsolidationsManual,
exportIntercompanyMatrix,
fetchChessConsolidationsResult,
fetchIntercompanyMatrix,
} 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 { chessMatrixMode } from '../../../utils/chessMatrixMode';
import { chessMatrixRequest, isWholePerimeter } from '../../../utils/chessMatrixRequest';
import { saveBlob, xlsxFilename } from '../../../utils/download';
import { useNotificationApi } from '../../../utils/useNotification';
import { ConsolidationChessFormatTableFilter } from '../ConsolidationChessFormatTableFilter/ConsolidationChessFormatTableFilter';
@ -30,6 +34,8 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
const [matrixExportLoading, setMatrixExportLoading] = useState(false);
const [resExportLoading, setResExportLoading] = useState(false);
const [companies, setCompanies] = useState<string[]>([]);
// варианты фильтра «Компании» — весь периметр версии; при выборе компаний ответ сужен и их не меняет
const [perimeter, setPerimeter] = useState<MatrixCompany[]>([]);
const [decimals, setDecimals] = useState(2);
const [units, setUnits] = useState('thousand');
const [debit, setDebit] = useState<string[]>([]);
@ -77,17 +83,15 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
const isHalfFiltered = matrixMode === 'half';
const loadData = useCallback(
async (vId: string, dIds: string[], cIds: string[], isActual: () => boolean) => {
async (vId: string, request: IntercompanyMatrixRequest, isActual: () => boolean) => {
setLoading(true);
try {
let res: IntercompanyMatrixResponse;
if (chessMatrixMode(dIds, cIds) === 'manual') {
res = await fetchChessConsolidationsManual(vId, { debit_account_ids: dIds, credit_account_ids: cIds });
} else {
res = await fetchChessConsolidations(vId);
}
const res = await fetchIntercompanyMatrix(vId, request);
if (isActual()) {
setData(res);
if (isWholePerimeter(request)) {
setPerimeter(res.companies);
}
}
} catch (e) {
if (isActual() && e instanceof ApiError) {
@ -126,13 +130,9 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
}
setMatrixExportLoading(true);
try {
if (matrixMode === 'manual') {
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));
}
// выгрузка по тем же счетам и компаниям, что на экране: итоги в xlsx совпадают с таблицей
const { blob, filename } = await exportIntercompanyMatrix(version_id, chessMatrixRequest(debit, credit, companies));
saveBlob(blob, filename ?? xlsxFilename('Шахматка', versionInfo));
} catch (e) {
if (e instanceof ApiError) {
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(() => {
// выбрана только одна сторона Дт/Кт — запрос не шлём, таблица показывает подсказку
if (!version_id || isHalfFiltered) return;
@ -175,13 +165,13 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
let isMounted = true;
const isActual = () => isMounted;
loadData(version_id, debit, credit, isActual);
loadData(version_id, chessMatrixRequest(debit, credit, companies), isActual);
return () => {
isMounted = false;
setLoading(false);
};
}, [version_id, debit, credit, isHalfFiltered, loadData]);
}, [version_id, debit, credit, companies, isHalfFiltered, loadData]);
useEffect(() => {
if (!version_id) return;
@ -211,7 +201,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
flexDirection: 'column',
}}>
<ConsolidationChessTable
data={filteredData}
data={data}
loading={loading}
decimals={decimals}
units={units}
@ -235,7 +225,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
onDebitChange={setDebit}
credit={credit}
onCreditChange={setCredit}
allCompanies={data?.companies || []}
allCompanies={perimeter}
year={card?.year}
quarter={card?.quarter}
/>

View File

@ -5,7 +5,6 @@ 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;
@ -66,7 +65,7 @@ export const ConsolidationChessResTable: React.FC<ConsolidationChessResTableI> =
}));
const totalColumn: ColumnsType<MatrixResCell>[number] = {
title: 'Итого',
title: 'Итого по группе',
dataIndex: 'group_total',
key: 'group_total',
width: 150,
@ -91,44 +90,6 @@ export const ConsolidationChessResTable: React.FC<ConsolidationChessResTableI> =
size='medium'
bordered
title={() => toolbar}
summary={() => {
if (!data?.articles) return null;
return (
<Table.Summary fixed>
<Table.Summary.Row className='resRow'>
<Table.Summary.Cell className='resRow' index={0} align='left'>
<div>
<Text strong>Итого</Text>
</div>
</Table.Summary.Cell>
{data.companies.map((company, index) => {
const amountsToSum: string[] = [];
data.articles.forEach((article) => {
const amount = matrixData[article.code]?.[company.id];
if (amount) amountsToSum.push(amount);
});
// сумму, которую не удалось сложить, не выдумываем нулём — ячейка покажет «—»
const colTotal = sumAmounts(amountsToSum) ?? '';
return (
<Table.Summary.Cell key={company.id} index={index + 1} align='right'>
<Text strong>{chessTableCellFormatValue(colTotal, units, decimals) || DASH}</Text>
</Table.Summary.Cell>
);
})}
<Table.Summary.Cell index={data.companies.length + 1} align='right'>
<Text strong>
{chessTableCellFormatValue(sumAmounts(data.articles.map((a) => a.group_total)) ?? '', units, decimals) || DASH}
</Text>
</Table.Summary.Cell>
</Table.Summary.Row>
</Table.Summary>
);
}}
/>
);
};

View File

@ -7,7 +7,6 @@ import type { IntercompanyMatrixResponse, MatrixCell, MatrixCompany } from '../.
import './ConsolidatedChessTable.css';
import { CHESS_HALF_FILTER_HINT } from '../../../utils/chessMatrixMode';
import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue';
import { sumAmounts } from '../../../utils/money';
const { Text } = Typography;
@ -21,11 +20,7 @@ interface ConsolidationChessTableI {
isHalfFiltered?: boolean;
}
type TotalRow = {
id: string;
name: string;
[key: string]: string; // Здесь будут храниться суммы по ID компаний
};
const DASH = <span style={{ color: '#bfbfbf' }}>—</span>;
export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
data,
@ -91,33 +86,20 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
},
}));
return [firstColumn, ...companyColumns];
}, [data?.companies, matrixData, decimals, units]);
const totalRowData = useMemo(() => {
if (!data?.companies) return null;
const totalRow: TotalRow = {
id: 'total-row',
name: 'Итого',
// «Итого Дт» — сумма дебетовых значений строки компании; считает сервер (DOC-221277, AURORA-1756)
const debitTotalColumn: ColumnsType<MatrixCompany>[number] = {
title: 'Итого Дт',
key: 'total_debit',
fixed: 'right',
width: 150,
align: 'right',
render: (_: unknown, row: MatrixCompany) => (
<Text strong>{chessTableCellFormatValue(data.total_debit?.[row.id], units, decimals) || DASH}</Text>
),
};
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]);
return [firstColumn, ...companyColumns, debitTotalColumn];
}, [data?.companies, data?.total_debit, matrixData, decimals, units]);
const scrollY = isFullscreen ? 'calc(100vh - 200px)' : 436;
@ -141,27 +123,26 @@ export const ConsolidationChessTable: React.FC<ConsolidationChessTableI> = ({
),
}}
summary={() => {
if (!totalRowData) return null;
if (!data?.companies.length || isHalfFiltered) return null;
// «Итого Кт» — сумма кредитовых значений столбца компании; пересечение с «Итого Дт» пустое
return (
<Table.Summary fixed>
{!isHalfFiltered && (
<Table.Summary.Row className='resRow'>
<Table.Summary.Cell className='resRow' index={0} align='left'>
<div style={{ backgroundColor: '#e6f4ea', width: '100%', height: '100%' }}>
<Text strong>Итого</Text>
</div>
</Table.Summary.Cell>
<Table.Summary.Row className='resRow'>
<Table.Summary.Cell className='resRow' index={0} align='left'>
<div style={{ backgroundColor: '#e6f4ea', width: '100%', height: '100%' }}>
<Text strong>Итого Кт</Text>
</div>
</Table.Summary.Cell>
{data?.companies.map((company, index) => (
<Table.Summary.Cell className='resRow' key={company.id} index={index + 1} align='right'>
<div>
<Text strong>{chessTableCellFormatValue((totalRowData as TotalRow)[company.id], units, decimals) || '—'}</Text>
</div>
</Table.Summary.Cell>
))}
</Table.Summary.Row>
)}
{data.companies.map((company, index) => (
<Table.Summary.Cell className='resRow' key={company.id} index={index + 1} align='right'>
<Text strong>{chessTableCellFormatValue(data.total_credit?.[company.id], units, decimals) || DASH}</Text>
</Table.Summary.Cell>
))}
<Table.Summary.Cell className='resRow' index={data.companies.length + 1} />
</Table.Summary.Row>
</Table.Summary>
);
}}