Merge pull request 'aurora-1544 добавление таблицы гудвил' (#220) from feat/goodWill into main
This commit is contained in:
commit
60cc190066
130
docs/superpowers/plans/2026-11-06-goodwill-review-front.md
Normal file
130
docs/superpowers/plans/2026-11-06-goodwill-review-front.md
Normal file
@ -0,0 +1,130 @@
|
||||
# Гудвилл: доводка фронта по ревью (arm-core #220) — план
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** довести PR arm-core #220 (ветка `feat/goodWill`, AURORA-1544) до мержа: убрать правки вне задачи, влить `main`,
|
||||
перевести вкладку «Гудвилл» на фактический API бэка (#222 после доводки по плану `2026-11-06-goodwill-review-back.md`),
|
||||
закрыть расхождения с DOC-259025 и макетом `31-03`.
|
||||
|
||||
**Architecture:** чистые функции вкладки — `web/src/utils/goodwill.ts` (+ vitest); типы — `web/src/types/goodwill/goodwill.ts`
|
||||
по схемам `api/src/schemas/goodwill.py`; запросы — `web/src/services/api/goodwill.ts`; экран —
|
||||
`web/src/widgets/ConsolidationGoodwill/*`. Агрегаты (итоги «Всего» по ГО и по листу) фронт не считает — берёт `groups[].totals`
|
||||
и `totals` из ответа.
|
||||
|
||||
**Tech Stack:** React 18 + TS strict, antd 6, vitest, biome + ESLint/Prettier.
|
||||
|
||||
**Spec:** контракт `../docs/dev/api-contract-goodwill.md` (обновлён в docs #204), DOC-259025 «Гудвилл» §4–§6, макет
|
||||
`../docs/mockup/screens/31-03-goodwill.png`, `31-03-01-goodwill-edit.png`.
|
||||
|
||||
Рабочий каталог: `/Users/andrey.epifancev/projects/awp-ca/.wt/review-220/arm-core` (ветка `feat/goodWill`). Бэк для ручной
|
||||
проверки — ветка `feat/goodwill-back` (worktree `.wt/review-222/arm-core`), пока #222 не в `main`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Деньги — строки; арифметика только через `toCents`/`fromCents` (`utils/money.ts`), никаких `Number` над суммами.
|
||||
- `null` — «—», `0.00` — ноль по формату единиц; нерассчитанный гудвилл не попадает ни в один фильтр знака.
|
||||
- Доля: ввод с запятой или точкой, 0–100, не больше двух знаков; ошибку показать у поля и заблокировать «Сохранить», значение не обрезать и не подменять.
|
||||
- Все запросы — через `api/client.ts`; ошибки показываются только для `ApiError`, ошибки валидации форм antd тостом не показываются.
|
||||
- Код вне `widgets/ConsolidationGoodwill`, `types/goodwill`, `services/api/goodwill.ts`, `utils/goodwill*`, `hooks/useFullScreen.tsx`,
|
||||
`components/ConsolidationFormatTableFilter` и вкладок отчёта (`ConsolidationReportPage.tsx`, `OsvTabs.tsx`) совпадает с `main`
|
||||
(кроме строки импорта фильтра в `ConsolidatedChessContent.tsx`).
|
||||
- Коммиты — по-русски, `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
|
||||
## Review Focus
|
||||
|
||||
- Снять галочку у строки, где доля ни разу не вводилась: пакет уходит с `ownership_share: null`, сервер отвечает 200.
|
||||
- Сервер вернул 422 по одной строке пакета: поле этой строки подсвечено, режим редактирования не закрыт, введённое не потеряно.
|
||||
- «Выделить все» в блоке, где есть «Нет вложений» и скрытые фильтром строки: отмечаются все действующие строки блока, «Нет вложений» — нет.
|
||||
- Версия без своей блокировки (`read_only_reason = LOCK_REQUIRED`): «Редактировать» и переключатель недоступны, видна причина.
|
||||
- Фильтр «Положительные» + Экспорт: в файл уходит тот же фильтр (`sign=positive`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Ветка — только гудвилл
|
||||
|
||||
**Files:**
|
||||
- Restore from `origin/main`: `web/src/layouts/CatalogueGroups.tsx`, `web/src/types/api.ts`,
|
||||
`web/src/widgets/CatalogueCompanyGroup/CompanyGroupForm/CompanyGroupForm.tsx`, `web/src/widgets/CatalogueJur/**`,
|
||||
`web/src/widgets/RelatedGroup/**`, `web/src/widgets/CorrectionTable/CorrectionContent.tsx`,
|
||||
`web/src/widgets/ConsolidationChess/**` (кроме удаления `ConsolidationChessFormatTableFilter` — см. Step 2)
|
||||
- Modify: `web/src/widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent.tsx` (только импорт фильтра),
|
||||
`web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx` (удалить `MOCK_DATA`)
|
||||
|
||||
- [ ] **Step 1:** `git merge origin/main` (конфликты в `CatalogueGroups.tsx`, `ConsolidatedChessContent.tsx`, `ConsolidationChessTable.tsx` —
|
||||
брать сторону `main`), затем `git checkout origin/main -- <файлы из списка>`.
|
||||
- [ ] **Step 2:** общий фильтр единиц — оставить `components/ConsolidationFormatTableFilter`, удалить
|
||||
`widgets/ConsolidationChess/ConsolidationChessFormatTableFilter/`, в шахматке поменять только импорт и имя компонента.
|
||||
- [ ] **Step 3:** `git diff --stat origin/main...HEAD` — изменены только файлы из Global Constraints; `useFullscreen` использует только вкладка гудвилла.
|
||||
- [ ] **Step 4:** из `web/`: `npm run format`, затем `make -C .. web-check`, `npm test`, `npm run lint` — зелёные.
|
||||
- [ ] **Step 5:** коммит «Гудвилл: ветка без правок вне задачи, влит main».
|
||||
|
||||
### Task 2: Типы, запросы и чистые функции
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/types/goodwill/goodwill.ts`, `web/src/services/api/goodwill.ts`
|
||||
- Create: `web/src/utils/goodwill.ts`, `web/src/utils/goodwill.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (типы, по `api/src/schemas/goodwill.py` ветки `feat/goodwill-back`): `GoodwillRow` — суммы `string | null`,
|
||||
`has_investments: boolean`, `warnings: {code: string; message: string}[]`; `GoodwillTotals` + `charter_capital_amount`;
|
||||
`GoodwillGroup {head_legal_entity: Ref; totals: GoodwillTotals}`; `GoodwillTableResponse.groups: GoodwillGroup[]`;
|
||||
`GoodwillRowKeyIn {head_legal_entity_id; subsidiary_legal_entity_id; ownership_share: string | null; include_in_kfo: boolean; comment: string | null}`;
|
||||
`GoodwillSign = 'all' | 'positive' | 'negative' | 'zero'`.
|
||||
- Produces (запросы): `saveGoodwillRows(versionId, rows: GoodwillRowKeyIn[]): Promise<GoodwillTableResponse>` (`PUT …/goodwill`, тело `{rows}`);
|
||||
`saveGoodwillSettings(versionId, {goodwill_enabled})`; `exportGoodwillTable(versionId, sign: GoodwillSign)` (`?sign=`). `saveGoodwillRow` удалить.
|
||||
- Produces (`utils/goodwill.ts`):
|
||||
`rowKey(row: Pick<GoodwillRow,'head_legal_entity'|'subsidiary_legal_entity'>): string` (`${head}:${sub}`);
|
||||
`parseShare(input: string): {ok: true; value: string | null} | {ok: false; error: string}` (пусто → `null`; запятая → точка);
|
||||
`previewAmounts(row: GoodwillRow, share: string | null, include: boolean): {participation: string | null; goodwill: string | null}`;
|
||||
`matchesSign(goodwill: string | null, sign: GoodwillSign): boolean`;
|
||||
`changedRows(rows: GoodwillRow[], edits: Record<string, GoodwillEdit>): GoodwillRowKeyIn[]`, где
|
||||
`GoodwillEdit = {include_in_kfo?: boolean; ownership_share?: string | null; comment?: string | null}`.
|
||||
|
||||
- [ ] **Step 1: тесты** `goodwill.test.ts`:
|
||||
`parseShare`: `"80,12"` → `{ok, "80.12"}`, `"100"` ok, `""` → `{ok, null}`, `"80,123"`, `"100,01"`, `"-1"`, `"abc"` → `{ok:false}`;
|
||||
`previewAmounts` (DOC-259025 §11.1): ФВ `"180000.00"`, УК `"100000.00"`, доля `"80"`, отмечена → `{participation:"80000.00", goodwill:"100000.00"}`;
|
||||
ФВ `"50000.00"` → goodwill `"-30000.00"`; УК `"371.17"`, доля `"99.99"` → participation `"371.13"` (HALF_UP: 371.13288…);
|
||||
не отмечена или доля `null` или УК `null` → `{null, null}`;
|
||||
`matchesSign`: `null` → false для всех, кроме `all`; `"0.00"` — только `zero`/`all`;
|
||||
`changedRows`: правка, совпадающая с исходной строкой, не попадает; снятая галочка у строки без доли даёт `ownership_share: null`.
|
||||
- [ ] **Step 2:** `npm test -- goodwill` — FAIL; реализовать (`previewAmounts` — в копейках через `toCents`/`fromCents`, доля в сотых долях
|
||||
процента `BigInt`, `P = round(УК_коп × доля_сотых / 10000)` HALF_UP); PASS.
|
||||
- [ ] **Step 3:** `npx tsc --noEmit` — ошибки только в компонентах вкладки (чинятся в Task 3); коммит «Гудвилл: типы и запросы по API бэка, чистые функции вкладки».
|
||||
|
||||
### Task 3: Вкладка по API, DOC-259025 и макету
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent.tsx`,
|
||||
`.../ConsolidationGoodwillTable/{ConsolidationGoodwillTable.tsx,config.tsx,ConsolidationGoodwillTable.css}`,
|
||||
`.../ConsolidationGoodwillSummary/ConsolidationGoodwillSummary.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2.
|
||||
|
||||
- [ ] **Step 1: отображение.** Суммы — `null` → «—», иначе формат единиц (ноль — `0,00`); строки «Нет вложений» (`!has_investments`) —
|
||||
серым, подпись «Нет вложений» в колонке сумм, чекбокс и поля недоступны; `warnings` — иконка `WarningOutlined` с `Tooltip` из `message`
|
||||
у названия ДО; «Всего» блока — из `groups[].totals`, «Итого по листу» — из `totals` (без `sumAmounts`/`Number`).
|
||||
- [ ] **Step 2: режим «Редактировать».** Правки — `Record<rowKey, GoodwillEdit>`, стартует пустым; P и G в режиме — `previewAmounts`;
|
||||
доля — `parseShare` с ошибкой под полем; групповой чекбокс отмечает/снимает все действующие строки блока, включая скрытые фильтром,
|
||||
`indeterminate` при частичном выборе; «Сохранить» недоступно, если у отмеченной действующей строки нет доли, доля с ошибкой или пустой комментарий;
|
||||
сохранение — один `saveGoodwillRows(changedRows(...))`, ответ заменяет таблицу и закрывает режим; `ApiError` с `body.detail.subsidiary_legal_entity_id`
|
||||
подсвечивает поле этой строки (`COMMENT_REQUIRED` → комментарий, `INVALID_SHARE` → доля), режим не закрывается; «Отменить изменения» — сброс правок.
|
||||
- [ ] **Step 3: переключатель.** `Switch` «Учитывать гудвилл в КФО» в панели инструментов, значение `goodwill_enabled`, доступен при `editable`;
|
||||
смена → `saveGoodwillSettings`, ответ заменяет таблицу; при выключенном — подпись «Гудвилл не учитывается в КФО».
|
||||
Если `!editable` — у «Редактировать» `Tooltip` с причиной (`LOCK_REQUIRED` — «Возьмите версию в работу», `LOCKED_BY_OTHER` — «Версия в работе у другого пользователя», `VERSION_NOT_EDITABLE` — «Версия недоступна для изменения»).
|
||||
- [ ] **Step 4: фильтр и экспорт.** Варианты «Все значения», «Положительные», «Отрицательные», «Нулевые» через `matchesSign`; экспорт передаёт текущий `sign`.
|
||||
- [ ] **Step 5:** `npm run format`, `make -C .. web-check`, `npm test`, `npm run lint` — зелёные; коммит «Гудвилл: вкладка по API и DOC-259025».
|
||||
|
||||
### Task 4: UI-спека и проверка на стенде
|
||||
|
||||
**Files:**
|
||||
- Create: `openspec/changes/goodwill-ui/{proposal.md,design.md,tasks.md,specs/goodwill-ui/spec.md}`
|
||||
|
||||
- [ ] **Step 1:** OpenSpec change `goodwill-ui` — требования Steps 1–4 Task 3 со сценариями WHEN/THEN, ссылки на контракт и DOC-259025;
|
||||
`design.md` — раздел `## Decisions made without discussion` (R-1: правки вне задачи откатены; R-2: итоги с бэка; R-3: предпросмотр P/G только по строкам,
|
||||
итоги — после сохранения); `openspec validate goodwill-ui --strict`.
|
||||
- [ ] **Step 2: стенд.** Поднять бэк из `.wt/review-222/arm-core` (ветка `feat/goodwill-back`, порт 8000, БД стенда), фронт отсюда
|
||||
(`REACT_APP_ROOT_PATH=/ npm run start`), версия «РБ 2024 Q3» или любая с ОСВ 58.01; в Playwright пройти: открыть вкладку, сверить
|
||||
с `31-03-goodwill.png`, «Редактировать» — сверить с `31-03-01-goodwill-edit.png`, ввести долю `80,5` и комментарий, сохранить, снять галочку
|
||||
у строки без доли и сохранить, переключить «Учитывать гудвилл», фильтр «Нулевые», экспорт. Скриншоты — в scratchpad, найденное — чинить в Task 3.
|
||||
- [ ] **Step 3:** коммит «Гудвилл: UI-спека goodwill-ui».
|
||||
2
openspec/changes/goodwill-ui/.openspec.yaml
Normal file
2
openspec/changes/goodwill-ui/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-06
|
||||
36
openspec/changes/goodwill-ui/design.md
Normal file
36
openspec/changes/goodwill-ui/design.md
Normal file
@ -0,0 +1,36 @@
|
||||
## Context
|
||||
|
||||
Экран — по контракту `../docs/dev/api-contract-goodwill.md`, DOC-259025 §4–§6 и макету `[31-03]`, `[31-03-01]`. Суммы —
|
||||
строки, арифметика только в копейках (`utils/money.ts`), чистые функции вкладки — `utils/goodwill.ts` с vitest.
|
||||
|
||||
## Decisions
|
||||
|
||||
- Итоги «Всего» и «Итого по листу» берутся из ответа (`groups[].totals`, `totals`), фронт их не считает.
|
||||
- В режиме редактирования P и G строки — предпросмотр (`previewAmounts`, та же формула и округление, что на бэке).
|
||||
- Сохранение — один `PUT …/goodwill` с изменёнными строками (`changedRows`); ошибка строки (422 с ключом пары)
|
||||
подсвечивает поле этой строки, режим не закрывается.
|
||||
- Переключатель `goodwill_enabled` меняется вне режима редактирования, отдельным запросом.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Ревью arm-core #220 (06.11.2026, владелец: «давай всё сделаем сами»).
|
||||
|
||||
- **R-1 (2026-11-06). Правки вне задачи в PR откатены к `main`.** Обработка ошибок в 8 файлах справочников, `Alert` в
|
||||
`CatalogueJurTable`, удалённые комментарии в `types/api.ts`, шахматке и корректировках. Обоснование: не относятся к
|
||||
AURORA-1544, давали ложный тост на незаполненной форме (`validateFields` бросает не `Error`) и конфликтовали с #219/#221.
|
||||
Оставлены общий фильтр единиц и `useFullscreen` (используется только вкладкой гудвилла). Цена ошибки: если правки нужны —
|
||||
отдельной задачей.
|
||||
- **R-2 (2026-11-06). Итоги — только с бэка** (правило владельца «агрегаты отдаёт бэк»). Цена ошибки: нет.
|
||||
- **R-3 (2026-11-06). Предпросмотр — только у строк;** «Всего» и «Итого по листу» в режиме правки показывают сохранённое
|
||||
и обновляются после «Сохранить». Обоснование: агрегаты не считаем на фронте (R-2). Цена ошибки: аналитик видит итог
|
||||
после сохранения, а не во время ввода (DOC-259025 §5.1 п. 3 говорит «предварительные … итоги»).
|
||||
- **R-4 (2026-11-06). `buildTree`/`rowClassName` без unit-теста.** Компоненты в проекте не тестируются (`web/CLAUDE.md`),
|
||||
логика тривиальна; чистая логика вкладки (`utils/goodwill.ts`, в т. ч. `effectiveRow`) покрыта vitest. Цена ошибки:
|
||||
регрессия дерева видна только глазами.
|
||||
- **R-5 (2026-11-06). Проверка на стенде — частично моками.** На локальном стенде нет пар 58.01 между участниками периметра:
|
||||
реальный бэк ветки проверен на пустой таблице, переключателе и экспорте, насыщенные состояния и 422 — подменой ответов
|
||||
в Playwright. Цена ошибки: совпадение предпросмотра P/G с сервером на реальных данных подтверждено только тестами; после
|
||||
мержа #222 пройти один сценарий на реальной паре.
|
||||
- **R-6 (2026-11-06). Снятая галочка с неверной долей уходит с сохранённой долей** (финальное ревью, I-1): поле доли у
|
||||
неотмеченной строки скрыто, неверный ввод там не исправить. Подсказка у «Сохранить» называет строки с ошибкой и
|
||||
предупреждает, если они скрыты фильтром. Цена ошибки: неверный ввод в скрытом поле молча отбрасывается.
|
||||
29
openspec/changes/goodwill-ui/proposal.md
Normal file
29
openspec/changes/goodwill-ui/proposal.md
Normal file
@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
AURORA-1544: вкладка «Гудвилл» страницы отчёта версии. Первая версия экрана (PR arm-core #220, Голов) строилась на
|
||||
фейковых данных по черновику контракта 30.10, а бэкенд (#222, AURORA-1543) сделан по аналитике DOC-259025 «Гудвилл»
|
||||
(Лысенко, 03.11.2026): пары по фактическим вложениям, строки «Нет вложений», nullable суммы, предупреждения, итоги по ГО,
|
||||
пакетное сохранение. Ревью 06.11.2026: экран переведён на фактический API и DOC-259025.
|
||||
|
||||
## What Changes
|
||||
|
||||
Репозиторий — arm-core, только `web/`. Контракт — `../docs/dev/api-contract-goodwill.md` (docs #204).
|
||||
|
||||
- Вкладка «Гудвилл» (`…/versions/{id}/goodwill`) доступна.
|
||||
- Таблица по макету `[31-03]`: блоки ГО, строки ДО, «Всего» блока и «Итого по листу» из ответа (`groups`, `totals`).
|
||||
- Режим «Редактировать» (`[31-03-01]`): галочки, доля, комментарий, предпросмотр P/G, пакетное сохранение.
|
||||
- Переключатель «Учитывать гудвилл в КФО» (`goodwill_enabled`, OQ-27), фильтр знака, экспорт с фильтром.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `goodwill-ui`: вкладка «Гудвилл».
|
||||
|
||||
### Modified Capabilities
|
||||
- `intercompany-matrix-ui`: вкладка «Гудвилл» больше не заглушка.
|
||||
|
||||
## Impact
|
||||
|
||||
`web/src/widgets/ConsolidationGoodwill/*`, `web/src/utils/goodwill.ts` (+ тесты), `web/src/types/goodwill/goodwill.ts`,
|
||||
`web/src/services/api/goodwill.ts`, `web/src/hooks/useFullScreen.tsx`, общий фильтр единиц
|
||||
`web/src/components/ConsolidationFormatTableFilter` (шахматка переведена на него), вкладки отчёта.
|
||||
62
openspec/changes/goodwill-ui/specs/goodwill-ui/spec.md
Normal file
62
openspec/changes/goodwill-ui/specs/goodwill-ui/spec.md
Normal file
@ -0,0 +1,62 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Таблица гудвилла
|
||||
Вкладка `goodwill` страницы отчёта версии SHALL загружать таблицу одним запросом `GET /consolidations/versions/{id}/goodwill`
|
||||
(контракт `../docs/dev/api-contract-goodwill.md`) и показывать блоки по ГО в порядке ответа: строка ГО, строки ДО, строка
|
||||
«Всего» блока из `groups[].totals`, внизу «Итого по листу» из `totals`. Колонки — по макету `[31-03]`: «Учет в отчете»,
|
||||
«Наименование дочерней организации», «Доля владения», «Фондовые вложения головной организации», «Доля участия головной
|
||||
организации в уставном капитале», «Уставный капитал», «Гудвилл (+/-)», «Комментарий». Сумма `null` SHALL показываться
|
||||
прочерком, ноль — нулём в выбранных единицах; итоги фронт MUST NOT считать сам.
|
||||
|
||||
#### Scenario: Нулевой и нерассчитанный гудвилл
|
||||
- **WHEN** у одной строки `goodwill_amount = "0.00"`, у другой `null`
|
||||
- **THEN** первая показывает «0,00», вторая — «—»
|
||||
|
||||
#### Scenario: Строка «Нет вложений»
|
||||
- **WHEN** у строки `has_investments = false`
|
||||
- **THEN** строка серая, с меткой «Нет вложений», чекбокс недоступен, суммы — прочерки, сохранённые доля и комментарий видны
|
||||
|
||||
#### Scenario: Предупреждения
|
||||
- **WHEN** у строки есть `warnings` (например, `CAPITAL_SOURCE_MISSING`)
|
||||
- **THEN** рядом с названием ДО иконка предупреждения, по наведению — тексты `message`
|
||||
|
||||
### Requirement: Редактирование таблицы гудвилла
|
||||
Кнопка «Редактировать» SHALL быть доступна при `editable = true`; иначе — недоступна с подсказкой причины
|
||||
(`read_only_reason`). В режиме редактирования (баннер «Доступны для редактирования столбцы «Доля владения» и
|
||||
«Комментарий».») у действующих строк доступна галочка; у отмеченной действующей строки — поля доли и комментария, P и G
|
||||
строки SHALL пересчитываться предварительно по введённой доле. Доля — 0–100, не больше двух знаков, запятая или точка;
|
||||
ошибочная доля MUST NOT обрезаться или подменяться, а показывается ошибкой у поля. «Сохранить» SHALL быть недоступно,
|
||||
пока у отмеченной действующей строки нет корректной доли или комментария. Групповой чекбокс блока SHALL отмечать и снимать
|
||||
все действующие строки блока, включая скрытые фильтром, и показывать промежуточное состояние при частичном выборе.
|
||||
«Отменить изменения» SHALL возвращать последнее сохранённое состояние.
|
||||
|
||||
#### Scenario: Сохранение набора
|
||||
- **WHEN** аналитик отметил две строки, ввёл доли «80,5» и «100» и комментарии и нажал «Сохранить»
|
||||
- **THEN** уходит один `PUT …/goodwill` только с изменёнными строками; ответ заменяет таблицу, режим редактирования закрывается
|
||||
|
||||
#### Scenario: Снятие галочки у строки без доли
|
||||
- **WHEN** аналитик снимает галочку у строки, где доля не вводилась, и сохраняет
|
||||
- **THEN** строка уходит с `ownership_share: null` и сохраняется без ошибки
|
||||
|
||||
#### Scenario: Ошибка строки от сервера
|
||||
- **WHEN** сервер отвечает 422 `COMMENT_REQUIRED` с ключом пары
|
||||
- **THEN** поле комментария этой строки подсвечено ошибкой, режим редактирования не закрыт, введённое не потеряно
|
||||
|
||||
#### Scenario: Лишние знаки доли
|
||||
- **WHEN** аналитик вводит долю «80,123»
|
||||
- **THEN** у поля ошибка «Число от 0 до 100, не больше двух знаков после запятой», значение в поле не меняется, «Сохранить» недоступно
|
||||
|
||||
### Requirement: Переключатель, фильтр и экспорт гудвилла
|
||||
Панель вкладки SHALL содержать переключатель «Учитывать гудвилл в КФО» (`PUT …/goodwill-settings`, доступен при
|
||||
`editable` вне режима редактирования) с подписью суммы `goodwill_in_kfo_amount` или «Гудвилл не учитывается в КФО»;
|
||||
фильтр «Гудвилл»: «Все значения», «Положительные», «Отрицательные», «Нулевые» (нерассчитанный гудвилл — только во «Все
|
||||
значения»); единицы и знаки после запятой; «Настройка таблицы»; «Экспорт» — `GET …/goodwill/export` с текущим фильтром
|
||||
(`sign`); «На весь экран».
|
||||
|
||||
#### Scenario: Экспорт с фильтром
|
||||
- **WHEN** выбран фильтр «Положительные» и нажат «Экспорт»
|
||||
- **THEN** запрос уходит с `sign=positive`
|
||||
|
||||
#### Scenario: Выключение гудвилла
|
||||
- **WHEN** аналитик выключает переключатель
|
||||
- **THEN** таблица перечитывается из ответа, под переключателем подпись «Гудвилл не учитывается в КФО», ручной ввод строк не меняется
|
||||
@ -0,0 +1,38 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Вкладки страницы отчёта по версии
|
||||
Страница `/groups/:group_id/versions/:version_id/:tab` SHALL показывать шапку версии (ГК, период, статус отчёта) один раз
|
||||
и под ней ряд вкладок шагов консолидации и содержимое активной вкладки. Активная вкладка определяется сегментом `:tab`:
|
||||
`osv` — «ОСВ» (сводная ОСВ), `chess` — «Шахматка», `correction` — «Таблица корректировки» (спека `corrections-table-ui`), `goodwill` — «Гудвилл» (спека `goodwill-ui`).
|
||||
Выбор вкладки SHALL менять последний сегмент адреса на ключ вкладки
|
||||
(навигация, не локальное состояние) заменой текущей записи истории браузера, без новой записи: кнопка «Назад» в шапке и
|
||||
кнопка «Назад» браузера ведут туда, откуда открыт отчёт, а не по переключениям вкладок (AURORA-1851). Активная вкладка
|
||||
подсвечена зелёным и выделена жирным. Неизвестный ключ вкладки SHALL
|
||||
заменяться на `osv` без новой записи в истории браузера. Вкладки «КФО», «КФО Общий»,
|
||||
«Подтверждение КФО клиента» SHALL быть недоступны с подсказкой «Будет в следующих срезах». При смене `version_id` всё
|
||||
состояние страницы (карточка версии, данные, фильтры) SHALL создаваться заново.
|
||||
|
||||
#### Scenario: Переход на шахматку
|
||||
- **WHEN** открыта сводная ОСВ версии по адресу `…/versions/{id}/osv` и нажата вкладка «Шахматка»
|
||||
- **THEN** адрес становится `…/versions/{id}/chess`, шапка версии остаётся, под вкладками показана шахматка
|
||||
|
||||
#### Scenario: Переход на таблицу корректировки
|
||||
- **WHEN** открыта шахматка версии по адресу `…/versions/{id}/chess` и нажата вкладка «Таблица корректировки»
|
||||
- **THEN** адрес становится `…/versions/{id}/correction`, шапка версии остаётся, под вкладками показана таблица корректировок
|
||||
|
||||
#### Scenario: Прежний адрес сводной ОСВ
|
||||
- **WHEN** открыт адрес `…/versions/{id}/osv`
|
||||
- **THEN** показана сводная ОСВ, вкладка «ОСВ» активна
|
||||
|
||||
#### Scenario: Неизвестная вкладка
|
||||
- **WHEN** открыт адрес `…/versions/{id}/abc`
|
||||
- **THEN** адрес заменяется на `…/versions/{id}/osv`, показана сводная ОСВ; кнопка «Назад» браузера не возвращает на `…/abc`
|
||||
|
||||
#### Scenario: Назад после переключения вкладок
|
||||
- **WHEN** аналитик открыл отчёт версии кнопкой «К отчётности» в списке консолидаций, переключился «Шахматка» → «ОСВ» →
|
||||
«Шахматка» и нажал «Назад» в шапке
|
||||
- **THEN** одним нажатием открыт список консолидаций ГК
|
||||
|
||||
#### Scenario: Переход на гудвилл
|
||||
- **WHEN** открыта таблица корректировки версии и нажата вкладка «Гудвилл»
|
||||
- **THEN** адрес становится `…/versions/{id}/goodwill`, шапка версии остаётся, под вкладками показана таблица гудвилла
|
||||
6
openspec/changes/goodwill-ui/tasks.md
Normal file
6
openspec/changes/goodwill-ui/tasks.md
Normal file
@ -0,0 +1,6 @@
|
||||
## 1. Доводка по ревью (план docs/superpowers/plans/2026-11-06-goodwill-review-front.md)
|
||||
|
||||
- [x] 1.1 Ветка без правок вне задачи, влит main (R-1).
|
||||
- [x] 1.2 Типы и запросы по API бэка, чистые функции `utils/goodwill.ts` с тестами.
|
||||
- [x] 1.3 Вкладка по API, DOC-259025 и макету.
|
||||
- [x] 1.4 UI-спека `goodwill-ui`, проверка на стенде.
|
||||
@ -1,14 +1,14 @@
|
||||
import { Select, Space } from 'antd';
|
||||
import type React from 'react';
|
||||
|
||||
interface ConsolidationChessFormatTableFilterI {
|
||||
interface ConsolidationFormatTableFilterI {
|
||||
decimals: number;
|
||||
onDecimalsChange: (val: number) => void;
|
||||
units: string;
|
||||
onUnitsChange: (val: string) => void;
|
||||
}
|
||||
|
||||
export const ConsolidationChessFormatTableFilter: React.FC<ConsolidationChessFormatTableFilterI> = ({
|
||||
export const ConsolidationFormatTableFilter: React.FC<ConsolidationFormatTableFilterI> = ({
|
||||
decimals,
|
||||
onDecimalsChange,
|
||||
units,
|
||||
29
web/src/hooks/useFullScreen.tsx
Normal file
29
web/src/hooks/useFullScreen.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNotificationApi } from '../utils/useNotification';
|
||||
|
||||
export function useFullscreen<T extends HTMLElement = HTMLDivElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const notification = useNotificationApi();
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setIsFullscreen(document.fullscreenElement === ref.current);
|
||||
document.addEventListener('fullscreenchange', onChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (!ref.current) return;
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await ref.current.requestFullscreen();
|
||||
}
|
||||
} catch (e) {
|
||||
notification.error({ description: e instanceof Error ? e.message : 'Не удалось переключить полноэкранный режим' });
|
||||
}
|
||||
}, [notification]);
|
||||
|
||||
return { ref, isFullscreen, toggle };
|
||||
}
|
||||
@ -16,6 +16,7 @@ import { statusColorMapping } from '../widgets/ConsoleTable/consts';
|
||||
import { OsvTabs } from '../widgets/ConsolidatedOsv/OsvTabs';
|
||||
import { OsvContent } from '../widgets/ConsolidatedOsvContent/ConsolidatedOsvContent';
|
||||
import { ChessContent } from '../widgets/ConsolidationChess/ConsolidationChessContent/ConsolidatedChessContent';
|
||||
import { GoodwillContent } from '../widgets/ConsolidationGoodwill/ConsolidationGoodwillContent/ConsolidationGoodwillContent';
|
||||
import { CorrectionContent } from '../widgets/CorrectionTable/CorrectionContent';
|
||||
import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal';
|
||||
|
||||
@ -34,6 +35,7 @@ function ReportContent() {
|
||||
chess: <ChessContent card={card} />,
|
||||
|
||||
correction: <CorrectionContent key={version_id} versionId={version_id ?? ''} card={card} />,
|
||||
goodwill: <GoodwillContent card={card} />,
|
||||
};
|
||||
|
||||
const ActiveTab = TAB_COMPONENTS[tab as string] ?? null;
|
||||
@ -64,7 +66,7 @@ function ReportContent() {
|
||||
}, [version_id]);
|
||||
|
||||
useEffect(() => {
|
||||
const isValidTab = !tab || tab === 'osv' || tab === 'chess' || tab === 'correction';
|
||||
const isValidTab = !tab || Object.keys(TAB_COMPONENTS).includes(tab);
|
||||
|
||||
if (!isValidTab) {
|
||||
const pathParts = pathname.split('/');
|
||||
|
||||
19
web/src/services/api/goodwill.ts
Normal file
19
web/src/services/api/goodwill.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { api, apiFetchBlob } from '../../api/client';
|
||||
import type { GoodwillRowKeyIn, GoodwillSettingsPayload, GoodwillSign, GoodwillTableResponse } from '../../types/goodwill/goodwill';
|
||||
|
||||
export function fetchGoodwillTable(versionId: string): Promise<GoodwillTableResponse> {
|
||||
return api.get<GoodwillTableResponse>(`/consolidations/versions/${versionId}/goodwill`);
|
||||
}
|
||||
|
||||
/** Пакет строк одной транзакцией: ошибка любой строки — ничего не сохранено, в `ApiError.body.detail` её ключ. */
|
||||
export function saveGoodwillRows(versionId: string, rows: GoodwillRowKeyIn[]): Promise<GoodwillTableResponse> {
|
||||
return api.put<GoodwillTableResponse>(`/consolidations/versions/${versionId}/goodwill`, { rows });
|
||||
}
|
||||
|
||||
export function saveGoodwillSettings(versionId: string, payload: GoodwillSettingsPayload): Promise<GoodwillTableResponse> {
|
||||
return api.put<GoodwillTableResponse>(`/consolidations/versions/${versionId}/goodwill-settings`, payload);
|
||||
}
|
||||
|
||||
export function exportGoodwillTable(versionId: string, sign: GoodwillSign): Promise<{ blob: Blob; filename: string | null }> {
|
||||
return apiFetchBlob(`/consolidations/versions/${versionId}/goodwill/export?sign=${sign}`);
|
||||
}
|
||||
77
web/src/types/goodwill/goodwill.ts
Normal file
77
web/src/types/goodwill/goodwill.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/** DTO таблицы гудвилла — по `api/src/schemas/goodwill.py` и `../docs/dev/api-contract-goodwill.md`. Деньги и доля — строки. */
|
||||
export interface Ref {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GoodwillWarning {
|
||||
code: 'CAPITAL_SOURCE_MISSING' | 'CAPITAL_SOURCE_AMBIGUOUS' | 'OWNERSHIP_TOTAL_EXCEEDED' | string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GoodwillRow {
|
||||
head_legal_entity: Ref;
|
||||
subsidiary_legal_entity: Ref;
|
||||
ownership_share: string | null;
|
||||
/** null — «Нет вложений» */
|
||||
financial_investment_amount: string | null;
|
||||
/** null — нет однозначного 1310 или «Нет вложений» */
|
||||
charter_capital_amount: string | null;
|
||||
additional_capital_amount: string | null;
|
||||
/** только у действующей отмеченной строки с долей и УК */
|
||||
participation_amount: string | null;
|
||||
goodwill_amount: string | null;
|
||||
include_in_kfo: boolean;
|
||||
comment: string | null;
|
||||
has_investments: boolean;
|
||||
warnings: GoodwillWarning[];
|
||||
updated_by: Ref | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface GoodwillTotals {
|
||||
financial_investment_amount: string;
|
||||
charter_capital_amount: string;
|
||||
participation_amount: string;
|
||||
goodwill_amount: string;
|
||||
goodwill_in_kfo_amount: string;
|
||||
}
|
||||
|
||||
export interface GoodwillGroup {
|
||||
head_legal_entity: Ref;
|
||||
totals: GoodwillTotals;
|
||||
}
|
||||
|
||||
export type GoodwillReadOnlyReason = 'VERSION_NOT_EDITABLE' | 'LOCK_REQUIRED' | 'LOCKED_BY_OTHER';
|
||||
|
||||
export interface GoodwillTableResponse {
|
||||
version_id: string;
|
||||
goodwill_enabled: boolean;
|
||||
editable: boolean;
|
||||
read_only_reason: GoodwillReadOnlyReason | null;
|
||||
rows: GoodwillRow[];
|
||||
groups: GoodwillGroup[];
|
||||
totals: GoodwillTotals;
|
||||
}
|
||||
|
||||
/** Строка пакета `PUT …/goodwill`. */
|
||||
export interface GoodwillRowKeyIn {
|
||||
head_legal_entity_id: string;
|
||||
subsidiary_legal_entity_id: string;
|
||||
ownership_share: string | null;
|
||||
include_in_kfo: boolean;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
export interface GoodwillSettingsPayload {
|
||||
goodwill_enabled: boolean;
|
||||
}
|
||||
|
||||
export type GoodwillSign = 'all' | 'positive' | 'negative' | 'zero';
|
||||
|
||||
/** Несохранённая правка строки в режиме «Редактировать»; отсутствующее поле — как в сохранённой строке. */
|
||||
export interface GoodwillEdit {
|
||||
include_in_kfo?: boolean;
|
||||
ownership_share?: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
112
web/src/utils/goodwill.test.ts
Normal file
112
web/src/utils/goodwill.test.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GoodwillRow } from '../types/goodwill/goodwill';
|
||||
import { changedRows, effectiveRow, matchesSign, parseShare, previewAmounts, rowKey } from './goodwill';
|
||||
|
||||
function row(patch: Partial<GoodwillRow> = {}): GoodwillRow {
|
||||
return {
|
||||
head_legal_entity: { id: 'a', name: 'Альфа' },
|
||||
subsidiary_legal_entity: { id: 'b', name: 'Бета' },
|
||||
ownership_share: null,
|
||||
financial_investment_amount: '180000.00',
|
||||
charter_capital_amount: '100000.00',
|
||||
additional_capital_amount: null,
|
||||
participation_amount: null,
|
||||
goodwill_amount: null,
|
||||
include_in_kfo: false,
|
||||
comment: null,
|
||||
has_investments: true,
|
||||
warnings: [],
|
||||
updated_by: null,
|
||||
updated_at: null,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe('parseShare', () => {
|
||||
it('принимает запятую и точку, не больше двух знаков', () => {
|
||||
expect(parseShare('80,12')).toEqual({ ok: true, value: '80.12' });
|
||||
expect(parseShare('100')).toEqual({ ok: true, value: '100' });
|
||||
expect(parseShare(' 7.5 ')).toEqual({ ok: true, value: '7.5' });
|
||||
});
|
||||
it('пустое — null', () => {
|
||||
expect(parseShare('')).toEqual({ ok: true, value: null });
|
||||
});
|
||||
it.each(['80,123', '100,01', '-1', 'abc', '1e2', '101'])('отклоняет %s без обрезки', (input) => {
|
||||
expect(parseShare(input).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previewAmounts (DOC-259025 §11.1)', () => {
|
||||
it('Бета: P = 80 000, G = +100 000', () => {
|
||||
expect(previewAmounts(row(), '80', true)).toEqual({ participation: '80000.00', goodwill: '100000.00' });
|
||||
});
|
||||
it('Гамма: G отрицательный', () => {
|
||||
expect(previewAmounts(row({ financial_investment_amount: '50000.00' }), '80', true)).toEqual({
|
||||
participation: '80000.00',
|
||||
goodwill: '-30000.00',
|
||||
});
|
||||
});
|
||||
it('округление половина вверх до копеек', () => {
|
||||
expect(previewAmounts(row({ charter_capital_amount: '371.17' }), '99.99', true).participation).toBe('371.13');
|
||||
expect(previewAmounts(row({ charter_capital_amount: '0.05' }), '50', true).participation).toBe('0.03');
|
||||
});
|
||||
it('не отмечена, нет доли или УК — не рассчитано', () => {
|
||||
const empty = { participation: null, goodwill: null };
|
||||
expect(previewAmounts(row(), '80', false)).toEqual(empty);
|
||||
expect(previewAmounts(row(), null, true)).toEqual(empty);
|
||||
expect(previewAmounts(row({ charter_capital_amount: null }), '80', true)).toEqual(empty);
|
||||
expect(previewAmounts(row({ has_investments: false, financial_investment_amount: null }), '80', true)).toEqual(empty);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchesSign', () => {
|
||||
it('нерассчитанный гудвилл попадает только во «все»', () => {
|
||||
expect(matchesSign(null, 'all')).toBe(true);
|
||||
expect(matchesSign(null, 'positive')).toBe(false);
|
||||
expect(matchesSign(null, 'negative')).toBe(false);
|
||||
expect(matchesSign(null, 'zero')).toBe(false);
|
||||
});
|
||||
it('ноль — только «нулевые»', () => {
|
||||
expect(matchesSign('0.00', 'zero')).toBe(true);
|
||||
expect(matchesSign('0.00', 'positive')).toBe(false);
|
||||
expect(matchesSign('-0.01', 'negative')).toBe(true);
|
||||
expect(matchesSign('0.01', 'positive')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changedRows', () => {
|
||||
const saved = row({ ownership_share: '80.00', include_in_kfo: true, comment: 'Устав' });
|
||||
it('правка, совпадающая с сохранённым, не уходит', () => {
|
||||
const edits = { [rowKey(saved)]: { include_in_kfo: true, ownership_share: '80', comment: 'Устав' } };
|
||||
expect(changedRows([saved], edits)).toEqual([]);
|
||||
});
|
||||
it('изменённая строка уходит целиком', () => {
|
||||
const edits = { [rowKey(saved)]: { comment: 'Устав 2024' } };
|
||||
expect(changedRows([saved], edits)).toEqual([
|
||||
{ head_legal_entity_id: 'a', subsidiary_legal_entity_id: 'b', ownership_share: '80.00', include_in_kfo: true, comment: 'Устав 2024' },
|
||||
]);
|
||||
});
|
||||
it('снятая галочка у строки без доли — ownership_share: null', () => {
|
||||
const fresh = row({ include_in_kfo: true });
|
||||
expect(changedRows([fresh], { [rowKey(fresh)]: { include_in_kfo: false } })).toEqual([
|
||||
{ head_legal_entity_id: 'a', subsidiary_legal_entity_id: 'b', ownership_share: null, include_in_kfo: false, comment: null },
|
||||
]);
|
||||
});
|
||||
it('доля с запятой уходит с точкой', () => {
|
||||
const fresh = row();
|
||||
const edits = { [rowKey(fresh)]: { include_in_kfo: true, ownership_share: '80,5', comment: 'Да' } };
|
||||
expect(changedRows([fresh], edits)[0].ownership_share).toBe('80.5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveRow', () => {
|
||||
it('снятая галочка с неверной долей уходит с сохранённой долей (ошибку у скрытого поля не исправить)', () => {
|
||||
const saved = row({ ownership_share: '80.00' });
|
||||
expect(effectiveRow(saved, { include_in_kfo: false, ownership_share: '80,123' }).ownership_share).toBe('80.00');
|
||||
const fresh = row();
|
||||
expect(effectiveRow(fresh, { include_in_kfo: false, ownership_share: 'abc' }).ownership_share).toBeNull();
|
||||
});
|
||||
it('у отмеченной строки неверная доля остаётся как введена — ошибку видно у поля', () => {
|
||||
expect(effectiveRow(row(), { include_in_kfo: true, ownership_share: '80,123' }).ownership_share).toBe('80,123');
|
||||
});
|
||||
});
|
||||
95
web/src/utils/goodwill.ts
Normal file
95
web/src/utils/goodwill.ts
Normal file
@ -0,0 +1,95 @@
|
||||
/** Чистые функции вкладки «Гудвилл» (DOC-259025 §4–§6): ввод доли, предпросмотр P/G, фильтр знака, изменённые строки. */
|
||||
import type { GoodwillEdit, GoodwillRow, GoodwillRowKeyIn, GoodwillSign } from '../types/goodwill/goodwill';
|
||||
import { fromCents, toCents } from './money';
|
||||
|
||||
const SHARE = /^\d{1,3}(?:\.\d{1,2})?$/;
|
||||
|
||||
export function rowKey(row: Pick<GoodwillRow, 'head_legal_entity' | 'subsidiary_legal_entity'>): string {
|
||||
return `${row.head_legal_entity.id}:${row.subsidiary_legal_entity.id}`;
|
||||
}
|
||||
|
||||
/** Доля 0–100, не больше двух знаков, запятая или точка; пусто — null. Лишние знаки — ошибка, не обрезка. */
|
||||
export function parseShare(input: string): { ok: true; value: string | null } | { ok: false; error: string } {
|
||||
const value = input.trim().replace(',', '.');
|
||||
if (value === '') return { ok: true, value: null };
|
||||
if (!SHARE.test(value) || shareHundredths(value) > 10000n) {
|
||||
return { ok: false, error: 'Число от 0 до 100, не больше двух знаков после запятой' };
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/** Доля строкой («80.5») → сотые доли процента (8050n). */
|
||||
function shareHundredths(value: string): bigint {
|
||||
const [int, frac = ''] = value.split('.');
|
||||
return BigInt(int) * 100n + BigInt(frac.padEnd(2, '0'));
|
||||
}
|
||||
|
||||
/** Предварительные P = round(УК × d / 100, 2) и G = ФВ − P в копейках, половина вверх; как на бэке. */
|
||||
export function previewAmounts(
|
||||
row: GoodwillRow,
|
||||
share: string | null,
|
||||
include: boolean,
|
||||
): { participation: string | null; goodwill: string | null } {
|
||||
const fv = row.financial_investment_amount;
|
||||
const capital = row.charter_capital_amount;
|
||||
if (!row.has_investments || !include || share === null || fv === null || capital === null) {
|
||||
return { participation: null, goodwill: null };
|
||||
}
|
||||
const parsed = parseShare(share);
|
||||
if (!parsed.ok || parsed.value === null) return { participation: null, goodwill: null };
|
||||
const product = toCents(capital) * shareHundredths(parsed.value);
|
||||
const divisor = 10000n;
|
||||
const negative = product < 0n;
|
||||
const abs = negative ? -product : product;
|
||||
const rounded = (abs + divisor / 2n) / divisor;
|
||||
const p = negative ? -rounded : rounded;
|
||||
return { participation: fromCents(p), goodwill: fromCents(toCents(fv) - p) };
|
||||
}
|
||||
|
||||
/** Фильтр «Гудвилл»: нерассчитанный (null) — только во «Все значения». */
|
||||
export function matchesSign(goodwill: string | null, sign: GoodwillSign): boolean {
|
||||
if (sign === 'all') return true;
|
||||
if (goodwill === null) return false;
|
||||
const cents = toCents(goodwill);
|
||||
if (sign === 'positive') return cents > 0n;
|
||||
if (sign === 'negative') return cents < 0n;
|
||||
return cents === 0n;
|
||||
}
|
||||
|
||||
/** Значения строки с учётом правки: что уйдёт в пакет. */
|
||||
export function effectiveRow(row: GoodwillRow, edit: GoodwillEdit | undefined): GoodwillRowKeyIn {
|
||||
const include = edit?.include_in_kfo ?? row.include_in_kfo;
|
||||
const share = edit && 'ownership_share' in edit ? (edit.ownership_share ?? null) : row.ownership_share;
|
||||
const parsed = share === null ? null : parseShare(share);
|
||||
const comment = edit && 'comment' in edit ? (edit.comment ?? null) : row.comment;
|
||||
let ownership = parsed?.ok ? parsed.value : share;
|
||||
// у неотмеченной строки поле доли скрыто: неверный ввод не исправить, поэтому уходит сохранённая доля
|
||||
if (!include && parsed && !parsed.ok) ownership = row.ownership_share;
|
||||
return {
|
||||
head_legal_entity_id: row.head_legal_entity.id,
|
||||
subsidiary_legal_entity_id: row.subsidiary_legal_entity.id,
|
||||
ownership_share: ownership,
|
||||
include_in_kfo: include,
|
||||
comment: comment?.trim() ? comment : null,
|
||||
};
|
||||
}
|
||||
|
||||
function sameShare(a: string | null, b: string | null): boolean {
|
||||
if (a === null || b === null) return a === b;
|
||||
if (!SHARE.test(a) || !SHARE.test(b)) return a === b;
|
||||
return shareHundredths(a) === shareHundredths(b);
|
||||
}
|
||||
|
||||
/** Строки пакета — только те, где правка отличается от сохранённого. */
|
||||
export function changedRows(rows: GoodwillRow[], edits: Record<string, GoodwillEdit>): GoodwillRowKeyIn[] {
|
||||
return rows.flatMap((row) => {
|
||||
const edit = edits[rowKey(row)];
|
||||
if (!edit) return [];
|
||||
const next = effectiveRow(row, edit);
|
||||
const same =
|
||||
next.include_in_kfo === row.include_in_kfo &&
|
||||
sameShare(next.ownership_share, row.ownership_share) &&
|
||||
(next.comment?.trim() ?? null) === (row.comment?.trim() || null);
|
||||
return same ? [] : [next];
|
||||
});
|
||||
}
|
||||
@ -22,8 +22,6 @@ const TAB_CONFIG = [
|
||||
{
|
||||
key: 'goodwill',
|
||||
label: 'Гудвилл',
|
||||
disabled: true,
|
||||
tooltip: 'Будет в следующих срезах',
|
||||
},
|
||||
{
|
||||
key: 'kfo',
|
||||
|
||||
@ -3,6 +3,7 @@ import { Button, ConfigProvider } from 'antd';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { ApiError } from '../../../api/client';
|
||||
import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter';
|
||||
import {
|
||||
exportChessConsolidationsResult,
|
||||
exportIntercompanyMatrix,
|
||||
@ -20,7 +21,6 @@ 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';
|
||||
import { ConsolidationChessResTable } from '../ConsolidationChessResTable/ConsolidationChessResTable';
|
||||
import { ConsolidationChessTable } from '../ConsolidationChessTable/ConsolidationChessTable';
|
||||
import { ConsolidationChessTableFilter } from '../ConsolidationChessTableFilter/ConsolidationChessTableFilter';
|
||||
@ -236,7 +236,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
||||
backgroundColor: '#e5e7eb',
|
||||
}}
|
||||
/>
|
||||
<ConsolidationChessFormatTableFilter
|
||||
<ConsolidationFormatTableFilter
|
||||
decimals={decimals}
|
||||
units={units}
|
||||
onDecimalsChange={setDecimals}
|
||||
@ -285,7 +285,7 @@ export const ChessContent = ({ card }: { card: VersionCard | null }) => {
|
||||
gap: 16,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
|
||||
<ConsolidationChessFormatTableFilter
|
||||
<ConsolidationFormatTableFilter
|
||||
decimals={chessResDecimals}
|
||||
units={chessResUnits}
|
||||
onDecimalsChange={setChessResDecimals}
|
||||
|
||||
@ -0,0 +1,320 @@
|
||||
import { DownloadOutlined, EditOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
|
||||
import { Alert, Button, ConfigProvider, Select, Space, Switch, Tooltip, Typography } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { ApiError } from '../../../api/client';
|
||||
import { ConsolidationFormatTableFilter } from '../../../components/ConsolidationFormatTableFilter/ConsolidationFormatTableFilter';
|
||||
import { type ColumnDef, ColumnSettings, useVisibleColumns } from '../../../components/GenericTable';
|
||||
import { useFullscreen } from '../../../hooks/useFullScreen';
|
||||
import { exportGoodwillTable, fetchGoodwillTable, saveGoodwillRows, saveGoodwillSettings } from '../../../services/api/goodwill';
|
||||
import type { VersionCard } from '../../../types/consolidations/version_members';
|
||||
import type { GoodwillEdit, GoodwillSign, GoodwillTableResponse } from '../../../types/goodwill/goodwill';
|
||||
import { saveBlob, xlsxFilename } from '../../../utils/download';
|
||||
import { changedRows, effectiveRow, matchesSign, parseShare, rowKey } from '../../../utils/goodwill';
|
||||
import { useNotificationApi } from '../../../utils/useNotification';
|
||||
import { readOnlyHint } from '../../CorrectionTable/readOnly';
|
||||
import { ConsolidationGoodwillTable } from '../ConsolidationGoodwillTable/ConsolidationGoodwillTable';
|
||||
import { type GoodwillTreeRow, type RowIssues, formatMoney, getGoodwillColumns } from '../ConsolidationGoodwillTable/config';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SIGN_OPTIONS: { value: GoodwillSign; label: string }[] = [
|
||||
{ value: 'all', label: 'Все значения' },
|
||||
{ value: 'positive', label: 'Положительные' },
|
||||
{ value: 'negative', label: 'Отрицательные' },
|
||||
{ value: 'zero', label: 'Нулевые' },
|
||||
];
|
||||
|
||||
/** Поле строки, к которому относится ошибка сервера (422 с ключом пары). */
|
||||
const ERROR_FIELD: Record<string, keyof RowIssues> = { COMMENT_REQUIRED: 'comment', INVALID_SHARE: 'ownership_share' };
|
||||
|
||||
type RowErrorDetail = { code?: string; message?: string; head_legal_entity_id?: string; subsidiary_legal_entity_id?: string };
|
||||
|
||||
/** Ключ пары из ошибки пакетного сохранения; ошибка не по строке — null. */
|
||||
function rowErrorDetail(error: unknown): RowErrorDetail | null {
|
||||
if (!(error instanceof ApiError)) return null;
|
||||
const detail = (error.body as { detail?: unknown } | undefined)?.detail;
|
||||
if (typeof detail !== 'object' || detail === null || Array.isArray(detail)) return null;
|
||||
const d = detail as RowErrorDetail;
|
||||
return d.head_legal_entity_id && d.subsidiary_legal_entity_id ? d : null;
|
||||
}
|
||||
|
||||
export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
|
||||
const { version_id } = useParams<{ version_id: string }>();
|
||||
const notification = useNotificationApi();
|
||||
|
||||
const [data, setData] = useState<GoodwillTableResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [settingsSaving, setSettingsSaving] = useState(false);
|
||||
const [decimals, setDecimals] = useState(2);
|
||||
const [units, setUnits] = useState('thousand');
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [edits, setEdits] = useState<Record<string, GoodwillEdit>>({});
|
||||
const [serverIssues, setServerIssues] = useState<Record<string, RowIssues>>({});
|
||||
const [sign, setSign] = useState<GoodwillSign>('all');
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const versionInfo = card ? { groupName: card.company_group.name, year: card.year, quarter: card.quarter } : null;
|
||||
const { ref: tableRef, isFullscreen, toggle: toggleFullscreen } = useFullscreen<HTMLDivElement>();
|
||||
|
||||
const loadData = useCallback(
|
||||
async (vId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await fetchGoodwillTable(vId));
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) notification.error({ description: e.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[notification],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (version_id) loadData(version_id);
|
||||
}, [version_id, loadData]);
|
||||
|
||||
const editRow = useCallback((key: string, patch: GoodwillEdit, clear?: keyof RowIssues) => {
|
||||
setEdits((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }));
|
||||
if (clear) {
|
||||
setServerIssues((prev) => (prev[key]?.[clear] ? { ...prev, [key]: { ...prev[key], [clear]: undefined } } : prev));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onToggleGroup = useCallback(
|
||||
(headId: string, checked: boolean) => {
|
||||
// все действующие строки блока, включая скрытые фильтром; «Нет вложений» не трогаем (DOC-259025 §4)
|
||||
const keys = (data?.rows ?? []).filter((r) => r.head_legal_entity.id === headId && r.has_investments).map(rowKey);
|
||||
setEdits((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const key of keys) next[key] = { ...next[key], include_in_kfo: checked };
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[data],
|
||||
);
|
||||
|
||||
/** Ошибки ввода: отмеченной действующей строке нужны корректная доля и комментарий (DOC-259025 §5.2). */
|
||||
const issues = useMemo(() => {
|
||||
const result: Record<string, RowIssues> = {};
|
||||
if (!data || !isEditMode) return result;
|
||||
for (const row of data.rows) {
|
||||
const key = rowKey(row);
|
||||
const next = effectiveRow(row, edits[key]);
|
||||
const own: RowIssues = { ...serverIssues[key] };
|
||||
if (row.has_investments && next.include_in_kfo) {
|
||||
const parsed = next.ownership_share === null ? null : parseShare(next.ownership_share);
|
||||
if (parsed === null) own.ownership_share = own.ownership_share ?? 'Заполните долю';
|
||||
else if (!parsed.ok) own.ownership_share = parsed.error;
|
||||
if (next.comment === null) own.comment = own.comment ?? 'Заполните комментарий';
|
||||
}
|
||||
if (own.ownership_share || own.comment) result[key] = own;
|
||||
}
|
||||
return result;
|
||||
}, [data, edits, isEditMode, serverIssues]);
|
||||
|
||||
const pending = useMemo(() => (data ? changedRows(data.rows, edits) : []), [data, edits]);
|
||||
const hasIssues = Object.keys(issues).length > 0;
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getGoodwillColumns({
|
||||
units,
|
||||
decimals,
|
||||
isEditMode,
|
||||
edits,
|
||||
issues,
|
||||
rows: data?.rows ?? [],
|
||||
onToggleRow: (key, checked) => editRow(key, { include_in_kfo: checked }),
|
||||
onToggleGroup,
|
||||
onShare: (key, value) => editRow(key, { ownership_share: value }, 'ownership_share'),
|
||||
onComment: (key, value) => editRow(key, { comment: value }, 'comment'),
|
||||
}),
|
||||
[units, decimals, isEditMode, edits, issues, data, editRow, onToggleGroup],
|
||||
);
|
||||
const columnsConfig = useMemo(() => ({ rowKey: 'key' as const, columns: columns as ColumnDef<GoodwillTreeRow>[] }), [columns]);
|
||||
const { visibleKeys, setVisibleKeys } = useVisibleColumns(columnsConfig);
|
||||
const visibleColumns = useMemo(
|
||||
() => columns.filter((col) => (visibleKeys as string[]).includes(String(col.key))),
|
||||
[columns, visibleKeys],
|
||||
);
|
||||
|
||||
const filteredRows = useMemo(() => (data?.rows ?? []).filter((row) => matchesSign(row.goodwill_amount, sign)), [data, sign]);
|
||||
|
||||
/** Подсказка у «Сохранить»: какие строки мешают, и не скрыты ли они фильтром или настройкой колонок. */
|
||||
const issuesHint = useMemo(() => {
|
||||
if (!data || !hasIssues) return undefined;
|
||||
const keys = Object.keys(issues);
|
||||
const names = data.rows.filter((r) => keys.includes(rowKey(r))).map((r) => r.subsidiary_legal_entity.name);
|
||||
const visible = new Set(filteredRows.map(rowKey));
|
||||
const hidden = keys.some((k) => !visible.has(k)) ? ' Часть строк скрыта фильтром — сбросьте фильтр.' : '';
|
||||
return `Заполните долю и комментарий: ${names.join(', ')}.${hidden}`;
|
||||
}, [data, hasIssues, issues, filteredRows]);
|
||||
|
||||
const closeEditing = () => {
|
||||
setEdits({});
|
||||
setServerIssues({});
|
||||
setIsEditMode(false);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!version_id || !data) return;
|
||||
if (pending.length === 0) {
|
||||
closeEditing();
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
setData(await saveGoodwillRows(version_id, pending));
|
||||
closeEditing();
|
||||
notification.success({ description: 'Изменения сохранены' });
|
||||
} catch (e) {
|
||||
const detail = rowErrorDetail(e);
|
||||
if (detail) {
|
||||
const key = `${detail.head_legal_entity_id}:${detail.subsidiary_legal_entity_id}`;
|
||||
const field = ERROR_FIELD[detail.code ?? ''] ?? 'ownership_share';
|
||||
setServerIssues((prev) => ({ ...prev, [key]: { ...prev[key], [field]: detail.message } }));
|
||||
}
|
||||
if (e instanceof ApiError) notification.error({ description: e.message });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettings = async (enabled: boolean) => {
|
||||
if (!version_id) return;
|
||||
setSettingsSaving(true);
|
||||
try {
|
||||
setData(await saveGoodwillSettings(version_id, { goodwill_enabled: enabled }));
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) notification.error({ description: e.message });
|
||||
} finally {
|
||||
setSettingsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
if (!version_id) return;
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const { blob, filename } = await exportGoodwillTable(version_id, sign);
|
||||
saveBlob(blob, filename ?? xlsxFilename('Гудвилл', versionInfo));
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) notification.error({ description: e.message });
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const readOnly = data && !data.editable ? readOnlyHint(data.read_only_reason) : null;
|
||||
|
||||
return (
|
||||
<ConfigProvider getPopupContainer={() => (document.fullscreenElement as HTMLElement) || document.body}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: '100%' }}>
|
||||
{isEditMode && (
|
||||
<Alert
|
||||
title={<span style={{ color: '#874d00' }}>Доступны для редактирования столбцы «Доля владения» и «Комментарий».</span>}
|
||||
type='warning'
|
||||
action={
|
||||
<Space>
|
||||
<Button onClick={closeEditing}>Отменить изменения</Button>
|
||||
<Tooltip title={issuesHint}>
|
||||
<Button type='primary' disabled={hasIssues} loading={saving} onClick={handleSave}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={tableRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: isFullscreen ? '100vh' : 'auto',
|
||||
padding: isFullscreen ? 16 : 0,
|
||||
backgroundColor: isFullscreen ? '#fff' : 'transparent',
|
||||
}}>
|
||||
<ConsolidationGoodwillTable
|
||||
data={data}
|
||||
rows={filteredRows}
|
||||
columns={visibleColumns}
|
||||
loading={loading}
|
||||
decimals={decimals}
|
||||
units={units}
|
||||
isFullscreen={isFullscreen}
|
||||
toolbar={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
flexWrap: 'wrap',
|
||||
gap: 16,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', flexWrap: 'wrap', gap: 16, flex: '1 1 auto' }}>
|
||||
<Space>
|
||||
<Space orientation='vertical' size={0}>
|
||||
<span style={{ fontSize: 12, color: '#667085' }}>Гудвилл</span>
|
||||
<Select style={{ minWidth: 200 }} value={sign} options={SIGN_OPTIONS} onChange={setSign} variant='underlined' />
|
||||
</Space>
|
||||
{sign !== 'all' ? <Button onClick={() => setSign('all')}>Сбросить фильтры</Button> : null}
|
||||
</Space>
|
||||
<div style={{ width: 1, height: 50, backgroundColor: '#e5e7eb' }} />
|
||||
<ConsolidationFormatTableFilter
|
||||
decimals={decimals}
|
||||
units={units}
|
||||
onDecimalsChange={setDecimals}
|
||||
onUnitsChange={setUnits}
|
||||
/>
|
||||
<div style={{ width: 1, height: 50, backgroundColor: '#e5e7eb' }} />
|
||||
<Space orientation='vertical' size={0}>
|
||||
<Space>
|
||||
<Tooltip title={readOnly}>
|
||||
<Switch
|
||||
checked={data?.goodwill_enabled ?? false}
|
||||
disabled={!data?.editable || isEditMode}
|
||||
loading={settingsSaving}
|
||||
onChange={handleSettings}
|
||||
aria-label='Учитывать гудвилл в КФО'
|
||||
/>
|
||||
</Tooltip>
|
||||
<span>Учитывать гудвилл в КФО</span>
|
||||
</Space>
|
||||
<Text type='secondary' style={{ fontSize: 12 }}>
|
||||
{data?.goodwill_enabled
|
||||
? `В КФО: ${formatMoney(data.totals.goodwill_in_kfo_amount, units, decimals)}`
|
||||
: 'Гудвилл не учитывается в КФО'}
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<ColumnSettings defs={columnsConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
|
||||
<Tooltip title={readOnly}>
|
||||
<Button
|
||||
type={isEditMode ? 'default' : 'primary'}
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setIsEditMode(true)}
|
||||
disabled={isEditMode || !data?.editable}>
|
||||
Редактировать
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button loading={exportLoading} onClick={download} icon={<DownloadOutlined />}>
|
||||
Экспорт
|
||||
</Button>
|
||||
<Button onClick={toggleFullscreen} icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
|
||||
{isFullscreen ? 'Свернуть' : 'На весь экран'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
import { Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { GoodwillTotals } from '../../../types/goodwill/goodwill';
|
||||
import { type GoodwillTreeRow, type MoneyKey, formatMoney } from '../ConsolidationGoodwillTable/config';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const MONEY_KEYS = new Set<string>(['financial_investment_amount', 'participation_amount', 'charter_capital_amount', 'goodwill_amount']);
|
||||
|
||||
interface ConsolidationGoodwillSummaryI {
|
||||
totals: GoodwillTotals | null;
|
||||
columns: ColumnsType<GoodwillTreeRow>;
|
||||
units: string;
|
||||
decimals: number;
|
||||
}
|
||||
|
||||
/** «Итого по листу» — `totals` ответа (по действующим строкам, P и G — по отмеченным). */
|
||||
export const ConsolidationGoodwillSummary: React.FC<ConsolidationGoodwillSummaryI> = ({ totals, columns, units, decimals }) => {
|
||||
if (!totals) return null;
|
||||
return (
|
||||
<Table.Summary fixed>
|
||||
<Table.Summary.Row className='resRow'>
|
||||
{columns.map((col, index) => {
|
||||
const key = String(col.key);
|
||||
if (key === 'name') {
|
||||
return (
|
||||
<Table.Summary.Cell key={key} index={index}>
|
||||
<Text strong>Итого по листу</Text>
|
||||
</Table.Summary.Cell>
|
||||
);
|
||||
}
|
||||
if (MONEY_KEYS.has(key)) {
|
||||
return (
|
||||
<Table.Summary.Cell key={key} index={index} align='right'>
|
||||
<Text strong>{formatMoney(totals[key as MoneyKey], units, decimals)}</Text>
|
||||
</Table.Summary.Cell>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Table.Summary.Cell key={key} index={index} align='center'>
|
||||
<Text strong>—</Text>
|
||||
</Table.Summary.Cell>
|
||||
);
|
||||
})}
|
||||
</Table.Summary.Row>
|
||||
</Table.Summary>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
.goodwillTable .empty-cell {
|
||||
background-color: #f2f4f7;
|
||||
}
|
||||
.goodwillTable .ant-table-tbody > tr:hover > td.empty-cell {
|
||||
background-color: #f2f4f7 !important;
|
||||
}
|
||||
|
||||
.goodwillTable .ant-table-summary {
|
||||
border-end-end-radius: 8px !important;
|
||||
border-end-start-radius: 8px !important;
|
||||
}
|
||||
|
||||
.goodwillTable .ant-table-cell-fix {
|
||||
background-color: #f8fafc !important;
|
||||
}
|
||||
.goodwillTable .resRow {
|
||||
background-color: #e6f4ea !important ;
|
||||
}
|
||||
|
||||
.goodwillTable .group-total-row td {
|
||||
background-color: #ecfdf3 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.goodwillTable .goodwill-inactive td {
|
||||
color: #98a2b3;
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import { Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { GoodwillRow, GoodwillTableResponse } from '../../../types/goodwill/goodwill';
|
||||
import { rowKey } from '../../../utils/goodwill';
|
||||
import './ConsolidationGoodwillTable.css';
|
||||
import { ConsolidationGoodwillSummary } from '../ConsolidationGoodwillSummary/ConsolidationGoodwillSummary';
|
||||
import type { GoodwillTreeRow } from './config';
|
||||
|
||||
interface ConsolidationGoodwillTableI {
|
||||
data: GoodwillTableResponse | null;
|
||||
/** строки после фильтра знака */
|
||||
rows: GoodwillRow[];
|
||||
columns: ColumnsType<GoodwillTreeRow>;
|
||||
loading: boolean;
|
||||
decimals: number;
|
||||
units: string;
|
||||
toolbar?: React.ReactNode;
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
/** Дерево «ГО → строки пар → Всего»; итоги блока — из `groups` ответа, фронт их не считает. */
|
||||
export function buildTree(data: GoodwillTableResponse | null, rows: GoodwillRow[]): GoodwillTreeRow[] {
|
||||
if (!data) return [];
|
||||
const totals = new Map(data.groups.map((g) => [g.head_legal_entity.id, g.totals]));
|
||||
const groups = new Map<string, Extract<GoodwillTreeRow, { kind: 'group' }>>();
|
||||
for (const row of rows) {
|
||||
const headId = row.head_legal_entity.id;
|
||||
let group = groups.get(headId);
|
||||
if (!group) {
|
||||
group = { key: `head:${headId}`, kind: 'group', headId, headName: row.head_legal_entity.name, children: [] };
|
||||
groups.set(headId, group);
|
||||
}
|
||||
group.children.push({ key: rowKey(row), kind: 'row', row });
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
group.children.push({ key: `total:${group.headId}`, kind: 'total', totals: totals.get(group.headId) ?? null });
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
function rowClassName(node: GoodwillTreeRow): string {
|
||||
if (node.kind === 'total') return 'group-total-row';
|
||||
if (node.kind === 'row' && !node.row.has_investments) return 'goodwill-inactive';
|
||||
return '';
|
||||
}
|
||||
|
||||
export const ConsolidationGoodwillTable: React.FC<ConsolidationGoodwillTableI> = ({
|
||||
data,
|
||||
rows,
|
||||
columns,
|
||||
loading,
|
||||
decimals,
|
||||
units,
|
||||
toolbar,
|
||||
isFullscreen = false,
|
||||
}) => {
|
||||
const treeData = useMemo(() => buildTree(data, rows), [data, rows]);
|
||||
|
||||
return (
|
||||
<Table<GoodwillTreeRow>
|
||||
className='goodwillTable'
|
||||
dataSource={treeData}
|
||||
columns={columns}
|
||||
rowKey='key'
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
locale={{ emptyText: 'Вложений по 58.01 между компаниями периметра нет' }}
|
||||
scroll={{ x: 'max-content', y: isFullscreen ? 'calc(100vh - 200px)' : undefined }}
|
||||
size='small'
|
||||
bordered
|
||||
title={() => toolbar}
|
||||
key={treeData.map((g) => g.key).join('|')}
|
||||
expandable={{ defaultExpandAllRows: true, expandIconColumnIndex: 1 }}
|
||||
summary={() => <ConsolidationGoodwillSummary totals={data?.totals ?? null} columns={columns} units={units} decimals={decimals} />}
|
||||
rowClassName={rowClassName}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,210 @@
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Checkbox, Input, Tag, Tooltip, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { GoodwillEdit, GoodwillRow, GoodwillTotals } from '../../../types/goodwill/goodwill';
|
||||
import { chessTableCellFormatValue } from '../../../utils/chessTableCellFormatValue';
|
||||
import { effectiveRow, previewAmounts, rowKey } from '../../../utils/goodwill';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/** Узел дерева таблицы: блок ГО, строка пары или «Всего» блока. */
|
||||
export type GoodwillTreeRow =
|
||||
| { key: string; kind: 'group'; headId: string; headName: string; children: GoodwillTreeRow[] }
|
||||
| { key: string; kind: 'row'; row: GoodwillRow }
|
||||
| { key: string; kind: 'total'; totals: GoodwillTotals | null };
|
||||
|
||||
/** Ошибки ввода строки: клиентская проверка и ответ сервера (422 с ключом пары). */
|
||||
export type RowIssues = { ownership_share?: string; comment?: string };
|
||||
|
||||
export type MoneyKey = 'financial_investment_amount' | 'participation_amount' | 'charter_capital_amount' | 'goodwill_amount';
|
||||
|
||||
interface GetColumnsParams {
|
||||
units: string;
|
||||
decimals: number;
|
||||
isEditMode: boolean;
|
||||
edits: Record<string, GoodwillEdit>;
|
||||
issues: Record<string, RowIssues>;
|
||||
/** все строки ответа (для группового чекбокса, включая скрытые фильтром) */
|
||||
rows: GoodwillRow[];
|
||||
onToggleRow: (key: string, checked: boolean) => void;
|
||||
onToggleGroup: (headId: string, checked: boolean) => void;
|
||||
onShare: (key: string, value: string) => void;
|
||||
onComment: (key: string, value: string) => void;
|
||||
}
|
||||
|
||||
export function formatMoney(value: string | null | undefined, units: string, decimals: number): string {
|
||||
return value === null || value === undefined ? '—' : chessTableCellFormatValue(value, units, decimals);
|
||||
}
|
||||
|
||||
function moneyColor(value: string | null): string | undefined {
|
||||
if (value === null || /^-?0(\.0+)?$/.test(value)) return undefined;
|
||||
return value.startsWith('-') ? '#cf1322' : '#3f8600';
|
||||
}
|
||||
|
||||
/** Суммы строки на экране: в режиме редактирования P и G — предварительный расчёт по правке. */
|
||||
export function displayedAmounts(row: GoodwillRow, edit: GoodwillEdit | undefined, isEditMode: boolean): Record<MoneyKey, string | null> {
|
||||
const base = {
|
||||
financial_investment_amount: row.financial_investment_amount,
|
||||
charter_capital_amount: row.charter_capital_amount,
|
||||
participation_amount: row.participation_amount,
|
||||
goodwill_amount: row.goodwill_amount,
|
||||
};
|
||||
if (!isEditMode) return base;
|
||||
const next = effectiveRow(row, edit);
|
||||
const preview = previewAmounts(row, next.ownership_share, next.include_in_kfo);
|
||||
return { ...base, participation_amount: preview.participation, goodwill_amount: preview.goodwill };
|
||||
}
|
||||
|
||||
export const getGoodwillColumns = ({
|
||||
units,
|
||||
decimals,
|
||||
isEditMode,
|
||||
edits,
|
||||
issues,
|
||||
rows,
|
||||
onToggleRow,
|
||||
onToggleGroup,
|
||||
onShare,
|
||||
onComment,
|
||||
}: GetColumnsParams): ColumnsType<GoodwillTreeRow> => {
|
||||
const included = (row: GoodwillRow) => edits[rowKey(row)]?.include_in_kfo ?? row.include_in_kfo;
|
||||
|
||||
const moneyColumn = (key: MoneyKey, title: string) => ({
|
||||
title,
|
||||
key,
|
||||
width: 160,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, node: GoodwillTreeRow) => {
|
||||
if (node.kind === 'group') return null;
|
||||
if (node.kind === 'total') return <Text strong>{formatMoney(node.totals?.[key], units, decimals)}</Text>;
|
||||
const value = displayedAmounts(node.row, edits[node.key], isEditMode)[key];
|
||||
const formatted = formatMoney(value, units, decimals);
|
||||
if (key !== 'goodwill_amount' || value === null) return formatted;
|
||||
return <Text style={{ color: moneyColor(value) }}>{formatted}</Text>;
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'Учет в отчете',
|
||||
key: 'include_in_kfo',
|
||||
width: 150,
|
||||
align: 'center',
|
||||
render: (_, node) => {
|
||||
if (node.kind === 'total') return <Text strong>—</Text>;
|
||||
if (node.kind === 'group') {
|
||||
const active = rows.filter((r) => r.head_legal_entity.id === node.headId && r.has_investments);
|
||||
const checked = active.filter(included).length;
|
||||
return (
|
||||
<Checkbox
|
||||
disabled={!isEditMode || active.length === 0}
|
||||
checked={active.length > 0 && checked === active.length}
|
||||
indeterminate={checked > 0 && checked < active.length}
|
||||
onChange={(e) => onToggleGroup(node.headId, e.target.checked)}>
|
||||
<Text type='secondary' style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||
Выделить все
|
||||
</Text>
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Checkbox
|
||||
disabled={!isEditMode || !node.row.has_investments}
|
||||
checked={included(node.row)}
|
||||
onChange={(e) => onToggleRow(node.key, e.target.checked)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Наименование дочерней организации',
|
||||
key: 'name',
|
||||
width: 300,
|
||||
render: (_, node) => {
|
||||
if (node.kind === 'group') return <Text strong>{node.headName}</Text>;
|
||||
if (node.kind === 'total') return <Text strong>Всего</Text>;
|
||||
const { row } = node;
|
||||
return (
|
||||
<span>
|
||||
{row.subsidiary_legal_entity.name}
|
||||
{!row.has_investments && <Tag style={{ marginLeft: 8 }}>Нет вложений</Tag>}
|
||||
{row.warnings.length > 0 && (
|
||||
<Tooltip title={row.warnings.map((w) => w.message).join('; ')}>
|
||||
<WarningOutlined style={{ marginLeft: 8, color: '#d48806' }} aria-label='Предупреждение' />
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Доля владения',
|
||||
key: 'ownership_share',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (_, node) => {
|
||||
if (node.kind !== 'row') return null;
|
||||
const { row } = node;
|
||||
const value = effectiveRow(row, edits[node.key]).ownership_share;
|
||||
if (isEditMode && row.has_investments && included(row)) {
|
||||
const edit = edits[node.key];
|
||||
const raw = edit && 'ownership_share' in edit ? (edit.ownership_share ?? '') : (row.ownership_share?.replace('.', ',') ?? '');
|
||||
const error = issues[node.key]?.ownership_share;
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={raw}
|
||||
status={error ? 'error' : undefined}
|
||||
onChange={(e) => onShare(node.key, e.target.value)}
|
||||
placeholder='%'
|
||||
aria-label='Доля владения'
|
||||
/>
|
||||
{error && (
|
||||
<Text type='danger' style={{ fontSize: 12 }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return value === null ? '—' : `${chessTableCellFormatValue(value, 'rub', 2)} %`;
|
||||
},
|
||||
},
|
||||
moneyColumn('financial_investment_amount', 'Фондовые вложения головной организации'),
|
||||
moneyColumn('participation_amount', 'Доля участия головной организации в уставном капитале'),
|
||||
moneyColumn('charter_capital_amount', 'Уставный капитал'),
|
||||
moneyColumn('goodwill_amount', 'Гудвилл (+/-)'),
|
||||
{
|
||||
title: 'Комментарий',
|
||||
key: 'comment',
|
||||
width: 240,
|
||||
render: (_, node) => {
|
||||
if (node.kind !== 'row') return null;
|
||||
const { row } = node;
|
||||
const edit = edits[node.key];
|
||||
const value = edit && 'comment' in edit ? (edit.comment ?? '') : (row.comment ?? '');
|
||||
if (isEditMode && row.has_investments && included(row)) {
|
||||
const error = issues[node.key]?.comment;
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
value={value}
|
||||
maxLength={1000}
|
||||
status={error ? 'error' : undefined}
|
||||
onChange={(e) => onComment(node.key, e.target.value)}
|
||||
placeholder='Комментарий'
|
||||
aria-label='Комментарий'
|
||||
/>
|
||||
{error && (
|
||||
<Text type='danger' style={{ fontSize: 12 }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return value || '—';
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user