feat(web): решения по строкам и отмена корректировки (#206)

Флажок «Учитывать в КФО» и меню строк (перенести, убрать, вернуть к
автоматике) отправляют allocate; «Отменить корректировку» открывает окно
с обязательным комментарием и вызывает revoke. Ошибки — уведомление с
текстом сервера, 409 — ещё перечитывание таблицы. Подсказки режима только
чтения вынесены в readOnly.ts, подсказка «Недоступно» убрана (T4.14).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 21:35:44 +04:00
parent 14693d8dce
commit 14cceb80be
6 changed files with 258 additions and 61 deletions

View File

@ -188,3 +188,8 @@
| 28.10.2026 | R-7: флажки «Учитывать в КФО» и меню строк неактивны всегда (`actionsEnabled=false`) до Task 5–6, подсказка «только чтение» подключена | бриф Task 4: решения по строкам — в Task 5 | забыть включить флаг в Task 5 | | 28.10.2026 | R-7: флажки «Учитывать в КФО» и меню строк неактивны всегда (`actionsEnabled=false`) до Task 5–6, подсказка «только чтение» подключена | бриф Task 4: решения по строкам — в Task 5 | забыть включить флаг в Task 5 |
| 28.10.2026 | R-8: ошибка загрузки показывается уведомлением с «Повторить» (по спеке) и кнопкой на месте таблицы | после закрытия уведомления аналитик не остаётся без выхода | две кнопки повтора | | 28.10.2026 | R-8: ошибка загрузки показывается уведомлением с «Повторить» (по спеке) и кнопкой на месте таблицы | после закрытия уведомления аналитик не остаётся без выхода | две кнопки повтора |
| 28.10.2026 | R-9: «Итого» показывается только при наличии видимых агрегатов (при «Нераспределенные записи» его нет) | сценарий спеки | показать «Итого» с пустыми значениями | | 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-12: после `allocate` таблица заменяется ответом целиком (`replace`), после `revoke` подменяется один агрегат (`replaceAggregate`); окно отмены при не-409 ошибке остаётся открытым, при 409 закрывается | `allocate` меняет и нераспределённые, `revoke` отдаёт агрегат; при сетевой ошибке комментарий не должен пропасть | после revoke нераспределённые не обновляются (по контракту revoke их не трогает) |
| 28.10.2026 | R-13: меню строки вынесено в чистую `allocationMenu` (ключ `MOVE` — «выбрать правило», уходит как `ASSIGN`); правило-кандидат, которого нет в `rules`, пункт «в правило» не включает | тестируемость без React; фронт не выдумывает правил | при рассинхроне справочника правил пункт неактивен |

View File

@ -3,40 +3,26 @@ import { App, Button, ConfigProvider, Spin } from 'antd';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import { useVisibleColumns } from '../../components/GenericTable/useVisibleColumns'; import { useVisibleColumns } from '../../components/GenericTable/useVisibleColumns';
import { exportCorrectionTable } from '../../services/api/corrections'; import { allocate, exportCorrectionTable, revokeCorrection } from '../../services/api/corrections';
import type { VersionCard } from '../../types/consolidations/version_members'; import type { VersionCard } from '../../types/consolidations/version_members';
import type { CorrectionTableOut, ReadOnlyReason } from '../../types/corrections'; import type { AllocationIn, CorrectionAggregateOut } from '../../types/corrections';
import { saveBlob, xlsxFilename } from '../../utils/download'; import { saveBlob, xlsxFilename } from '../../utils/download';
import { useNotificationApi } from '../../utils/useNotification'; import { useNotificationApi } from '../../utils/useNotification';
import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay'; import { useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
import { CorrectionTable, type RowActions } from './CorrectionTable'; import { CorrectionTable, type RowActions } from './CorrectionTable';
import { CorrectionToolbar } from './CorrectionToolbar'; import { CorrectionToolbar } from './CorrectionToolbar';
import { RevokeModal } from './RevokeModal';
import { COLUMN_DEFS, type ColKey, type ColShape } from './columns'; import { COLUMN_DEFS, type ColKey, type ColShape } from './columns';
import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, visibleTotals } from './tableModel'; import { needsReload, readOnlyHint } from './readOnly';
import { EMPTY_FILTERS, type TableFilters, buildRows, filterTable, lineOptions, toAggregateKey, visibleTotals } from './tableModel';
import { useCorrectionTable } from './useCorrectionTable'; import { useCorrectionTable } from './useCorrectionTable';
/** Подсказки для режима «только чтение» (дословно из спеки). */
const READ_ONLY_HINTS: Record<ReadOnlyReason, string> = {
VERSION_NOT_EDITABLE: 'Версия закрыта для изменений',
LOCK_REQUIRED: 'Возьмите консолидацию в работу',
LOCKED_BY_OTHER: 'Консолидацию редактирует другой пользователь',
};
/** Подсказка «почему только чтение»; правки открыты или причина не названа — `null`. */
function readOnlyHint(t: CorrectionTableOut): string | null {
if (t.editable || !t.read_only_reason) return null;
return READ_ONLY_HINTS[t.read_only_reason];
}
/** Решения по строкам и окно корректировки подключаются в Task 5–6: тогда флаг становится true (одна правка). */
const ROW_ACTIONS_READY = false;
const LOAD_ERROR_KEY = 'corrections-load-error'; const LOAD_ERROR_KEY = 'corrections-load-error';
type Props = { versionId: string; card?: VersionCard | null }; type Props = { versionId: string; card?: VersionCard | null };
export function CorrectionContent({ versionId, card = null }: Props) { export function CorrectionContent({ versionId, card = null }: Props) {
const { table, loading, error, reload } = useCorrectionTable(versionId); const { table, loading, error, reload, replace, replaceAggregate } = useCorrectionTable(versionId);
const { notification } = App.useApp(); const { notification } = App.useApp();
const notify = useNotificationApi(); const notify = useNotificationApi();
// по умолчанию — «тыс. руб.», 2 знака, как в дизайне // по умолчанию — «тыс. руб.», 2 знака, как в дизайне
@ -46,6 +32,9 @@ export function CorrectionContent({ versionId, card = null }: Props) {
const { visibleKeys, setVisibleKeys } = useVisibleColumns<ColShape>({ rowKey: 'include', columns: COLUMN_DEFS }); const { visibleKeys, setVisibleKeys } = useVisibleColumns<ColShape>({ rowKey: 'include', columns: COLUMN_DEFS });
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
// идёт запрос решения по строке / отмены: на это время контролы строк неактивны, чтобы не слать два запроса подряд
const [busy, setBusy] = useState(false);
const [revoking, setRevoking] = useState<CorrectionAggregateOut | null>(null);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
// ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить» // ошибка загрузки — уведомление с текстом сервера и кнопкой «Повторить»
@ -117,11 +106,45 @@ export function CorrectionContent({ versionId, card = null }: Props) {
return next; return next;
}); });
// заглушки: окно корректировки и решения по строкам подключают следующие задачи плана // ошибка правки: текст сервера в уведомлении; 409 (версию закрыли, блокировку забрали, корректировку уже изменили) — ещё перечитываем таблицу
const fail = (e: unknown, fallback: string) => {
notify.error({ description: e instanceof ApiError ? e.message : fallback });
if (needsReload(e)) reload();
};
// решение по строкам: сервер отвечает всей таблицей (в ней меняются и агрегаты, и нераспределённые) — подменяем её целиком
const onAllocate = async (body: AllocationIn) => {
setBusy(true);
try {
replace(await allocate(versionId, body));
} catch (e) {
fail(e, 'Не удалось сохранить решение по строке');
} finally {
setBusy(false);
}
};
// отмена корректировки: ответ — один агрегат, подменяем только его
const onRevoke = async (comment: string) => {
if (!revoking) return;
setBusy(true);
try {
replaceAggregate(await revokeCorrection(versionId, toAggregateKey(revoking), { comment }));
setRevoking(null);
} catch (e) {
// 409 — данные устарели, окно закрываем (таблица перечитается); иная ошибка — окно остаётся, можно повторить
if (needsReload(e)) setRevoking(null);
fail(e, 'Не удалось отменить корректировку');
} finally {
setBusy(false);
}
};
// «Корректировать» остаётся заглушкой до Task 6
const actions: RowActions = { const actions: RowActions = {
onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }), onCorrect: () => notify.info({ description: 'Окно корректировки будет в следующей версии экрана' }),
onRevoke: () => notify.info({ description: 'Отмена корректировки будет в следующей версии экрана' }), onRevoke: setRevoking,
onAllocate: () => notify.info({ description: 'Решения по строкам будут в следующей версии экрана' }), onAllocate,
}; };
const noData = table !== null && table.aggregates.length === 0 && table.unallocated.length === 0; const noData = table !== null && table.aggregates.length === 0 && table.unallocated.length === 0;
@ -130,7 +153,7 @@ export function CorrectionContent({ versionId, card = null }: Props) {
let body: ReactNode; let body: ReactNode;
if (table) { if (table) {
body = ( body = (
<Spin spinning={loading}> <Spin spinning={loading || busy}>
<CorrectionTable <CorrectionTable
rows={rows} rows={rows}
totals={totals} totals={totals}
@ -139,8 +162,8 @@ export function CorrectionContent({ versionId, card = null }: Props) {
onToggle={toggle} onToggle={toggle}
rules={table.rules} rules={table.rules}
editable={table.editable} editable={table.editable}
readOnlyHint={readOnlyHint(table)} readOnlyHint={table.editable ? null : readOnlyHint(table.read_only_reason)}
actionsEnabled={ROW_ACTIONS_READY && table.editable} busy={busy}
actions={actions} actions={actions}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={setFilters}
@ -199,6 +222,13 @@ export function CorrectionContent({ versionId, card = null }: Props) {
onToggleFullscreen={toggleFullscreen} onToggleFullscreen={toggleFullscreen}
/> />
{body} {body}
<RevokeModal
open={revoking !== null}
subtitle={revoking ? `${revoking.company_1.name} — ${revoking.company_2.name}` : ''}
saving={busy}
onCancel={() => setRevoking(null)}
onSubmit={onRevoke}
/>
</div> </div>
</ConfigProvider> </ConfigProvider>
); );

View File

@ -8,6 +8,7 @@ import { type ReactNode, useMemo } from 'react';
import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections'; import type { AllocationIn, CorrectionAggregateOut, CorrectionLineOut, CorrectionRuleOut } from '../../types/corrections';
import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter'; import { HeaderFilter, SelectFilter, TextFilter } from './HeaderFilter';
import { COLUMN_DEFS, type ColKey } from './columns'; import { COLUMN_DEFS, type ColKey } from './columns';
import { allocationMenu } from './readOnly';
import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel'; import { type DeviationFilter, type TableFilters, type TableRow, articleCodes, ruleLabel } from './tableModel';
import type { visibleTotals } from './tableModel'; import type { visibleTotals } from './tableModel';
@ -30,8 +31,8 @@ type Props = {
editable: boolean; editable: boolean;
/** Подсказка, почему правки закрыты; `null` — открыты. */ /** Подсказка, почему правки закрыты; `null` — открыты. */
readOnlyHint: string | null; readOnlyHint: string | null;
/** Выключатель флажков и меню строк: пока не подключены решения по строкам — false. */ /** Запрос решения уже идёт: контролы строк на это время неактивны (без подсказки). */
actionsEnabled: boolean; busy: boolean;
actions: RowActions; actions: RowActions;
filters: TableFilters; filters: TableFilters;
onFiltersChange: (next: TableFilters) => void; onFiltersChange: (next: TableFilters) => void;
@ -57,8 +58,6 @@ const BG_CHANGED = '#fff6e5';
/** «394.27» → «394,27%»; строка не переводится в число. */ /** «394.27» → «394,27%»; строка не переводится в число. */
const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`); const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`);
const UNAVAILABLE_HINT = 'Недоступно в этой версии экрана';
const showLine = (line: string): string => articleCodes(line).join(' / '); const showLine = (line: string): string => articleCodes(line).join(' / ');
const sideSuffix = (row: TableRow) => { const sideSuffix = (row: TableRow) => {
@ -69,8 +68,8 @@ const sideSuffix = (row: TableRow) => {
}; };
export function CorrectionTable(props: Props) { export function CorrectionTable(props: Props) {
const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, actionsEnabled, actions, filters, onFiltersChange } = props; const { rows, totals, format, expanded, onToggle, rules, editable, readOnlyHint, busy, actions, filters, onFiltersChange } = props;
const canAct = editable && actionsEnabled; const canAct = editable && !busy;
const ruleById = useMemo(() => new Map(rules.map((r) => [r.id, r])), [rules]); const ruleById = useMemo(() => new Map(rules.map((r) => [r.id, r])), [rules]);
const rowBg = (row: TableRow): string | undefined => { const rowBg = (row: TableRow): string | undefined => {
@ -79,38 +78,25 @@ export function CorrectionTable(props: Props) {
return undefined; return undefined;
}; };
// меню решений по строке ОСВ / нераспределённой строке // меню решений по строке ОСВ / нераспределённой строке: пункты даёт allocationMenu, здесь только переводим в формат antd
const entryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>): MenuProps['items'] => { const entryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>): MenuProps['items'] =>
const e = row.entry; allocationMenu(row, rules).map((item) => {
const ruleItems = e.candidate_rule_ids.flatMap((id) => { if (item.key !== 'MOVE') return { key: item.key, label: item.label, disabled: item.disabled };
const r = ruleById.get(id); const children = row.entry.candidate_rule_ids.flatMap((id) => {
return r ? [{ key: `assign:${id}`, label: ruleLabel(r) }] : []; const r = ruleById.get(id);
return r ? [{ key: `ASSIGN:${id}`, label: ruleLabel(r) }] : [];
});
return { key: 'MOVE', label: item.label, disabled: item.disabled, children };
}); });
const items: NonNullable<MenuProps['items']> = [
{
key: 'assign',
label: row.kind === 'entry' ? 'Перенести в правило…' : 'Отнести к правилу…',
disabled: ruleItems.length === 0,
children: ruleItems,
},
];
if (row.kind === 'entry') items.push({ key: 'unassign', label: 'Убрать из правила' });
if ((row.kind === 'entry' && row.entry.decision === 'ASSIGN') || (row.kind === 'unallocated' && row.entry.reason === 'UNASSIGNED')) {
items.push({ key: 'reset', label: 'Вернуть к автоматике' });
}
return items;
};
const onEntryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>, key: string) => { const onEntryMenu = (row: Extract<TableRow, { kind: 'entry' | 'unallocated' }>, key: string) => {
const base = { statement_type: 'balance' as const, entry_ids: row.entry.entry_ids }; 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) }); if (key.startsWith('ASSIGN:')) actions.onAllocate({ ...base, action: 'ASSIGN', rule_id: key.slice('ASSIGN:'.length) });
else if (key === 'unassign') actions.onAllocate({ ...base, action: 'UNASSIGN' }); else if (key === 'UNASSIGN' || key === 'RESET') actions.onAllocate({ ...base, action: key });
else if (key === 'reset') actions.onAllocate({ ...base, action: 'RESET' });
}; };
// почему контрол неактивен: сервер закрыл правки — его причина; иначе действие ещё не подключено в этой версии экрана // почему контрол неактивен: подсказка нужна только когда сервер закрыл правки (editable = false); пока идёт запрос — без неё
const inactiveHint = readOnlyHint ?? (actionsEnabled ? null : UNAVAILABLE_HINT); const withHint = (node: ReactNode, hint: string | null = readOnlyHint) =>
const withHint = (node: ReactNode, hint: string | null = inactiveHint) =>
hint ? ( hint ? (
<Tooltip title={hint}> <Tooltip title={hint}>
<span>{node}</span> <span>{node}</span>
@ -140,7 +126,6 @@ export function CorrectionTable(props: Props) {
</Tooltip> </Tooltip>
)} )}
</span>, </span>,
readOnlyHint,
); );
case 'd_entity': case 'd_entity':
return a.company_1.name; return a.company_1.name;
@ -192,11 +177,12 @@ export function CorrectionTable(props: Props) {
{withHint( {withHint(
<Dropdown <Dropdown
trigger={['click']} trigger={['click']}
disabled={!canAct}
menu={{ menu={{
items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !canAct || !a.correction }], items: [{ key: 'revoke', label: 'Отменить корректировку', disabled: !a.correction }],
onClick: () => actions.onRevoke(a), onClick: () => actions.onRevoke(a),
}}> }}>
<Button size='small' type='text' icon={<MoreOutlined />} aria-label='Действия' /> <Button size='small' type='text' icon={<MoreOutlined />} aria-label='Действия' disabled={!canAct} />
</Dropdown>, </Dropdown>,
)} )}
</span> </span>

