[AURORA-2006] - Гудвилл (вкладка): мелкие дефекты после ревью #244

Merged
aep merged 3 commits from fix/2006-goodwill into main 2026-11-12 10:19:28 +03:00
8 changed files with 265 additions and 105 deletions

View File

@ -0,0 +1,37 @@
# Proposal
## Why
AURORA-2006 (PR arm-core #244): дефекты вкладки «Гудвилл», отложенные финальным ревью PR #220 (AURORA-1544).
- Ответы 409 и 404 на «Сохранить» не перечитывали таблицу: если версию забрал другой пользователь, экран оставался
редактируемым и каждое «Сохранить» снова получало 409; при 404 по исчезнувшей паре подсвечивалась доля строки.
- Стрелка раскрытия блока была привязана к номеру колонки: если скрыть «Учет в отчете» в «Настройке таблицы», стрелка
уезжала в другую колонку.
- После ошибки загрузки таблица писала «Вложений по 58.01 между компаниями периметра нет» вместо ошибки.
## What Changes
- 409 и 404 на «Сохранить» и на переключатель «Учитывать гудвилл в КФО» — уведомление с текстом сервера и перечитывание
таблицы, без подсветки строки; если после перечитывания `editable = false`, режим редактирования закрывается и
введённое сбрасывается. Так же, как в таблице корректировок (`errorOutcome`).
- Стрелка раскрытия блока — в колонке «Наименование дочерней организации», где бы она ни стояла; колонка скрыта — в первой
видимой.
- Ошибка загрузки — текст ошибки и кнопка «Повторить» вместо таблицы.
## Capabilities
### Modified Capabilities
- `goodwill-ui` — «Таблица гудвилла»: стрелка блока и ошибка загрузки; «Редактирование таблицы гудвилла»: 409/404 при
сохранении; «Переключатель, фильтр и экспорт гудвилла»: 409/404 при переключении.
## Impact
Только фронт arm-core: `web/src/widgets/ConsolidationGoodwill/*`, `web/src/types/goodwill/goodwill.ts` (типы `Ref` и
причина «только чтение» — из `types/corrections.ts`), `web/src/hooks/useFullscreen.ts` (переименован из
`useFullScreen.tsx`). API, DBML, правила `05-domain-rules.md` не меняются.
## Non-goals
- Сверка предпросмотра P/G с ответом сервера на реальной паре 58.01 — проверка после выкладки (AURORA-2006, раздел
«Проверить после выкладки»), не код.

View File

@ -0,0 +1,93 @@
## MODIFIED Requirements
### Requirement: Таблица гудвилла
Вкладка `goodwill` страницы отчёта версии SHALL загружать таблицу одним запросом `GET /consolidations/versions/{id}/goodwill`
(контракт `../docs/dev/api-contract-goodwill.md`) и показывать блоки по ГО в порядке ответа: строка ГО, строки ДО, строка
«Всего» блока из `groups[].totals`, внизу «Итого по листу» из `totals`. Колонки — по макету `[31-03]`: «Учет в отчете»,
«Наименование дочерней организации», «Доля владения», «Фондовые вложения головной организации», «Доля участия головной
организации в уставном капитале», «Уставный капитал», «Гудвилл (+/-)», «Комментарий». Сумма `null` SHALL показываться
прочерком, ноль — нулём в выбранных единицах; итоги фронт MUST NOT считать сам. Стрелка раскрытия блока SHALL стоять в
колонке «Наименование дочерней организации» при любом наборе видимых колонок; если эта колонка скрыта — в первой видимой.
Если загрузка не удалась, вместо таблицы SHALL показываться текст ошибки (сообщение сервера или «Не удалось загрузить
таблицу гудвилла») и кнопка «Повторить»; сообщение «Вложений по 58.01 между компаниями периметра нет» 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`
#### Scenario: Стрелка блока при скрытой колонке
- **WHEN** в «Настройке таблицы» снята колонка «Учет в отчете»
- **THEN** стрелка раскрытия блока остаётся в колонке «Наименование дочерней организации»
#### Scenario: Ошибка загрузки
- **WHEN** `GET …/goodwill` отвечает ошибкой
- **THEN** вместо таблицы — текст ошибки и «Повторить»; «Повторить» запрашивает таблицу заново и при успехе показывает её
### Requirement: Редактирование таблицы гудвилла
Кнопка «Редактировать» SHALL быть доступна при `editable = true`; иначе — недоступна с подсказкой причины
(`read_only_reason`). В режиме редактирования (баннер «Доступны для редактирования столбцы «Доля владения» и
«Комментарий».») у действующих строк доступна галочка; у отмеченной действующей строки — поля доли и комментария, P и G
строки SHALL пересчитываться предварительно по введённой доле. Доля — 0–100, не больше двух знаков, запятая или точка;
ошибочная доля MUST NOT обрезаться или подменяться, а показывается ошибкой у поля. «Сохранить» SHALL быть недоступно,
пока у отмеченной действующей строки нет корректной доли или комментария. Групповой чекбокс блока SHALL отмечать и снимать
все действующие строки блока, включая скрытые фильтром, и показывать промежуточное состояние при частичном выборе.
«Отменить изменения» SHALL возвращать последнее сохранённое состояние. На ответ 409 (версия закрыта, блокировку забрали)
или 404 (пары уже нет) при «Сохранить» экран SHALL показать уведомление с текстом сервера и перечитать таблицу, строку
MUST NOT подсвечивать; если после перечитывания `editable = false`, режим редактирования 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, не больше двух знаков после запятой», значение в поле не меняется, «Сохранить» недоступно
#### Scenario: Версию забрал другой пользователь
- **WHEN** аналитик в режиме редактирования нажимает «Сохранить», сервер отвечает 409 `LOCKED_BY_OTHER`, перечитанная
таблица приходит с `editable = false`
- **THEN** уведомление с текстом сервера, режим редактирования закрыт, показаны сохранённые значения, «Редактировать»
недоступна с подсказкой «Консолидацию редактирует другой пользователь»
#### Scenario: Пары больше нет
- **WHEN** сервер отвечает на «Сохранить» 404 по паре, которой больше нет в версии
- **THEN** уведомление с текстом сервера, таблица перечитана, ни одна строка не подсвечена; правки этой пары в запрос
больше не попадают
### Requirement: Переключатель, фильтр и экспорт гудвилла
Панель вкладки SHALL содержать переключатель «Учитывать гудвилл в КФО» (`PUT …/goodwill-settings`, доступен при
`editable` вне режима редактирования) с подписью суммы `goodwill_in_kfo_amount` или «Гудвилл не учитывается в КФО»;
фильтр «Гудвилл»: «Все значения», «Положительные», «Отрицательные», «Нулевые» (нерассчитанный гудвилл — только во «Все
значения»); единицы и знаки после запятой; «Настройка таблицы»; «Экспорт» — `GET …/goodwill/export` с текущим фильтром
(`sign`); «На весь экран». На ответ 409 или 404 при переключении экран SHALL показать уведомление с текстом сервера и
перечитать таблицу.
#### Scenario: Экспорт с фильтром
- **WHEN** выбран фильтр «Положительные» и нажат «Экспорт»
- **THEN** запрос уходит с `sign=positive`
#### Scenario: Выключение гудвилла
- **WHEN** аналитик выключает переключатель
- **THEN** таблица перечитывается из ответа, под переключателем подпись «Гудвилл не учитывается в КФО», ручной ввод строк не меняется
#### Scenario: Переключатель после закрытия версии
- **WHEN** аналитик переключает «Учитывать гудвилл в КФО», а сервер отвечает 409 `VERSION_NOT_EDITABLE`
- **THEN** уведомление с текстом сервера, таблица перечитана, переключатель недоступен

View File

@ -0,0 +1,9 @@
# Tasks
- [x] 1.1 arm-core `web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillContent` — 409/404 при «Сохранить» и при
переключателе: `errorOutcome`, перечитывание, выход из редактирования при `editable = false` (PR #244, автор)
- [x] 1.2 arm-core `web/src/widgets/ConsolidationGoodwill/ConsolidationGoodwillTable` — стрелка блока по ключу колонки
`name`; лишний CSS шахматки удалён (PR #244, автор)
- [x] 1.3 arm-core `web/` — ошибка загрузки с «Повторить»; `useFullscreen.ts`; типы из `types/corrections.ts` (PR #244, автор)
- [x] 1.4 Ревью: прогон в браузере на ГК «Пример» 2025 К3 — скрытие «Учет в отчете», 409 на `PUT …/goodwill` с
перечитыванием `editable = false`, 500 на `GET …/goodwill` и «Повторить» (ответы сервера подменены в Playwright)

View File

@ -1,8 +1,5 @@
/** DTO таблицы гудвилла — по `api/src/schemas/goodwill.py` и `../docs/dev/api-contract-goodwill.md`. Деньги и доля — строки. */
export interface Ref {
id: string;
name: string;
}
import type { ReadOnlyReason, Ref } from '../corrections';
export interface GoodwillWarning {
code: 'CAPITAL_SOURCE_MISSING' | 'CAPITAL_SOURCE_AMBIGUOUS' | 'OWNERSHIP_TOTAL_EXCEEDED' | string;
@ -42,13 +39,11 @@ export interface GoodwillGroup {
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;
read_only_reason: ReadOnlyReason | null;
rows: GoodwillRow[];
groups: GoodwillGroup[];
totals: GoodwillTotals;

View File

@ -1,18 +1,18 @@
import { DownloadOutlined, EditOutlined, FullscreenExitOutlined, FullscreenOutlined } from '@ant-design/icons';
import { Alert, Button, ConfigProvider, Select, Space, Switch, Tooltip, Typography } from 'antd';
import { Alert, Button, ConfigProvider, Flex, 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 { 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 { errorOutcome, readOnlyHint } from '../../CorrectionTable/readOnly';
import { ConsolidationGoodwillTable } from '../ConsolidationGoodwillTable/ConsolidationGoodwillTable';
import { type GoodwillTreeRow, type RowIssues, formatMoney, getGoodwillColumns } from '../ConsolidationGoodwillTable/config';
@ -45,6 +45,7 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
const [data, setData] = useState<GoodwillTableResponse | null>(null);
const [loading, setLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [settingsSaving, setSettingsSaving] = useState(false);
const [decimals, setDecimals] = useState(2);
@ -60,10 +61,14 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
const loadData = useCallback(
async (vId: string) => {
setLoading(true);
setLoadError(null);
try {
setData(await fetchGoodwillTable(vId));
} catch (e) {
if (e instanceof ApiError) notification.error({ description: e.message });
const message = e instanceof ApiError ? e.message : 'Не удалось загрузить таблицу гудвилла';
setData(null);
setLoadError(message);
notification.error({ description: message });
} finally {
setLoading(false);
}
@ -158,6 +163,16 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
setIsEditMode(false);
};
// сервер закрыл правки (перечитали после 409, версию забрали или закрыли) — выходим из редактирования:
// иначе экран остаётся редактируемым и каждое «Сохранить» снова получает 409
useEffect(() => {
if (data && !data.editable) {
setEdits({});
setServerIssues({});
setIsEditMode(false);
}
}, [data]);
const handleSave = async () => {
if (!version_id || !data) return;
if (pending.length === 0) {
@ -170,13 +185,21 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
closeEditing();
notification.success({ description: 'Изменения сохранены' });
} catch (e) {
// 409 (версию закрыли, блокировку забрали) и 404 (пары уже нет) — перечитываем
// таблицу, строку не подсвечиваем; правки в несуществующих парах при пересчёте cбрасываем
const out = errorOutcome(e, 'Не удалось сохранить изменения');
notification.error({ description: out.message });
if (out.reload) {
setServerIssues({});
loadData(version_id);
return;
}
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);
}
@ -188,7 +211,9 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
try {
setData(await saveGoodwillSettings(version_id, { goodwill_enabled: enabled }));
} catch (e) {
if (e instanceof ApiError) notification.error({ description: e.message });
const out = errorOutcome(e, 'Не удалось изменить настройку гудвилла');
notification.error({ description: out.message });
if (out.reload) loadData(version_id);
} finally {
setSettingsSaving(false);
}
@ -229,91 +254,98 @@ export const GoodwillContent = ({ card }: { card: VersionCard | null }) => {
/>
)}
<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}>
{loadError && !loading ? (
<Flex vertical align='center' gap={8} style={{ padding: 48 }}>
<span>{loadError}</span>
<Button onClick={() => version_id && loadData(version_id)}>Повторить</Button>
</Flex>
) : (
<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>
<Tooltip title={readOnly}>
<Switch
checked={data?.goodwill_enabled ?? false}
disabled={!data?.editable || isEditMode}
loading={settingsSaving}
onChange={handleSettings}
aria-label='Учитывать гудвилл в КФО'
/>
</Tooltip>
<span>Учитывать гудвилл в КФО</span>
<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>
<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}>
Редактировать
<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>
</Tooltip>
<Button loading={exportLoading} onClick={download} icon={<DownloadOutlined />}>
Экспорт
</Button>
<Button onClick={toggleFullscreen} icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
{isFullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
<Button onClick={toggleFullscreen} icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}>
{isFullscreen ? 'Свернуть' : 'На весь экран'}
</Button>
</div>
</div>
</div>
}
/>
</div>
}
/>
</div>
)}
</div>
</ConfigProvider>
);

View File

@ -1,18 +1,8 @@
.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 ;
}

View File

@ -57,6 +57,10 @@ export const ConsolidationGoodwillTable: React.FC<ConsolidationGoodwillTableI> =
isFullscreen = false,
}) => {
const treeData = useMemo(() => buildTree(data, rows), [data, rows]);
const expandIconColumnIndex = Math.max(
0,
columns.findIndex((col) => col.key === 'name'),
);
return (
<Table<GoodwillTreeRow>
@ -72,7 +76,7 @@ export const ConsolidationGoodwillTable: React.FC<ConsolidationGoodwillTableI> =
bordered
title={() => toolbar}
key={treeData.map((g) => g.key).join('|')}
expandable={{ defaultExpandAllRows: true, expandIconColumnIndex: 1 }}
expandable={{ defaultExpandAllRows: true, expandIconColumnIndex }}
summary={() => <ConsolidationGoodwillSummary totals={data?.totals ?? null} columns={columns} units={units} decimals={decimals} />}
rowClassName={rowClassName}
/>