fix(web): правки по ревью Task 5 — лимит комментария, подсказки, защита от двойного запроса (#206)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:39:26 +04:00
parent 14cceb80be
commit c2cbbf5dfd
7 changed files with 113 additions and 23 deletions

View File

@ -189,7 +189,7 @@
| 28.10.2026 | R-8: ошибка загрузки показывается уведомлением с «Повторить» (по спеке) и кнопкой на месте таблицы | после закрытия уведомления аналитик не остаётся без выхода | две кнопки повтора |
| 28.10.2026 | R-9: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями |
| 28.10.2026 | R-10: `ROW_ACTIONS_READY`/`actionsEnabled` удалены; контролы строк неактивны при `!editable` (с подсказкой `readOnlyHint`) и на время запроса (`busy`, без подсказки) | включение R-7 в Task 5; блокировка на время запроса не даёт отправить два решения подряд | аналитик не видит, почему контрол замер, пока идёт запрос (виден спиннер таблицы) |
| 28.10.2026 | R-11: любая ошибка 409 при решении/отмене — уведомление с текстом сервера и `reload()` (`needsReload`) | 409 = версия закрылась, блокировку забрали, корректировку уже изменили/отозвали: локальная таблица устарела | лишний запрос при 409 без смысла для таблицы |
| 28.10.2026 | R-11: любая ошибка 409 при решении/отмене — уведомление с текстом сервера и `reload()` (`needsReload`) | 409 = версия закрылась, блокировку забрали, корректировку уже изменили/отозвали: локальная таблица устарела | лишний запрос при 409 без смысла для таблицы; комментарий окна отмены теряется при 409 и перезагрузке — допустимо, корректировка уже изменена; перечитывание и при 404 (`needsReload`) |
| 28.10.2026 | R-12: после `allocate` таблица заменяется ответом целиком (`replace`), после `revoke` подменяется один агрегат (`replaceAggregate`); окно отмены при не-409 ошибке остаётся открытым, при 409 закрывается | `allocate` меняет и нераспределённые, `revoke` отдаёт агрегат; при сетевой ошибке комментарий не должен пропасть | после revoke нераспределённые не обновляются (по контракту revoke их не трогает) |
| 28.10.2026 | R-13: меню строки вынесено в чистую `allocationMenu` (ключ `MOVE` — «выбрать правило», уходит как `ASSIGN`); правило-кандидат, которого нет в `rules`, пункт «в правило» не включает | тестируемость без React; фронт не выдумывает правил | при рассинхроне справочника правил пункт неактивен |

View File

@ -33,7 +33,7 @@ function ReportContent() {
chess: <ChessContent card={card} />,
correction: <CorrectionContent versionId={version_id ?? ''} card={card} />,
correction: <CorrectionContent key={version_id} versionId={version_id ?? ''} card={card} />,
};
const ActiveTab = TAB_COMPONENTS[tab as string] ?? null;

View File

@ -13,7 +13,7 @@ import { CorrectionTable, type RowActions } from './CorrectionTable';
import { CorrectionToolbar } from './CorrectionToolbar';
import { RevokeModal } from './RevokeModal';
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
import { needsReload, readOnlyHint } from './readOnly';
import { errorOutcome, needsReload, readOnlyHint } from './readOnly';
import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, toAggregateKey, visibleTotals } from './tableModel';
import { useCorrectionTable } from './useCorrectionTable';
@ -34,6 +34,8 @@ export function CorrectionContent({ versionId, card = null }: Props) {
const [fullscreen, setFullscreen] = useState(false);
// идёт запрос решения по строке / отмены: на это время контролы строк неактивны, чтобы не слать два запроса подряд
const [busy, setBusy] = useState(false);
// ref, а не состояние: setBusy применяется после перерисовки, а два клика могут прийти до неё — ref меняется мгновенно
const inflight = useRef(false);
const [revoking, setRevoking] = useState<CorrectionAggregateOut | null>(null);
const boxRef = useRef<HTMLDivElement>(null);
@ -108,25 +110,30 @@ export function CorrectionContent({ versionId, card = null }: Props) {
// ошибка правки: текст сервера в уведомлении; 409 (версию закрыли, блокировку забрали, корректировку уже изменили) — ещё перечитываем таблицу
const fail = (e: unknown, fallback: string) => {
notify.error({ description: e instanceof ApiError ? e.message : fallback });
if (needsReload(e)) reload();
const out = errorOutcome(e, fallback);
notify.error({ description: out.message });
if (out.reload) reload();
};
// решение по строкам: сервер отвечает всей таблицей (в ней меняются и агрегаты, и нераспределённые) — подменяем её целиком
const onAllocate = async (body: AllocationIn) => {
if (inflight.current) return;
inflight.current = true;
setBusy(true);
try {
replace(await allocate(versionId, body));
} catch (e) {
fail(e, 'Не удалось сохранить решение по строке');
} finally {
inflight.current = false;
setBusy(false);
}
};
// отмена корректировки: ответ — один агрегат, подменяем только его
const onRevoke = async (comment: string) => {
if (!revoking) return;
if (!revoking || inflight.current) return;
inflight.current = true;
setBusy(true);
try {
replaceAggregate(await revokeCorrection(versionId, toAggregateKey(revoking), { comment }));
@ -136,6 +143,7 @@ export function CorrectionContent({ versionId, card = null }: Props) {
if (needsReload(e)) setRevoking(null);
fail(e, 'Не удалось отменить корректировку');
} finally {
inflight.current = false;
setBusy(false);
}
};

View File

@ -8,7 +8,7 @@ import { type ReactNode, useMemo } from 'react';
import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections';
import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter';
import { COLUMN_DEFS, type ColKey } from './columns';
import { allocationMenu } from './readOnly';
import { AGGREGATE_FLAG_HINT, allocationMenu, includeBody, menuBody } from './readOnly';
import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel';
import type { visibleTotals } from './tableModel';
@ -90,9 +90,8 @@ export function CorrectionTable(props: Props) {
});
const onEntryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>, key: string) => {
const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids };
if (key.startsWith('ASSIGN:')) actions.onAllocate({ ...base, action: 'ASSIGN', rule_id: key.slice('ASSIGN:'.length) });
else if (key === 'UNASSIGN' || key === 'RESET') actions.onAllocate({ ...base, action: key });
const body = menuBody(row, key);
if (body) actions.onAllocate(body);
};
// почему контрол неактивен: подсказка нужна только когда сервер закрыл правки (editable = false); пока идёт запрос — без неё
@ -118,7 +117,7 @@ export function CorrectionTable(props: Props) {
case 'include':
return withHint(
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Checkbox disabled checked={row.include !== 'Нет'} indeterminate={row.include === 'Частично'} />
<Checkbox disabled aria-label='Учитывать в КФО (состояние агрегата)' checked={row.include !== 'Нет'} indeterminate={row.include === 'Частично'} />
<span style={{ fontSize: 12 }}>{row.include}</span>
{a.composition_changed && (
<Tooltip title='Состав изменился после корректировки'>
@ -126,6 +125,7 @@ export function CorrectionTable(props: Props) {
</Tooltip>
)}
</span>,
AGGREGATE_FLAG_HINT,
);
case 'd_entity':
return a.company_1.name;
@ -182,7 +182,7 @@ export function CorrectionTable(props: Props) {
items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !a.correction }],
onClick: () => actions.onRevoke(a),
}}>
<Button size='small' type='text' icon={<MoreOutlined />} aria-label='Действия' disabled={!canAct} />
<Button size='small' type='text' icon={<MoreOutlined />} aria-label={`Действия: ${a.company_1.name} — ${a.company_2.name}`} disabled={!canAct} />
</Dropdown>,
)}
</span>
@ -202,7 +202,8 @@ export function CorrectionTable(props: Props) {
<Checkbox
disabled={!canAct}
checked={!e.excluded}
onChange={(ev) => actions.onAllocate({ statement_type: 'balance', entry_ids: e.entry_ids, action: ev.target.checked ? 'INCLUDE' : 'EXCLUDE' })}
aria-label={`Учитывать в КФО: ${e.account_code}`}
onChange={(ev) => actions.onAllocate(includeBody(e.entry_ids, ev.target.checked))}
/>,
);
case 'd_entity':
@ -235,7 +236,7 @@ export function CorrectionTable(props: Props) {
trigger={['click']}
disabled={!canAct}
menu={{ items: entryMenu(row), onClick: ({ key: k }) => onEntryMenu(row, k) }}>
<Button size='small' type='text' icon={<MoreOutlined />} aria-label='Действия' disabled={!canAct} />
<Button size='small' type='text' icon={<MoreOutlined />} aria-label={`Действия: ${e.account_code}`} disabled={!canAct} />
</Dropdown>,
);
default:

