diff --git a/openspec/changes/corrections-table-ui/design.md b/openspec/changes/corrections-table-ui/design.md
index 0528a33..0f5d0ac 100644
--- a/openspec/changes/corrections-table-ui/design.md
+++ b/openspec/changes/corrections-table-ui/design.md
@@ -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; фронт не выдумывает правил | при рассинхроне справочника правил пункт неактивен |
diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx
index 214bf63..dd2cf1e 100644
--- a/web/src/layouts/ConsolidationReportPage.tsx
+++ b/web/src/layouts/ConsolidationReportPage.tsx
@@ -33,7 +33,7 @@ function ReportContent() {
chess: ,
- correction: ,
+ correction: ,
};
const ActiveTab = TAB_COMPONENTS[tab as string] ?? null;
diff --git a/web/src/widgets/CorrectionTable/CorrectionContent.tsx b/web/src/widgets/CorrectionTable/CorrectionContent.tsx
index b9b80f3..9b495dc 100644
--- a/web/src/widgets/CorrectionTable/CorrectionContent.tsx
+++ b/web/src/widgets/CorrectionTable/CorrectionContent.tsx
@@ -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(null);
const boxRef = useRef(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);
}
};
diff --git a/web/src/widgets/CorrectionTable/CorrectionTable.tsx b/web/src/widgets/CorrectionTable/CorrectionTable.tsx
index ac2f006..f6d787f 100644
--- a/web/src/widgets/CorrectionTable/CorrectionTable.tsx
+++ b/web/src/widgets/CorrectionTable/CorrectionTable.tsx
@@ -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, 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(
-
+
{row.include}
{a.composition_changed && (
@@ -126,6 +125,7 @@ export function CorrectionTable(props: Props) {
)}
,
+ 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),
}}>
- } aria-label='Действия' disabled={!canAct} />
+ } aria-label={`Действия: ${a.company_1.name} — ${a.company_2.name}`} disabled={!canAct} />
,
)}
@@ -202,7 +202,8 @@ export function CorrectionTable(props: Props) {
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) }}>
- } aria-label='Действия' disabled={!canAct} />
+ } aria-label={`Действия: ${e.account_code}`} disabled={!canAct} />
,
);
default:
diff --git a/web/src/widgets/CorrectionTable/RevokeModal.tsx b/web/src/widgets/CorrectionTable/RevokeModal.tsx
index fa1967a..9de9e16 100644
--- a/web/src/widgets/CorrectionTable/RevokeModal.tsx
+++ b/web/src/widgets/CorrectionTable/RevokeModal.tsx
@@ -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)}
diff --git a/web/src/widgets/CorrectionTable/readOnly.test.ts b/web/src/widgets/CorrectionTable/readOnly.test.ts
index 19e503b..771d2a0 100644
--- a/web/src/widgets/CorrectionTable/readOnly.test.ts
+++ b/web/src/widgets/CorrectionTable/readOnly.test.ts
@@ -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);
+});
diff --git a/web/src/widgets/CorrectionTable/readOnly.ts b/web/src/widgets/CorrectionTable/readOnly.ts
index 017e6bf..3a26b68 100644
--- a/web/src/widgets/CorrectionTable/readOnly.ts
+++ b/web/src/widgets/CorrectionTable/readOnly.ts
@@ -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:` (подменю правил), `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) };
}