View File

@ -0,0 +1,48 @@
// Окно «Отменить корректировку»: комментарий обязателен, пустой — «Сохранить» неактивна.
import { Input, Modal } from 'antd';
import { useEffect, useState } from 'react';
type Props = {
open: boolean;
/** Подпись агрегата в заголовке окна (кто с кем). */
subtitle: string;
/** Запрос уходит: кнопка в процессе, окно не закрывается. */
saving: boolean;
onCancel(): void;
onSubmit(comment: string): void;
};
export function RevokeModal({ open, subtitle, saving, onCancel, onSubmit }: Props) {
const [comment, setComment] = useState('');
// при каждом открытии — чистое поле
useEffect(() => {
if (open) setComment('');
}, [open]);
const trimmed = comment.trim();
return (
<Modal
open={open}
title='Отменить корректировку'
okText='Сохранить'
cancelText='Отменить'
okButtonProps={{ disabled: trimmed === '' }}
confirmLoading={saving}
mask={{ closable: false }}
onCancel={saving ? undefined : onCancel}
onOk={() => onSubmit(trimmed)}
destroyOnHidden>
<div style={{ marginBottom: 8, color: '#667085' }}>{subtitle}</div>
<Input.TextArea
autoFocus
rows={4}
value={comment}
maxLength={2000}
placeholder='Комментарий к отмене (обязателен)'
aria-label='Комментарий к отмене корректировки'
onChange={(e) => setComment(e.target.value)}
/>
</Modal>
);
}

