AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215
@ -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; фронт не выдумывает правил | при рассинхроне справочника правил пункт неактивен |
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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) };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user