fix(web): окно корректировки — исходный контроль из ответа сервера, предел суммы, один percentText (#206)

I-2: sourceDiff = суммы сторон, отклонение и порог из агрегата; diffOf только для распределения.
M-1: целая часть суммы не более 16 цифр («Слишком большая сумма»). M-6, M-7: комментарий, один percentText.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 22:15:41 +04:00
parent f4a4f01c28
commit d76cad1210
4 changed files with 44 additions and 11 deletions

View File

@ -1,4 +1,4 @@
// Таблица корректировок (только отображение): строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого». // Таблица корректировок: строки агрегатов, их строки ОСВ при раскрытии, нераспределённые, «Итого»; действия над строками — через `actions`.
// Фильтры живут в родителе (CorrectionContent); здесь только рисуем и сообщаем о действиях через `actions`. // Фильтры живут в родителе (CorrectionContent); здесь только рисуем и сообщаем о действиях через `actions`.
import { DownOutlined, EditOutlined, MoreOutlined, RightOutlined, WarningOutlined } from '@ant-design/icons'; import { DownOutlined, EditOutlined, MoreOutlined, RightOutlined, WarningOutlined } from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Table, Tag, Tooltip } from 'antd'; import { Button, Checkbox, Dropdown, Table, Tag, Tooltip } from 'antd';
@ -8,11 +8,12 @@ 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 { percentText } from './editorView';
import { AGGREGATE_FLAG_HINT, allocationMenu, includeBody, menuBody } from './readOnly'; import { AGGREGATE_FLAG_HINT, allocationMenu, includeBody, menuBody } 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';
/** Действия над строками; их подключают задачи окна корректировки и решений по строкам. */ /** Действия над строками: подключает CorrectionContent. */
export type RowActions = { export type RowActions = {
onCorrect(a: CorrectionAggregateOut): void; onCorrect(a: CorrectionAggregateOut): void;
onRevoke(a: CorrectionAggregateOut): void; onRevoke(a: CorrectionAggregateOut): void;
@ -55,9 +56,6 @@ const BG_CREDIT = '#f6f8fc';
const BG_UNALLOCATED = '#f2f4f7'; const BG_UNALLOCATED = '#f2f4f7';
const BG_CHANGED = '#fff6e5'; const BG_CHANGED = '#fff6e5';
/** «394.27» → «394,27%»; строка не переводится в число. */
const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`);
const showLine = (line: string): string => articleCodes(line).join(' / '); const showLine = (line: string): string => articleCodes(line).join(' / ');
const sideSuffix = (row: TableRow) => { const sideSuffix = (row: TableRow) => {

View File

@ -76,14 +76,36 @@ describe('validateLines', () => {
}); });
}); });
describe('предел суммы numeric(18,2)', () => {
const run = (amount: string) => validateLines({ debit: [{ line_code: '1230', amount }], credit: [], comment: '' });
it('16 цифр до запятой — можно, 17 — «Слишком большая сумма»', () => {
expect(run('9999999999999999.99').ok).toBe(true);
const r = run('10000000000000000');
expect(r.ok).toBe(false);
if (!r.ok) expect(r.errors['debit.0.amount']).toBe('Слишком большая сумма');
});
});
describe('liveControl', () => { describe('liveControl', () => {
it('исходные суммы агрегата ЭПТ — ИЛС: процент 394.27, красный', () => { it('исходный контроль берётся из ответа сервера: суммы сторон, отклонение, порог', () => {
const a = findBy(table(), 'ЭПТ'); const a = findBy(table(), 'ЭПТ');
const c = liveControl(a, initialEditorState(a)); const c = liveControl(a, initialEditorState(a));
expect(c.sourceDiff).toEqual({
left: a.side_1.amount,
right: a.side_2.amount,
difference: a.deviation_amount,
percent: a.deviation_percent,
red: a.exceeds_threshold,
});
expect(c.sourceDiff.percent).toBe('394.27'); expect(c.sourceDiff.percent).toBe('394.27');
expect(c.sourceDiff.red).toBe(true); expect(c.sourceDiff.red).toBe(true);
}); });
it('сервер сказал «не превышает» — не красный, что бы ни вышло из сумм', () => {
const a = { ...findBy(table(), 'ЭПТ'), exceeds_threshold: false };
expect(liveControl(a, initialEditorState(a)).sourceDiff.red).toBe(false);
});
it('распределение Дт 1000000.00 и Кт 1000000.00 — разница 0.00, не красный', () => { it('распределение Дт 1000000.00 и Кт 1000000.00 — разница 0.00, не красный', () => {
const a = findBy(table(), 'ЭПТ'); const a = findBy(table(), 'ЭПТ');
const s: EditorState = { const s: EditorState = {

View File

@ -44,11 +44,14 @@ function withCents(value: string): string {
return `${int}.${frac.padEnd(2, '0')}`; return `${int}.${frac.padEnd(2, '0')}`;
} }
const MAX_INTEGER_DIGITS = 16;
const AMOUNT_ERROR = { const AMOUNT_ERROR = {
empty: 'Введите сумму', empty: 'Введите сумму',
spaces: 'Сумма без пробелов', spaces: 'Сумма без пробелов',
format: 'Сумма: не более двух знаков после запятой', format: 'Сумма: не более двух знаков после запятой',
negative: 'Сумма не может быть отрицательной', negative: 'Сумма не может быть отрицательной',
tooBig: 'Слишком большая сумма',
} as const; } as const;
type AmountResult = { ok: true; value: string } | { ok: false; error: string }; type AmountResult = { ok: true; value: string } | { ok: false; error: string };
@ -61,7 +64,10 @@ function parseLineAmount(raw: string): AmountResult {
const parsed = parseAmountInput(text); const parsed = parseAmountInput(text);
if (!parsed.ok || parsed.value === null) return { ok: false, error: AMOUNT_ERROR.format }; if (!parsed.ok || parsed.value === null) return { ok: false, error: AMOUNT_ERROR.format };
if (parsed.value.startsWith('-')) return { ok: false, error: AMOUNT_ERROR.negative }; if (parsed.value.startsWith('-')) return { ok: false, error: AMOUNT_ERROR.negative };
return { ok: true, value: withCents(parsed.value) }; const value = withCents(parsed.value);
// numeric(18,2): не более 16 цифр до запятой
if (value.split('.')[0].replace(/^0+(?=\d)/, '').length > MAX_INTEGER_DIGITS) return { ok: false, error: AMOUNT_ERROR.tooBig };
return { ok: true, value };
} }
function lineError(line: EditLine, prefix: string, errors: Record<string, string>): CorrectionLineIn | null { function lineError(line: EditLine, prefix: string, errors: Record<string, string>): CorrectionLineIn | null {
@ -104,7 +110,7 @@ function sideSum(lines: EditLine[]): string | null {
); );
} }
/** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */ /** Разница двух сумм и процент к меньшей (для распределения статей); красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */
function diffOf(left: string | null, right: string | null): Diff { function diffOf(left: string | null, right: string | null): Diff {
const l = left ?? '0.00'; const l = left ?? '0.00';
const r = right ?? '0.00'; const r = right ?? '0.00';
@ -120,7 +126,14 @@ function diffOf(left: string | null, right: string | null): Diff {
/** Живой контроль окна: исходные суммы сторон агрегата и распределение статей из текущего состояния редактора. */ /** Живой контроль окна: исходные суммы сторон агрегата и распределение статей из текущего состояния редактора. */
export function liveControl(a: CorrectionAggregateOut, s: EditorState): { sourceDiff: Diff; allocationDiff: Diff } { export function liveControl(a: CorrectionAggregateOut, s: EditorState): { sourceDiff: Diff; allocationDiff: Diff } {
return { return {
sourceDiff: diffOf(a.side_1.amount, a.side_2.amount), // исходный контроль — как посчитал сервер (те же цифры, что в таблице), распределение — живой пересчёт
sourceDiff: {
left: a.side_1.amount,
right: a.side_2.amount,
difference: a.deviation_amount,
percent: a.deviation_percent,
red: a.exceeds_threshold,
},
allocationDiff: diffOf(sideSum(s.debit), sideSum(s.credit)), allocationDiff: diffOf(sideSum(s.debit), sideSum(s.credit)),
}; };
} }

View File

@ -17,8 +17,8 @@ export const formatRub = (value: string | null): string => formatScaledAmount(va
export const pickLabel = (counterparty: string, account: string, amount: string): string => export const pickLabel = (counterparty: string, account: string, amount: string): string =>
`Выбрать: ${counterparty}, ${account}, ${formatRub(amount)}`; `Выбрать: ${counterparty}, ${account}, ${formatRub(amount)}`;
/** «394.27» → «394,27%»; строка в число не переводится. */ /** «394.27» → «394,27%»; строка в число не переводится; нет значения — «—». */
export const percentText = (p: string): string => `${p.replace('.', ',')}%`; export const percentText = (p: string | null): string => (p === null ? '—' : `${p.replace('.', ',')}%`);
export type ControlText = { left: string; middle: string; right: string; red: boolean }; export type ControlText = { left: string; middle: string; right: string; red: boolean };