View File

@ -1,6 +1,7 @@
// Окно «Отменить корректировку»: комментарий обязателен, пустой — «Сохранить» неактивна.
import { Input, Modal } from 'antd';
import { useEffect, useState } from 'react';
import { COMMENT_MAX_LENGTH } from './readOnly';
type Props = {
open: boolean;
@ -38,7 +39,7 @@ export function RevokeModal({ open, subtitle, saving, onCancel, onSubmit }: Prop
autoFocus
rows={4}
value={comment}
maxLength={2000}
maxLength={COMMENT_MAX_LENGTH}
placeholder='Комментарий к отмене (обязателен)'
aria-label='Комментарий к отмене корректировки'
onChange={(e) => setComment(e.target.value)}

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { ApiError } from '../../api/client';
import { makeMockTable } from './mockData';
import { allocationMenu, needsReload, readOnlyHint } from './readOnly';
import { COMMENT_MAX_LENGTH, allocationMenu, errorOutcome, includeBody, menuBody, needsReload, readOnlyHint } from './readOnly';
import type { TableRow } from './tableModel';
describe('readOnlyHint', () => {
@ -78,9 +78,54 @@ describe('needsReload', () => {
expect(needsReload(new ApiError(409, 'LOCK_REQUIRED', 'x'))).toBe(true);
expect(needsReload(new ApiError(409, 'CORRECTION_VERSION_CONFLICT', 'x'))).toBe(true);
});
it('404 — тоже перечитать', () => {
expect(needsReload(new ApiError(404, 'NOT_FOUND', 'x'))).toBe(true);
});
it('422, сеть и мусор — нет', () => {
expect(needsReload(new ApiError(422, 'COMMENT_REQUIRED', 'x'))).toBe(false);
expect(needsReload(new Error('сеть'))).toBe(false);
expect(needsReload(null)).toBe(false);
});
});
describe('includeBody / menuBody', () => {
const t = makeMockTable();
const entry = t.aggregates[1].side_1.entries[0];
const row: TableRow = { kind: 'entry', key: 'k', parentKey: 'p', side: 1, entry };
const un: TableRow = { kind: 'unallocated', key: 'u', entry: t.unallocated[0] };
it('флажок: поставлен — INCLUDE, снят — EXCLUDE, entry_ids из строки', () => {
expect(includeBody(entry.entry_ids, true)).toEqual({ statement_type: 'balance', entry_ids: entry.entry_ids, action: 'INCLUDE' });
expect(includeBody(entry.entry_ids, false).action).toBe('EXCLUDE');
});
it('ASSIGN с правилом из ключа подменю', () => {
expect(menuBody(row, 'ASSIGN:r6')).toEqual({ statement_type: 'balance', entry_ids: entry.entry_ids, action: 'ASSIGN', rule_id: 'r6' });
expect(menuBody(un, 'ASSIGN:r7')?.entry_ids).toEqual(t.unallocated[0].entry_ids);
});
it('UNASSIGN и RESET без rule_id', () => {
expect(menuBody(row, 'UNASSIGN')).toEqual({ statement_type: 'balance', entry_ids: entry.entry_ids, action: 'UNASSIGN' });
expect(menuBody(row, 'RESET')?.action).toBe('RESET');
});
it('родитель подменю, пустое правило, неизвестный ключ и агрегат — запроса нет', () => {
expect(menuBody(row, 'MOVE')).toBeNull();
expect(menuBody(row, 'ASSIGN:')).toBeNull();
expect(menuBody(row, 'что-то')).toBeNull();
expect(menuBody({ kind: 'aggregate', key: 'a', aggregate: t.aggregates[0], include: 'Да' }, 'RESET')).toBeNull();
});
});
describe('errorOutcome', () => {
it('ошибка API: текст сервера; 409 — перечитать', () => {
expect(errorOutcome(new ApiError(409, 'LOCK_REQUIRED', 'Возьмите блокировку'), 'запасной')).toEqual({ message: 'Возьмите блокировку', reload: true });
});
it('422 — без перечитывания', () => {
expect(errorOutcome(new ApiError(422, 'COMMENT_REQUIRED', 'Комментарий обязателен'), 'з').reload).toBe(false);
});
it('не ApiError — запасной текст', () => {
expect(errorOutcome(new Error('сеть'), 'Не удалось сохранить')).toEqual({ message: 'Не удалось сохранить', reload: false });
});
});
it('лимит комментария — 1000 (контракт §3.2)', () => {
expect(COMMENT_MAX_LENGTH).toBe(1000);
});

View File

@ -1,5 +1,6 @@
// Чистые функции для режима «только чтение» и меню решений по строке (без React — тестируются отдельно).
import type { AllocationAction, CorrectionRuleOut, ReadOnlyReason } from '../../types/corrections';
import { ApiError } from '../../api/client';
import type { AllocationAction, AllocationIn, CorrectionRuleOut, ReadOnlyReason } from '../../types/corrections';
import type { TableRow } from './tableModel';
/** Подсказки для режима «только чтение» (дословно из спеки). */
@ -33,10 +34,44 @@ export function allocationMenu(row: TableRow, rules: CorrectionRuleOut[]): Alloc
return items;
}
/**
* Ошибка правки, после которой таблицу надо перечитать: любой 409 — версия закрылась, блокировку забрали,
* корректировку уже изменили или отозвали (`CORRECTION_VERSION_CONFLICT`, `CORRECTION_NOT_APPLIED`).
*/
export function needsReload(e: { status?: number } | unknown): boolean {
return typeof e === 'object' && e !== null && (e as { status?: number }).status === 409;
/** Максимальная длина комментария к корректировке и к её отмене (контракт §3.2); нужна и окну корректировки (Task 6). */
export const COMMENT_MAX_LENGTH = 1000;
/** Подсказка у флажка агрегата: он только показывает состояние, решение принимается на строках. */
export const AGGREGATE_FLAG_HINT = 'Показывает состояние строк; менять — в раскрытии';
/** Тело запроса для флажка «Учитывать в КФО» строки: поставлен — INCLUDE, снят — EXCLUDE. */
export function includeBody(entryIds: string[], checked: boolean): AllocationIn {
return { statement_type: 'balance', entry_ids: entryIds, action: checked ? 'INCLUDE' : 'EXCLUDE' };
}
/**
* Тело запроса по ключу пункта меню строки: `ASSIGN:<id правила>` (подменю правил), `UNASSIGN`, `RESET`.
* Пункт-родитель `MOVE` и неизвестные ключи — `null` (запроса нет).
*/
export function menuBody(row: TableRow, key: string): AllocationIn | null {
if (row.kind === 'aggregate') return null;
const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids };
if (key.startsWith('ASSIGN:')) {
const ruleId = key.slice('ASSIGN:'.length);
return ruleId ? { ...base, action: 'ASSIGN', rule_id: ruleId } : null;
}
if (key === 'UNASSIGN' || key === 'RESET') return { ...base, action: key };
return null;
}
/**
* Ошибка правки, после которой таблицу надо перечитать: 409 (версия закрылась, блокировку забрали, корректировку уже
* изменили или отозвали) и 404 (правило или агрегата больше нет в версии).
*/
export function needsReload(e: unknown): boolean {
if (typeof e !== 'object' || e === null) return false;
const status = (e as { status?: unknown }).status;
return status === 409 || status === 404;
}
/** Что делать с ошибкой правки: текст уведомления (сообщение сервера или запасной), перечитывать ли таблицу. */
export function errorOutcome(e: unknown, fallback: string): { message: string; reload: boolean } {
const serverMessage = e instanceof ApiError ? e.message : null;
return { message: serverMessage || fallback, reload: needsReload(e) };
}