View File

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { ApiError } from '../../api/client';
import { makeMockTable } from './mockData';
import { allocationMenu, needsReload, readOnlyHint } from './readOnly';
import type { TableRow } from './tableModel';
describe('readOnlyHint', () => {
it('тексты причин', () => {
expect(readOnlyHint('LOCK_REQUIRED')).toBe('Возьмите консолидацию в работу');
expect(readOnlyHint('VERSION_NOT_EDITABLE')).toBe('Версия закрыта для изменений');
expect(readOnlyHint('LOCKED_BY_OTHER')).toBe('Консолидацию редактирует другой пользователь');
});
it('причины нет — подсказки нет', () => {
expect(readOnlyHint(null)).toBeNull();
});
});
describe('allocationMenu', () => {
const t = makeMockTable();
const firstEntry = () => {
for (const a of t.aggregates) for (const e of [...a.side_1.entries, ...a.side_2.entries]) return e;
throw new Error('нет строк ОСВ');
};
const entryRow = (patch: Partial<ReturnType<typeof firstEntry>>): TableRow => ({
kind: 'entry',
key: 'k',
parentKey: 'p',
side: 1,
entry: { ...firstEntry(), ...patch },
});
const unallocRow = (patch: Partial<(typeof t.unallocated)[number]>): TableRow => ({
kind: 'unallocated',
key: 'u',
entry: { ...t.unallocated[0], ...patch },
});
const keys = (row: TableRow) => allocationMenu(row, t.rules).map((i) => i.key);
it('у строки агрегата с автоматическим решением нет «Вернуть к автоматике»', () => {
const items = allocationMenu(entryRow({ decision: 'AUTO', candidate_rule_ids: [t.rules[0].id] }), t.rules);
expect(items.map((i) => i.label)).not.toContain('Вернуть к автоматике');
expect(items.map((i) => i.key)).toEqual(['MOVE', 'UNASSIGN']);
});
it('у строки с ручным переносом «Вернуть к автоматике» есть', () => {
expect(keys(entryRow({ decision: 'ASSIGN' }))).toContain('RESET');
});
it('пустой список правил-кандидатов — «Перенести в правило…» неактивен', () => {
const move = allocationMenu(entryRow({ candidate_rule_ids: [] }), t.rules).find((i) => i.key === 'MOVE');
expect(move?.label).toBe('Перенести в правило…');
expect(move?.disabled).toBe(true);
});
it('кандидат, которого нет в справочнике правил, не считается', () => {
const move = allocationMenu(entryRow({ candidate_rule_ids: ['нет-такого'] }), t.rules).find((i) => i.key === 'MOVE');
expect(move?.disabled).toBe(true);
});
it('у нераспределённой с reason = UNASSIGNED есть «Вернуть к автоматике», «Убрать из правила» нет', () => {
const items = allocationMenu(unallocRow({ reason: 'UNASSIGNED', candidate_rule_ids: [t.rules[0].id] }), t.rules);
expect(items.map((i) => i.key)).toEqual(['MOVE', 'RESET']);
expect(items[0].label).toBe('Отнести к правилу…');
expect(items[0].disabled).toBe(false);
});
it('у нераспределённой с MULTIPLE_RULES возврата к автоматике нет', () => {
expect(keys(unallocRow({ reason: 'MULTIPLE_RULES' }))).toEqual(['MOVE']);
});
it('у агрегата меню строки пусто', () => {
const agg: TableRow = { kind: 'aggregate', key: 'a', aggregate: t.aggregates[0], include: 'Да' };
expect(allocationMenu(agg, t.rules)).toEqual([]);
});
});
describe('needsReload', () => {
it('409 — перечитать таблицу', () => {
expect(needsReload(new ApiError(409, 'LOCK_REQUIRED', 'x'))).toBe(true);
expect(needsReload(new ApiError(409, 'CORRECTION_VERSION_CONFLICT', '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);
});
});

View File

@ -0,0 +1,42 @@
// Чистые функции для режима «только чтение» и меню решений по строке (без React — тестируются отдельно).
import type { AllocationAction, CorrectionRuleOut, ReadOnlyReason } from '../../types/corrections';
import type { TableRow } from './tableModel';
/** Подсказки для режима «только чтение» (дословно из спеки). */
const READ_ONLY_HINTS: Record<ReadOnlyReason, string> = {
VERSION_NOT_EDITABLE: 'Версия закрыта для изменений',
LOCK_REQUIRED: 'Возьмите консолидацию в работу',
LOCKED_BY_OTHER: 'Консолидацию редактирует другой пользователь',
};
/** Подсказка «почему только чтение»; причины нет — `null`. */
export function readOnlyHint(reason: ReadOnlyReason | null): string | null {
return reason ? READ_ONLY_HINTS[reason] : null;
}
export type AllocationMenuItem = { key: AllocationAction | 'MOVE'; label: string; disabled: boolean };
/**
* Пункты меню решений по строке ОСВ или нераспределённой строке.
* 'MOVE' — не действие сервера, а «выбрать правило»: потом уходит как ASSIGN с rule_id.
*/
export function allocationMenu(row: TableRow, rules: CorrectionRuleOut[]): AllocationMenuItem[] {
if (row.kind === 'aggregate') return [];
const known = new Set(rules.map((r) => r.id));
const hasCandidates = row.entry.candidate_rule_ids.some((id) => known.has(id));
const items: AllocationMenuItem[] = [
{ key: 'MOVE', label: row.kind === 'entry' ? 'Перенести в правило…' : 'Отнести к правилу…', disabled: !hasCandidates },
];
if (row.kind === 'entry') items.push({ key: 'UNASSIGN', label: 'Убрать из правила', disabled: false });
const manual = row.kind === 'entry' ? row.entry.decision === 'ASSIGN' : row.entry.reason === 'UNASSIGNED';
if (manual) items.push({ key: 'RESET', label: 'Вернуть к автоматике', disabled: false });
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;
}