AURORA-1541: таблица корректировки — подключена к реальному API (#206) #215

Merged
aep merged 29 commits from feat/206-corrections-ui into main 2026-11-02 20:12:17 +03:00
9 changed files with 167 additions and 82 deletions
Showing only changes of commit ec6ece127f - Show all commits

View File

@ -183,5 +183,5 @@
| 28.10.2026 | R-2: в фейк добавлен `widgets/CorrectionTable/mockCalc.ts` (пересчёт агрегата и итогов через BigInt-копейки) — общий для `mockData` и `correctionsFake`, иначе циклический импорт | план не называл файл; без него циклический импорт | перенос функций, без поведения |
| 28.10.2026 | R-3: в `mockData` сверх дизайна добавлены агрегат правила 6 «ООО "БСК"» — «ПМП ООО» (310000.00 / 300000.00) и агрегат правила 4 «Агро-Холдинг» без второй стороны (ONE_SIDED); `status` в фейке — ONE_SIDED / DISCREPANCY / AUTO, MANUAL не выдаётся | тест фильтра `ооо "бск"` из плана требует агрегат с таким ЮЛ; ONE_SIDED нужен для проверки отображения | поправка данных фейка, на прод не влияет |
| 28.10.2026 | R-4: в окне корректировки сумма с пробелами («1 000 000,5») отклоняется с ошибкой «Сумма без пробелов», хотя `parseAmountInput` пробелы принимает | Review Focus плана прямо требует ошибку для этого ввода | если заказчик хочет пробелы — снять одну проверку в `validateLines` |
| 28.10.2026 | R-5: `editorModel` переиспользует `percentOfSmaller`/`toCents`/`fromCents` из `mockCalc.ts`; вынос в `utils/money.ts` отложен | не раздувать задачу; поведение одинаково | при подключении API `mockCalc` может уйти — функции придётся переехать |
| 28.10.2026 | R-5: `percentOfSmaller`, `toCents`, `fromCents` и порог 5 % (`THRESHOLD_PERCENT_HUNDREDTHS`) вынесены в `utils/money.ts`; `editorModel` и `mockCalc` импортируют оттуда, зависимости `editorModel` → `mockCalc` нет | фейк `mockCalc` вырезается при подключении API, экран не должен от него зависеть (ревью Task 3, T3.1) | поведение то же; тесты перенесены в `utils/money.test.ts` |
| 28.10.2026 | R-6: тексты ошибок валидации окна и ключ ошибки `lines` для «Добавьте статью» выбраны исполнителем | бриф их не задаёт; тексты в spec не указаны | правка строк |

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatAmount, formatScaledAmount, parseAmountInput, sumAmounts } from './money';
import { THRESHOLD_PERCENT_HUNDREDTHS, formatAmount, formatScaledAmount, fromCents, parseAmountInput, percentOfSmaller, sumAmounts, toCents } from './money';
const NBSP = ' ';
@ -115,3 +115,28 @@ describe('formatScaledAmount', () => {
expect(formatScaledAmount('1e5', rub(2))).toBe('1e5');
});
});
describe('percentOfSmaller', () => {
it('округляет половину вверх', () => {
expect(percentOfSmaller('9.00', '8.00')).toBe('12.50');
expect(percentOfSmaller('1.00', '3.00')).toBe('200.00');
// отклонение 0.01 к 8.00 = 0.125 % -> 0.13 (половина вверх)
expect(percentOfSmaller('8.01', '8.00')).toBe('0.13');
expect(percentOfSmaller('0.00', '5.00')).toBeNull();
});
});
describe('toCents / fromCents', () => {
it('копейки и обратно, без float', () => {
expect(toCents('-1.5')).toBe(-150n);
expect(toCents('9007199254740993.01')).toBe(900719925474099301n);
expect(fromCents(-5n)).toBe('-0.05');
expect(fromCents(0n)).toBe('0.00');
});
it('не сумма — ошибка', () => {
expect(() => toCents('1e5')).toThrow();
});
it('порог — 5 % в сотых', () => {
expect(THRESHOLD_PERCENT_HUNDREDTHS).toBe(500n);
});
});

View File

@ -114,3 +114,30 @@ export function sumAmounts(values: readonly (string | null | undefined)[]): stri
const abs = negative ? -cents : cents;
return `${negative ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`;
}
/** Порог отклонения в сотых процента: 5 % = 500. Выше или равно — «красный» (см. контракт, `exceeds_threshold`). */
export const THRESHOLD_PERCENT_HUNDREDTHS = 500n;
/** Строка суммы (`"-12.5"`, до двух знаков) → копейки BigInt. Не сумма — ошибка. */
export function toCents(value: string): bigint {
const match = MONEY.exec(value);
if (!match) throw new Error(`Не сумма: ${value}`);
const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? '').padEnd(2, '0'));
return match[1] ? -cents : cents;
}
/** Копейки BigInt → строка суммы с двумя знаками (`"-0.05"`). */
export function fromCents(cents: bigint): string {
const abs = cents < 0n ? -cents : cents;
return `${cents < 0n ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`;
}
/** Процент отклонения к меньшей сумме, два знака, половина вверх; `null`, если меньшая сумма не положительна. */
export function percentOfSmaller(a: string, b: string): string | null {
const x = toCents(a);
const y = toCents(b);
const min = x < y ? x : y;
if (min <= 0n) return null;
const diff = x < y ? y - x : x - y;
return fromCents((diff * 20000n + min) / (2n * min));
}

View File

@ -47,6 +47,14 @@ describe('saveDraft / loadDraft / clearDraft', () => {
expect(loadDraft('k', s)).toBeNull();
});
it('мусорный savedAt — null', () => {
const s = mapStorage();
s.setItem('k', JSON.stringify({ ...draft(null), savedAt: 'не дата' }));
expect(loadDraft('k', s)).toBeNull();
s.setItem('k', JSON.stringify({ ...draft(null), savedAt: '' }));
expect(loadDraft('k', s)).toBeNull();
});
it('испорченный JSON — null', () => {
const s = mapStorage();
s.setItem('k', '{не json');

View File

@ -29,7 +29,7 @@ function isLine(value: unknown): value is { line_code: string; amount: string }
function isDraft(value: unknown): value is Draft {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
if (typeof v.savedAt !== 'string') return false;
if (typeof v.savedAt !== 'string' || !Number.isFinite(Date.parse(v.savedAt))) return false;
if (v.baseVersionNo !== null && typeof v.baseVersionNo !== 'number') return false;
const s = v.state as Record<string, unknown> | null;
if (typeof s !== 'object' || s === null) return false;

View File

@ -110,6 +110,49 @@ describe('liveControl', () => {
});
});
describe('граница порога и пустые стороны', () => {
const base = { debit: [], credit: [], comment: '' };
it('отклонение ровно 5.00 % — красный', () => {
const a = findBy(table(), 'ЭПТ');
// 100.00 к 105.00 — 5.00 % к меньшей
const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '105.00' }] });
expect(c.allocationDiff.percent).toBe('5.00');
expect(c.allocationDiff.red).toBe(true);
});
it('отклонение 4.99 % — не красный', () => {
const a = findBy(table(), 'ЭПТ');
const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '100.00' }], credit: [{ line_code: '1520', amount: '104.99' }] });
expect(c.allocationDiff.percent).toBe('4.99');
expect(c.allocationDiff.red).toBe(false);
});
it('обе стороны распределения пусты — не красный, процент null', () => {
const a = findBy(table(), 'ЭПТ');
const c = liveControl(a, base);
expect(c.allocationDiff).toEqual({ left: null, right: null, difference: '0.00', percent: null, red: false });
});
it('обе стороны с нулевой суммой — не красный', () => {
const a = findBy(table(), 'ЭПТ');
const c = liveControl(a, { ...base, debit: [{ line_code: '1230', amount: '0' }], credit: [{ line_code: '1520', amount: '0.00' }] });
expect(c.allocationDiff.red).toBe(false);
});
});
describe('живой контроль не считает некорректные строки', () => {
it('строки с пробелами и отрицательные не входят в сумму стороны', () => {
const a = findBy(table(), 'ЭПТ');
const c = liveControl(a, {
debit: [
{ line_code: '1230', amount: '1 000' },
{ line_code: '1230', amount: '-5' },
{ line_code: '1230', amount: '5,5' },
],
credit: [],
comment: '',
});
expect(c.allocationDiff.left).toBe('5.50');
});
});
describe('isDirty', () => {
it('одинаковые состояния — не грязно; правка комментария — грязно', () => {
const a = findBy(table(), 'Пензенский Мир Продуктов');
@ -117,6 +160,15 @@ describe('isDirty', () => {
expect(isDirty(init, initialEditorState(a))).toBe(false);
expect(isDirty(init, { ...init, comment: 'x' })).toBe(true);
});
it('сумма сравнивается копейками: 1000000.00 и 1000000,00 равны; другая сумма — нет', () => {
const base: EditorState = { debit: [{ line_code: '1230', amount: '1000000.00' }], credit: [], comment: '' };
expect(isDirty(base, { ...base, debit: [{ line_code: '1230', amount: '1000000,00' }] })).toBe(false);
expect(isDirty(base, { ...base, debit: [{ line_code: '1230', amount: '1000000,01' }] })).toBe(true);
});
it('комментарий сравнивается точно: пробел в конце — правка', () => {
const base: EditorState = { debit: [], credit: [], comment: 'сверка' };
expect(isDirty(base, { ...base, comment: 'сверка ' })).toBe(true);
});
});
describe('toCorrectionIn', () => {
@ -167,6 +219,12 @@ describe('pickerCandidates', () => {
expect(pickerCandidates(t, a, 1).map((u) => u.entry_ids[0])).toEqual(['d-ok']);
});
it('неизвестный rule_id — пустой список', () => {
const t = table();
const a: CorrectionAggregateOut = { ...findBy(t, 'БЕКОВСКИЙ САХАРНЫЙ ЗАВОД'), rule_id: 'no-such-rule' };
expect(pickerCandidates(t, a, 2)).toEqual([]);
});
it('ключ агрегата не меняется от состояния редактора', () => {
const a = findBy(table(), 'ЭПТ');
expect(aggregateKey(a)).toBe('r6|c-ept|c-ils');

View File

@ -1,5 +1,5 @@
// Модель окна «Корректировка операции»: состояние редактора, проверки ввода, живой контроль, кандидаты для «Добавить строку ОСВ».
// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, процент и BigInt-копейки — из mockCalc.
// Деньги — строки; сложение — sumAmounts, разбор ввода — parseAmountInput, копейки и процент — из utils/money.
import type {
CorrectionAggregateOut,
CorrectionIn,
@ -9,8 +9,7 @@ import type {
Side,
UnallocatedEntryOut,
} from '../../types/corrections';
import { parseAmountInput, sumAmounts } from '../../utils/money';
import { fromCents, percentOfSmaller, toCents } from './mockCalc';
import { THRESHOLD_PERCENT_HUNDREDTHS, fromCents, parseAmountInput, percentOfSmaller, sumAmounts, toCents } from '../../utils/money';
import { articleCodes } from './tableModel';
export type EditLine = { line_code: string; amount: string };
@ -22,9 +21,6 @@ export type LinesValidation =
| { ok: true; body: Pick<CorrectionIn, 'debit' | 'credit' | 'comment'> }
| { ok: false; errors: Record<string, string> };
/** Порог отклонения, с которого процент и сумма краснеют (в сотых процента). */
const RED_PERCENT_HUNDREDTHS = 500n;
/** Без корректировки — по одной статье на сторону (первый код составной статьи) с суммой стороны; есть корректировка — её статьи. */
export function initialEditorState(a: CorrectionAggregateOut): EditorState {
const c = a.correction;
@ -48,30 +44,34 @@ function withCents(value: string): string {
return `${int}.${frac.padEnd(2, '0')}`;
}
const AMOUNT_ERROR = {
empty: 'Введите сумму',
spaces: 'Сумма без пробелов',
format: 'Сумма: не более двух знаков после запятой',
negative: 'Сумма не может быть отрицательной',
} as const;
type AmountResult = { ok: true; value: string } | { ok: false; error: string };
/** Разбор суммы строки ввода — общий для проверки, живого контроля и сравнения. Строка с двумя знаками или причина отказа. */
function parseLineAmount(raw: string): AmountResult {
const text = raw.trim();
if (text === '') return { ok: false, error: AMOUNT_ERROR.empty };
if (/\s/.test(text)) return { ok: false, error: AMOUNT_ERROR.spaces };
const parsed = parseAmountInput(text);
if (!parsed.ok || parsed.value === null) return { ok: false, error: AMOUNT_ERROR.format };
if (parsed.value.startsWith('-')) return { ok: false, error: AMOUNT_ERROR.negative };
return { ok: true, value: withCents(parsed.value) };
}
function lineError(line: EditLine, prefix: string, errors: Record<string, string>): CorrectionLineIn | null {
const code = line.line_code.trim();
const raw = line.amount.trim();
const parsed = raw === '' || /\s/.test(raw) ? null : parseAmountInput(raw);
let ok = true;
if (!/^\d{4}$/.test(code)) {
errors[`${prefix}.line_code`] = 'Код статьи — 4 цифры';
ok = false;
}
if (raw === '') {
errors[`${prefix}.amount`] = 'Введите сумму';
ok = false;
} else if (/\s/.test(raw)) {
errors[`${prefix}.amount`] = 'Сумма без пробелов';
ok = false;
} else if (!parsed?.ok || parsed.value === null) {
errors[`${prefix}.amount`] = 'Сумма: не более двух знаков после запятой';
ok = false;
} else if (parsed.value.startsWith('-')) {
errors[`${prefix}.amount`] = 'Сумма не может быть отрицательной';
ok = false;
}
if (!ok || !parsed?.ok || parsed.value === null) return null;
return { line_code: code, amount: withCents(parsed.value) };
const codeOk = /^\d{4}$/.test(code);
const amount = parseLineAmount(line.amount);
if (!codeOk) errors[`${prefix}.line_code`] = 'Код статьи — 4 цифры';
if (!amount.ok) errors[`${prefix}.amount`] = amount.error;
if (!codeOk || !amount.ok) return null;
return { line_code: code, amount: amount.value };
}
/** Проверка статей перед сохранением: код — 4 цифры, сумма — неотрицательная, до двух знаков; пустая сторона допустима. */
@ -94,14 +94,12 @@ export function validateLines(s: EditorState): LinesValidation {
return { ok: true, body: { debit, credit, comment: s.comment.trim() } };
}
/** Сумма статей стороны; некорректные строки в живом контроле пропускаются (их покажет validateLines). */
/** Сумма статей стороны для живого контроля: строки, не прошедшие parseLineAmount, в сумму не входят (их покажет validateLines). */
function sideSum(lines: EditLine[]): string | null {
return sumAmounts(
lines.map((l) => {
const parsed = parseAmountInput(l.amount);
return parsed.ok && parsed.value !== null ? withCents(parsed.value) : null;
}),
);
return sumAmounts(lines.map((l) => {
const r = parseLineAmount(l.amount);
return r.ok ? r.value : null;
}));
}
/** Разница двух сумм и процент к меньшей; красный — процент ≥ 5 или одна сторона 0 при ненулевой другой. */
@ -113,7 +111,7 @@ function diffOf(left: string | null, right: string | null): Diff {
const difference = fromCents(lc > rc ? lc - rc : rc - lc);
const percent: Percent = percentOfSmaller(l, r);
const oneZero = (lc === 0n) !== (rc === 0n);
const red = (percent !== null && toCents(percent) >= RED_PERCENT_HUNDREDTHS) || oneZero;
const red = (percent !== null && toCents(percent) >= THRESHOLD_PERCENT_HUNDREDTHS) || oneZero;
return { left, right, difference, percent, red };
}
@ -125,11 +123,17 @@ export function liveControl(a: CorrectionAggregateOut, s: EditorState): { source
};
}
function sameLines(x: EditLine[], y: EditLine[]): boolean {
return x.length === y.length && x.every((l, i) => l.line_code === y[i].line_code && l.amount === y[i].amount);
/** Сумма для сравнения черновика с исходным состоянием: копейки, если разбирается; иначе сама строка. */
function amountKey(raw: string): string {
const r = parseLineAmount(raw);
return r.ok ? toCents(r.value).toString() : `raw:${raw}`;
}
/** Есть ли несохранённые правки относительно исходного состояния окна. */
function sameLines(x: EditLine[], y: EditLine[]): boolean {
return x.length === y.length && x.every((l, i) => l.line_code === y[i].line_code && amountKey(l.amount) === amountKey(y[i].amount));
}
/** Есть ли несохранённые правки. Суммы сравниваются копейками (`1000000.00` = `1000000,00`); комментарий — точно, как введён. */
export function isDirty(initial: EditorState, current: EditorState): boolean {
return !sameLines(initial.debit, current.debit) || !sameLines(initial.credit, current.credit) || initial.comment !== current.comment;
}

View File

@ -1,12 +0,0 @@
import { describe, expect, it } from 'vitest';
import { percentOfSmaller } from './mockCalc';
describe('percentOfSmaller', () => {
it('округляет половину вверх', () => {
expect(percentOfSmaller('9.00', '8.00')).toBe('12.50');
expect(percentOfSmaller('1.00', '3.00')).toBe('200.00');
// отклонение 0.01 к 8.00 = 0.125 % -> 0.13 (половина вверх)
expect(percentOfSmaller('8.01', '8.00')).toBe('0.13');
expect(percentOfSmaller('0.00', '5.00')).toBeNull();
});
});

View File

@ -1,35 +1,10 @@
import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money, Percent } from '../../types/corrections';
import { sumAmounts } from '../../utils/money';
import type { AggregateSideOut, CorrectionAggregateOut, CorrectionBalanceOut, CorrectionTotalsOut, Money } from '../../types/corrections';
import { THRESHOLD_PERCENT_HUNDREDTHS, fromCents, percentOfSmaller, sumAmounts, toCents } from '../../utils/money';
// Пересчёт агрегата таблицы корректировок — общий для данных фейка и самого фейка. Деньги — строки, арифметика — BigInt-копейки.
// Это же делает сервер (контракт, раздел 4); на экране эти функции не используются: фронт только показывает ответ.
export const THRESHOLD_PERCENT_HUNDREDTHS = 500n;
// toCents/fromCents дублируют BigInt-разбор из utils/money.ts: там он не экспортируется, а правка утилиты вне объёма задачи.
export function toCents(value: Money): bigint {
const match = /^(-?)(\d+)(?:\.(\d{1,2}))?$/.exec(value);
if (!match) throw new Error(`Не сумма: ${value}`);
const cents = BigInt(match[2]) * 100n + BigInt((match[3] ?? '').padEnd(2, '0'));
return match[1] ? -cents : cents;
}
export function fromCents(cents: bigint): Money {
const abs = cents < 0n ? -cents : cents;
return `${cents < 0n ? '-' : ''}${abs / 100n}.${(abs % 100n).toString().padStart(2, '0')}`;
}
// Это фейк контракта (раздел 4): сервер считает то же сам. Экран эти функции не вызывает, только утилиты из utils/money.
const sumOrZero = (values: readonly Money[]): Money => sumAmounts(values) ?? '0.00';
/** Процент отклонения к меньшей сумме, два знака, половина вверх; `null`, если меньшая сумма не положительна. */
export function percentOfSmaller(a: Money, b: Money): Percent {
const x = toCents(a);
const y = toCents(b);
const min = x < y ? x : y;
if (min <= 0n) return null;
const diff = x < y ? y - x : x - y;
return fromCents((diff * 20000n + min) / (2n * min));
}
function sideAmount(side: AggregateSideOut): void {
side.amount = sumOrZero(side.entries.filter((e) => !e.excluded).map((e) => e.amount));
side.accounts = [...new Set(side.entries.map((e) => e.account_code))].sort();